diff --git a/.gitignore b/.gitignore index 28ed45aa..bd9cbb44 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ __pycache__ build *.pyc *.swp +MANIFEST +deb_build +dist +.idea +rtr.cvs diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..80cd5067 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,230 @@ +language: python + +# Credit: Original .travis.yml lifted from VisPy + +# Here we use anaconda for 2.6 and 3.3, since it provides the simplest +# interface for running different versions of Python. We could also use +# it for 2.7, but the Ubuntu system has installable 2.7 Qt4-GL, which +# allows for more complete testing. +notifications: + email: false + +virtualenv: + system_site_packages: true + + +env: + # Enable python 2 and python 3 builds + # Note that the 2.6 build doesn't get flake8, and runs old versions of + # Pyglet and GLFW to make sure we deal with those correctly + #- PYTHON=2.6 QT=pyqt TEST=standard + - PYTHON=2.7 QT=pyqt TEST=extra + - PYTHON=2.7 QT=pyside TEST=standard + - PYTHON=3.2 QT=pyqt TEST=standard + - PYTHON=3.2 QT=pyside TEST=standard + #- PYTHON=3.2 QT=pyqt5 TEST=standard + + +before_install: + - TRAVIS_DIR=`pwd` + - travis_retry sudo apt-get update; +# - if [ "${PYTHON}" != "2.7" ]; then +# wget http://repo.continuum.io/miniconda/Miniconda-2.2.2-Linux-x86_64.sh -O miniconda.sh && +# chmod +x miniconda.sh && +# ./miniconda.sh -b && +# export PATH=/home/$USER/anaconda/bin:$PATH && +# conda update --yes conda && +# travis_retry sudo apt-get -qq -y install libgl1-mesa-dri; +# fi; + - if [ "${TRAVIS_PULL_REQUEST}" != "false" ]; then + GIT_TARGET_EXTRA="+refs/heads/${TRAVIS_BRANCH}"; + GIT_SOURCE_EXTRA="+refs/pull/${TRAVIS_PULL_REQUEST}/merge"; + else + GIT_TARGET_EXTRA=""; + GIT_SOURCE_EXTRA=""; + fi; + + # to aid in debugging + - echo ${TRAVIS_BRANCH} + - echo ${TRAVIS_REPO_SLUG} + - echo ${GIT_TARGET_EXTRA} + - echo ${GIT_SOURCE_EXTRA} + +install: + # Dependencies + - if [ "${PYTHON}" == "2.7" ]; then + travis_retry sudo apt-get -qq -y install python-numpy && + export PIP=pip && + sudo ${PIP} install pytest && + sudo ${PIP} install flake8 && + export PYTEST=py.test; + else + travis_retry sudo apt-get -qq -y install python3-numpy && + curl http://python-distribute.org/distribute_setup.py | sudo python3 && + curl https://raw.github.com/pypa/pip/master/contrib/get-pip.py | sudo python3 && + export PIP=pip3.2 && + sudo ${PIP} install pytest && + sudo ${PIP} install flake8 && + export PYTEST=py.test-3.2; + fi; + + # Qt + - if [ "${PYTHON}" == "2.7" ]; then + if [ ${QT} == 'pyqt' ]; then + travis_retry sudo apt-get -qq -y install python-qt4 python-qt4-gl; + else + travis_retry sudo apt-get -qq -y install python-pyside.qtcore python-pyside.qtgui python-pyside.qtsvg python-pyside.qtopengl; + fi; + elif [ "${PYTHON}" == "3.2" ]; then + if [ ${QT} == 'pyqt' ]; then + travis_retry sudo apt-get -qq -y install python3-pyqt4; + elif [ ${QT} == 'pyside' ]; then + travis_retry sudo apt-get -qq -y install python3-pyside; + else + ${PIP} search PyQt5; + ${PIP} install PyQt5; + cat /home/travis/.pip/pip.log; + fi; + else + conda create -n testenv --yes --quiet pip python=$PYTHON && + source activate testenv && + if [ ${QT} == 'pyqt' ]; then + conda install --yes --quiet pyside; + else + conda install --yes --quiet pyside; + fi; + fi; + + # Install PyOpenGL + - if [ "${PYTHON}" == "2.7" ]; then + echo "Using OpenGL stable version (apt)"; + travis_retry sudo apt-get -qq -y install python-opengl; + else + echo "Using OpenGL stable version (pip)"; + ${PIP} install -q PyOpenGL; + cat /home/travis/.pip/pip.log; + fi; + + + # Debugging helpers + - uname -a + - cat /etc/issue + - if [ "${PYTHON}" == "2.7" ]; then + python --version; + else + python3 --version; + fi; + - apt-cache search python3-pyqt + - apt-cache search python3-pyside + - apt-cache search pytest + - apt-cache search python pip + - apt-cache search python qt5 + + +before_script: + # We need to create a (fake) display on Travis, let's use a funny resolution + - export DISPLAY=:99.0 + - /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render + + # Make sure everyone uses the correct python + - mkdir ~/bin && ln -s `which python${PYTHON}` ~/bin/python + - export PATH=/home/travis/bin:$PATH + - which python + - python --version + # Help color output from each test + - RESET='\033[0m'; + RED='\033[00;31m'; + GREEN='\033[00;32m'; + YELLOW='\033[00;33m'; + BLUE='\033[00;34m'; + PURPLE='\033[00;35m'; + CYAN='\033[00;36m'; + WHITE='\033[00;37m'; + start_test() { + echo -e "${BLUE}======== Starting $1 ========${RESET}"; + }; + check_output() { + ret=$?; + if [ $ret == 0 ]; then + echo -e "${GREEN}>>>>>> $1 passed <<<<<<${RESET}"; + else + echo -e "${RED}>>>>>> $1 FAILED <<<<<<${RESET}"; + fi; + return $ret; + }; + + - if [ "${TEST}" == "extra" ]; then + start_test "repo size check"; + mkdir ~/repo-clone && cd ~/repo-clone && + git init && git remote add -t ${TRAVIS_BRANCH} origin git://github.com/${TRAVIS_REPO_SLUG}.git && + git fetch origin ${GIT_TARGET_EXTRA} && + git checkout -qf FETCH_HEAD && + git tag travis-merge-target && + git gc --aggressive && + TARGET_SIZE=`du -s . | sed -e "s/\t.*//"` && + git pull origin ${GIT_SOURCE_EXTRA} && + git gc --aggressive && + MERGE_SIZE=`du -s . | sed -e "s/\t.*//"` && + if [ "${MERGE_SIZE}" != "${TARGET_SIZE}" ]; then + SIZE_DIFF=`expr \( ${MERGE_SIZE} - ${TARGET_SIZE} \)`; + else + SIZE_DIFF=0; + fi; + fi; + + - cd $TRAVIS_DIR + + +script: + + # Run unit tests + - start_test "unit tests"; + PYTHONPATH=. ${PYTEST} pyqtgraph/; + check_output "unit tests"; + + + # check line endings + - if [ "${TEST}" == "extra" ]; then + start_test "line ending check"; + ! find ./ -name "*.py" | xargs file | grep CRLF && + ! find ./ -name "*.rst" | xargs file | grep CRLF; + check_output "line ending check"; + fi; + + # Check repo size does not expand too much + - if [ "${TEST}" == "extra" ]; then + start_test "repo size check"; + echo -e "Estimated content size difference = ${SIZE_DIFF} kB" && + test ${SIZE_DIFF} -lt 100; + check_output "repo size check"; + fi; + + # Check for style issues + - if [ "${TEST}" == "extra" ]; then + start_test "style check"; + cd ~/repo-clone && + git reset -q travis-merge-target && + python setup.py style && + check_output "style check"; + fi; + + - cd $TRAVIS_DIR + + # Check install works + - start_test "install test"; + sudo python${PYTHON} setup.py --quiet install; + check_output "install test"; + + # Check double-install fails + # Note the bash -c is because travis strips off the ! otherwise. + - start_test "double install test"; + bash -c "! sudo python${PYTHON} setup.py --quiet install"; + check_output "double install test"; + + # Check we can import pg + - start_test "import test"; + echo "import sys; print(sys.path)" | python && + cd /; echo "import pyqtgraph.examples" | python; + check_output "import test"; + + diff --git a/CHANGELOG b/CHANGELOG index 9fa10984..09489523 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,105 @@ +pyqtgraph-0.9.9 + + API / behavior changes: + - Dynamic import system abandoned; pg now uses static imports throughout. + - Flowcharts and exporters have new pluggin systems + - Version strings: + - __init__.py in git repo now contains latest release version string + (previously, only packaged releases had version strings). + - installing from git checkout that does not correspond to a release + commit will result in a more descriptive version string. + - Speed improvements in functions.makeARGB + - ImageItem is faster by avoiding makeQImage(transpose=True) + - ComboBox will raise error when adding multiple items of the same name + - ArrowItem.setStyle now updates style options rather than replacing them + - Renamed GraphicsView signals to avoid collision with ViewBox signals that + are wrapped in PlotWidget: sigRangeChanged => sigDeviceRangeChanged and + sigTransformChanged => sigDeviceTransformChanged. + - GLViewWidget.itemsAt() now measures y from top of widget to match mouse + event position. + - Made setPen() methods consistent throughout the package + - Fix in GLScatterPlotItem requires that points will appear slightly more opaque + (so you may need to adjust to lower alpha to achieve the same results) + + New Features: + - Added ViewBox.setLimits() method + - Adde ImageItem downsampling + - New HDF5 example for working with very large datasets + - Removed all dependency on scipy + - Added Qt.loadUiType function for PySide + - Simplified Profilers; can be activated with environmental variables + - Added Dock.raiseDock() method + - ComboBox updates: + - Essentially a graphical interface to dict; all items have text and value + - Assigns previously-selected text after list is cleared and repopulated + - Get, set current value + - Flowchart updates + - Added Flowchart.sigChartChanged + - Custom nodes may now be registered in sub-menu trees + - ImageItem.getHistogram is more clever about constructing histograms + - Added FillBetweenItem.setCurves() + - MultiPlotWidget now has setMinimumPlotHeight method and displays scroll bar + when plots do not fit inside the widget. + - Added BarGraphItem.shape() to allow better mouse interaction + - Added MeshData.cylinder + - Added ViewBox.setBackgroundColor() and GLViewWidget.setBackgroundColor() + - Utilities / debugging tools + - Mutex used for tracing deadlocks + - Color output on terminal + - Multiprocess debugging colors messages by process + - Stdout filter that colors text by thread + - PeriodicTrace used to report deadlocks + - Added AxisItem.setStyle() + - Added configurable formatting for TableWidget + - Added 'stepMode' argument to PlotDataItem() + - Added ViewBox.invertX() + - Docks now have optional close button + - Added InfiniteLine.setHoverPen + - Added GLVolumeItem.setData + - Added PolyLineROI.setPoints, clearPoints, saveState, setState + - Added ErrorBarItem.setData + + Bugfixes: + - PlotCurveItem now has correct clicking behavior--clicks within a few px + of the line will trigger a signal. + - Fixes related to CSV exporter: + - CSV headers include data names, if available + - Exporter correctly handles items with no data + - pg.plot() avoids creating empty data item + - removed call to reduce() from exporter; not available in python 3 + - Gave .name() methods to PlotDataItem, PlotCurveItem, and ScatterPlotItem + - fixed ImageItem handling of rgb images + - fixed makeARGB re-ordering of color channels + - fixed unicode usage in AxisItem tick strings + - fixed PlotCurveItem generating exceptions when data has length=0 + - fixed ImageView.setImage only working once + - PolyLineROI.setPen() now changes the pen of its segments as well + - Prevent divide-by-zero in AxisItem + - Major speedup when using ScatterPlotItem in pxMode + - PlotCurveItem ignores clip-to-view when auto range is enabled + - FillBetweenItem now forces PlotCurveItem to generate path + - Fixed import errors and py3 issues in MultiPlotWidget + - Isosurface works for arrays with shapes > 255 + - Fixed ImageItem exception building histogram when image has only one value + - Fixed MeshData exception caused when vertexes have no matching faces + - Fixed GLViewWidget exception handler + - Fixed unicode support in Dock + - Fixed PySide crash caused by emitting signal from GraphicsObject.itemChange + - Fixed possible infinite loop from FiniteCache + - Allow images with NaN in ImageView + - MeshData can generate edges from face-indexed vertexes + - Fixed multiprocess deadlocks on windows + - Fixed GLGridItem.setSize + - Fixed parametertree.Parameter.sigValueChanging + - Fixed AxisItem.__init__(showValues=False) + - Fixed TableWidget append / sort issues + - Fixed AxisItem not resizing text area when setTicks() is used + - Removed a few cyclic references + - Fixed Parameter 'readonly' option for bool, color, and text parameter types + - Fixed alpha on GLScatterPlotItem spots (formerly maxed out at alpha=200) + - Fixed a few bugs causing exit crashes + + pyqtgraph-0.9.8 2013-11-24 API / behavior changes: diff --git a/CONTRIBUTING.txt b/CONTRIBUTING.txt new file mode 100644 index 00000000..0b4b1beb --- /dev/null +++ b/CONTRIBUTING.txt @@ -0,0 +1,51 @@ +Contributions to pyqtgraph are welcome! + +Please use the following guidelines when preparing changes: + +* The preferred method for submitting changes is by github pull request + against the "develop" branch. If this is inconvenient, don't hesitate to + submit by other means. + +* Pull requests should include only a focused and related set of changes. + Mixed features and unrelated changes (such as .gitignore) will usually be + rejected. + +* For major changes, it is recommended to discuss your plans on the mailing + list or in a github issue before putting in too much effort. + + * Along these lines, please note that pyqtgraph.opengl will be deprecated + soon and replaced with VisPy. + +* Writing proper documentation and unit tests is highly encouraged. PyQtGraph + uses nose / py.test style testing, so tests should usually be included in a + tests/ directory adjacent to the relevant code. + +* Documentation is generated with sphinx; please check that docstring changes + compile correctly. + +* Style guidelines: + + * PyQtGraph prefers PEP8 for most style issues, but this is not enforced + rigorously as long as the code is clean and readable. + + * Use `python setup.py style` to see whether your code follows + the mandatory style guidelines checked by flake8. + + * Exception 1: All variable names should use camelCase rather than + underscore_separation. This is done for consistency with Qt + + * Exception 2: Function docstrings use ReStructuredText tables for + describing arguments: + + ``` + ============== ======================================================== + **Arguments:** + argName1 (type) Description of argument + argName2 (type) Description of argument. Longer descriptions must + be wrapped within the column guidelines defined by the + "====" header and footer. + ============== ======================================================== + ``` + + QObject subclasses that implement new signals should also describe + these in a similar table. diff --git a/MANIFEST.in b/MANIFEST.in index 02d67f6f..86ae0f60 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,9 @@ recursive-include pyqtgraph *.py *.ui *.m README *.txt recursive-include tests *.py *.ui -recursive-include examples *.py *.ui +recursive-include examples *.py *.ui *.gz *.cfg recursive-include doc *.rst *.py *.svg *.png *.jpg recursive-include doc/build/html * recursive-include tools * -include doc/Makefile doc/make.bat README.txt LICENSE.txt +include doc/Makefile doc/make.bat README.md LICENSE.txt CHANGELOG +global-exclude *.pyc diff --git a/README.md b/README.md index 23f47ea7..83327089 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Copyright 2012 Luke Campagnola, University of North Carolina at Chapel Hill Maintainer ---------- - * Luke Campagnola ('luke.campagnola@%s.com' % 'gmail') + * Luke Campagnola Contributors ------------ @@ -23,14 +23,24 @@ Contributors * Ulrich Leutner * Felix Schill * Guillaume Poulin + * Antony Lee + * Mattias Põldaru + * Thomas S. + * Fabio Zadrozny + * Mikhail Terekhov + * Pietro Zambelli + * Stefan Holzmann + * Nicholas TJ + * John David Reaver + * David Kaplan Requirements ------------ * PyQt 4.7+ or PySide * python 2.6, 2.7, or 3.x - * numpy, scipy - * For 3D graphics: pyopengl + * NumPy + * For 3D graphics: pyopengl and qt-opengl * Known to run on Windows, Linux, and Mac. Support @@ -42,10 +52,14 @@ Installation Methods -------------------- * To use with a specific project, simply copy the pyqtgraph subdirectory - anywhere that is importable from your project + anywhere that is importable from your project. PyQtGraph may also be + used as a git subtree by cloning the git-core repository from github. * To install system-wide from source distribution: `$ python setup.py install` - * For instalation packages, see the website (pyqtgraph.org) + * For installation packages, see the website (pyqtgraph.org) + * On debian-like systems, pyqtgraph requires the following packages: + python-numpy, python-qt4 | python-pyside + For 3D support: python-opengl, python-qt4-gl | python-pyside.qtopengl Documentation ------------- @@ -61,4 +75,4 @@ Some (incomplete) documentation exists at this time. `$ make html` Please feel free to pester Luke or post to the forum if you need a specific - section of documentation. + section of documentation to be expanded. diff --git a/doc/source/3dgraphics.rst b/doc/source/3dgraphics.rst index effa288d..0a0a0210 100644 --- a/doc/source/3dgraphics.rst +++ b/doc/source/3dgraphics.rst @@ -1,7 +1,7 @@ 3D Graphics =========== -Pyqtgraph uses OpenGL to provide a 3D scenegraph system. This system is functional but still early in development. +PyQtGraph uses OpenGL to provide a 3D scenegraph system. This system is functional but still early in development. Current capabilities include: * 3D view widget with zoom/rotate controls (mouse drag and wheel) diff --git a/doc/source/3dgraphics/index.rst b/doc/source/3dgraphics/index.rst index d025a4c7..08202d31 100644 --- a/doc/source/3dgraphics/index.rst +++ b/doc/source/3dgraphics/index.rst @@ -1,11 +1,14 @@ -Pyqtgraph's 3D Graphics System +PyQtGraph's 3D Graphics System ============================== The 3D graphics system in pyqtgraph is composed of a :class:`view widget ` and several graphics items (all subclasses of :class:`GLGraphicsItem `) which can be added to a view widget. -**Note:** use of this system requires python-opengl bindings. Linux users should install the python-opengl +**Note 1:** pyqtgraph.opengl is based on the deprecated OpenGL fixed-function pipeline. Although it is +currently a functioning system, it is likely to be superceded in the future by `VisPy `_. + +**Note 2:** use of this system requires python-opengl bindings. Linux users should install the python-opengl packages from their distribution. Windows/OSX users can download from ``_. Contents: diff --git a/doc/source/conf.py b/doc/source/conf.py index 5475fc60..604ea549 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -50,9 +50,9 @@ copyright = '2011, Luke Campagnola' # built documents. # # The short X.Y version. -version = '' +version = '0.9.9' # The full version, including alpha/beta/rc tags. -release = '' +release = '0.9.9' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/doc/source/exporting.rst b/doc/source/exporting.rst index 137e6584..ccd017d7 100644 --- a/doc/source/exporting.rst +++ b/doc/source/exporting.rst @@ -39,13 +39,14 @@ Exporting from the API To export a file programatically, follow this example:: import pyqtgraph as pg + import pyqtgraph.exporters # generate something to export plt = pg.plot([1,5,2,4,3]) # create an exporter instance, as an argument give it # the item you wish to export - exporter = pg.exporters.ImageExporter.ImageExporter(plt.plotItem) + exporter = pg.exporters.ImageExporter(plt.plotItem) # set export parameters if needed exporter.parameters()['width'] = 100 # (note this also affects height parameter) diff --git a/doc/source/functions.rst b/doc/source/functions.rst index 556c5be0..5d328ad9 100644 --- a/doc/source/functions.rst +++ b/doc/source/functions.rst @@ -1,4 +1,4 @@ -Pyqtgraph's Helper Functions +PyQtGraph's Helper Functions ============================ Simple Data Display Functions @@ -13,7 +13,7 @@ Simple Data Display Functions Color, Pen, and Brush Functions ------------------------------- -Qt uses the classes QColor, QPen, and QBrush to determine how to draw lines and fill shapes. These classes are highly capable but somewhat awkward to use. Pyqtgraph offers the functions :func:`~pyqtgraph.mkColor`, :func:`~pyqtgraph.mkPen`, and :func:`~pyqtgraph.mkBrush` to simplify the process of creating these classes. In most cases, however, it will be unnecessary to call these functions directly--any function or method that accepts *pen* or *brush* arguments will make use of these functions for you. For example, the following three lines all have the same effect:: +Qt uses the classes QColor, QPen, and QBrush to determine how to draw lines and fill shapes. These classes are highly capable but somewhat awkward to use. PyQtGraph offers the functions :func:`~pyqtgraph.mkColor`, :func:`~pyqtgraph.mkPen`, and :func:`~pyqtgraph.mkBrush` to simplify the process of creating these classes. In most cases, however, it will be unnecessary to call these functions directly--any function or method that accepts *pen* or *brush* arguments will make use of these functions for you. For example, the following three lines all have the same effect:: pg.plot(xdata, ydata, pen='r') pg.plot(xdata, ydata, pen=pg.mkPen('r')) diff --git a/doc/source/graphicsItems/errorbaritem.rst b/doc/source/graphicsItems/errorbaritem.rst new file mode 100644 index 00000000..be68a5dd --- /dev/null +++ b/doc/source/graphicsItems/errorbaritem.rst @@ -0,0 +1,8 @@ +ErrorBarItem +============ + +.. autoclass:: pyqtgraph.ErrorBarItem + :members: + + .. automethod:: pyqtgraph.ErrorBarItem.__init__ + diff --git a/doc/source/graphicsItems/index.rst b/doc/source/graphicsItems/index.rst index b15c205c..7042d27e 100644 --- a/doc/source/graphicsItems/index.rst +++ b/doc/source/graphicsItems/index.rst @@ -1,4 +1,4 @@ -Pyqtgraph's Graphics Items +PyQtGraph's Graphics Items ========================== Since pyqtgraph relies on Qt's GraphicsView framework, most of its graphics functionality is implemented as QGraphicsItem subclasses. This has two important consequences: 1) virtually anything you want to draw can be easily accomplished using the functionality provided by Qt. 2) Many of pyqtgraph's GraphicsItem classes can be used in any normal QGraphicsScene. @@ -23,6 +23,7 @@ Contents: isocurveitem axisitem textitem + errorbaritem arrowitem fillbetweenitem curvepoint diff --git a/doc/source/graphicsItems/roi.rst b/doc/source/graphicsItems/roi.rst index 22945ade..f4f4346d 100644 --- a/doc/source/graphicsItems/roi.rst +++ b/doc/source/graphicsItems/roi.rst @@ -4,5 +4,25 @@ ROI .. autoclass:: pyqtgraph.ROI :members: - .. automethod:: pyqtgraph.ROI.__init__ +.. autoclass:: pyqtgraph.RectROI + :members: + +.. autoclass:: pyqtgraph.EllipseROI + :members: + +.. autoclass:: pyqtgraph.CircleROI + :members: + +.. autoclass:: pyqtgraph.LineSegmentROI + :members: + +.. autoclass:: pyqtgraph.PolyLineROI + :members: + +.. autoclass:: pyqtgraph.LineROI + :members: + +.. autoclass:: pyqtgraph.MultiRectROI + :members: + diff --git a/doc/source/how_to_use.rst b/doc/source/how_to_use.rst index 0e00af59..e4424374 100644 --- a/doc/source/how_to_use.rst +++ b/doc/source/how_to_use.rst @@ -12,7 +12,7 @@ There are a few suggested ways to use pyqtgraph: Command-line use ---------------- -Pyqtgraph makes it very easy to visualize data from the command line. Observe:: +PyQtGraph makes it very easy to visualize data from the command line. Observe:: import pyqtgraph as pg pg.plot(data) # data can be a list of values or a numpy array @@ -43,7 +43,7 @@ While I consider this approach somewhat lazy, it is often the case that 'lazy' i Embedding widgets inside PyQt applications ------------------------------------------ -For the serious application developer, all of the functionality in pyqtgraph is available via :ref:`widgets ` that can be embedded just like any other Qt widgets. Most importantly, see: :class:`PlotWidget `, :class:`ImageView `, :class:`GraphicsLayoutWidget `, and :class:`GraphicsView `. Pyqtgraph's widgets can be included in Designer's ui files via the "Promote To..." functionality: +For the serious application developer, all of the functionality in pyqtgraph is available via :ref:`widgets ` that can be embedded just like any other Qt widgets. Most importantly, see: :class:`PlotWidget `, :class:`ImageView `, :class:`GraphicsLayoutWidget `, and :class:`GraphicsView `. PyQtGraph's widgets can be included in Designer's ui files via the "Promote To..." functionality: #. In Designer, create a QGraphicsView widget ("Graphics View" under the "Display Widgets" category). #. Right-click on the QGraphicsView and select "Promote To...". @@ -51,13 +51,13 @@ For the serious application developer, all of the functionality in pyqtgraph is #. Under "Header file", enter "pyqtgraph". #. Click "Add", then click "Promote". -See the designer documentation for more information on promoting widgets. +See the designer documentation for more information on promoting widgets. The "VideoSpeedTest" and "ScatterPlotSpeedTest" examples both demonstrate the use of .ui files that are compiled to .py modules using pyuic4 or pyside-uic. The "designerExample" example demonstrates dynamically generating python classes from .ui files (no pyuic4 / pyside-uic needed). PyQt and PySide --------------- -Pyqtgraph supports two popular python wrappers for the Qt library: PyQt and PySide. Both packages provide nearly identical +PyQtGraph supports two popular python wrappers for the Qt library: PyQt and PySide. Both packages provide nearly identical APIs and functionality, but for various reasons (discussed elsewhere) you may prefer to use one package or the other. When pyqtgraph is first imported, it automatically determines which library to use by making the fillowing checks: @@ -71,3 +71,53 @@ make sure it is imported before pyqtgraph:: import PySide ## this will force pyqtgraph to use PySide instead of PyQt4 import pyqtgraph as pg + + +Embedding PyQtGraph as a sub-package of a larger project +-------------------------------------------------------- + +When writing applications or python packages that make use of pyqtgraph, it is most common to install pyqtgraph system-wide (or within a virtualenv) and simply call `import pyqtgraph` from within your application. The main benefit to this is that pyqtgraph is configured independently of your application and thus you (or your users) are free to install newer versions of pyqtgraph without changing anything in your application. This is standard practice when developing with python. + +However, it is also often the case, especially for scientific applications, that software is written for a very specific purpose and then archived. If we want to ensure that the software will still work ten years later, then it is preferrable to tie the application to a very specific version of pyqtgraph and *avoid* importing the system-installed version of pyqtgraph, which may be much newer (and potentially incompatible). This is especially the case when the application requires site-specific modifications to the pyqtgraph package which may not be present in the main releases. + +PyQtGraph facilitates this usage through two mechanisms. First, all internal import statements in pyqtgraph are relative, which allows the package to be renamed or used as a sub-package without any naming conflicts with other versions of pyqtgraph on the system (that is, pyqtgraph never refers to itself internally as 'pyqtgraph'). Second, a git subtree repository is available at https://github.com/pyqtgraph/pyqtgraph-core.git that contains only the 'pyqtgraph/' subtree, allowing the code to be cloned directly as a subtree of the application which uses it. + +The basic approach is to clone the repository into the appropriate location in your package. When you import pyqtgraph from within your package, be sure to use the full name to avoid importing any system-installed pyqtgraph packages. For example, imagine a simple project has the following structure:: + + my_project/ + __init__.py + plotting.py + """Plotting functions used by this package""" + import pyqtgraph as pg + def my_plot_function(*data): + pg.plot(*data) + +To embed a specific version of pyqtgraph, we would clone the pyqtgraph-core repository inside the project:: + + my_project$ git clone https://github.com/pyqtgraph/pyqtgraph-core.git + +Then adjust the import statements accordingly:: + + my_project/ + __init__.py + pyqtgraph/ + plotting.py + """Plotting functions used by this package""" + import my_project.pyqtgraph as pg # be sure to use the local subpackage + # rather than any globally-installed + # versions. + def my_plot_function(*data): + pg.plot(*data) + +Use ``git checkout pyqtgraph-core-x.x.x`` to select a specific version of the repository, or use ``git pull`` to pull pyqtgraph updates from upstream (see the git documentation for more information). + +For projects that already use git for code control, it is also possible to include pyqtgraph as a git subtree within your own repository. The major advantage to this approach is that, in addition to being able to pull pyqtgraph updates from the upstream repository, it is also possible to commit your local pyqtgraph changes into the project repository and push those changes upstream:: + + my_project$ git remote add pyqtgraph-core https://github.com/pyqtgraph/pyqtgraph-core.git + my_project$ git fetch pyqtgraph-core + my_project$ git merge -s ours --no-commit pyqtgraph-core/core + my_project$ mkdir pyqtgraph + my_project$ git read-tree -u --prefix=pyqtgraph/ pyqtgraph-core/core + my_project$ git commit -m "Added pyqtgraph to project repository" + +See the ``git subtree`` documentation for more information. diff --git a/doc/source/images.rst b/doc/source/images.rst index 00d45650..0a4ac147 100644 --- a/doc/source/images.rst +++ b/doc/source/images.rst @@ -1,7 +1,7 @@ Displaying images and video =========================== -Pyqtgraph displays 2D numpy arrays as images and provides tools for determining how to translate between the numpy data type and RGB values on the screen. If you want to display data from common image and video file formats, you will need to load the data first using another library (PIL works well for images and built-in numpy conversion). +PyQtGraph displays 2D numpy arrays as images and provides tools for determining how to translate between the numpy data type and RGB values on the screen. If you want to display data from common image and video file formats, you will need to load the data first using another library (PIL works well for images and built-in numpy conversion). The easiest way to display 2D or 3D data is using the :func:`pyqtgraph.image` function:: diff --git a/doc/source/images/plottingClasses.png b/doc/source/images/plottingClasses.png index 7c8325a5..3f968f50 100644 Binary files a/doc/source/images/plottingClasses.png and b/doc/source/images/plottingClasses.png differ diff --git a/doc/source/images/plottingClasses.svg b/doc/source/images/plottingClasses.svg index 393d16d7..9d9cd902 100644 --- a/doc/source/images/plottingClasses.svg +++ b/doc/source/images/plottingClasses.svg @@ -13,7 +13,7 @@ height="268.51233" id="svg2" version="1.1" - inkscape:version="0.48.1 r9760" + inkscape:version="0.48.4 r9939" sodipodi:docname="plottingClasses.svg" inkscape:export-filename="/home/luke/work/manis_lab/code/pyqtgraph/documentation/source/images/plottingClasses.png" inkscape:export-xdpi="124.99" @@ -50,12 +50,12 @@ inkscape:cx="383.64946" inkscape:cy="21.059243" inkscape:document-units="px" - inkscape:current-layer="g3978" + inkscape:current-layer="g3891" showgrid="false" - inkscape:window-width="1400" + inkscape:window-width="1918" inkscape:window-height="1030" - inkscape:window-x="-3" - inkscape:window-y="-3" + inkscape:window-x="1" + inkscape:window-y="0" inkscape:window-maximized="1" fit-margin-top="0" fit-margin-left="0" @@ -69,7 +69,7 @@ image/svg+xml - + @@ -345,7 +345,7 @@ id="tspan3897" x="124.24876" y="376.57013" - style="font-size:18px">GraphicsLayoutItem(GraphicsItem) + style="font-size:18px">GraphicsLayout(GraphicsItem) ` Add a new set of data to an existing plot widget -:func:`PlotItem.plot() ` Add a new set of data to an existing plot widget -:func:`GraphicsWindow.addPlot() ` Add a new plot to a grid of plots -================================================================ ================================================== +=================================================================== ================================================== +:func:`pyqtgraph.plot` Create a new plot window showing your data +:func:`PlotWidget.plot() ` Add a new set of data to an existing plot widget +:func:`PlotItem.plot() ` Add a new set of data to an existing plot widget +:func:`GraphicsLayout.addPlot() ` Add a new plot to a grid of plots +=================================================================== ================================================== All of these will accept the same basic arguments which control how the plot data is interpreted and displayed: @@ -28,20 +28,20 @@ All of the above functions also return handles to the objects that are created, Organization of Plotting Classes -------------------------------- -There are several classes invloved in displaying plot data. Most of these classes are instantiated automatically, but it is useful to understand how they are organized and relate to each other. Pyqtgraph is based heavily on Qt's GraphicsView framework--if you are not already familiar with this, it's worth reading about (but not essential). Most importantly: 1) Qt GUIs are composed of QWidgets, 2) A special widget called QGraphicsView is used for displaying complex graphics, and 3) QGraphicsItems define the objects that are displayed within a QGraphicsView. +There are several classes invloved in displaying plot data. Most of these classes are instantiated automatically, but it is useful to understand how they are organized and relate to each other. PyQtGraph is based heavily on Qt's GraphicsView framework--if you are not already familiar with this, it's worth reading about (but not essential). Most importantly: 1) Qt GUIs are composed of QWidgets, 2) A special widget called QGraphicsView is used for displaying complex graphics, and 3) QGraphicsItems define the objects that are displayed within a QGraphicsView. * Data Classes (all subclasses of QGraphicsItem) - * PlotCurveItem - Displays a plot line given x,y data - * ScatterPlotItem - Displays points given x,y data - * :class:`PlotDataItem ` - Combines PlotCurveItem and ScatterPlotItem. The plotting functions discussed above create objects of this type. + * :class:`PlotCurveItem ` - Displays a plot line given x,y data + * :class:`ScatterPlotItem ` - Displays points given x,y data + * :class:`PlotDataItem ` - Combines PlotCurveItem and ScatterPlotItem. The plotting functions discussed above create objects of this type. * Container Classes (subclasses of QGraphicsItem; contain other QGraphicsItem objects and must be viewed from within a GraphicsView) - * PlotItem - Contains a ViewBox for displaying data as well as AxisItems and labels for displaying the axes and title. This is a QGraphicsItem subclass and thus may only be used from within a GraphicsView - * GraphicsLayoutItem - QGraphicsItem subclass which displays a grid of items. This is used to display multiple PlotItems together. - * ViewBox - A QGraphicsItem subclass for displaying data. The user may scale/pan the contents of a ViewBox using the mouse. Typically all PlotData/PlotCurve/ScatterPlotItems are displayed from within a ViewBox. - * AxisItem - Displays axis values, ticks, and labels. Most commonly used with PlotItem. + * :class:`PlotItem ` - Contains a ViewBox for displaying data as well as AxisItems and labels for displaying the axes and title. This is a QGraphicsItem subclass and thus may only be used from within a GraphicsView + * :class:`GraphicsLayout ` - QGraphicsItem subclass which displays a grid of items. This is used to display multiple PlotItems together. + * :class:`ViewBox ` - A QGraphicsItem subclass for displaying data. The user may scale/pan the contents of a ViewBox using the mouse. Typically all PlotData/PlotCurve/ScatterPlotItems are displayed from within a ViewBox. + * :class:`AxisItem ` - Displays axis values, ticks, and labels. Most commonly used with PlotItem. * Container Classes (subclasses of QWidget; may be embedded in PyQt GUIs) - * PlotWidget - A subclass of GraphicsView with a single PlotItem displayed. Most of the methods provided by PlotItem are also available through PlotWidget. - * GraphicsLayoutWidget - QWidget subclass displaying a single GraphicsLayoutItem. Most of the methods provided by GraphicsLayoutItem are also available through GraphicsLayoutWidget. + * :class:`PlotWidget ` - A subclass of GraphicsView with a single PlotItem displayed. Most of the methods provided by PlotItem are also available through PlotWidget. + * :class:`GraphicsLayoutWidget ` - QWidget subclass displaying a single GraphicsLayoutItem. Most of the methods provided by GraphicsLayoutItem are also available through GraphicsLayoutWidget. .. image:: images/plottingClasses.png diff --git a/doc/source/prototyping.rst b/doc/source/prototyping.rst index e8dffb66..71dcd4ce 100644 --- a/doc/source/prototyping.rst +++ b/doc/source/prototyping.rst @@ -3,7 +3,7 @@ Rapid GUI prototyping [Just an overview; documentation is not complete yet] -Pyqtgraph offers several powerful features which are commonly used in engineering and scientific applications. +PyQtGraph offers several powerful features which are commonly used in engineering and scientific applications. Parameter Trees --------------- @@ -16,7 +16,7 @@ See the `parametertree documentation `_ for more information. Visual Programming Flowcharts ----------------------------- -Pyqtgraph's flowcharts provide a visual programming environment similar in concept to LabView--functional modules are added to a flowchart and connected by wires to define a more complex and arbitrarily configurable algorithm. A small number of predefined modules (called Nodes) are included with pyqtgraph, but most flowchart developers will want to define their own library of Nodes. At their core, the Nodes are little more than 1) a Python function 2) a list of input/output terminals, and 3) an optional widget providing a control panel for the Node. Nodes may transmit/receive any type of Python object via their terminals. +PyQtGraph's flowcharts provide a visual programming environment similar in concept to LabView--functional modules are added to a flowchart and connected by wires to define a more complex and arbitrarily configurable algorithm. A small number of predefined modules (called Nodes) are included with pyqtgraph, but most flowchart developers will want to define their own library of Nodes. At their core, the Nodes are little more than 1) a Python function 2) a list of input/output terminals, and 3) an optional widget providing a control panel for the Node. Nodes may transmit/receive any type of Python object via their terminals. See the `flowchart documentation `_ and the flowchart examples for more information. @@ -30,6 +30,6 @@ The Canvas is a system designed to allow the user to add/remove items to a 2D ca Dockable Widgets ---------------- -The dockarea system allows the design of user interfaces which can be rearranged by the user at runtime. Docks can be moved, resized, stacked, and torn out of the main window. This is similar in principle to the docking system built into Qt, but offers a more deterministic dock placement API (in Qt it is very difficult to programatically generate complex dock arrangements). Additionally, Qt's docks are designed to be used as small panels around the outer edge of a window. Pyqtgraph's docks were created with the notion that the entire window (or any portion of it) would consist of dockable components. +The dockarea system allows the design of user interfaces which can be rearranged by the user at runtime. Docks can be moved, resized, stacked, and torn out of the main window. This is similar in principle to the docking system built into Qt, but offers a more deterministic dock placement API (in Qt it is very difficult to programatically generate complex dock arrangements). Additionally, Qt's docks are designed to be used as small panels around the outer edge of a window. PyQtGraph's docks were created with the notion that the entire window (or any portion of it) would consist of dockable components. diff --git a/doc/source/qtcrashcourse.rst b/doc/source/qtcrashcourse.rst index f117bb7f..083a78ee 100644 --- a/doc/source/qtcrashcourse.rst +++ b/doc/source/qtcrashcourse.rst @@ -1,7 +1,7 @@ Qt Crash Course =============== -Pyqtgraph makes extensive use of Qt for generating nearly all of its visual output and interfaces. Qt's documentation is very well written and we encourage all pyqtgraph developers to familiarize themselves with it. The purpose of this section is to provide an introduction to programming with Qt (using either PyQt or PySide) for the pyqtgraph developer. +PyQtGraph makes extensive use of Qt for generating nearly all of its visual output and interfaces. Qt's documentation is very well written and we encourage all pyqtgraph developers to familiarize themselves with it. The purpose of this section is to provide an introduction to programming with Qt (using either PyQt or PySide) for the pyqtgraph developer. QWidgets and Layouts -------------------- @@ -12,7 +12,7 @@ A Qt GUI is almost always composed of a few basic components: * Multiple QWidget instances such as QPushButton, QLabel, QComboBox, etc. * QLayout instances (optional, but strongly encouraged) which automatically manage the positioning of widgets to allow the GUI to resize in a usable way. -Pyqtgraph fits into this scheme by providing its own QWidget subclasses to be inserted into your GUI. +PyQtGraph fits into this scheme by providing its own QWidget subclasses to be inserted into your GUI. Example:: diff --git a/doc/source/region_of_interest.rst b/doc/source/region_of_interest.rst index eda9cacc..e972cae8 100644 --- a/doc/source/region_of_interest.rst +++ b/doc/source/region_of_interest.rst @@ -1,7 +1,7 @@ Interactive Data Selection Controls =================================== -Pyqtgraph includes graphics items which allow the user to select and mark regions of data. +PyQtGraph includes graphics items which allow the user to select and mark regions of data. Linear Selection and Marking ---------------------------- diff --git a/doc/source/widgets/consolewidget.rst b/doc/source/widgets/consolewidget.rst new file mode 100644 index 00000000..a85327f9 --- /dev/null +++ b/doc/source/widgets/consolewidget.rst @@ -0,0 +1,6 @@ +ConsoleWidget +============= + +.. autoclass:: pyqtgraph.console.ConsoleWidget + :members: + \ No newline at end of file diff --git a/doc/source/widgets/index.rst b/doc/source/widgets/index.rst index 7e6973a2..9cfbc0c4 100644 --- a/doc/source/widgets/index.rst +++ b/doc/source/widgets/index.rst @@ -1,9 +1,9 @@ .. _api_widgets: -Pyqtgraph's Widgets +PyQtGraph's Widgets =================== -Pyqtgraph provides several QWidget subclasses which are useful for building user interfaces. These widgets can generally be used in any Qt application and provide functionality that is frequently useful in science and engineering applications. +PyQtGraph provides several QWidget subclasses which are useful for building user interfaces. These widgets can generally be used in any Qt application and provide functionality that is frequently useful in science and engineering applications. Contents: @@ -17,10 +17,10 @@ Contents: gradientwidget histogramlutwidget parametertree + consolewidget colormapwidget scatterplotwidget graphicsview - rawimagewidget datatreewidget tablewidget treewidget diff --git a/examples/Arrow.py b/examples/Arrow.py index 2cbff113..d5ea2a74 100644 --- a/examples/Arrow.py +++ b/examples/Arrow.py @@ -2,7 +2,7 @@ """ Display an animated arrowhead following a curve. This example uses the CurveArrow class, which is a combination -of ArrowItem and CurvePoint. +of ArrowItem and CurvePoint. To place a static arrow anywhere in a scene, use ArrowItem. To attach other types of item to a curve, use CurvePoint. @@ -45,6 +45,7 @@ p.setRange(QtCore.QRectF(-20, -10, 60, 20)) ## Animated arrow following curve c = p2.plot(x=np.sin(np.linspace(0, 2*np.pi, 1000)), y=np.cos(np.linspace(0, 6*np.pi, 1000))) a = pg.CurveArrow(c) +a.setStyle(headLen=40) p2.addItem(a) anim = a.makeAnimation(loop=-1) anim.start() diff --git a/examples/BarGraphItem.py b/examples/BarGraphItem.py new file mode 100644 index 00000000..6caa8862 --- /dev/null +++ b/examples/BarGraphItem.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +""" +Simple example using BarGraphItem +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +win = pg.plot() +win.setWindowTitle('pyqtgraph example: BarGraphItem') + +x = np.arange(10) +y1 = np.sin(x) +y2 = 1.1 * np.sin(x+1) +y3 = 1.2 * np.sin(x+2) + +bg1 = pg.BarGraphItem(x=x, height=y1, width=0.3, brush='r') +bg2 = pg.BarGraphItem(x=x+0.33, height=y2, width=0.3, brush='g') +bg3 = pg.BarGraphItem(x=x+0.66, height=y3, width=0.3, brush='b') + +win.addItem(bg1) +win.addItem(bg2) +win.addItem(bg3) + + +# Final example shows how to handle mouse clicks: +class BarGraph(pg.BarGraphItem): + def mouseClickEvent(self, event): + print("clicked") + + +bg = BarGraph(x=x, y=y1*0.3+2, height=0.4+y1*0.2, width=0.8) +win.addItem(bg) + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/CustomGraphItem.py b/examples/CustomGraphItem.py new file mode 100644 index 00000000..695768e2 --- /dev/null +++ b/examples/CustomGraphItem.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +""" +Simple example of subclassing GraphItem. +""" + +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +# Enable antialiasing for prettier plots +pg.setConfigOptions(antialias=True) + +w = pg.GraphicsWindow() +w.setWindowTitle('pyqtgraph example: CustomGraphItem') +v = w.addViewBox() +v.setAspectLocked() + +class Graph(pg.GraphItem): + def __init__(self): + self.dragPoint = None + self.dragOffset = None + self.textItems = [] + pg.GraphItem.__init__(self) + self.scatter.sigClicked.connect(self.clicked) + + def setData(self, **kwds): + self.text = kwds.pop('text', []) + self.data = kwds + if 'pos' in self.data: + npts = self.data['pos'].shape[0] + self.data['data'] = np.empty(npts, dtype=[('index', int)]) + self.data['data']['index'] = np.arange(npts) + self.setTexts(self.text) + self.updateGraph() + + def setTexts(self, text): + for i in self.textItems: + i.scene().removeItem(i) + self.textItems = [] + for t in text: + item = pg.TextItem(t) + self.textItems.append(item) + item.setParentItem(self) + + def updateGraph(self): + pg.GraphItem.setData(self, **self.data) + for i,item in enumerate(self.textItems): + item.setPos(*self.data['pos'][i]) + + + def mouseDragEvent(self, ev): + if ev.button() != QtCore.Qt.LeftButton: + ev.ignore() + return + + if ev.isStart(): + # We are already one step into the drag. + # Find the point(s) at the mouse cursor when the button was first + # pressed: + pos = ev.buttonDownPos() + pts = self.scatter.pointsAt(pos) + if len(pts) == 0: + ev.ignore() + return + self.dragPoint = pts[0] + ind = pts[0].data()[0] + self.dragOffset = self.data['pos'][ind] - pos + elif ev.isFinish(): + self.dragPoint = None + return + else: + if self.dragPoint is None: + ev.ignore() + return + + ind = self.dragPoint.data()[0] + self.data['pos'][ind] = ev.pos() + self.dragOffset + self.updateGraph() + ev.accept() + + def clicked(self, pts): + print("clicked: %s" % pts) + + +g = Graph() +v.addItem(g) + +## Define positions of nodes +pos = np.array([ + [0,0], + [10,0], + [0,10], + [10,10], + [5,5], + [15,5] + ], dtype=float) + +## Define the set of connections in the graph +adj = np.array([ + [0,1], + [1,3], + [3,2], + [2,0], + [1,5], + [3,5], + ]) + +## Define the symbol to use for each node (this is optional) +symbols = ['o','o','o','o','t','+'] + +## Define the line style for each connection (this is optional) +lines = np.array([ + (255,0,0,255,1), + (255,0,255,255,2), + (255,0,255,255,3), + (255,255,0,255,2), + (255,0,0,255,1), + (255,255,255,255,4), + ], dtype=[('red',np.ubyte),('green',np.ubyte),('blue',np.ubyte),('alpha',np.ubyte),('width',float)]) + +## Define text to show next to each symbol +texts = ["Point %d" % i for i in range(6)] + +## Update the graph +g.setData(pos=pos, adj=adj, pen=lines, size=1, symbol=symbols, pxMode=False, text=texts) + + + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/DataSlicing.py b/examples/DataSlicing.py index bd201832..d766e7e3 100644 --- a/examples/DataSlicing.py +++ b/examples/DataSlicing.py @@ -11,7 +11,6 @@ a 2D plane and interpolate data along that plane to generate a slice image import initExample import numpy as np -import scipy from pyqtgraph.Qt import QtCore, QtGui import pyqtgraph as pg diff --git a/examples/ErrorBarItem.py b/examples/ErrorBarItem.py index 3bbf06d1..cd576d51 100644 --- a/examples/ErrorBarItem.py +++ b/examples/ErrorBarItem.py @@ -7,7 +7,7 @@ Demonstrates basic use of ErrorBarItem import initExample ## Add path to library (just for examples; you do not need this) import pyqtgraph as pg -from pyqtgraph.Qt import QtGui +from pyqtgraph.Qt import QtGui, QtCore import numpy as np import pyqtgraph as pg diff --git a/examples/FillBetweenItem.py b/examples/FillBetweenItem.py new file mode 100644 index 00000000..74dd89bc --- /dev/null +++ b/examples/FillBetweenItem.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +""" +Demonstrates use of FillBetweenItem to fill the space between two plot curves. +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtGui, QtCore +import numpy as np + +win = pg.plot() +win.setWindowTitle('pyqtgraph example: FillBetweenItem') +win.setXRange(-10, 10) +win.setYRange(-10, 10) + +N = 200 +x = np.linspace(-10, 10, N) +gauss = np.exp(-x**2 / 20.) +mn = mx = np.zeros(len(x)) +curves = [win.plot(x=x, y=np.zeros(len(x)), pen='k') for i in range(4)] +brushes = [0.5, (100, 100, 255), 0.5] +fills = [pg.FillBetweenItem(curves[i], curves[i+1], brushes[i]) for i in range(3)] +for f in fills: + win.addItem(f) + +def update(): + global mx, mn, curves, gauss, x + a = 5 / abs(np.random.normal(loc=1, scale=0.2)) + y1 = -np.abs(a*gauss + np.random.normal(size=len(x))) + y2 = np.abs(a*gauss + np.random.normal(size=len(x))) + + s = 0.01 + mn = np.where(y1mx, y2, mx) * (1-s) + y2 * s + curves[0].setData(x, mn) + curves[1].setData(x, y1) + curves[2].setData(x, y2) + curves[3].setData(x, mx) + + +timer = QtCore.QTimer() +timer.timeout.connect(update) +timer.start(30) + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/Flowchart.py b/examples/Flowchart.py index 09ea1f93..86c2564b 100644 --- a/examples/Flowchart.py +++ b/examples/Flowchart.py @@ -58,11 +58,15 @@ fc.setInput(dataIn=data) ## populate the flowchart with a basic set of processing nodes. ## (usually we let the user do this) +plotList = {'Top Plot': pw1, 'Bottom Plot': pw2} + pw1Node = fc.createNode('PlotWidget', pos=(0, -150)) +pw1Node.setPlotList(plotList) pw1Node.setPlot(pw1) pw2Node = fc.createNode('PlotWidget', pos=(150, -150)) pw2Node.setPlot(pw2) +pw2Node.setPlotList(plotList) fNode = fc.createNode('GaussianFilter', pos=(0, 0)) fNode.ctrls['sigma'].setValue(5) diff --git a/examples/FlowchartCustomNode.py b/examples/FlowchartCustomNode.py index bce37982..1cf1ba10 100644 --- a/examples/FlowchartCustomNode.py +++ b/examples/FlowchartCustomNode.py @@ -12,7 +12,6 @@ from pyqtgraph.flowchart.library.common import CtrlNode from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg import numpy as np -import scipy.ndimage app = QtGui.QApplication([]) @@ -44,7 +43,7 @@ win.show() ## generate random input data data = np.random.normal(size=(100,100)) -data = 25 * scipy.ndimage.gaussian_filter(data, (5,5)) +data = 25 * pg.gaussianFilter(data, (5,5)) data += np.random.normal(size=(100,100)) data[40:60, 40:60] += 15.0 data[30:50, 30:50] += 15.0 @@ -83,15 +82,14 @@ class ImageViewNode(Node): else: self.view.setImage(data) -## register the class so it will appear in the menu of node types. -## It will appear in the 'display' sub-menu. -fclib.registerNodeType(ImageViewNode, [('Display',)]) + + ## We will define an unsharp masking filter node as a subclass of CtrlNode. ## CtrlNode is just a convenience class that automatically creates its ## control widget based on a simple data structure. class UnsharpMaskNode(CtrlNode): - """Return the input data passed through scipy.ndimage.gaussian_filter.""" + """Return the input data passed through pg.gaussianFilter.""" nodeName = "UnsharpMask" uiTemplate = [ ('sigma', 'spin', {'value': 1.0, 'step': 1.0, 'range': [0.0, None]}), @@ -111,14 +109,30 @@ class UnsharpMaskNode(CtrlNode): # CtrlNode has created self.ctrls, which is a dict containing {ctrlName: widget} sigma = self.ctrls['sigma'].value() strength = self.ctrls['strength'].value() - output = dataIn - (strength * scipy.ndimage.gaussian_filter(dataIn, (sigma,sigma))) + output = dataIn - (strength * pg.gaussianFilter(dataIn, (sigma,sigma))) return {'dataOut': output} + + +## To make our custom node classes available in the flowchart context menu, +## we can either register them with the default node library or make a +## new library. + -## register the class so it will appear in the menu of node types. -## It will appear in a new 'image' sub-menu. -fclib.registerNodeType(UnsharpMaskNode, [('Image',)]) - - +## Method 1: Register to global default library: +#fclib.registerNodeType(ImageViewNode, [('Display',)]) +#fclib.registerNodeType(UnsharpMaskNode, [('Image',)]) + +## Method 2: If we want to make our custom node available only to this flowchart, +## then instead of registering the node type globally, we can create a new +## NodeLibrary: +library = fclib.LIBRARY.copy() # start with the default node set +library.addNodeType(ImageViewNode, [('Display',)]) +# Add the unsharp mask node to two locations in the menu to demonstrate +# that we can create arbitrary menu structures +library.addNodeType(UnsharpMaskNode, [('Image',), + ('Submenu_test','submenu2','submenu3')]) +fc.setLibrary(library) + ## Now we will programmatically add nodes to define the function of the flowchart. ## Normally, the user will do this manually or by loading a pre-generated diff --git a/examples/GLImageItem.py b/examples/GLImageItem.py index dfdaad0c..581474fd 100644 --- a/examples/GLImageItem.py +++ b/examples/GLImageItem.py @@ -12,7 +12,6 @@ from pyqtgraph.Qt import QtCore, QtGui import pyqtgraph.opengl as gl import pyqtgraph as pg import numpy as np -import scipy.ndimage as ndi app = QtGui.QApplication([]) w = gl.GLViewWidget() @@ -22,8 +21,8 @@ w.setWindowTitle('pyqtgraph example: GLImageItem') ## create volume data set to slice three images from shape = (100,100,70) -data = ndi.gaussian_filter(np.random.normal(size=shape), (4,4,4)) -data += ndi.gaussian_filter(np.random.normal(size=shape), (15,15,15))*15 +data = pg.gaussianFilter(np.random.normal(size=shape), (4,4,4)) +data += pg.gaussianFilter(np.random.normal(size=shape), (15,15,15))*15 ## slice out three planes, convert to RGBA for OpenGL texture levels = (-0.08, 0.08) diff --git a/examples/GLMeshItem.py b/examples/GLMeshItem.py index 5ef8eb51..1caa3490 100644 --- a/examples/GLMeshItem.py +++ b/examples/GLMeshItem.py @@ -53,21 +53,26 @@ m1.translate(5, 5, 0) m1.setGLOptions('additive') w.addItem(m1) + ## Example 2: ## Array of vertex positions, three per face +verts = np.empty((36, 3, 3), dtype=np.float32) +theta = np.linspace(0, 2*np.pi, 37)[:-1] +verts[:,0] = np.vstack([2*np.cos(theta), 2*np.sin(theta), [0]*36]).T +verts[:,1] = np.vstack([4*np.cos(theta+0.2), 4*np.sin(theta+0.2), [-1]*36]).T +verts[:,2] = np.vstack([4*np.cos(theta-0.2), 4*np.sin(theta-0.2), [1]*36]).T + ## Colors are specified per-vertex - -verts = verts[faces] ## Same mesh geometry as example 2, but now we are passing in 12 vertexes colors = np.random.random(size=(verts.shape[0], 3, 4)) -#colors[...,3] = 1.0 - -m2 = gl.GLMeshItem(vertexes=verts, vertexColors=colors, smooth=False, shader='balloon') +m2 = gl.GLMeshItem(vertexes=verts, vertexColors=colors, smooth=False, shader='balloon', + drawEdges=True, edgeColor=(1, 1, 0, 1)) m2.translate(-5, 5, 0) w.addItem(m2) + ## Example 3: -## icosahedron +## sphere md = gl.MeshData.sphere(rows=10, cols=20) #colors = np.random.random(size=(md.faceCount(), 4)) @@ -79,7 +84,7 @@ colors[:,1] = np.linspace(0, 1, colors.shape[0]) md.setFaceColors(colors) m3 = gl.GLMeshItem(meshdata=md, smooth=False)#, shader='balloon') -#m3.translate(-5, -5, 0) +m3.translate(5, -5, 0) w.addItem(m3) @@ -91,49 +96,29 @@ m4 = gl.GLMeshItem(meshdata=md, smooth=False, drawFaces=False, drawEdges=True, e m4.translate(0,10,0) w.addItem(m4) +# Example 5: +# cylinder +md = gl.MeshData.cylinder(rows=10, cols=20, radius=[1., 2.0], length=5.) +md2 = gl.MeshData.cylinder(rows=10, cols=20, radius=[2., 0.5], length=10.) +colors = np.ones((md.faceCount(), 4), dtype=float) +colors[::2,0] = 0 +colors[:,1] = np.linspace(0, 1, colors.shape[0]) +md.setFaceColors(colors) +m5 = gl.GLMeshItem(meshdata=md, smooth=True, drawEdges=True, edgeColor=(1,0,0,1), shader='balloon') +colors = np.ones((md.faceCount(), 4), dtype=float) +colors[::2,0] = 0 +colors[:,1] = np.linspace(0, 1, colors.shape[0]) +md2.setFaceColors(colors) +m6 = gl.GLMeshItem(meshdata=md2, smooth=True, drawEdges=False, shader='balloon') +m6.translate(0,0,7.5) + +m6.rotate(0., 0, 1, 1) +#m5.translate(-3,3,0) +w.addItem(m5) +w.addItem(m6) - - -#def psi(i, j, k, offset=(25, 25, 50)): - #x = i-offset[0] - #y = j-offset[1] - #z = k-offset[2] - #th = np.arctan2(z, (x**2+y**2)**0.5) - #phi = np.arctan2(y, x) - #r = (x**2 + y**2 + z **2)**0.5 - #a0 = 1 - ##ps = (1./81.) * (2./np.pi)**0.5 * (1./a0)**(3/2) * (6 - r/a0) * (r/a0) * np.exp(-r/(3*a0)) * np.cos(th) - #ps = (1./81.) * 1./(6.*np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * np.exp(-r/(3*a0)) * (3 * np.cos(th)**2 - 1) - - #return ps - - ##return ((1./81.) * (1./np.pi)**0.5 * (1./a0)**(3/2) * (r/a0)**2 * (r/a0) * np.exp(-r/(3*a0)) * np.sin(th) * np.cos(th) * np.exp(2 * 1j * phi))**2 - - -#print("Generating scalar field..") -#data = np.abs(np.fromfunction(psi, (50,50,100))) - - -##data = np.fromfunction(lambda i,j,k: np.sin(0.2*((i-25)**2+(j-15)**2+k**2)**0.5), (50,50,50)); -#print("Generating isosurface..") -#verts = pg.isosurface(data, data.max()/4.) - -#md = gl.MeshData.MeshData(vertexes=verts) - -#colors = np.ones((md.vertexes(indexed='faces').shape[0], 4), dtype=float) -#colors[:,3] = 0.3 -#colors[:,2] = np.linspace(0, 1, colors.shape[0]) -#m1 = gl.GLMeshItem(meshdata=md, color=colors, smooth=False) - -#w.addItem(m1) -#m1.translate(-25, -25, -20) - -#m2 = gl.GLMeshItem(vertexes=verts, color=colors, smooth=True) - -#w.addItem(m2) -#m2.translate(-25, -25, -50) diff --git a/examples/GLSurfacePlot.py b/examples/GLSurfacePlot.py index 963cf4cf..e9896e07 100644 --- a/examples/GLSurfacePlot.py +++ b/examples/GLSurfacePlot.py @@ -10,7 +10,6 @@ import initExample from pyqtgraph.Qt import QtCore, QtGui import pyqtgraph as pg import pyqtgraph.opengl as gl -import scipy.ndimage as ndi import numpy as np ## Create a GL View widget to display data @@ -29,7 +28,7 @@ w.addItem(g) ## Simple surface plot example ## x, y values are not specified, so assumed to be 0:50 -z = ndi.gaussian_filter(np.random.normal(size=(50,50)), (1,1)) +z = pg.gaussianFilter(np.random.normal(size=(50,50)), (1,1)) p1 = gl.GLSurfacePlotItem(z=z, shader='shaded', color=(0.5, 0.5, 1, 1)) p1.scale(16./49., 16./49., 1.0) p1.translate(-18, 2, 0) @@ -46,7 +45,7 @@ w.addItem(p2) ## Manually specified colors -z = ndi.gaussian_filter(np.random.normal(size=(50,50)), (1,1)) +z = pg.gaussianFilter(np.random.normal(size=(50,50)), (1,1)) x = np.linspace(-12, 12, 50) y = np.linspace(-12, 12, 50) colors = np.ones((50,50,4), dtype=float) diff --git a/examples/HistogramLUT.py b/examples/HistogramLUT.py index 5d66cb5d..4d89dd3f 100644 --- a/examples/HistogramLUT.py +++ b/examples/HistogramLUT.py @@ -7,7 +7,6 @@ Use a HistogramLUTWidget to control the contrast / coloration of an image. import initExample import numpy as np -import scipy.ndimage as ndi from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg @@ -34,7 +33,7 @@ l.addWidget(v, 0, 0) w = pg.HistogramLUTWidget() l.addWidget(w, 0, 1) -data = ndi.gaussian_filter(np.random.normal(size=(256, 256)), (20, 20)) +data = pg.gaussianFilter(np.random.normal(size=(256, 256)), (20, 20)) for i in range(32): for j in range(32): data[i*8, j*8] += .1 diff --git a/examples/ImageView.py b/examples/ImageView.py index d0bbd31b..22168409 100644 --- a/examples/ImageView.py +++ b/examples/ImageView.py @@ -14,7 +14,6 @@ displaying and analyzing 2D and 3D data. ImageView provides: import initExample import numpy as np -import scipy from pyqtgraph.Qt import QtCore, QtGui import pyqtgraph as pg @@ -29,7 +28,7 @@ win.show() win.setWindowTitle('pyqtgraph example: ImageView') ## Create random 3D data set with noisy signals -img = scipy.ndimage.gaussian_filter(np.random.normal(size=(200, 200)), (5, 5)) * 20 + 100 +img = pg.gaussianFilter(np.random.normal(size=(200, 200)), (5, 5)) * 20 + 100 img = img[np.newaxis,:,:] decay = np.exp(-np.linspace(0,0.3,100))[:,np.newaxis,np.newaxis] data = np.random.normal(size=(100, 200, 200)) diff --git a/examples/Legend.py b/examples/Legend.py index 2cd982ea..f7841151 100644 --- a/examples/Legend.py +++ b/examples/Legend.py @@ -14,7 +14,7 @@ plt.addLegend() #l = pg.LegendItem((100,60), offset=(70,30)) # args are (size, offset) #l.setParentItem(plt.graphicsItem()) # Note we do NOT call plt.addItem in this case -c1 = plt.plot([1,3,2,4], pen='r', name='red plot') +c1 = plt.plot([1,3,2,4], pen='r', symbol='o', symbolPen='r', symbolBrush=0.5, name='red plot') c2 = plt.plot([2,1,4,3], pen='g', fillLevel=0, fillBrush=(255,255,255,30), name='green plot') #l.addItem(c1, 'red plot') #l.addItem(c2, 'green plot') diff --git a/examples/MouseSelection.py b/examples/MouseSelection.py new file mode 100644 index 00000000..3a573751 --- /dev/null +++ b/examples/MouseSelection.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +""" +Demonstrates selecting plot curves by mouse click +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +win = pg.plot() +win.setWindowTitle('pyqtgraph example: Plot data selection') + +curves = [ + pg.PlotCurveItem(y=np.sin(np.linspace(0, 20, 1000)), pen='r', clickable=True), + pg.PlotCurveItem(y=np.sin(np.linspace(1, 21, 1000)), pen='g', clickable=True), + pg.PlotCurveItem(y=np.sin(np.linspace(2, 22, 1000)), pen='b', clickable=True), + ] + +def plotClicked(curve): + global curves + for i,c in enumerate(curves): + if c is curve: + c.setPen('rgb'[i], width=3) + else: + c.setPen('rgb'[i], width=1) + + +for c in curves: + win.addItem(c) + c.sigClicked.connect(plotClicked) + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/MultiPlotSpeedTest.py b/examples/MultiPlotSpeedTest.py index e38c90e2..0d0d701b 100644 --- a/examples/MultiPlotSpeedTest.py +++ b/examples/MultiPlotSpeedTest.py @@ -22,17 +22,25 @@ p.setWindowTitle('pyqtgraph example: MultiPlotSpeedTest') #p.setRange(QtCore.QRectF(0, -10, 5000, 20)) p.setLabel('bottom', 'Index', units='B') -nPlots = 10 +nPlots = 100 +nSamples = 500 #curves = [p.plot(pen=(i,nPlots*1.3)) for i in range(nPlots)] -curves = [pg.PlotCurveItem(pen=(i,nPlots*1.3)) for i in range(nPlots)] -for c in curves: +curves = [] +for i in range(nPlots): + c = pg.PlotCurveItem(pen=(i,nPlots*1.3)) p.addItem(c) + c.setPos(0,i*6) + curves.append(c) -rgn = pg.LinearRegionItem([1,100]) +p.setYRange(0, nPlots*6) +p.setXRange(0, nSamples) +p.resize(600,900) + +rgn = pg.LinearRegionItem([nSamples/5.,nSamples/3.]) p.addItem(rgn) -data = np.random.normal(size=(53,5000/nPlots)) +data = np.random.normal(size=(nPlots*23,nSamples)) ptr = 0 lastTime = time() fps = None @@ -42,7 +50,8 @@ def update(): count += 1 #print "---------", count for i in range(nPlots): - curves[i].setData(i+data[(ptr+i)%data.shape[0]]) + curves[i].setData(data[(ptr+i)%data.shape[0]]) + #print " setData done." ptr += nPlots now = time() diff --git a/examples/MultiPlotWidget.py b/examples/MultiPlotWidget.py index 28492f64..5ab4b21d 100644 --- a/examples/MultiPlotWidget.py +++ b/examples/MultiPlotWidget.py @@ -10,11 +10,11 @@ from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg from pyqtgraph import MultiPlotWidget try: - from metaarray import * + from pyqtgraph.metaarray import * except: print("MultiPlot is only used with MetaArray for now (and you do not have the metaarray package)") exit() - + app = QtGui.QApplication([]) mw = QtGui.QMainWindow() mw.resize(800,800) @@ -22,7 +22,15 @@ pw = MultiPlotWidget() mw.setCentralWidget(pw) mw.show() -ma = MetaArray(random.random((3, 1000)), info=[{'name': 'Signal', 'cols': [{'name': 'Col1'}, {'name': 'Col2'}, {'name': 'Col3'}]}, {'name': 'Time', 'vals': linspace(0., 1., 1000)}]) +data = random.normal(size=(3, 1000)) * np.array([[0.1], [1e-5], [1]]) +ma = MetaArray(data, info=[ + {'name': 'Signal', 'cols': [ + {'name': 'Col1', 'units': 'V'}, + {'name': 'Col2', 'units': 'A'}, + {'name': 'Col3'}, + ]}, + {'name': 'Time', 'values': linspace(0., 1., 1000), 'units': 's'} + ]) pw.plot(ma) ## Start Qt event loop unless running in interactive mode. diff --git a/examples/Plotting.py b/examples/Plotting.py index 6578fb2b..8476eae8 100644 --- a/examples/Plotting.py +++ b/examples/Plotting.py @@ -27,9 +27,9 @@ pg.setConfigOptions(antialias=True) p1 = win.addPlot(title="Basic array plotting", y=np.random.normal(size=100)) p2 = win.addPlot(title="Multiple curves") -p2.plot(np.random.normal(size=100), pen=(255,0,0)) -p2.plot(np.random.normal(size=100)+5, pen=(0,255,0)) -p2.plot(np.random.normal(size=100)+10, pen=(0,0,255)) +p2.plot(np.random.normal(size=100), pen=(255,0,0), name="Red curve") +p2.plot(np.random.normal(size=110)+5, pen=(0,255,0), name="Blue curve") +p2.plot(np.random.normal(size=120)+10, pen=(0,0,255), name="Green curve") p3 = win.addPlot(title="Drawing with points") p3.plot(np.random.normal(size=100), pen=(200,200,200), symbolBrush=(255,0,0), symbolPen='w') diff --git a/examples/ROIExamples.py b/examples/ROIExamples.py index 56b15bcf..55c671ad 100644 --- a/examples/ROIExamples.py +++ b/examples/ROIExamples.py @@ -132,7 +132,7 @@ label4 = w4.addLabel(text, row=0, col=0) v4 = w4.addViewBox(row=1, col=0, lockAspect=True) g = pg.GridItem() v4.addItem(g) -r4 = pg.ROI([0,0], [100,100]) +r4 = pg.ROI([0,0], [100,100], removable=True) r4.addRotateHandle([1,0], [0.5, 0.5]) r4.addRotateHandle([0,1], [0.5, 0.5]) img4 = pg.ImageItem(arr) @@ -142,6 +142,12 @@ img4.setParentItem(r4) v4.disableAutoRange('xy') v4.autoRange() +# Provide a callback to remove the ROI (and its children) when +# "remove" is selected from the context menu. +def remove(): + v4.removeItem(r4) +r4.sigRemoveRequested.connect(remove) + diff --git a/examples/ScatterPlot.py b/examples/ScatterPlot.py index 805cf09f..72022acc 100644 --- a/examples/ScatterPlot.py +++ b/examples/ScatterPlot.py @@ -58,8 +58,9 @@ s1.sigClicked.connect(clicked) ## 2) Spots are transform-invariant, but not identical (top-right plot). -## In this case, drawing is as fast as 1), but there is more startup overhead -## and memory usage since each spot generates its own pre-rendered image. +## In this case, drawing is almsot as fast as 1), but there is more startup +## overhead and memory usage since each spot generates its own pre-rendered +## image. s2 = pg.ScatterPlotItem(size=10, pen=pg.mkPen('w'), pxMode=True) pos = np.random.normal(size=(2,n), scale=1e-5) diff --git a/examples/ScatterPlotSpeedTest.py b/examples/ScatterPlotSpeedTest.py index b79c6641..4dbe57db 100644 --- a/examples/ScatterPlotSpeedTest.py +++ b/examples/ScatterPlotSpeedTest.py @@ -32,6 +32,7 @@ ui.setupUi(win) win.show() p = ui.plot +p.setRange(xRange=[-500, 500], yRange=[-500, 500]) data = np.random.normal(size=(50,500), scale=100) sizeArray = (np.random.random(500) * 20.).astype(int) @@ -45,7 +46,9 @@ def update(): size = sizeArray else: size = ui.sizeSpin.value() - curve = pg.ScatterPlotItem(x=data[ptr%50], y=data[(ptr+1)%50], pen='w', brush='b', size=size, pxMode=ui.pixelModeCheck.isChecked()) + curve = pg.ScatterPlotItem(x=data[ptr%50], y=data[(ptr+1)%50], + pen='w', brush='b', size=size, + pxMode=ui.pixelModeCheck.isChecked()) p.addItem(curve) ptr += 1 now = time() diff --git a/examples/SimplePlot.py b/examples/SimplePlot.py index f572743a..03ee2204 100644 --- a/examples/SimplePlot.py +++ b/examples/SimplePlot.py @@ -1,15 +1,12 @@ import initExample ## Add path to library (just for examples; you do not need this) -from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg +import pyqtgraph.exporters import numpy as np plt = pg.plot(np.random.normal(size=100), title="Simplest possible plotting example") -plt.getAxis('bottom').setTicks([[(x*20, str(x*20)) for x in range(6)]]) -## Start Qt event loop unless running in interactive mode or using pyside. -ex = pg.exporters.SVGExporter.SVGExporter(plt.plotItem.scene()) -ex.export('/home/luke/tmp/test.svg') +## Start Qt event loop unless running in interactive mode or using pyside. if __name__ == '__main__': import sys - if sys.flags.interactive != 1 or not hasattr(QtCore, 'PYQT_VERSION'): + if sys.flags.interactive != 1 or not hasattr(pg.QtCore, 'PYQT_VERSION'): pg.QtGui.QApplication.exec_() diff --git a/examples/VideoSpeedTest.py b/examples/VideoSpeedTest.py index 1341ec0e..6fce8a86 100644 --- a/examples/VideoSpeedTest.py +++ b/examples/VideoSpeedTest.py @@ -13,7 +13,6 @@ import initExample ## Add path to library (just for examples; you do not need th from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE import numpy as np import pyqtgraph as pg -import scipy.ndimage as ndi import pyqtgraph.ptime as ptime if USE_PYSIDE: @@ -71,40 +70,67 @@ ui.rgbLevelsCheck.toggled.connect(updateScale) cache = {} def mkData(): - global data, cache, ui - dtype = (ui.dtypeCombo.currentText(), ui.rgbCheck.isChecked()) - if dtype not in cache: - if dtype[0] == 'uint8': - dt = np.uint8 - loc = 128 - scale = 64 - mx = 255 - elif dtype[0] == 'uint16': - dt = np.uint16 - loc = 4096 - scale = 1024 - mx = 2**16 - elif dtype[0] == 'float': - dt = np.float - loc = 1.0 - scale = 0.1 - - if ui.rgbCheck.isChecked(): - data = np.random.normal(size=(20,512,512,3), loc=loc, scale=scale) - data = ndi.gaussian_filter(data, (0, 6, 6, 0)) - else: - data = np.random.normal(size=(20,512,512), loc=loc, scale=scale) - data = ndi.gaussian_filter(data, (0, 6, 6)) - if dtype[0] != 'float': - data = np.clip(data, 0, mx) - data = data.astype(dt) - cache[dtype] = data - - data = cache[dtype] - updateLUT() + with pg.BusyCursor(): + global data, cache, ui + frames = ui.framesSpin.value() + width = ui.widthSpin.value() + height = ui.heightSpin.value() + dtype = (ui.dtypeCombo.currentText(), ui.rgbCheck.isChecked(), frames, width, height) + if dtype not in cache: + if dtype[0] == 'uint8': + dt = np.uint8 + loc = 128 + scale = 64 + mx = 255 + elif dtype[0] == 'uint16': + dt = np.uint16 + loc = 4096 + scale = 1024 + mx = 2**16 + elif dtype[0] == 'float': + dt = np.float + loc = 1.0 + scale = 0.1 + + if ui.rgbCheck.isChecked(): + data = np.random.normal(size=(frames,width,height,3), loc=loc, scale=scale) + data = pg.gaussianFilter(data, (0, 6, 6, 0)) + else: + data = np.random.normal(size=(frames,width,height), loc=loc, scale=scale) + data = pg.gaussianFilter(data, (0, 6, 6)) + if dtype[0] != 'float': + data = np.clip(data, 0, mx) + data = data.astype(dt) + cache = {dtype: data} # clear to save memory (but keep one to prevent unnecessary regeneration) + + data = cache[dtype] + updateLUT() + updateSize() + +def updateSize(): + global ui + frames = ui.framesSpin.value() + width = ui.widthSpin.value() + height = ui.heightSpin.value() + dtype = np.dtype(str(ui.dtypeCombo.currentText())) + rgb = 3 if ui.rgbCheck.isChecked() else 1 + ui.sizeLabel.setText('%d MB' % (frames * width * height * rgb * dtype.itemsize / 1e6)) + + mkData() + + ui.dtypeCombo.currentIndexChanged.connect(mkData) ui.rgbCheck.toggled.connect(mkData) +ui.widthSpin.editingFinished.connect(mkData) +ui.heightSpin.editingFinished.connect(mkData) +ui.framesSpin.editingFinished.connect(mkData) + +ui.widthSpin.valueChanged.connect(updateSize) +ui.heightSpin.valueChanged.connect(updateSize) +ui.framesSpin.valueChanged.connect(updateSize) + + ptr = 0 lastTime = ptime.time() @@ -115,6 +141,8 @@ def update(): useLut = LUT else: useLut = None + + downsample = ui.downsampleCheck.isChecked() if ui.scaleCheck.isChecked(): if ui.rgbLevelsCheck.isChecked(): @@ -134,7 +162,7 @@ def update(): ui.rawGLImg.setImage(data[ptr%data.shape[0]], lut=useLut, levels=useScale) ui.stack.setCurrentIndex(2) else: - img.setImage(data[ptr%data.shape[0]], autoLevels=False, levels=useScale, lut=useLut) + img.setImage(data[ptr%data.shape[0]], autoLevels=False, levels=useScale, lut=useLut, autoDownsample=downsample) ui.stack.setCurrentIndex(0) #img.setImage(data[ptr%data.shape[0]], autoRange=False) diff --git a/examples/VideoTemplate.ui b/examples/VideoTemplate.ui index 9560a19b..6bde7fe2 100644 --- a/examples/VideoTemplate.ui +++ b/examples/VideoTemplate.ui @@ -15,6 +15,20 @@ + + + + Auto downsample + + + + + + + Scale Data + + + @@ -78,14 +92,7 @@ - - - - Data type - - - - + @@ -105,40 +112,20 @@ - + - Scale Data + Data type - + RGB - - - - - - - - - <---> - - - Qt::AlignCenter - - - - - - - - - + @@ -166,7 +153,27 @@ - + + + + + + + + + <---> + + + Qt::AlignCenter + + + + + + + + + @@ -194,21 +201,21 @@ - + Use Lookup Table - + alpha - + @@ -218,7 +225,7 @@ - + Qt::Horizontal @@ -246,13 +253,67 @@ - + RGB + + + + Image size + + + + + + + + + QAbstractSpinBox::NoButtons + + + 10 + + + + + + + QAbstractSpinBox::PlusMinus + + + 10000 + + + 512 + + + + + + + QAbstractSpinBox::NoButtons + + + 10000 + + + 512 + + + + + + + + + + + + diff --git a/examples/VideoTemplate_pyqt.py b/examples/VideoTemplate_pyqt.py index 91fc1b1e..e2481df7 100644 --- a/examples/VideoTemplate_pyqt.py +++ b/examples/VideoTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './VideoTemplate.ui' +# Form implementation generated from reading ui file './examples/VideoTemplate.ui' # -# Created: Sat Nov 16 20:07:09 2013 -# by: PyQt4 UI code generator 4.10 +# Created: Mon Feb 17 20:39:30 2014 +# by: PyQt4 UI code generator 4.10.3 # # WARNING! All changes made in this file will be lost! @@ -31,6 +31,12 @@ class Ui_MainWindow(object): self.centralwidget.setObjectName(_fromUtf8("centralwidget")) self.gridLayout_2 = QtGui.QGridLayout(self.centralwidget) self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) + self.downsampleCheck = QtGui.QCheckBox(self.centralwidget) + self.downsampleCheck.setObjectName(_fromUtf8("downsampleCheck")) + self.gridLayout_2.addWidget(self.downsampleCheck, 8, 0, 1, 2) + self.scaleCheck = QtGui.QCheckBox(self.centralwidget) + self.scaleCheck.setObjectName(_fromUtf8("scaleCheck")) + self.gridLayout_2.addWidget(self.scaleCheck, 4, 0, 1, 1) self.gridLayout = QtGui.QGridLayout() self.gridLayout.setObjectName(_fromUtf8("gridLayout")) self.rawRadio = QtGui.QRadioButton(self.centralwidget) @@ -76,34 +82,18 @@ class Ui_MainWindow(object): self.rawGLRadio.setObjectName(_fromUtf8("rawGLRadio")) self.gridLayout.addWidget(self.rawGLRadio, 4, 0, 1, 1) self.gridLayout_2.addLayout(self.gridLayout, 1, 0, 1, 4) - self.label = QtGui.QLabel(self.centralwidget) - self.label.setObjectName(_fromUtf8("label")) - self.gridLayout_2.addWidget(self.label, 2, 0, 1, 1) self.dtypeCombo = QtGui.QComboBox(self.centralwidget) self.dtypeCombo.setObjectName(_fromUtf8("dtypeCombo")) self.dtypeCombo.addItem(_fromUtf8("")) self.dtypeCombo.addItem(_fromUtf8("")) self.dtypeCombo.addItem(_fromUtf8("")) - self.gridLayout_2.addWidget(self.dtypeCombo, 2, 2, 1, 1) - self.scaleCheck = QtGui.QCheckBox(self.centralwidget) - self.scaleCheck.setObjectName(_fromUtf8("scaleCheck")) - self.gridLayout_2.addWidget(self.scaleCheck, 3, 0, 1, 1) + self.gridLayout_2.addWidget(self.dtypeCombo, 3, 2, 1, 1) + self.label = QtGui.QLabel(self.centralwidget) + self.label.setObjectName(_fromUtf8("label")) + self.gridLayout_2.addWidget(self.label, 3, 0, 1, 1) self.rgbLevelsCheck = QtGui.QCheckBox(self.centralwidget) self.rgbLevelsCheck.setObjectName(_fromUtf8("rgbLevelsCheck")) - self.gridLayout_2.addWidget(self.rgbLevelsCheck, 3, 1, 1, 1) - self.horizontalLayout = QtGui.QHBoxLayout() - self.horizontalLayout.setObjectName(_fromUtf8("horizontalLayout")) - self.minSpin1 = SpinBox(self.centralwidget) - self.minSpin1.setObjectName(_fromUtf8("minSpin1")) - self.horizontalLayout.addWidget(self.minSpin1) - self.label_2 = QtGui.QLabel(self.centralwidget) - self.label_2.setAlignment(QtCore.Qt.AlignCenter) - self.label_2.setObjectName(_fromUtf8("label_2")) - self.horizontalLayout.addWidget(self.label_2) - self.maxSpin1 = SpinBox(self.centralwidget) - self.maxSpin1.setObjectName(_fromUtf8("maxSpin1")) - self.horizontalLayout.addWidget(self.maxSpin1) - self.gridLayout_2.addLayout(self.horizontalLayout, 3, 2, 1, 1) + self.gridLayout_2.addWidget(self.rgbLevelsCheck, 4, 1, 1, 1) self.horizontalLayout_2 = QtGui.QHBoxLayout() self.horizontalLayout_2.setObjectName(_fromUtf8("horizontalLayout_2")) self.minSpin2 = SpinBox(self.centralwidget) @@ -118,7 +108,20 @@ class Ui_MainWindow(object): self.maxSpin2.setEnabled(False) self.maxSpin2.setObjectName(_fromUtf8("maxSpin2")) self.horizontalLayout_2.addWidget(self.maxSpin2) - self.gridLayout_2.addLayout(self.horizontalLayout_2, 4, 2, 1, 1) + self.gridLayout_2.addLayout(self.horizontalLayout_2, 5, 2, 1, 1) + self.horizontalLayout = QtGui.QHBoxLayout() + self.horizontalLayout.setObjectName(_fromUtf8("horizontalLayout")) + self.minSpin1 = SpinBox(self.centralwidget) + self.minSpin1.setObjectName(_fromUtf8("minSpin1")) + self.horizontalLayout.addWidget(self.minSpin1) + self.label_2 = QtGui.QLabel(self.centralwidget) + self.label_2.setAlignment(QtCore.Qt.AlignCenter) + self.label_2.setObjectName(_fromUtf8("label_2")) + self.horizontalLayout.addWidget(self.label_2) + self.maxSpin1 = SpinBox(self.centralwidget) + self.maxSpin1.setObjectName(_fromUtf8("maxSpin1")) + self.horizontalLayout.addWidget(self.maxSpin1) + self.gridLayout_2.addLayout(self.horizontalLayout, 4, 2, 1, 1) self.horizontalLayout_3 = QtGui.QHBoxLayout() self.horizontalLayout_3.setObjectName(_fromUtf8("horizontalLayout_3")) self.minSpin3 = SpinBox(self.centralwidget) @@ -133,13 +136,13 @@ class Ui_MainWindow(object): self.maxSpin3.setEnabled(False) self.maxSpin3.setObjectName(_fromUtf8("maxSpin3")) self.horizontalLayout_3.addWidget(self.maxSpin3) - self.gridLayout_2.addLayout(self.horizontalLayout_3, 5, 2, 1, 1) + self.gridLayout_2.addLayout(self.horizontalLayout_3, 6, 2, 1, 1) self.lutCheck = QtGui.QCheckBox(self.centralwidget) self.lutCheck.setObjectName(_fromUtf8("lutCheck")) - self.gridLayout_2.addWidget(self.lutCheck, 6, 0, 1, 1) + self.gridLayout_2.addWidget(self.lutCheck, 7, 0, 1, 1) self.alphaCheck = QtGui.QCheckBox(self.centralwidget) self.alphaCheck.setObjectName(_fromUtf8("alphaCheck")) - self.gridLayout_2.addWidget(self.alphaCheck, 6, 1, 1, 1) + self.gridLayout_2.addWidget(self.alphaCheck, 7, 1, 1, 1) self.gradient = GradientWidget(self.centralwidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) @@ -147,9 +150,9 @@ class Ui_MainWindow(object): sizePolicy.setHeightForWidth(self.gradient.sizePolicy().hasHeightForWidth()) self.gradient.setSizePolicy(sizePolicy) self.gradient.setObjectName(_fromUtf8("gradient")) - self.gridLayout_2.addWidget(self.gradient, 6, 2, 1, 2) + self.gridLayout_2.addWidget(self.gradient, 7, 2, 1, 2) spacerItem = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) - self.gridLayout_2.addItem(spacerItem, 2, 3, 1, 1) + self.gridLayout_2.addItem(spacerItem, 3, 3, 1, 1) self.fpsLabel = QtGui.QLabel(self.centralwidget) font = QtGui.QFont() font.setPointSize(12) @@ -159,7 +162,34 @@ class Ui_MainWindow(object): self.gridLayout_2.addWidget(self.fpsLabel, 0, 0, 1, 4) self.rgbCheck = QtGui.QCheckBox(self.centralwidget) self.rgbCheck.setObjectName(_fromUtf8("rgbCheck")) - self.gridLayout_2.addWidget(self.rgbCheck, 2, 1, 1, 1) + self.gridLayout_2.addWidget(self.rgbCheck, 3, 1, 1, 1) + self.label_5 = QtGui.QLabel(self.centralwidget) + self.label_5.setObjectName(_fromUtf8("label_5")) + self.gridLayout_2.addWidget(self.label_5, 2, 0, 1, 1) + self.horizontalLayout_4 = QtGui.QHBoxLayout() + self.horizontalLayout_4.setObjectName(_fromUtf8("horizontalLayout_4")) + self.framesSpin = QtGui.QSpinBox(self.centralwidget) + self.framesSpin.setButtonSymbols(QtGui.QAbstractSpinBox.NoButtons) + self.framesSpin.setProperty("value", 10) + self.framesSpin.setObjectName(_fromUtf8("framesSpin")) + self.horizontalLayout_4.addWidget(self.framesSpin) + self.widthSpin = QtGui.QSpinBox(self.centralwidget) + self.widthSpin.setButtonSymbols(QtGui.QAbstractSpinBox.PlusMinus) + self.widthSpin.setMaximum(10000) + self.widthSpin.setProperty("value", 512) + self.widthSpin.setObjectName(_fromUtf8("widthSpin")) + self.horizontalLayout_4.addWidget(self.widthSpin) + self.heightSpin = QtGui.QSpinBox(self.centralwidget) + self.heightSpin.setButtonSymbols(QtGui.QAbstractSpinBox.NoButtons) + self.heightSpin.setMaximum(10000) + self.heightSpin.setProperty("value", 512) + self.heightSpin.setObjectName(_fromUtf8("heightSpin")) + self.horizontalLayout_4.addWidget(self.heightSpin) + self.gridLayout_2.addLayout(self.horizontalLayout_4, 2, 1, 1, 2) + self.sizeLabel = QtGui.QLabel(self.centralwidget) + self.sizeLabel.setText(_fromUtf8("")) + self.sizeLabel.setObjectName(_fromUtf8("sizeLabel")) + self.gridLayout_2.addWidget(self.sizeLabel, 2, 3, 1, 1) MainWindow.setCentralWidget(self.centralwidget) self.retranslateUi(MainWindow) @@ -168,22 +198,24 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow", None)) + self.downsampleCheck.setText(_translate("MainWindow", "Auto downsample", None)) + self.scaleCheck.setText(_translate("MainWindow", "Scale Data", None)) self.rawRadio.setText(_translate("MainWindow", "RawImageWidget", None)) self.gfxRadio.setText(_translate("MainWindow", "GraphicsView + ImageItem", None)) self.rawGLRadio.setText(_translate("MainWindow", "RawGLImageWidget", None)) - self.label.setText(_translate("MainWindow", "Data type", None)) self.dtypeCombo.setItemText(0, _translate("MainWindow", "uint8", None)) self.dtypeCombo.setItemText(1, _translate("MainWindow", "uint16", None)) self.dtypeCombo.setItemText(2, _translate("MainWindow", "float", None)) - self.scaleCheck.setText(_translate("MainWindow", "Scale Data", None)) + self.label.setText(_translate("MainWindow", "Data type", None)) self.rgbLevelsCheck.setText(_translate("MainWindow", "RGB", None)) - self.label_2.setText(_translate("MainWindow", "<--->", None)) self.label_3.setText(_translate("MainWindow", "<--->", None)) + self.label_2.setText(_translate("MainWindow", "<--->", None)) self.label_4.setText(_translate("MainWindow", "<--->", None)) self.lutCheck.setText(_translate("MainWindow", "Use Lookup Table", None)) self.alphaCheck.setText(_translate("MainWindow", "alpha", None)) self.fpsLabel.setText(_translate("MainWindow", "FPS", None)) self.rgbCheck.setText(_translate("MainWindow", "RGB", None)) + self.label_5.setText(_translate("MainWindow", "Image size", None)) from pyqtgraph.widgets.RawImageWidget import RawImageGLWidget, RawImageWidget from pyqtgraph import GradientWidget, SpinBox, GraphicsView diff --git a/examples/VideoTemplate_pyside.py b/examples/VideoTemplate_pyside.py index c1f8bc57..faebd546 100644 --- a/examples/VideoTemplate_pyside.py +++ b/examples/VideoTemplate_pyside.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './VideoTemplate.ui' +# Form implementation generated from reading ui file './examples/VideoTemplate.ui' # -# Created: Sat Nov 16 20:07:10 2013 +# Created: Mon Feb 17 20:39:30 2014 # by: pyside-uic 0.2.14 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! @@ -17,6 +17,12 @@ class Ui_MainWindow(object): self.centralwidget.setObjectName("centralwidget") self.gridLayout_2 = QtGui.QGridLayout(self.centralwidget) self.gridLayout_2.setObjectName("gridLayout_2") + self.downsampleCheck = QtGui.QCheckBox(self.centralwidget) + self.downsampleCheck.setObjectName("downsampleCheck") + self.gridLayout_2.addWidget(self.downsampleCheck, 8, 0, 1, 2) + self.scaleCheck = QtGui.QCheckBox(self.centralwidget) + self.scaleCheck.setObjectName("scaleCheck") + self.gridLayout_2.addWidget(self.scaleCheck, 4, 0, 1, 1) self.gridLayout = QtGui.QGridLayout() self.gridLayout.setObjectName("gridLayout") self.rawRadio = QtGui.QRadioButton(self.centralwidget) @@ -62,34 +68,18 @@ class Ui_MainWindow(object): self.rawGLRadio.setObjectName("rawGLRadio") self.gridLayout.addWidget(self.rawGLRadio, 4, 0, 1, 1) self.gridLayout_2.addLayout(self.gridLayout, 1, 0, 1, 4) - self.label = QtGui.QLabel(self.centralwidget) - self.label.setObjectName("label") - self.gridLayout_2.addWidget(self.label, 2, 0, 1, 1) self.dtypeCombo = QtGui.QComboBox(self.centralwidget) self.dtypeCombo.setObjectName("dtypeCombo") self.dtypeCombo.addItem("") self.dtypeCombo.addItem("") self.dtypeCombo.addItem("") - self.gridLayout_2.addWidget(self.dtypeCombo, 2, 2, 1, 1) - self.scaleCheck = QtGui.QCheckBox(self.centralwidget) - self.scaleCheck.setObjectName("scaleCheck") - self.gridLayout_2.addWidget(self.scaleCheck, 3, 0, 1, 1) + self.gridLayout_2.addWidget(self.dtypeCombo, 3, 2, 1, 1) + self.label = QtGui.QLabel(self.centralwidget) + self.label.setObjectName("label") + self.gridLayout_2.addWidget(self.label, 3, 0, 1, 1) self.rgbLevelsCheck = QtGui.QCheckBox(self.centralwidget) self.rgbLevelsCheck.setObjectName("rgbLevelsCheck") - self.gridLayout_2.addWidget(self.rgbLevelsCheck, 3, 1, 1, 1) - self.horizontalLayout = QtGui.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.minSpin1 = SpinBox(self.centralwidget) - self.minSpin1.setObjectName("minSpin1") - self.horizontalLayout.addWidget(self.minSpin1) - self.label_2 = QtGui.QLabel(self.centralwidget) - self.label_2.setAlignment(QtCore.Qt.AlignCenter) - self.label_2.setObjectName("label_2") - self.horizontalLayout.addWidget(self.label_2) - self.maxSpin1 = SpinBox(self.centralwidget) - self.maxSpin1.setObjectName("maxSpin1") - self.horizontalLayout.addWidget(self.maxSpin1) - self.gridLayout_2.addLayout(self.horizontalLayout, 3, 2, 1, 1) + self.gridLayout_2.addWidget(self.rgbLevelsCheck, 4, 1, 1, 1) self.horizontalLayout_2 = QtGui.QHBoxLayout() self.horizontalLayout_2.setObjectName("horizontalLayout_2") self.minSpin2 = SpinBox(self.centralwidget) @@ -104,7 +94,20 @@ class Ui_MainWindow(object): self.maxSpin2.setEnabled(False) self.maxSpin2.setObjectName("maxSpin2") self.horizontalLayout_2.addWidget(self.maxSpin2) - self.gridLayout_2.addLayout(self.horizontalLayout_2, 4, 2, 1, 1) + self.gridLayout_2.addLayout(self.horizontalLayout_2, 5, 2, 1, 1) + self.horizontalLayout = QtGui.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.minSpin1 = SpinBox(self.centralwidget) + self.minSpin1.setObjectName("minSpin1") + self.horizontalLayout.addWidget(self.minSpin1) + self.label_2 = QtGui.QLabel(self.centralwidget) + self.label_2.setAlignment(QtCore.Qt.AlignCenter) + self.label_2.setObjectName("label_2") + self.horizontalLayout.addWidget(self.label_2) + self.maxSpin1 = SpinBox(self.centralwidget) + self.maxSpin1.setObjectName("maxSpin1") + self.horizontalLayout.addWidget(self.maxSpin1) + self.gridLayout_2.addLayout(self.horizontalLayout, 4, 2, 1, 1) self.horizontalLayout_3 = QtGui.QHBoxLayout() self.horizontalLayout_3.setObjectName("horizontalLayout_3") self.minSpin3 = SpinBox(self.centralwidget) @@ -119,13 +122,13 @@ class Ui_MainWindow(object): self.maxSpin3.setEnabled(False) self.maxSpin3.setObjectName("maxSpin3") self.horizontalLayout_3.addWidget(self.maxSpin3) - self.gridLayout_2.addLayout(self.horizontalLayout_3, 5, 2, 1, 1) + self.gridLayout_2.addLayout(self.horizontalLayout_3, 6, 2, 1, 1) self.lutCheck = QtGui.QCheckBox(self.centralwidget) self.lutCheck.setObjectName("lutCheck") - self.gridLayout_2.addWidget(self.lutCheck, 6, 0, 1, 1) + self.gridLayout_2.addWidget(self.lutCheck, 7, 0, 1, 1) self.alphaCheck = QtGui.QCheckBox(self.centralwidget) self.alphaCheck.setObjectName("alphaCheck") - self.gridLayout_2.addWidget(self.alphaCheck, 6, 1, 1, 1) + self.gridLayout_2.addWidget(self.alphaCheck, 7, 1, 1, 1) self.gradient = GradientWidget(self.centralwidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) @@ -133,9 +136,9 @@ class Ui_MainWindow(object): sizePolicy.setHeightForWidth(self.gradient.sizePolicy().hasHeightForWidth()) self.gradient.setSizePolicy(sizePolicy) self.gradient.setObjectName("gradient") - self.gridLayout_2.addWidget(self.gradient, 6, 2, 1, 2) + self.gridLayout_2.addWidget(self.gradient, 7, 2, 1, 2) spacerItem = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) - self.gridLayout_2.addItem(spacerItem, 2, 3, 1, 1) + self.gridLayout_2.addItem(spacerItem, 3, 3, 1, 1) self.fpsLabel = QtGui.QLabel(self.centralwidget) font = QtGui.QFont() font.setPointSize(12) @@ -145,7 +148,34 @@ class Ui_MainWindow(object): self.gridLayout_2.addWidget(self.fpsLabel, 0, 0, 1, 4) self.rgbCheck = QtGui.QCheckBox(self.centralwidget) self.rgbCheck.setObjectName("rgbCheck") - self.gridLayout_2.addWidget(self.rgbCheck, 2, 1, 1, 1) + self.gridLayout_2.addWidget(self.rgbCheck, 3, 1, 1, 1) + self.label_5 = QtGui.QLabel(self.centralwidget) + self.label_5.setObjectName("label_5") + self.gridLayout_2.addWidget(self.label_5, 2, 0, 1, 1) + self.horizontalLayout_4 = QtGui.QHBoxLayout() + self.horizontalLayout_4.setObjectName("horizontalLayout_4") + self.framesSpin = QtGui.QSpinBox(self.centralwidget) + self.framesSpin.setButtonSymbols(QtGui.QAbstractSpinBox.NoButtons) + self.framesSpin.setProperty("value", 10) + self.framesSpin.setObjectName("framesSpin") + self.horizontalLayout_4.addWidget(self.framesSpin) + self.widthSpin = QtGui.QSpinBox(self.centralwidget) + self.widthSpin.setButtonSymbols(QtGui.QAbstractSpinBox.PlusMinus) + self.widthSpin.setMaximum(10000) + self.widthSpin.setProperty("value", 512) + self.widthSpin.setObjectName("widthSpin") + self.horizontalLayout_4.addWidget(self.widthSpin) + self.heightSpin = QtGui.QSpinBox(self.centralwidget) + self.heightSpin.setButtonSymbols(QtGui.QAbstractSpinBox.NoButtons) + self.heightSpin.setMaximum(10000) + self.heightSpin.setProperty("value", 512) + self.heightSpin.setObjectName("heightSpin") + self.horizontalLayout_4.addWidget(self.heightSpin) + self.gridLayout_2.addLayout(self.horizontalLayout_4, 2, 1, 1, 2) + self.sizeLabel = QtGui.QLabel(self.centralwidget) + self.sizeLabel.setText("") + self.sizeLabel.setObjectName("sizeLabel") + self.gridLayout_2.addWidget(self.sizeLabel, 2, 3, 1, 1) MainWindow.setCentralWidget(self.centralwidget) self.retranslateUi(MainWindow) @@ -154,22 +184,24 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): MainWindow.setWindowTitle(QtGui.QApplication.translate("MainWindow", "MainWindow", None, QtGui.QApplication.UnicodeUTF8)) + self.downsampleCheck.setText(QtGui.QApplication.translate("MainWindow", "Auto downsample", None, QtGui.QApplication.UnicodeUTF8)) + self.scaleCheck.setText(QtGui.QApplication.translate("MainWindow", "Scale Data", None, QtGui.QApplication.UnicodeUTF8)) self.rawRadio.setText(QtGui.QApplication.translate("MainWindow", "RawImageWidget", None, QtGui.QApplication.UnicodeUTF8)) self.gfxRadio.setText(QtGui.QApplication.translate("MainWindow", "GraphicsView + ImageItem", None, QtGui.QApplication.UnicodeUTF8)) self.rawGLRadio.setText(QtGui.QApplication.translate("MainWindow", "RawGLImageWidget", None, QtGui.QApplication.UnicodeUTF8)) - self.label.setText(QtGui.QApplication.translate("MainWindow", "Data type", None, QtGui.QApplication.UnicodeUTF8)) self.dtypeCombo.setItemText(0, QtGui.QApplication.translate("MainWindow", "uint8", None, QtGui.QApplication.UnicodeUTF8)) self.dtypeCombo.setItemText(1, QtGui.QApplication.translate("MainWindow", "uint16", None, QtGui.QApplication.UnicodeUTF8)) self.dtypeCombo.setItemText(2, QtGui.QApplication.translate("MainWindow", "float", None, QtGui.QApplication.UnicodeUTF8)) - self.scaleCheck.setText(QtGui.QApplication.translate("MainWindow", "Scale Data", None, QtGui.QApplication.UnicodeUTF8)) + self.label.setText(QtGui.QApplication.translate("MainWindow", "Data type", None, QtGui.QApplication.UnicodeUTF8)) self.rgbLevelsCheck.setText(QtGui.QApplication.translate("MainWindow", "RGB", None, QtGui.QApplication.UnicodeUTF8)) - self.label_2.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) self.label_3.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) + self.label_2.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) self.label_4.setText(QtGui.QApplication.translate("MainWindow", "<--->", None, QtGui.QApplication.UnicodeUTF8)) self.lutCheck.setText(QtGui.QApplication.translate("MainWindow", "Use Lookup Table", None, QtGui.QApplication.UnicodeUTF8)) self.alphaCheck.setText(QtGui.QApplication.translate("MainWindow", "alpha", None, QtGui.QApplication.UnicodeUTF8)) self.fpsLabel.setText(QtGui.QApplication.translate("MainWindow", "FPS", None, QtGui.QApplication.UnicodeUTF8)) self.rgbCheck.setText(QtGui.QApplication.translate("MainWindow", "RGB", None, QtGui.QApplication.UnicodeUTF8)) + self.label_5.setText(QtGui.QApplication.translate("MainWindow", "Image size", None, QtGui.QApplication.UnicodeUTF8)) from pyqtgraph.widgets.RawImageWidget import RawImageGLWidget, RawImageWidget from pyqtgraph import GradientWidget, SpinBox, GraphicsView diff --git a/examples/ViewBox.py b/examples/ViewBox.py index 2dcbb758..3a66afe3 100644 --- a/examples/ViewBox.py +++ b/examples/ViewBox.py @@ -14,7 +14,6 @@ import initExample ## This example uses a ViewBox to create a PlotWidget-like interface -#from scipy import random import numpy as np from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg diff --git a/examples/ViewBoxFeatures.py b/examples/ViewBoxFeatures.py new file mode 100644 index 00000000..6388e41b --- /dev/null +++ b/examples/ViewBoxFeatures.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +""" +ViewBox is the general-purpose graphical container that allows the user to +zoom / pan to inspect any area of a 2D coordinate system. + +This example demonstrates many of the features ViewBox provides. +""" + +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +x = np.arange(1000, dtype=float) +y = np.random.normal(size=1000) +y += 5 * np.sin(x/100) + +win = pg.GraphicsWindow() +win.setWindowTitle('pyqtgraph example: ____') +win.resize(1000, 800) +win.ci.setBorder((50, 50, 100)) + +sub1 = win.addLayout() +sub1.addLabel("Standard mouse interaction:
left-drag to pan, right-drag to zoom.") +sub1.nextRow() +v1 = sub1.addViewBox() +l1 = pg.PlotDataItem(y) +v1.addItem(l1) + + +sub2 = win.addLayout() +sub2.addLabel("One-button mouse interaction:
left-drag zoom to box, wheel to zoom out.") +sub2.nextRow() +v2 = sub2.addViewBox() +v2.setMouseMode(v2.RectMode) +l2 = pg.PlotDataItem(y) +v2.addItem(l2) + +win.nextRow() + +sub3 = win.addLayout() +sub3.addLabel("Locked aspect ratio when zooming.") +sub3.nextRow() +v3 = sub3.addViewBox() +v3.setAspectLocked(1.0) +l3 = pg.PlotDataItem(y) +v3.addItem(l3) + +sub4 = win.addLayout() +sub4.addLabel("View limits:
prevent panning or zooming past limits.") +sub4.nextRow() +v4 = sub4.addViewBox() +v4.setLimits(xMin=-100, xMax=1100, + minXRange=20, maxXRange=500, + yMin=-10, yMax=10, + minYRange=1, maxYRange=10) +l4 = pg.PlotDataItem(y) +v4.addItem(l4) + +win.nextRow() + +sub5 = win.addLayout() +sub5.addLabel("Linked axes: Data in this plot is always X-aligned to
the plot above.") +sub5.nextRow() +v5 = sub5.addViewBox() +v5.setXLink(v3) +l5 = pg.PlotDataItem(y) +v5.addItem(l5) + +sub6 = win.addLayout() +sub6.addLabel("Disable mouse: Per-axis control over mouse input.
" + "Auto-scale-visible: Automatically fit *visible* data within view
" + "(try panning left-right).") +sub6.nextRow() +v6 = sub6.addViewBox() +v6.setMouseEnabled(x=True, y=False) +v6.enableAutoRange(x=False, y=True) +v6.setXRange(300, 450) +v6.setAutoVisible(x=False, y=True) +l6 = pg.PlotDataItem(y) +v6.addItem(l6) + + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/ViewLimits.py b/examples/ViewLimits.py new file mode 100644 index 00000000..c8f0dd21 --- /dev/null +++ b/examples/ViewLimits.py @@ -0,0 +1,15 @@ +import initExample ## Add path to library (just for examples; you do not need this) + +from pyqtgraph.Qt import QtGui, QtCore +import pyqtgraph as pg +import numpy as np + +plt = pg.plot(np.random.normal(size=100), title="View limit example") +plt.centralWidget.vb.setLimits(xMin=-20, xMax=120, minXRange=5, maxXRange=100) + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if sys.flags.interactive != 1 or not hasattr(QtCore, 'PYQT_VERSION'): + pg.QtGui.QApplication.exec_() diff --git a/examples/__main__.py b/examples/__main__.py index a397cf05..cb1b87a1 100644 --- a/examples/__main__.py +++ b/examples/__main__.py @@ -8,6 +8,7 @@ if __name__ == "__main__" and (__package__ is None or __package__==''): from . import initExample from pyqtgraph.Qt import QtCore, QtGui, USE_PYSIDE +import pyqtgraph as pg if USE_PYSIDE: from .exampleLoaderTemplate_pyside import Ui_Form @@ -25,17 +26,26 @@ examples = OrderedDict([ ('Crosshair / Mouse interaction', 'crosshair.py'), ('Data Slicing', 'DataSlicing.py'), ('Plot Customization', 'customPlot.py'), + ('Image Analysis', 'imageAnalysis.py'), ('Dock widgets', 'dockarea.py'), ('Console', 'ConsoleWidget.py'), ('Histograms', 'histogram.py'), ('Auto-range', 'PlotAutoRange.py'), ('Remote Plotting', 'RemoteSpeedTest.py'), + ('Scrolling plots', 'scrollingPlots.py'), + ('HDF5 big data', 'hdf5.py'), + ('Demos', OrderedDict([ + ('Optics', 'optics_demos.py'), + ('Special relativity', 'relativity_demo.py'), + ('Verlet chain', 'verlet_chain_demo.py'), + ])), ('GraphicsItems', OrderedDict([ ('Scatter Plot', 'ScatterPlot.py'), #('PlotItem', 'PlotItem.py'), ('IsocurveItem', 'isocurve.py'), ('GraphItem', 'GraphItem.py'), ('ErrorBarItem', 'ErrorBarItem.py'), + ('FillBetweenItem', 'FillBetweenItem.py'), ('ImageItem - video', 'ImageItem.py'), ('ImageItem - draw', 'Draw.py'), ('Region-of-Interest', 'ROIExamples.py'), @@ -51,6 +61,7 @@ examples = OrderedDict([ ('Video speed test', 'VideoSpeedTest.py'), ('Line Plot update', 'PlotSpeedTest.py'), ('Scatter Plot update', 'ScatterPlotSpeedTest.py'), + ('Multiple plots', 'MultiPlotSpeedTest.py'), ])), ('3D Graphics', OrderedDict([ ('Volumetric', 'GLVolumeItem.py'), @@ -281,6 +292,9 @@ except: if __name__ == '__main__': if '--test' in sys.argv[1:]: + # get rid of orphaned cache files first + pg.renamePyc(path) + files = buildFileList(examples) if '--pyside' in sys.argv[1:]: lib = 'PySide' diff --git a/examples/contextMenu.py b/examples/contextMenu.py new file mode 100644 index 00000000..c2c5918d --- /dev/null +++ b/examples/contextMenu.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +""" +Demonstrates adding a custom context menu to a GraphicsItem +and extending the context menu of a ViewBox. + +PyQtGraph implements a system that allows each item in a scene to implement its +own context menu, and for the menus of its parent items to be automatically +displayed as well. + +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +win = pg.GraphicsWindow() +win.setWindowTitle('pyqtgraph example: context menu') + + +view = win.addViewBox() + +# add two new actions to the ViewBox context menu: +zoom1 = view.menu.addAction('Zoom to box 1') +zoom2 = view.menu.addAction('Zoom to box 2') + +# define callbacks for these actions +def zoomTo1(): + # note that box1 is defined below + view.autoRange(items=[box1]) +zoom1.triggered.connect(zoomTo1) + +def zoomTo2(): + # note that box1 is defined below + view.autoRange(items=[box2]) +zoom2.triggered.connect(zoomTo2) + + + +class MenuBox(pg.GraphicsObject): + """ + This class draws a rectangular area. Right-clicking inside the area will + raise a custom context menu which also includes the context menus of + its parents. + """ + def __init__(self, name): + self.name = name + self.pen = pg.mkPen('r') + + # menu creation is deferred because it is expensive and often + # the user will never see the menu anyway. + self.menu = None + + # note that the use of super() is often avoided because Qt does not + # allow to inherit from multiple QObject subclasses. + pg.GraphicsObject.__init__(self) + + + # All graphics items must have paint() and boundingRect() defined. + def boundingRect(self): + return QtCore.QRectF(0, 0, 10, 10) + + def paint(self, p, *args): + p.setPen(self.pen) + p.drawRect(self.boundingRect()) + + + # On right-click, raise the context menu + def mouseClickEvent(self, ev): + if ev.button() == QtCore.Qt.RightButton: + if self.raiseContextMenu(ev): + ev.accept() + + def raiseContextMenu(self, ev): + menu = self.getContextMenus() + + # Let the scene add on to the end of our context menu + # (this is optional) + menu = self.scene().addParentContextMenus(self, menu, ev) + + pos = ev.screenPos() + menu.popup(QtCore.QPoint(pos.x(), pos.y())) + return True + + # This method will be called when this item's _children_ want to raise + # a context menu that includes their parents' menus. + def getContextMenus(self, event=None): + if self.menu is None: + self.menu = QtGui.QMenu() + self.menu.setTitle(self.name+ " options..") + + green = QtGui.QAction("Turn green", self.menu) + green.triggered.connect(self.setGreen) + self.menu.addAction(green) + self.menu.green = green + + blue = QtGui.QAction("Turn blue", self.menu) + blue.triggered.connect(self.setBlue) + self.menu.addAction(blue) + self.menu.green = blue + + alpha = QtGui.QWidgetAction(self.menu) + alphaSlider = QtGui.QSlider() + alphaSlider.setOrientation(QtCore.Qt.Horizontal) + alphaSlider.setMaximum(255) + alphaSlider.setValue(255) + alphaSlider.valueChanged.connect(self.setAlpha) + alpha.setDefaultWidget(alphaSlider) + self.menu.addAction(alpha) + self.menu.alpha = alpha + self.menu.alphaSlider = alphaSlider + return self.menu + + # Define context menu callbacks + def setGreen(self): + self.pen = pg.mkPen('g') + # inform Qt that this item must be redrawn. + self.update() + + def setBlue(self): + self.pen = pg.mkPen('b') + self.update() + + def setAlpha(self, a): + self.setOpacity(a/255.) + + +# This box's context menu will include the ViewBox's menu +box1 = MenuBox("Menu Box #1") +view.addItem(box1) + +# This box's context menu will include both the ViewBox's menu and box1's menu +box2 = MenuBox("Menu Box #2") +box2.setParentItem(box1) +box2.setPos(5, 5) +box2.scale(0.2, 0.2) + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/crosshair.py b/examples/crosshair.py index 67d3cc5f..076fab49 100644 --- a/examples/crosshair.py +++ b/examples/crosshair.py @@ -7,7 +7,6 @@ the mouse. import initExample ## Add path to library (just for examples; you do not need this) import numpy as np -import scipy.ndimage as ndi import pyqtgraph as pg from pyqtgraph.Qt import QtGui, QtCore from pyqtgraph.Point import Point @@ -33,8 +32,8 @@ p1.setAutoVisible(y=True) #create numpy arrays #make the numbers large to show that the xrange shows data from 10000 to all the way 0 -data1 = 10000 + 15000 * ndi.gaussian_filter(np.random.random(size=10000), 10) + 3000 * np.random.random(size=10000) -data2 = 15000 + 15000 * ndi.gaussian_filter(np.random.random(size=10000), 10) + 3000 * np.random.random(size=10000) +data1 = 10000 + 15000 * pg.gaussianFilter(np.random.random(size=10000), 10) + 3000 * np.random.random(size=10000) +data2 = 15000 + 15000 * pg.gaussianFilter(np.random.random(size=10000), 10) + 3000 * np.random.random(size=10000) p1.plot(data1, pen="r") p1.plot(data2, pen="g") diff --git a/examples/cx_freeze/plotTest.py b/examples/cx_freeze/plotTest.py new file mode 100644 index 00000000..1a53a984 --- /dev/null +++ b/examples/cx_freeze/plotTest.py @@ -0,0 +1,20 @@ +import sys +from PyQt4 import QtGui +import pyqtgraph as pg +from pyqtgraph.graphicsItems import TextItem +# For packages that require scipy, these may be needed: +# from scipy.stats import futil +# from scipy.sparse.csgraph import _validation + +from pyqtgraph import setConfigOption +pg.setConfigOption('background','w') +pg.setConfigOption('foreground','k') +app = QtGui.QApplication(sys.argv) + +pw = pg.plot(x = [0, 1, 2, 4], y = [4, 5, 9, 6]) +pw.showGrid(x=True,y=True) +text = pg.TextItem(html='
%s
' % "here",anchor=(0.0, 0.0)) +text.setPos(1.0, 5.0) +pw.addItem(text) +status = app.exec_() +sys.exit(status) diff --git a/examples/cx_freeze/setup.py b/examples/cx_freeze/setup.py new file mode 100644 index 00000000..bdace733 --- /dev/null +++ b/examples/cx_freeze/setup.py @@ -0,0 +1,36 @@ +# Build with `python setup.py build_exe` +from cx_Freeze import setup, Executable + +import shutil +from glob import glob +# Remove the build folder +shutil.rmtree("build", ignore_errors=True) +shutil.rmtree("dist", ignore_errors=True) +import sys + +includes = ['PyQt4.QtCore', 'PyQt4.QtGui', 'sip', 'pyqtgraph.graphicsItems', + 'numpy', 'atexit'] +excludes = ['cvxopt','_gtkagg', '_tkagg', 'bsddb', 'curses', 'email', 'pywin.debugger', + 'pywin.debugger.dbgcon', 'pywin.dialogs', 'tcl','tables', + 'Tkconstants', 'Tkinter', 'zmq','PySide','pysideuic','scipy','matplotlib'] + +if sys.version[0] == '2': + # causes syntax error on py2 + excludes.append('PyQt4.uic.port_v3') + +base = None +if sys.platform == "win32": + base = "Win32GUI" + +build_exe_options = {'excludes': excludes, + 'includes':includes, 'include_msvcr':True, + 'compressed':True, 'copy_dependent_files':True, 'create_shared_zip':True, + 'include_in_shared_zip':True, 'optimize':2} + +setup(name = "cx_freeze plot test", + version = "0.1", + description = "cx_freeze plot test", + options = {"build_exe": build_exe_options}, + executables = [Executable("plotTest.py", base=base)]) + + diff --git a/examples/designerExample.py b/examples/designerExample.py new file mode 100644 index 00000000..812eff6b --- /dev/null +++ b/examples/designerExample.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" +Simple example of loading UI template created with Qt Designer. + +This example uses uic.loadUiType to parse and load the ui at runtime. It is also +possible to pre-compile the .ui file using pyuic (see VideoSpeedTest and +ScatterPlotSpeedTest examples; these .ui files have been compiled with the +tools/rebuildUi.py script). +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np +import os + +pg.mkQApp() + +## Define main window class from template +path = os.path.dirname(os.path.abspath(__file__)) +uiFile = os.path.join(path, 'designerExample.ui') +WindowTemplate, TemplateBaseClass = pg.Qt.loadUiType(uiFile) + +class MainWindow(TemplateBaseClass): + def __init__(self): + TemplateBaseClass.__init__(self) + self.setWindowTitle('pyqtgraph example: Qt Designer') + + # Create the main window + self.ui = WindowTemplate() + self.ui.setupUi(self) + self.ui.plotBtn.clicked.connect(self.plot) + + self.show() + + def plot(self): + self.ui.plot.plot(np.random.normal(size=100), clear=True) + +win = MainWindow() + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/designerExample.ui b/examples/designerExample.ui new file mode 100644 index 00000000..41d06089 --- /dev/null +++ b/examples/designerExample.ui @@ -0,0 +1,38 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + Plot! + + + + + + + + + + + PlotWidget + QGraphicsView +
pyqtgraph
+
+
+ + +
diff --git a/examples/dockarea.py b/examples/dockarea.py index 2b33048d..9cc79f1b 100644 --- a/examples/dockarea.py +++ b/examples/dockarea.py @@ -35,7 +35,7 @@ win.setWindowTitle('pyqtgraph example: dockarea') ## Note that size arguments are only a suggestion; docks will still have to ## fill the entire dock area and obey the limits of their internal widgets. d1 = Dock("Dock1", size=(1, 1)) ## give this dock the minimum possible size -d2 = Dock("Dock2 - Console", size=(500,300)) +d2 = Dock("Dock2 - Console", size=(500,300), closable=True) d3 = Dock("Dock3", size=(500,400)) d4 = Dock("Dock4 (tabbed) - Plot", size=(500,200)) d5 = Dock("Dock5 - Image", size=(500,200)) diff --git a/examples/hdf5.py b/examples/hdf5.py new file mode 100644 index 00000000..b43ae24a --- /dev/null +++ b/examples/hdf5.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +""" +In this example we create a subclass of PlotCurveItem for displaying a very large +data set from an HDF5 file that does not fit in memory. + +The basic approach is to override PlotCurveItem.viewRangeChanged such that it +reads only the portion of the HDF5 data that is necessary to display the visible +portion of the data. This is further downsampled to reduce the number of samples +being displayed. + +A more clever implementation of this class would employ some kind of caching +to avoid re-reading the entire visible waveform at every update. +""" + +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np +import h5py +import sys, os + +pg.mkQApp() + + +plt = pg.plot() +plt.setWindowTitle('pyqtgraph example: HDF5 big data') +plt.enableAutoRange(False, False) +plt.setXRange(0, 500) + +class HDF5Plot(pg.PlotCurveItem): + def __init__(self, *args, **kwds): + self.hdf5 = None + self.limit = 10000 # maximum number of samples to be plotted + pg.PlotCurveItem.__init__(self, *args, **kwds) + + def setHDF5(self, data): + self.hdf5 = data + self.updateHDF5Plot() + + def viewRangeChanged(self): + self.updateHDF5Plot() + + def updateHDF5Plot(self): + if self.hdf5 is None: + self.setData([]) + return + + vb = self.getViewBox() + if vb is None: + return # no ViewBox yet + + # Determine what data range must be read from HDF5 + xrange = vb.viewRange()[0] + start = max(0,int(xrange[0])-1) + stop = min(len(self.hdf5), int(xrange[1]+2)) + + # Decide by how much we should downsample + ds = int((stop-start) / self.limit) + 1 + + if ds == 1: + # Small enough to display with no intervention. + visible = self.hdf5[start:stop] + scale = 1 + else: + # Here convert data into a down-sampled array suitable for visualizing. + # Must do this piecewise to limit memory usage. + samples = 1 + ((stop-start) // ds) + visible = np.zeros(samples*2, dtype=self.hdf5.dtype) + sourcePtr = start + targetPtr = 0 + + # read data in chunks of ~1M samples + chunkSize = (1000000//ds) * ds + while sourcePtr < stop-1: + chunk = self.hdf5[sourcePtr:min(stop,sourcePtr+chunkSize)] + sourcePtr += len(chunk) + + # reshape chunk to be integral multiple of ds + chunk = chunk[:(len(chunk)//ds) * ds].reshape(len(chunk)//ds, ds) + + # compute max and min + chunkMax = chunk.max(axis=1) + chunkMin = chunk.min(axis=1) + + # interleave min and max into plot data to preserve envelope shape + visible[targetPtr:targetPtr+chunk.shape[0]*2:2] = chunkMin + visible[1+targetPtr:1+targetPtr+chunk.shape[0]*2:2] = chunkMax + targetPtr += chunk.shape[0]*2 + + visible = visible[:targetPtr] + scale = ds * 0.5 + + self.setData(visible) # update the plot + self.setPos(start, 0) # shift to match starting index + self.resetTransform() + self.scale(scale, 1) # scale to match downsampling + + + + +def createFile(finalSize=2000000000): + """Create a large HDF5 data file for testing. + Data consists of 1M random samples tiled through the end of the array. + """ + + chunk = np.random.normal(size=1000000).astype(np.float32) + + f = h5py.File('test.hdf5', 'w') + f.create_dataset('data', data=chunk, chunks=True, maxshape=(None,)) + data = f['data'] + + nChunks = finalSize // (chunk.size * chunk.itemsize) + with pg.ProgressDialog("Generating test.hdf5...", 0, nChunks) as dlg: + for i in range(nChunks): + newshape = [data.shape[0] + chunk.shape[0]] + data.resize(newshape) + data[-chunk.shape[0]:] = chunk + dlg += 1 + if dlg.wasCanceled(): + f.close() + os.remove('test.hdf5') + sys.exit() + dlg += 1 + f.close() + +if len(sys.argv) > 1: + fileName = sys.argv[1] +else: + fileName = 'test.hdf5' + if not os.path.isfile(fileName): + size, ok = QtGui.QInputDialog.getDouble(None, "Create HDF5 Dataset?", "This demo requires a large HDF5 array. To generate a file, enter the array size (in GB) and press OK.", 2.0) + if not ok: + sys.exit(0) + else: + createFile(int(size*1e9)) + #raise Exception("No suitable HDF5 file found. Use createFile() to generate an example file.") + +f = h5py.File(fileName, 'r') +curve = HDF5Plot() +curve.setHDF5(f['data']) +plt.addItem(curve) + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + + + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() + + + + diff --git a/examples/histogram.py b/examples/histogram.py index 057abffd..2674ba30 100644 --- a/examples/histogram.py +++ b/examples/histogram.py @@ -2,8 +2,6 @@ """ In this example we draw two different kinds of histogram. """ - - import initExample ## Add path to library (just for examples; you do not need this) import pyqtgraph as pg @@ -22,11 +20,9 @@ vals = np.hstack([np.random.normal(size=500), np.random.normal(size=260, loc=4)] ## compute standard histogram y,x = np.histogram(vals, bins=np.linspace(-3, 8, 40)) +## Using stepMode=True causes the plot to draw two lines for each sample. ## notice that len(x) == len(y)+1 -## We are required to use stepMode=True so that PlotCurveItem will interpret this data correctly. -curve = pg.PlotCurveItem(x, y, stepMode=True, fillLevel=0, brush=(0, 0, 255, 80)) -plt1.addItem(curve) - +plt1.plot(x, y, stepMode=True, fillLevel=0, brush=(0,0,255,150)) ## Now draw all points as a nicely-spaced scatter plot y = pg.pseudoScatter(vals, spacing=0.15) diff --git a/examples/imageAnalysis.py b/examples/imageAnalysis.py new file mode 100644 index 00000000..8283144e --- /dev/null +++ b/examples/imageAnalysis.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" +Demonstrates common image analysis tools. + +Many of the features demonstrated here are already provided by the ImageView +widget, but here we present a lower-level approach that provides finer control +over the user interface. +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +pg.mkQApp() + +win = pg.GraphicsLayoutWidget() +win.setWindowTitle('pyqtgraph example: Image Analysis') + +# A plot area (ViewBox + axes) for displaying the image +p1 = win.addPlot() + +# Item for displaying image data +img = pg.ImageItem() +p1.addItem(img) + +# Custom ROI for selecting an image region +roi = pg.ROI([-8, 14], [6, 5]) +roi.addScaleHandle([0.5, 1], [0.5, 0.5]) +roi.addScaleHandle([0, 0.5], [0.5, 0.5]) +p1.addItem(roi) +roi.setZValue(10) # make sure ROI is drawn above image + +# Isocurve drawing +iso = pg.IsocurveItem(level=0.8, pen='g') +iso.setParentItem(img) +iso.setZValue(5) + +# Contrast/color control +hist = pg.HistogramLUTItem() +hist.setImageItem(img) +win.addItem(hist) + +# Draggable line for setting isocurve level +isoLine = pg.InfiniteLine(angle=0, movable=True, pen='g') +hist.vb.addItem(isoLine) +hist.vb.setMouseEnabled(y=False) # makes user interaction a little easier +isoLine.setValue(0.8) +isoLine.setZValue(1000) # bring iso line above contrast controls + +# Another plot area for displaying ROI data +win.nextRow() +p2 = win.addPlot(colspan=2) +p2.setMaximumHeight(250) +win.resize(800, 800) +win.show() + + +# Generate image data +data = np.random.normal(size=(100, 200)) +data[20:80, 20:80] += 2. +data = pg.gaussianFilter(data, (3, 3)) +data += np.random.normal(size=(100, 200)) * 0.1 +img.setImage(data) +hist.setLevels(data.min(), data.max()) + +# build isocurves from smoothed data +iso.setData(pg.gaussianFilter(data, (2, 2))) + +# set position and scale of image +img.scale(0.2, 0.2) +img.translate(-50, 0) + +# zoom to fit imageo +p1.autoRange() + + +# Callbacks for handling user interaction +def updatePlot(): + global img, roi, data, p2 + selected = roi.getArrayRegion(data, img) + p2.plot(selected.mean(axis=1), clear=True) + +roi.sigRegionChanged.connect(updatePlot) +updatePlot() + +def updateIsocurve(): + global isoLine, iso + iso.setLevel(isoLine.value()) + +isoLine.sigDragged.connect(updateIsocurve) + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/isocurve.py b/examples/isocurve.py index fa451063..b401dfe1 100644 --- a/examples/isocurve.py +++ b/examples/isocurve.py @@ -10,7 +10,6 @@ import initExample ## Add path to library (just for examples; you do not need th from pyqtgraph.Qt import QtGui, QtCore import numpy as np import pyqtgraph as pg -import scipy.ndimage as ndi app = QtGui.QApplication([]) @@ -18,7 +17,7 @@ app = QtGui.QApplication([]) frames = 200 data = np.random.normal(size=(frames,30,30), loc=0, scale=100) data = np.concatenate([data, data], axis=0) -data = ndi.gaussian_filter(data, (10, 10, 10))[frames/2:frames + frames/2] +data = pg.gaussianFilter(data, (10, 10, 10))[frames/2:frames + frames/2] data[:, 15:16, 15:17] += 1 win = pg.GraphicsWindow() diff --git a/examples/optics/__init__.py b/examples/optics/__init__.py new file mode 100644 index 00000000..b3d31cd0 --- /dev/null +++ b/examples/optics/__init__.py @@ -0,0 +1 @@ +from .pyoptic import * \ No newline at end of file diff --git a/examples/optics/pyoptic.py b/examples/optics/pyoptic.py new file mode 100644 index 00000000..dc493568 --- /dev/null +++ b/examples/optics/pyoptic.py @@ -0,0 +1,582 @@ +# -*- coding: utf-8 -*- +from PyQt4 import QtGui, QtCore +import pyqtgraph as pg +#from pyqtgraph.canvas import Canvas, CanvasItem +import numpy as np +import csv, gzip, os +from pyqtgraph import Point + +class GlassDB: + """ + Database of dispersion coefficients for Schott glasses + + Corning 7980 + """ + def __init__(self, fileName='schott_glasses.csv'): + path = os.path.dirname(__file__) + fh = gzip.open(os.path.join(path, 'schott_glasses.csv.gz'), 'rb') + r = csv.reader(map(str, fh.readlines())) + lines = [x for x in r] + self.data = {} + header = lines[0] + for l in lines[1:]: + info = {} + for i in range(1, len(l)): + info[header[i]] = l[i] + self.data[l[0]] = info + self.data['Corning7980'] = { ## Thorlabs UV fused silica--not in schott catalog. + 'B1': 0.68374049400, + 'B2': 0.42032361300, + 'B3': 0.58502748000, + 'C1': 0.00460352869, + 'C2': 0.01339688560, + 'C3': 64.49327320000, + 'TAUI25/250': 0.95, ## transmission data is fabricated, but close. + 'TAUI25/1400': 0.98, + } + + for k in self.data: + self.data[k]['ior_cache'] = {} + + + def ior(self, glass, wl): + """ + Return the index of refraction for *glass* at wavelength *wl*. + + The *glass* argument must be a key in self.data. + """ + info = self.data[glass] + cache = info['ior_cache'] + if wl not in cache: + B = list(map(float, [info['B1'], info['B2'], info['B3']])) + C = list(map(float, [info['C1'], info['C2'], info['C3']])) + w2 = (wl/1000.)**2 + n = np.sqrt(1.0 + (B[0]*w2 / (w2-C[0])) + (B[1]*w2 / (w2-C[1])) + (B[2]*w2 / (w2-C[2]))) + cache[wl] = n + return cache[wl] + + def transmissionCurve(self, glass): + data = self.data[glass] + keys = [int(x[7:]) for x in data.keys() if 'TAUI25' in x] + keys.sort() + curve = np.empty((2,len(keys))) + for i in range(len(keys)): + curve[0][i] = keys[i] + key = 'TAUI25/%d' % keys[i] + val = data[key] + if val == '': + val = 0 + else: + val = float(val) + curve[1][i] = val + return curve + + +GLASSDB = GlassDB() + + +def wlPen(wl): + """Return a pen representing the given wavelength""" + l1 = 400 + l2 = 700 + hue = np.clip(((l2-l1) - (wl-l1)) * 0.8 / (l2-l1), 0, 0.8) + val = 1.0 + if wl > 700: + val = 1.0 * (((700-wl)/700.) + 1) + elif wl < 400: + val = wl * 1.0/400. + #print hue, val + color = pg.hsvColor(hue, 1.0, val) + pen = pg.mkPen(color) + return pen + + +class ParamObj: + # Just a helper for tracking parameters and responding to changes + def __init__(self): + self.__params = {} + + def __setitem__(self, item, val): + self.setParam(item, val) + + def setParam(self, param, val): + self.setParams(**{param:val}) + + def setParams(self, **params): + """Set parameters for this optic. This is a good function to override for subclasses.""" + self.__params.update(params) + self.paramStateChanged() + + def paramStateChanged(self): + pass + + def __getitem__(self, item): + return self.getParam(item) + + def getParam(self, param): + return self.__params[param] + + +class Optic(pg.GraphicsObject, ParamObj): + + sigStateChanged = QtCore.Signal() + + + def __init__(self, gitem, **params): + ParamObj.__init__(self) + pg.GraphicsObject.__init__(self) #, [0,0], [1,1]) + + self.gitem = gitem + self.surfaces = gitem.surfaces + gitem.setParentItem(self) + + self.roi = pg.ROI([0,0], [1,1]) + self.roi.addRotateHandle([1, 1], [0.5, 0.5]) + self.roi.setParentItem(self) + + defaults = { + 'pos': Point(0,0), + 'angle': 0, + } + defaults.update(params) + self._ior_cache = {} + self.roi.sigRegionChanged.connect(self.roiChanged) + self.setParams(**defaults) + + def updateTransform(self): + self.resetTransform() + self.setPos(0, 0) + self.translate(Point(self['pos'])) + self.rotate(self['angle']) + + def setParam(self, param, val): + ParamObj.setParam(self, param, val) + + def paramStateChanged(self): + """Some parameters of the optic have changed.""" + # Move graphics item + self.gitem.setPos(Point(self['pos'])) + self.gitem.resetTransform() + self.gitem.rotate(self['angle']) + + # Move ROI to match + try: + self.roi.sigRegionChanged.disconnect(self.roiChanged) + br = self.gitem.boundingRect() + o = self.gitem.mapToParent(br.topLeft()) + self.roi.setAngle(self['angle']) + self.roi.setPos(o) + self.roi.setSize([br.width(), br.height()]) + finally: + self.roi.sigRegionChanged.connect(self.roiChanged) + + self.sigStateChanged.emit() + + def roiChanged(self, *args): + pos = self.roi.pos() + # rotate gitem temporarily so we can decide where it will need to move + self.gitem.resetTransform() + self.gitem.rotate(self.roi.angle()) + br = self.gitem.boundingRect() + o1 = self.gitem.mapToParent(br.topLeft()) + self.setParams(angle=self.roi.angle(), pos=pos + (self.gitem.pos() - o1)) + + def boundingRect(self): + return QtCore.QRectF() + + def paint(self, p, *args): + pass + + def ior(self, wavelength): + return GLASSDB.ior(self['glass'], wavelength) + + + +class Lens(Optic): + def __init__(self, **params): + defaults = { + 'dia': 25.4, ## diameter of lens + 'r1': 50., ## positive means convex, use 0 for planar + 'r2': 0, ## negative means convex + 'd': 4.0, + 'glass': 'N-BK7', + 'reflect': False, + } + defaults.update(params) + d = defaults.pop('d') + defaults['x1'] = -d/2. + defaults['x2'] = d/2. + + gitem = CircularSolid(brush=(100, 100, 130, 100), **defaults) + Optic.__init__(self, gitem, **defaults) + + def propagateRay(self, ray): + """Refract, reflect, absorb, and/or scatter ray. This function may create and return new rays""" + + """ + NOTE:: We can probably use this to compute refractions faster: (from GLSL 120 docs) + + For the incident vector I and surface normal N, and the + ratio of indices of refraction eta, return the refraction + vector. The result is computed by + k = 1.0 - eta * eta * (1.0 - dot(N, I) * dot(N, I)) + if (k < 0.0) + return genType(0.0) + else + return eta * I - (eta * dot(N, I) + sqrt(k)) * N + The input parameters for the incident vector I and the + surface normal N must already be normalized to get the + desired results. eta == ratio of IORs + + + For reflection: + For the incident vector I and surface orientation N, + returns the reflection direction: + I – 2 ∗ dot(N, I) ∗ N + N must already be normalized in order to achieve the + desired result. + """ + + + + iors = [self.ior(ray['wl']), 1.0] + for i in [0,1]: + surface = self.surfaces[i] + ior = iors[i] + p1, ai = surface.intersectRay(ray) + #print "surface intersection:", p1, ai*180/3.14159 + #trans = self.sceneTransform().inverted()[0] * surface.sceneTransform() + #p1 = trans.map(p1) + if p1 is None: + ray.setEnd(None) + break + p1 = surface.mapToItem(ray, p1) + + #print "adjusted position:", p1 + #ior = self.ior(ray['wl']) + rd = ray['dir'] + a1 = np.arctan2(rd[1], rd[0]) + ar = a1 - ai + np.arcsin((np.sin(ai) * ray['ior'] / ior)) + #print [x for x in [a1, ai, (np.sin(ai) * ray['ior'] / ior), ar]] + #print ai, np.sin(ai), ray['ior'], ior + ray.setEnd(p1) + dp = Point(np.cos(ar), np.sin(ar)) + #p2 = p1+dp + #p1p = self.mapToScene(p1) + #p2p = self.mapToScene(p2) + #dpp = Point(p2p-p1p) + ray = Ray(parent=ray, ior=ior, dir=dp) + return [ray] + + +class Mirror(Optic): + def __init__(self, **params): + defaults = { + 'r1': 0, + 'r2': 0, + 'd': 0.01, + } + defaults.update(params) + d = defaults.pop('d') + defaults['x1'] = -d/2. + defaults['x2'] = d/2. + gitem = CircularSolid(brush=(100,100,100,255), **defaults) + Optic.__init__(self, gitem, **defaults) + + def propagateRay(self, ray): + """Refract, reflect, absorb, and/or scatter ray. This function may create and return new rays""" + + surface = self.surfaces[0] + p1, ai = surface.intersectRay(ray) + if p1 is not None: + p1 = surface.mapToItem(ray, p1) + rd = ray['dir'] + a1 = np.arctan2(rd[1], rd[0]) + ar = a1 + np.pi - 2*ai + ray.setEnd(p1) + dp = Point(np.cos(ar), np.sin(ar)) + ray = Ray(parent=ray, dir=dp) + else: + ray.setEnd(None) + return [ray] + + +class CircularSolid(pg.GraphicsObject, ParamObj): + """GraphicsObject with two circular or flat surfaces.""" + def __init__(self, pen=None, brush=None, **opts): + """ + Arguments for each surface are: + x1,x2 - position of center of _physical surface_ + r1,r2 - radius of curvature + d1,d2 - diameter of optic + """ + defaults = dict(x1=-2, r1=100, d1=25.4, x2=2, r2=100, d2=25.4) + defaults.update(opts) + ParamObj.__init__(self) + self.surfaces = [CircleSurface(defaults['r1'], defaults['d1']), CircleSurface(-defaults['r2'], defaults['d2'])] + pg.GraphicsObject.__init__(self) + for s in self.surfaces: + s.setParentItem(self) + + if pen is None: + self.pen = pg.mkPen((220,220,255,200), width=1, cosmetic=True) + else: + self.pen = pg.mkPen(pen) + + if brush is None: + self.brush = pg.mkBrush((230, 230, 255, 30)) + else: + self.brush = pg.mkBrush(brush) + + self.setParams(**defaults) + + def paramStateChanged(self): + self.updateSurfaces() + + def updateSurfaces(self): + self.surfaces[0].setParams(self['r1'], self['d1']) + self.surfaces[1].setParams(-self['r2'], self['d2']) + self.surfaces[0].setPos(self['x1'], 0) + self.surfaces[1].setPos(self['x2'], 0) + + self.path = QtGui.QPainterPath() + self.path.connectPath(self.surfaces[0].path.translated(self.surfaces[0].pos())) + self.path.connectPath(self.surfaces[1].path.translated(self.surfaces[1].pos()).toReversed()) + self.path.closeSubpath() + + def boundingRect(self): + return self.path.boundingRect() + + def shape(self): + return self.path + + def paint(self, p, *args): + p.setRenderHints(p.renderHints() | p.Antialiasing) + p.setPen(self.pen) + p.fillPath(self.path, self.brush) + p.drawPath(self.path) + + +class CircleSurface(pg.GraphicsObject): + def __init__(self, radius=None, diameter=None): + """center of physical surface is at 0,0 + radius is the radius of the surface. If radius is None, the surface is flat. + diameter is of the optic's edge.""" + pg.GraphicsObject.__init__(self) + + self.r = radius + self.d = diameter + self.mkPath() + + def setParams(self, r, d): + self.r = r + self.d = d + self.mkPath() + + def mkPath(self): + self.prepareGeometryChange() + r = self.r + d = self.d + h2 = d/2. + self.path = QtGui.QPainterPath() + if r == 0: ## flat surface + self.path.moveTo(0, h2) + self.path.lineTo(0, -h2) + else: + ## half-height of surface can't be larger than radius + h2 = min(h2, abs(r)) + + #dx = abs(r) - (abs(r)**2 - abs(h2)**2)**0.5 + #p.moveTo(-d*w/2.+ d*dx, d*h2) + arc = QtCore.QRectF(0, -r, r*2, r*2) + #self.surfaces.append((arc.center(), r, h2)) + a1 = np.arcsin(h2/r) * 180. / np.pi + a2 = -2*a1 + a1 += 180. + self.path.arcMoveTo(arc, a1) + self.path.arcTo(arc, a1, a2) + #if d == -1: + #p1 = QtGui.QPainterPath() + #p1.addRect(arc) + #self.paths.append(p1) + self.h2 = h2 + + def boundingRect(self): + return self.path.boundingRect() + + def paint(self, p, *args): + return ## usually we let the optic draw. + #p.setPen(pg.mkPen('r')) + #p.drawPath(self.path) + + def intersectRay(self, ray): + ## return the point of intersection and the angle of incidence + #print "intersect ray" + h = self.h2 + r = self.r + p, dir = ray.currentState(relativeTo=self) # position and angle of ray in local coords. + #print " ray: ", p, dir + p = p - Point(r, 0) ## move position so center of circle is at 0,0 + #print " adj: ", p, r + + if r == 0: + #print " flat" + if dir[0] == 0: + y = 0 + else: + y = p[1] - p[0] * dir[1]/dir[0] + if abs(y) > h: + return None, None + else: + return (Point(0, y), np.arctan2(dir[1], dir[0])) + else: + #print " curve" + ## find intersection of circle and line (quadratic formula) + dx = dir[0] + dy = dir[1] + dr = (dx**2 + dy**2) ** 0.5 + D = p[0] * (p[1]+dy) - (p[0]+dx) * p[1] + idr2 = 1.0 / dr**2 + disc = r**2 * dr**2 - D**2 + if disc < 0: + return None, None + disc2 = disc**0.5 + if dy < 0: + sgn = -1 + else: + sgn = 1 + + + br = self.path.boundingRect() + x1 = (D*dy + sgn*dx*disc2) * idr2 + y1 = (-D*dx + abs(dy)*disc2) * idr2 + if br.contains(x1+r, y1): + pt = Point(x1, y1) + else: + x2 = (D*dy - sgn*dx*disc2) * idr2 + y2 = (-D*dx - abs(dy)*disc2) * idr2 + pt = Point(x2, y2) + if not br.contains(x2+r, y2): + return None, None + raise Exception("No intersection!") + + norm = np.arctan2(pt[1], pt[0]) + if r < 0: + norm += np.pi + #print " norm:", norm*180/3.1415 + dp = p - pt + #print " dp:", dp + ang = np.arctan2(dp[1], dp[0]) + #print " ang:", ang*180/3.1415 + #print " ai:", (ang-norm)*180/3.1415 + + #print " intersection:", pt + return pt + Point(r, 0), ang-norm + + +class Ray(pg.GraphicsObject, ParamObj): + """Represents a single straight segment of a ray""" + + sigStateChanged = QtCore.Signal() + + def __init__(self, **params): + ParamObj.__init__(self) + defaults = { + 'ior': 1.0, + 'wl': 500, + 'end': None, + 'dir': Point(1,0), + } + self.params = {} + pg.GraphicsObject.__init__(self) + self.children = [] + parent = params.get('parent', None) + if parent is not None: + defaults['start'] = parent['end'] + defaults['wl'] = parent['wl'] + self['ior'] = parent['ior'] + self['dir'] = parent['dir'] + parent.addChild(self) + + defaults.update(params) + defaults['dir'] = Point(defaults['dir']) + self.setParams(**defaults) + self.mkPath() + + def clearChildren(self): + for c in self.children: + c.clearChildren() + c.setParentItem(None) + self.scene().removeItem(c) + self.children = [] + + def paramStateChanged(self): + pass + + def addChild(self, ch): + self.children.append(ch) + ch.setParentItem(self) + + def currentState(self, relativeTo=None): + pos = self['start'] + dir = self['dir'] + if relativeTo is None: + return pos, dir + else: + trans = self.itemTransform(relativeTo)[0] + p1 = trans.map(pos) + p2 = trans.map(pos + dir) + return Point(p1), Point(p2-p1) + + + def setEnd(self, end): + self['end'] = end + self.mkPath() + + def boundingRect(self): + return self.path.boundingRect() + + def paint(self, p, *args): + #p.setPen(pg.mkPen((255,0,0, 150))) + p.setRenderHints(p.renderHints() | p.Antialiasing) + p.setCompositionMode(p.CompositionMode_Plus) + p.setPen(wlPen(self['wl'])) + p.drawPath(self.path) + + def mkPath(self): + self.prepareGeometryChange() + self.path = QtGui.QPainterPath() + self.path.moveTo(self['start']) + if self['end'] is not None: + self.path.lineTo(self['end']) + else: + self.path.lineTo(self['start']+500*self['dir']) + + +def trace(rays, optics): + if len(optics) < 1 or len(rays) < 1: + return + for r in rays: + r.clearChildren() + o = optics[0] + r2 = o.propagateRay(r) + trace(r2, optics[1:]) + +class Tracer(QtCore.QObject): + """ + Simple ray tracer. + + Initialize with a list of rays and optics; + calling trace() will cause rays to be extended by propagating them through + each optic in sequence. + """ + def __init__(self, rays, optics): + QtCore.QObject.__init__(self) + self.optics = optics + self.rays = rays + for o in self.optics: + o.sigStateChanged.connect(self.trace) + self.trace() + + def trace(self): + trace(self.rays, self.optics) + diff --git a/examples/optics/schott_glasses.csv.gz b/examples/optics/schott_glasses.csv.gz new file mode 100644 index 00000000..8df4ae14 Binary files /dev/null and b/examples/optics/schott_glasses.csv.gz differ diff --git a/examples/optics_demos.py b/examples/optics_demos.py new file mode 100644 index 00000000..36bfc7f9 --- /dev/null +++ b/examples/optics_demos.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +""" +Optical system design demo + + + +""" + +import initExample ## Add path to library (just for examples; you do not need this) + +from optics import * + +import pyqtgraph as pg + +import numpy as np +from pyqtgraph import Point + +app = pg.QtGui.QApplication([]) + +w = pg.GraphicsWindow(border=0.5) +w.resize(1000, 900) +w.show() + + + +### Curved mirror demo + +view = w.addViewBox() +view.setAspectLocked() +#grid = pg.GridItem() +#view.addItem(grid) +view.setRange(pg.QtCore.QRectF(-50, -30, 100, 100)) + +optics = [] +rays = [] +m1 = Mirror(r1=-100, pos=(5,0), d=5, angle=-15) +optics.append(m1) +m2 = Mirror(r1=-70, pos=(-40, 30), d=6, angle=180-15) +optics.append(m2) + +allRays = [] +for y in np.linspace(-10, 10, 21): + r = Ray(start=Point(-100, y)) + view.addItem(r) + allRays.append(r) + +for o in optics: + view.addItem(o) + +t1 = Tracer(allRays, optics) + + + +### Dispersion demo + +optics = [] + +view = w.addViewBox() + +view.setAspectLocked() +#grid = pg.GridItem() +#view.addItem(grid) +view.setRange(pg.QtCore.QRectF(-10, -50, 90, 60)) + +optics = [] +rays = [] +l1 = Lens(r1=20, r2=20, d=10, angle=8, glass='Corning7980') +optics.append(l1) + +allRays = [] +for wl in np.linspace(355,1040, 25): + for y in [10]: + r = Ray(start=Point(-100, y), wl=wl) + view.addItem(r) + allRays.append(r) + +for o in optics: + view.addItem(o) + +t2 = Tracer(allRays, optics) + + + +### Scanning laser microscopy demo + +w.nextRow() +view = w.addViewBox(colspan=2) + +optics = [] + + +#view.setAspectLocked() +view.setRange(QtCore.QRectF(200, -50, 500, 200)) + + + +## Scan mirrors +scanx = 250 +scany = 20 +m1 = Mirror(dia=4.2, d=0.001, pos=(scanx, 0), angle=315) +m2 = Mirror(dia=8.4, d=0.001, pos=(scanx, scany), angle=135) + +## Scan lenses +l3 = Lens(r1=23.0, r2=0, d=5.8, pos=(scanx+50, scany), glass='Corning7980') ## 50mm UVFS (LA4148) +l4 = Lens(r1=0, r2=69.0, d=3.2, pos=(scanx+250, scany), glass='Corning7980') ## 150mm UVFS (LA4874) + +## Objective +obj = Lens(r1=15, r2=15, d=10, dia=8, pos=(scanx+400, scany), glass='Corning7980') + +IROptics = [m1, m2, l3, l4, obj] + + + +## Scan mirrors +scanx = 250 +scany = 30 +m1a = Mirror(dia=4.2, d=0.001, pos=(scanx, 2*scany), angle=315) +m2a = Mirror(dia=8.4, d=0.001, pos=(scanx, 3*scany), angle=135) + +## Scan lenses +l3a = Lens(r1=46, r2=0, d=3.8, pos=(scanx+50, 3*scany), glass='Corning7980') ## 100mm UVFS (LA4380) +l4a = Lens(r1=0, r2=46, d=3.8, pos=(scanx+250, 3*scany), glass='Corning7980') ## 100mm UVFS (LA4380) + +## Objective +obja = Lens(r1=15, r2=15, d=10, dia=8, pos=(scanx+400, 3*scany), glass='Corning7980') + +IROptics2 = [m1a, m2a, l3a, l4a, obja] + + + +for o in set(IROptics+IROptics2): + view.addItem(o) + +IRRays = [] +IRRays2 = [] + +for dy in [-0.4, -0.15, 0, 0.15, 0.4]: + IRRays.append(Ray(start=Point(-50, dy), dir=(1, 0), wl=780)) + IRRays2.append(Ray(start=Point(-50, dy+2*scany), dir=(1, 0), wl=780)) + +for r in set(IRRays+IRRays2): + view.addItem(r) + +IRTracer = Tracer(IRRays, IROptics) +IRTracer2 = Tracer(IRRays2, IROptics2) + +phase = 0.0 +def update(): + global phase + if phase % (8*np.pi) > 4*np.pi: + m1['angle'] = 315 + 1.5*np.sin(phase) + m1a['angle'] = 315 + 1.5*np.sin(phase) + else: + m2['angle'] = 135 + 1.5*np.sin(phase) + m2a['angle'] = 135 + 1.5*np.sin(phase) + phase += 0.2 + +timer = QtCore.QTimer() +timer.timeout.connect(update) +timer.start(40) + + + + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/parametertree.py b/examples/parametertree.py index c0eb50db..6e8e0dbd 100644 --- a/examples/parametertree.py +++ b/examples/parametertree.py @@ -123,6 +123,17 @@ def change(param, changes): p.sigTreeStateChanged.connect(change) +def valueChanging(param, value): + print("Value changing (not finalized):", param, value) + +# Too lazy for recursion: +for child in p.children(): + child.sigValueChanging.connect(valueChanging) + for ch2 in child.children(): + ch2.sigValueChanging.connect(valueChanging) + + + def save(): global state state = p.saveState() diff --git a/examples/py2exe/plotTest.py b/examples/py2exe/plotTest.py new file mode 100644 index 00000000..1a53a984 --- /dev/null +++ b/examples/py2exe/plotTest.py @@ -0,0 +1,20 @@ +import sys +from PyQt4 import QtGui +import pyqtgraph as pg +from pyqtgraph.graphicsItems import TextItem +# For packages that require scipy, these may be needed: +# from scipy.stats import futil +# from scipy.sparse.csgraph import _validation + +from pyqtgraph import setConfigOption +pg.setConfigOption('background','w') +pg.setConfigOption('foreground','k') +app = QtGui.QApplication(sys.argv) + +pw = pg.plot(x = [0, 1, 2, 4], y = [4, 5, 9, 6]) +pw.showGrid(x=True,y=True) +text = pg.TextItem(html='
%s
' % "here",anchor=(0.0, 0.0)) +text.setPos(1.0, 5.0) +pw.addItem(text) +status = app.exec_() +sys.exit(status) diff --git a/examples/py2exe/setup.py b/examples/py2exe/setup.py new file mode 100644 index 00000000..c342f90d --- /dev/null +++ b/examples/py2exe/setup.py @@ -0,0 +1,36 @@ +from distutils.core import setup + +import shutil +from glob import glob +# Remove the build folder +shutil.rmtree("build", ignore_errors=True) +shutil.rmtree("dist", ignore_errors=True) +import py2exe +import sys + +includes = ['PyQt4', 'PyQt4.QtGui', 'PyQt4.QtSvg', 'sip', 'pyqtgraph.graphicsItems'] +excludes = ['_gtkagg', '_tkagg', 'bsddb', 'curses', 'email', 'pywin.debugger', + 'pywin.debugger.dbgcon', 'pywin.dialogs', 'tcl', + 'Tkconstants', 'Tkinter', 'zmq'] +if sys.version[0] == '2': + # causes syntax error on py2 + excludes.append('PyQt4.uic.port_v3') + +packages = [] +dll_excludes = ['libgdk-win32-2.0-0.dll', 'libgobject-2.0-0.dll', 'tcl84.dll', + 'tk84.dll', 'MSVCP90.dll'] +icon_resources = [] +bitmap_resources = [] +other_resources = [] +data_files = [] +setup( + data_files=data_files, + console=['plotTest.py'] , + options={"py2exe": {"excludes": excludes, + "includes": includes, + "dll_excludes": dll_excludes, + "optimize": 0, + "compressed": 2, + "bundle_files": 1}}, + zipfile=None, +) diff --git a/examples/relativity/__init__.py b/examples/relativity/__init__.py new file mode 100644 index 00000000..093806ef --- /dev/null +++ b/examples/relativity/__init__.py @@ -0,0 +1 @@ +from relativity import * diff --git a/examples/relativity/presets/Grid Expansion.cfg b/examples/relativity/presets/Grid Expansion.cfg new file mode 100644 index 00000000..0ab77795 --- /dev/null +++ b/examples/relativity/presets/Grid Expansion.cfg @@ -0,0 +1,411 @@ +name: 'params' +strictNaming: False +default: None +renamable: False +enabled: True +value: None +visible: True +readonly: False +removable: False +type: 'group' +children: + Load Preset..: + name: 'Load Preset..' + limits: ['', 'Twin Paradox (grid)', 'Twin Paradox'] + strictNaming: False + default: None + renamable: False + enabled: True + value: 'Twin Paradox (grid)' + visible: True + readonly: False + values: [] + removable: False + type: 'list' + children: + Duration: + name: 'Duration' + limits: [0.1, None] + strictNaming: False + default: 10.0 + renamable: False + enabled: True + readonly: False + value: 20.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Reference Frame: + name: 'Reference Frame' + limits: ['Grid00', 'Grid01', 'Grid02', 'Grid03', 'Grid04'] + strictNaming: False + default: None + renamable: False + enabled: True + value: 'Grid02' + visible: True + readonly: False + values: [] + removable: False + type: 'list' + children: + Animate: + name: 'Animate' + strictNaming: False + default: True + renamable: False + enabled: True + value: True + visible: True + readonly: False + removable: False + type: 'bool' + children: + Animation Speed: + name: 'Animation Speed' + limits: [0.0001, None] + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + dec: True + type: 'float' + children: + Recalculate Worldlines: + name: 'Recalculate Worldlines' + strictNaming: False + default: None + renamable: False + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'action' + children: + Save: + name: 'Save' + strictNaming: False + default: None + renamable: False + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'action' + children: + Load: + name: 'Load' + strictNaming: False + default: None + renamable: False + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'action' + children: + Objects: + name: 'Objects' + strictNaming: False + default: None + renamable: False + addText: 'Add New..' + enabled: True + value: None + visible: True + readonly: False + removable: False + type: None + children: + Grid: + name: 'Grid' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: 'Grid' + autoIncrementName: True + children: + Number of Clocks: + name: 'Number of Clocks' + limits: [1, None] + strictNaming: False + default: 5 + renamable: False + enabled: True + value: 5 + visible: True + readonly: False + removable: False + type: 'int' + children: + Spacing: + name: 'Spacing' + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + ClockTemplate: + name: 'ClockTemplate' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: 'Clock' + autoIncrementName: True + children: + Initial Position: + name: 'Initial Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: -2.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: None + renamable: False + addText: 'Add Command..' + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'AccelerationGroup' + children: + Command: + name: 'Command' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + value: 1.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.5 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command2: + name: 'Command2' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 2.0 + renamable: False + enabled: True + value: 3.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command3: + name: 'Command3' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 4.0 + renamable: False + enabled: True + value: 11.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: -0.5 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command4: + name: 'Command4' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 8.0 + renamable: False + enabled: True + value: 13.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Rest Mass: + name: 'Rest Mass' + limits: [1e-09, None] + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Color: + name: 'Color' + strictNaming: False + default: (100, 100, 150) + renamable: False + enabled: True + value: (100, 100, 150, 255) + visible: True + readonly: False + removable: False + type: 'color' + children: + Size: + name: 'Size' + strictNaming: False + default: 0.5 + renamable: False + enabled: True + value: 0.5 + visible: True + readonly: False + removable: False + type: 'float' + children: + Vertical Position: + name: 'Vertical Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + addList: ['Clock', 'Grid'] diff --git a/examples/relativity/presets/Twin Paradox (grid).cfg b/examples/relativity/presets/Twin Paradox (grid).cfg new file mode 100644 index 00000000..ebe366bf --- /dev/null +++ b/examples/relativity/presets/Twin Paradox (grid).cfg @@ -0,0 +1,667 @@ +name: 'params' +strictNaming: False +default: None +renamable: False +enabled: True +value: None +visible: True +readonly: False +removable: False +type: 'group' +children: + Load Preset..: + name: 'Load Preset..' + limits: ['', 'Twin Paradox (grid)', 'Twin Paradox'] + strictNaming: False + default: None + renamable: False + enabled: True + value: 'Twin Paradox (grid)' + visible: True + readonly: False + values: [] + removable: False + type: 'list' + children: + Duration: + name: 'Duration' + limits: [0.1, None] + strictNaming: False + default: 10.0 + renamable: False + enabled: True + readonly: False + value: 27.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Reference Frame: + name: 'Reference Frame' + limits: ['Grid00', 'Grid01', 'Grid02', 'Grid03', 'Grid04', 'Grid05', 'Grid06', 'Grid07', 'Grid08', 'Grid09', 'Grid10', 'Alice', 'Bob'] + strictNaming: False + default: None + renamable: False + enabled: True + value: 'Alice' + visible: True + readonly: False + values: [] + removable: False + type: 'list' + children: + Animate: + name: 'Animate' + strictNaming: False + default: True + renamable: False + enabled: True + value: True + visible: True + readonly: False + removable: False + type: 'bool' + children: + Animation Speed: + name: 'Animation Speed' + limits: [0.0001, None] + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + dec: True + type: 'float' + children: + Recalculate Worldlines: + name: 'Recalculate Worldlines' + strictNaming: False + default: None + renamable: False + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'action' + children: + Save: + name: 'Save' + strictNaming: False + default: None + renamable: False + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'action' + children: + Load: + name: 'Load' + strictNaming: False + default: None + renamable: False + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'action' + children: + Objects: + name: 'Objects' + strictNaming: False + default: None + renamable: False + addText: 'Add New..' + enabled: True + value: None + visible: True + readonly: False + removable: False + type: None + children: + Grid: + name: 'Grid' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: 'Grid' + autoIncrementName: True + children: + Number of Clocks: + name: 'Number of Clocks' + limits: [1, None] + strictNaming: False + default: 5 + renamable: False + enabled: True + value: 11 + visible: True + readonly: False + removable: False + type: 'int' + children: + Spacing: + name: 'Spacing' + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 2.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + ClockTemplate: + name: 'ClockTemplate' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: 'Clock' + autoIncrementName: True + children: + Initial Position: + name: 'Initial Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: -10.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: None + renamable: False + addText: 'Add Command..' + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'AccelerationGroup' + children: + Rest Mass: + name: 'Rest Mass' + limits: [1e-09, None] + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Color: + name: 'Color' + strictNaming: False + default: (100, 100, 150) + renamable: False + enabled: True + value: (77, 77, 77, 255) + visible: True + readonly: False + removable: False + type: 'color' + children: + Size: + name: 'Size' + strictNaming: False + default: 0.5 + renamable: False + enabled: True + value: 1.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Vertical Position: + name: 'Vertical Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: -2.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Alice: + name: 'Alice' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: 'Clock' + autoIncrementName: True + children: + Initial Position: + name: 'Initial Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: None + renamable: False + addText: 'Add Command..' + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'AccelerationGroup' + children: + Command: + name: 'Command' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + value: 1.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.5 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command2: + name: 'Command2' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 2.0 + renamable: False + enabled: True + value: 3.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command3: + name: 'Command3' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 3.0 + renamable: False + enabled: True + value: 8.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: -0.5 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command4: + name: 'Command4' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 4.0 + renamable: False + enabled: True + value: 12.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command5: + name: 'Command5' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 6.0 + renamable: False + enabled: True + value: 17.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.5 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command6: + name: 'Command6' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 7.0 + renamable: False + enabled: True + value: 19.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Rest Mass: + name: 'Rest Mass' + limits: [1e-09, None] + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Color: + name: 'Color' + strictNaming: False + default: (100, 100, 150) + renamable: False + enabled: True + value: (82, 123, 44, 255) + visible: True + readonly: False + removable: False + type: 'color' + children: + Size: + name: 'Size' + strictNaming: False + default: 0.5 + renamable: False + enabled: True + value: 1.5 + visible: True + readonly: False + removable: False + type: 'float' + children: + Vertical Position: + name: 'Vertical Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 3.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Bob: + name: 'Bob' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: 'Clock' + autoIncrementName: True + children: + Initial Position: + name: 'Initial Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: None + renamable: False + addText: 'Add Command..' + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'AccelerationGroup' + children: + Rest Mass: + name: 'Rest Mass' + limits: [1e-09, None] + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Color: + name: 'Color' + strictNaming: False + default: (100, 100, 150) + renamable: False + enabled: True + value: (69, 69, 126, 255) + visible: True + readonly: False + removable: False + type: 'color' + children: + Size: + name: 'Size' + strictNaming: False + default: 0.5 + renamable: False + enabled: True + value: 1.5 + visible: True + readonly: False + removable: False + type: 'float' + children: + Vertical Position: + name: 'Vertical Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + addList: ['Clock', 'Grid'] diff --git a/examples/relativity/presets/Twin Paradox.cfg b/examples/relativity/presets/Twin Paradox.cfg new file mode 100644 index 00000000..569c3a04 --- /dev/null +++ b/examples/relativity/presets/Twin Paradox.cfg @@ -0,0 +1,538 @@ +name: 'params' +strictNaming: False +default: None +renamable: False +enabled: True +value: None +visible: True +readonly: False +removable: False +type: 'group' +children: + Load Preset..: + name: 'Load Preset..' + limits: ['', 'Twin Paradox', 'test'] + strictNaming: False + default: None + renamable: False + enabled: True + value: 'Twin Paradox' + visible: True + readonly: False + values: [] + removable: False + type: 'list' + children: + Duration: + name: 'Duration' + limits: [0.1, None] + strictNaming: False + default: 10.0 + renamable: False + enabled: True + readonly: False + value: 27.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Reference Frame: + name: 'Reference Frame' + limits: ['Alice', 'Bob'] + strictNaming: False + default: None + renamable: False + enabled: True + value: 'Alice' + visible: True + readonly: False + values: [] + removable: False + type: 'list' + children: + Animate: + name: 'Animate' + strictNaming: False + default: True + renamable: False + enabled: True + value: True + visible: True + readonly: False + removable: False + type: 'bool' + children: + Animation Speed: + name: 'Animation Speed' + limits: [0.0001, None] + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + dec: True + type: 'float' + children: + Recalculate Worldlines: + name: 'Recalculate Worldlines' + strictNaming: False + default: None + renamable: False + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'action' + children: + Save: + name: 'Save' + strictNaming: False + default: None + renamable: False + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'action' + children: + Load: + name: 'Load' + strictNaming: False + default: None + renamable: False + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'action' + children: + Objects: + name: 'Objects' + strictNaming: False + default: None + renamable: False + addText: 'Add New..' + enabled: True + value: None + visible: True + readonly: False + removable: False + type: None + children: + Alice: + name: 'Alice' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: 'Clock' + autoIncrementName: True + children: + Initial Position: + name: 'Initial Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: None + renamable: False + addText: 'Add Command..' + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'AccelerationGroup' + children: + Command: + name: 'Command' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + value: 1.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.5 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command2: + name: 'Command2' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 2.0 + renamable: False + enabled: True + value: 3.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command3: + name: 'Command3' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 3.0 + renamable: False + enabled: True + value: 8.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: -0.5 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command4: + name: 'Command4' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 4.0 + renamable: False + enabled: True + value: 12.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command5: + name: 'Command5' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 6.0 + renamable: False + enabled: True + value: 17.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.5 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Command6: + name: 'Command6' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: None + autoIncrementName: True + children: + Proper Time: + name: 'Proper Time' + strictNaming: False + default: 7.0 + renamable: False + enabled: True + value: 19.0 + visible: True + readonly: False + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Rest Mass: + name: 'Rest Mass' + limits: [1e-09, None] + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Color: + name: 'Color' + strictNaming: False + default: (100, 100, 150) + renamable: False + enabled: True + value: (82, 123, 44, 255) + visible: True + readonly: False + removable: False + type: 'color' + children: + Size: + name: 'Size' + strictNaming: False + default: 0.5 + renamable: False + enabled: True + value: 0.5 + visible: True + readonly: False + removable: False + type: 'float' + children: + Vertical Position: + name: 'Vertical Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.5 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Bob: + name: 'Bob' + strictNaming: False + default: None + renamable: True + enabled: True + value: None + visible: True + readonly: False + removable: True + type: 'Clock' + autoIncrementName: True + children: + Initial Position: + name: 'Initial Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Acceleration: + name: 'Acceleration' + strictNaming: False + default: None + renamable: False + addText: 'Add Command..' + enabled: True + value: None + visible: True + readonly: False + removable: False + type: 'AccelerationGroup' + children: + Rest Mass: + name: 'Rest Mass' + limits: [1e-09, None] + strictNaming: False + default: 1.0 + renamable: False + enabled: True + readonly: False + value: 1.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + Color: + name: 'Color' + strictNaming: False + default: (100, 100, 150) + renamable: False + enabled: True + value: (69, 69, 126, 255) + visible: True + readonly: False + removable: False + type: 'color' + children: + Size: + name: 'Size' + strictNaming: False + default: 0.5 + renamable: False + enabled: True + value: 0.5 + visible: True + readonly: False + removable: False + type: 'float' + children: + Vertical Position: + name: 'Vertical Position' + strictNaming: False + default: 0.0 + renamable: False + enabled: True + readonly: False + value: 0.0 + visible: True + step: 0.1 + removable: False + type: 'float' + children: + addList: ['Clock', 'Grid'] diff --git a/examples/relativity/relativity.py b/examples/relativity/relativity.py new file mode 100644 index 00000000..80a56d64 --- /dev/null +++ b/examples/relativity/relativity.py @@ -0,0 +1,773 @@ +import pyqtgraph as pg +from pyqtgraph.Qt import QtGui, QtCore +from pyqtgraph.parametertree import Parameter, ParameterTree +from pyqtgraph.parametertree import types as pTypes +import pyqtgraph.configfile +import numpy as np +import user +import collections +import sys, os + + + +class RelativityGUI(QtGui.QWidget): + def __init__(self): + QtGui.QWidget.__init__(self) + + self.animations = [] + self.animTimer = QtCore.QTimer() + self.animTimer.timeout.connect(self.stepAnimation) + self.animTime = 0 + self.animDt = .016 + self.lastAnimTime = 0 + + self.setupGUI() + + self.objectGroup = ObjectGroupParam() + + self.params = Parameter.create(name='params', type='group', children=[ + dict(name='Load Preset..', type='list', values=[]), + #dict(name='Unit System', type='list', values=['', 'MKS']), + dict(name='Duration', type='float', value=10.0, step=0.1, limits=[0.1, None]), + dict(name='Reference Frame', type='list', values=[]), + dict(name='Animate', type='bool', value=True), + dict(name='Animation Speed', type='float', value=1.0, dec=True, step=0.1, limits=[0.0001, None]), + dict(name='Recalculate Worldlines', type='action'), + dict(name='Save', type='action'), + dict(name='Load', type='action'), + self.objectGroup, + ]) + self.tree.setParameters(self.params, showTop=False) + self.params.param('Recalculate Worldlines').sigActivated.connect(self.recalculate) + self.params.param('Save').sigActivated.connect(self.save) + self.params.param('Load').sigActivated.connect(self.load) + self.params.param('Load Preset..').sigValueChanged.connect(self.loadPreset) + self.params.sigTreeStateChanged.connect(self.treeChanged) + + ## read list of preset configs + presetDir = os.path.join(os.path.abspath(os.path.dirname(sys.argv[0])), 'presets') + if os.path.exists(presetDir): + presets = [os.path.splitext(p)[0] for p in os.listdir(presetDir)] + self.params.param('Load Preset..').setLimits(['']+presets) + + + + + def setupGUI(self): + self.layout = QtGui.QVBoxLayout() + self.layout.setContentsMargins(0,0,0,0) + self.setLayout(self.layout) + self.splitter = QtGui.QSplitter() + self.splitter.setOrientation(QtCore.Qt.Horizontal) + self.layout.addWidget(self.splitter) + + self.tree = ParameterTree(showHeader=False) + self.splitter.addWidget(self.tree) + + self.splitter2 = QtGui.QSplitter() + self.splitter2.setOrientation(QtCore.Qt.Vertical) + self.splitter.addWidget(self.splitter2) + + self.worldlinePlots = pg.GraphicsLayoutWidget() + self.splitter2.addWidget(self.worldlinePlots) + + self.animationPlots = pg.GraphicsLayoutWidget() + self.splitter2.addWidget(self.animationPlots) + + self.splitter2.setSizes([int(self.height()*0.8), int(self.height()*0.2)]) + + self.inertWorldlinePlot = self.worldlinePlots.addPlot() + self.refWorldlinePlot = self.worldlinePlots.addPlot() + + self.inertAnimationPlot = self.animationPlots.addPlot() + self.inertAnimationPlot.setAspectLocked(1) + self.refAnimationPlot = self.animationPlots.addPlot() + self.refAnimationPlot.setAspectLocked(1) + + self.inertAnimationPlot.setXLink(self.inertWorldlinePlot) + self.refAnimationPlot.setXLink(self.refWorldlinePlot) + + def recalculate(self): + ## build 2 sets of clocks + clocks1 = collections.OrderedDict() + clocks2 = collections.OrderedDict() + for cl in self.params.param('Objects'): + clocks1.update(cl.buildClocks()) + clocks2.update(cl.buildClocks()) + + ## Inertial simulation + dt = self.animDt * self.params['Animation Speed'] + sim1 = Simulation(clocks1, ref=None, duration=self.params['Duration'], dt=dt) + sim1.run() + sim1.plot(self.inertWorldlinePlot) + self.inertWorldlinePlot.autoRange(padding=0.1) + + ## reference simulation + ref = self.params['Reference Frame'] + dur = clocks1[ref].refData['pt'][-1] ## decide how long to run the reference simulation + sim2 = Simulation(clocks2, ref=clocks2[ref], duration=dur, dt=dt) + sim2.run() + sim2.plot(self.refWorldlinePlot) + self.refWorldlinePlot.autoRange(padding=0.1) + + + ## create animations + self.refAnimationPlot.clear() + self.inertAnimationPlot.clear() + self.animTime = 0 + + self.animations = [Animation(sim1), Animation(sim2)] + self.inertAnimationPlot.addItem(self.animations[0]) + self.refAnimationPlot.addItem(self.animations[1]) + + ## create lines representing all that is visible to a particular reference + #self.inertSpaceline = Spaceline(sim1, ref) + #self.refSpaceline = Spaceline(sim2) + self.inertWorldlinePlot.addItem(self.animations[0].items[ref].spaceline()) + self.refWorldlinePlot.addItem(self.animations[1].items[ref].spaceline()) + + + + + def setAnimation(self, a): + if a: + self.lastAnimTime = pg.ptime.time() + self.animTimer.start(self.animDt*1000) + else: + self.animTimer.stop() + + def stepAnimation(self): + now = pg.ptime.time() + dt = (now-self.lastAnimTime) * self.params['Animation Speed'] + self.lastAnimTime = now + self.animTime += dt + if self.animTime > self.params['Duration']: + self.animTime = 0 + for a in self.animations: + a.restart() + + for a in self.animations: + a.stepTo(self.animTime) + + + def treeChanged(self, *args): + clocks = [] + for c in self.params.param('Objects'): + clocks.extend(c.clockNames()) + #for param, change, data in args[1]: + #if change == 'childAdded': + self.params.param('Reference Frame').setLimits(clocks) + self.setAnimation(self.params['Animate']) + + def save(self): + fn = str(pg.QtGui.QFileDialog.getSaveFileName(self, "Save State..", "untitled.cfg", "Config Files (*.cfg)")) + if fn == '': + return + state = self.params.saveState() + pg.configfile.writeConfigFile(state, fn) + + def load(self): + fn = str(pg.QtGui.QFileDialog.getOpenFileName(self, "Save State..", "", "Config Files (*.cfg)")) + if fn == '': + return + state = pg.configfile.readConfigFile(fn) + self.loadState(state) + + def loadPreset(self, param, preset): + if preset == '': + return + path = os.path.abspath(os.path.dirname(__file__)) + fn = os.path.join(path, 'presets', preset+".cfg") + state = pg.configfile.readConfigFile(fn) + self.loadState(state) + + def loadState(self, state): + if 'Load Preset..' in state['children']: + del state['children']['Load Preset..']['limits'] + del state['children']['Load Preset..']['value'] + self.params.param('Objects').clearChildren() + self.params.restoreState(state, removeChildren=False) + self.recalculate() + + +class ObjectGroupParam(pTypes.GroupParameter): + def __init__(self): + pTypes.GroupParameter.__init__(self, name="Objects", addText="Add New..", addList=['Clock', 'Grid']) + + def addNew(self, typ): + if typ == 'Clock': + self.addChild(ClockParam()) + elif typ == 'Grid': + self.addChild(GridParam()) + +class ClockParam(pTypes.GroupParameter): + def __init__(self, **kwds): + defs = dict(name="Clock", autoIncrementName=True, renamable=True, removable=True, children=[ + dict(name='Initial Position', type='float', value=0.0, step=0.1), + #dict(name='V0', type='float', value=0.0, step=0.1), + AccelerationGroup(), + + dict(name='Rest Mass', type='float', value=1.0, step=0.1, limits=[1e-9, None]), + dict(name='Color', type='color', value=(100,100,150)), + dict(name='Size', type='float', value=0.5), + dict(name='Vertical Position', type='float', value=0.0, step=0.1), + ]) + #defs.update(kwds) + pTypes.GroupParameter.__init__(self, **defs) + self.restoreState(kwds, removeChildren=False) + + def buildClocks(self): + x0 = self['Initial Position'] + y0 = self['Vertical Position'] + color = self['Color'] + m = self['Rest Mass'] + size = self['Size'] + prog = self.param('Acceleration').generate() + c = Clock(x0=x0, m0=m, y0=y0, color=color, prog=prog, size=size) + return {self.name(): c} + + def clockNames(self): + return [self.name()] + +pTypes.registerParameterType('Clock', ClockParam) + +class GridParam(pTypes.GroupParameter): + def __init__(self, **kwds): + defs = dict(name="Grid", autoIncrementName=True, renamable=True, removable=True, children=[ + dict(name='Number of Clocks', type='int', value=5, limits=[1, None]), + dict(name='Spacing', type='float', value=1.0, step=0.1), + ClockParam(name='ClockTemplate'), + ]) + #defs.update(kwds) + pTypes.GroupParameter.__init__(self, **defs) + self.restoreState(kwds, removeChildren=False) + + def buildClocks(self): + clocks = {} + template = self.param('ClockTemplate') + spacing = self['Spacing'] + for i in range(self['Number of Clocks']): + c = template.buildClocks().values()[0] + c.x0 += i * spacing + clocks[self.name() + '%02d' % i] = c + return clocks + + def clockNames(self): + return [self.name() + '%02d' % i for i in range(self['Number of Clocks'])] + +pTypes.registerParameterType('Grid', GridParam) + +class AccelerationGroup(pTypes.GroupParameter): + def __init__(self, **kwds): + defs = dict(name="Acceleration", addText="Add Command..") + pTypes.GroupParameter.__init__(self, **defs) + self.restoreState(kwds, removeChildren=False) + + def addNew(self): + nextTime = 0.0 + if self.hasChildren(): + nextTime = self.children()[-1]['Proper Time'] + 1 + self.addChild(Parameter.create(name='Command', autoIncrementName=True, type=None, renamable=True, removable=True, children=[ + dict(name='Proper Time', type='float', value=nextTime), + dict(name='Acceleration', type='float', value=0.0, step=0.1), + ])) + + def generate(self): + prog = [] + for cmd in self: + prog.append((cmd['Proper Time'], cmd['Acceleration'])) + return prog + +pTypes.registerParameterType('AccelerationGroup', AccelerationGroup) + + +class Clock(object): + nClocks = 0 + + def __init__(self, x0=0.0, y0=0.0, m0=1.0, v0=0.0, t0=0.0, color=None, prog=None, size=0.5): + Clock.nClocks += 1 + self.pen = pg.mkPen(color) + self.brush = pg.mkBrush(color) + self.y0 = y0 + self.x0 = x0 + self.v0 = v0 + self.m0 = m0 + self.t0 = t0 + self.prog = prog + self.size = size + + def init(self, nPts): + ## Keep records of object from inertial frame as well as reference frame + self.inertData = np.empty(nPts, dtype=[('x', float), ('t', float), ('v', float), ('pt', float), ('m', float), ('f', float)]) + self.refData = np.empty(nPts, dtype=[('x', float), ('t', float), ('v', float), ('pt', float), ('m', float), ('f', float)]) + + ## Inertial frame variables + self.x = self.x0 + self.v = self.v0 + self.m = self.m0 + self.t = 0.0 ## reference clock always starts at 0 + self.pt = self.t0 ## proper time starts at t0 + + ## reference frame variables + self.refx = None + self.refv = None + self.refm = None + self.reft = None + + self.recordFrame(0) + + def recordFrame(self, i): + f = self.force() + self.inertData[i] = (self.x, self.t, self.v, self.pt, self.m, f) + self.refData[i] = (self.refx, self.reft, self.refv, self.pt, self.refm, f) + + def force(self, t=None): + if len(self.prog) == 0: + return 0.0 + if t is None: + t = self.pt + + ret = 0.0 + for t1,f in self.prog: + if t >= t1: + ret = f + return ret + + def acceleration(self, t=None): + return self.force(t) / self.m0 + + def accelLimits(self): + ## return the proper time values which bound the current acceleration command + if len(self.prog) == 0: + return -np.inf, np.inf + t = self.pt + ind = -1 + for i, v in enumerate(self.prog): + t1,f = v + if t >= t1: + ind = i + + if ind == -1: + return -np.inf, self.prog[0][0] + elif ind == len(self.prog)-1: + return self.prog[-1][0], np.inf + else: + return self.prog[ind][0], self.prog[ind+1][0] + + + def getCurve(self, ref=True): + + if ref is False: + data = self.inertData + else: + data = self.refData[1:] + + x = data['x'] + y = data['t'] + + curve = pg.PlotCurveItem(x=x, y=y, pen=self.pen) + #x = self.data['x'] - ref.data['x'] + #y = self.data['t'] + + step = 1.0 + #mod = self.data['pt'] % step + #inds = np.argwhere(abs(mod[1:] - mod[:-1]) > step*0.9) + inds = [0] + pt = data['pt'] + for i in range(1,len(pt)): + diff = pt[i] - pt[inds[-1]] + if abs(diff) >= step: + inds.append(i) + inds = np.array(inds) + + #t = self.data['t'][inds] + #x = self.data['x'][inds] + pts = [] + for i in inds: + x = data['x'][i] + y = data['t'][i] + if i+1 < len(data): + dpt = data['pt'][i+1]-data['pt'][i] + dt = data['t'][i+1]-data['t'][i] + else: + dpt = 1 + + if dpt > 0: + c = pg.mkBrush((0,0,0)) + else: + c = pg.mkBrush((200,200,200)) + pts.append({'pos': (x, y), 'brush': c}) + + points = pg.ScatterPlotItem(pts, pen=self.pen, size=7) + + return curve, points + + +class Simulation: + def __init__(self, clocks, ref, duration, dt): + self.clocks = clocks + self.ref = ref + self.duration = duration + self.dt = dt + + @staticmethod + def hypTStep(dt, v0, x0, tau0, g): + ## Hyperbolic step. + ## If an object has proper acceleration g and starts at position x0 with speed v0 and proper time tau0 + ## as seen from an inertial frame, then return the new v, x, tau after time dt has elapsed. + if g == 0: + return v0, x0 + v0*dt, tau0 + dt * (1. - v0**2)**0.5 + v02 = v0**2 + g2 = g**2 + + tinit = v0 / (g * (1 - v02)**0.5) + + B = (1 + (g2 * (dt+tinit)**2))**0.5 + + v1 = g * (dt+tinit) / B + + dtau = (np.arcsinh(g * (dt+tinit)) - np.arcsinh(g * tinit)) / g + + tau1 = tau0 + dtau + + x1 = x0 + (1.0 / g) * ( B - 1. / (1.-v02)**0.5 ) + + return v1, x1, tau1 + + + @staticmethod + def tStep(dt, v0, x0, tau0, g): + ## Linear step. + ## Probably not as accurate as hyperbolic step, but certainly much faster. + gamma = (1. - v0**2)**-0.5 + dtau = dt / gamma + return v0 + dtau * g, x0 + v0*dt, tau0 + dtau + + @staticmethod + def tauStep(dtau, v0, x0, t0, g): + ## linear step in proper time of clock. + ## If an object has proper acceleration g and starts at position x0 with speed v0 at time t0 + ## as seen from an inertial frame, then return the new v, x, t after proper time dtau has elapsed. + + + ## Compute how much t will change given a proper-time step of dtau + gamma = (1. - v0**2)**-0.5 + if g == 0: + dt = dtau * gamma + else: + v0g = v0 * gamma + dt = (np.sinh(dtau * g + np.arcsinh(v0g)) - v0g) / g + + #return v0 + dtau * g, x0 + v0*dt, t0 + dt + v1, x1, t1 = Simulation.hypTStep(dt, v0, x0, t0, g) + return v1, x1, t0+dt + + @staticmethod + def hypIntersect(x0r, t0r, vr, x0, t0, v0, g): + ## given a reference clock (seen from inertial frame) has rx, rt, and rv, + ## and another clock starts at x0, t0, and v0, with acceleration g, + ## compute the intersection time of the object clock's hyperbolic path with + ## the reference plane. + + ## I'm sure we can simplify this... + + if g == 0: ## no acceleration, path is linear (and hyperbola is undefined) + #(-t0r + t0 v0 vr - vr x0 + vr x0r)/(-1 + v0 vr) + + t = (-t0r + t0 *v0 *vr - vr *x0 + vr *x0r)/(-1 + v0 *vr) + return t + + gamma = (1.0-v0**2)**-0.5 + sel = (1 if g>0 else 0) + (1 if vr<0 else 0) + sel = sel%2 + if sel == 0: + #(1/(g^2 (-1 + vr^2)))(-g^2 t0r + g gamma vr + g^2 t0 vr^2 - + #g gamma v0 vr^2 - g^2 vr x0 + + #g^2 vr x0r + \[Sqrt](g^2 vr^2 (1 + gamma^2 (v0 - vr)^2 - vr^2 + + #2 g gamma (v0 - vr) (-t0 + t0r + vr (x0 - x0r)) + + #g^2 (t0 - t0r + vr (-x0 + x0r))^2))) + + t = (1./(g**2 *(-1. + vr**2)))*(-g**2 *t0r + g *gamma *vr + g**2 *t0 *vr**2 - g *gamma *v0 *vr**2 - g**2 *vr *x0 + g**2 *vr *x0r + np.sqrt(g**2 *vr**2 *(1. + gamma**2 *(v0 - vr)**2 - vr**2 + 2 *g *gamma *(v0 - vr)* (-t0 + t0r + vr *(x0 - x0r)) + g**2 *(t0 - t0r + vr* (-x0 + x0r))**2))) + + else: + + #-(1/(g^2 (-1 + vr^2)))(g^2 t0r - g gamma vr - g^2 t0 vr^2 + + #g gamma v0 vr^2 + g^2 vr x0 - + #g^2 vr x0r + \[Sqrt](g^2 vr^2 (1 + gamma^2 (v0 - vr)^2 - vr^2 + + #2 g gamma (v0 - vr) (-t0 + t0r + vr (x0 - x0r)) + + #g^2 (t0 - t0r + vr (-x0 + x0r))^2))) + + t = -(1./(g**2 *(-1. + vr**2)))*(g**2 *t0r - g *gamma* vr - g**2 *t0 *vr**2 + g *gamma *v0 *vr**2 + g**2* vr* x0 - g**2 *vr *x0r + np.sqrt(g**2* vr**2 *(1. + gamma**2 *(v0 - vr)**2 - vr**2 + 2 *g *gamma *(v0 - vr) *(-t0 + t0r + vr *(x0 - x0r)) + g**2 *(t0 - t0r + vr *(-x0 + x0r))**2))) + return t + + def run(self): + nPts = int(self.duration/self.dt)+1 + for cl in self.clocks.itervalues(): + cl.init(nPts) + + if self.ref is None: + self.runInertial(nPts) + else: + self.runReference(nPts) + + def runInertial(self, nPts): + clocks = self.clocks + dt = self.dt + tVals = np.linspace(0, dt*(nPts-1), nPts) + for cl in self.clocks.itervalues(): + for i in xrange(1,nPts): + nextT = tVals[i] + while True: + tau1, tau2 = cl.accelLimits() + x = cl.x + v = cl.v + tau = cl.pt + g = cl.acceleration() + + v1, x1, tau1 = self.hypTStep(dt, v, x, tau, g) + if tau1 > tau2: + dtau = tau2-tau + cl.v, cl.x, cl.t = self.tauStep(dtau, v, x, cl.t, g) + cl.pt = tau2 + else: + cl.v, cl.x, cl.pt = v1, x1, tau1 + cl.t += dt + + if cl.t >= nextT: + cl.refx = cl.x + cl.refv = cl.v + cl.reft = cl.t + cl.recordFrame(i) + break + + + def runReference(self, nPts): + clocks = self.clocks + ref = self.ref + dt = self.dt + dur = self.duration + + ## make sure reference clock is not present in the list of clocks--this will be handled separately. + clocks = clocks.copy() + for k,v in clocks.iteritems(): + if v is ref: + del clocks[k] + break + + ref.refx = 0 + ref.refv = 0 + ref.refm = ref.m0 + + ## These are the set of proper times (in the reference frame) that will be simulated + ptVals = np.linspace(ref.pt, ref.pt + dt*(nPts-1), nPts) + + for i in xrange(1,nPts): + + ## step reference clock ahead one time step in its proper time + nextPt = ptVals[i] ## this is where (when) we want to end up + while True: + tau1, tau2 = ref.accelLimits() + dtau = min(nextPt-ref.pt, tau2-ref.pt) ## do not step past the next command boundary + g = ref.acceleration() + v, x, t = Simulation.tauStep(dtau, ref.v, ref.x, ref.t, g) + ref.pt += dtau + ref.v = v + ref.x = x + ref.t = t + ref.reft = ref.pt + if ref.pt >= nextPt: + break + #else: + #print "Stepped to", tau2, "instead of", nextPt + ref.recordFrame(i) + + ## determine plane visible to reference clock + ## this plane goes through the point ref.x, ref.t and has slope = ref.v + + + ## update all other clocks + for cl in clocks.itervalues(): + while True: + g = cl.acceleration() + tau1, tau2 = cl.accelLimits() + ##Given current position / speed of clock, determine where it will intersect reference plane + #t1 = (ref.v * (cl.x - cl.v * cl.t) + (ref.t - ref.v * ref.x)) / (1. - cl.v) + t1 = Simulation.hypIntersect(ref.x, ref.t, ref.v, cl.x, cl.t, cl.v, g) + dt1 = t1 - cl.t + + ## advance clock by correct time step + v, x, tau = Simulation.hypTStep(dt1, cl.v, cl.x, cl.pt, g) + + ## check to see whether we have gone past an acceleration command boundary. + ## if so, we must instead advance the clock to the boundary and start again + if tau < tau1: + dtau = tau1 - cl.pt + cl.v, cl.x, cl.t = Simulation.tauStep(dtau, cl.v, cl.x, cl.t, g) + cl.pt = tau1-0.000001 + continue + if tau > tau2: + dtau = tau2 - cl.pt + cl.v, cl.x, cl.t = Simulation.tauStep(dtau, cl.v, cl.x, cl.t, g) + cl.pt = tau2 + continue + + ## Otherwise, record the new values and exit the loop + cl.v = v + cl.x = x + cl.pt = tau + cl.t = t1 + cl.m = None + break + + ## transform position into reference frame + x = cl.x - ref.x + t = cl.t - ref.t + gamma = (1.0 - ref.v**2) ** -0.5 + vg = -ref.v * gamma + + cl.refx = gamma * (x - ref.v * t) + cl.reft = ref.pt # + gamma * (t - ref.v * x) # this term belongs here, but it should always be equal to 0. + cl.refv = (cl.v - ref.v) / (1.0 - cl.v * ref.v) + cl.refm = None + cl.recordFrame(i) + + t += dt + + def plot(self, plot): + plot.clear() + for cl in self.clocks.itervalues(): + c, p = cl.getCurve() + plot.addItem(c) + plot.addItem(p) + +class Animation(pg.ItemGroup): + def __init__(self, sim): + pg.ItemGroup.__init__(self) + self.sim = sim + self.clocks = sim.clocks + + self.items = {} + for name, cl in self.clocks.items(): + item = ClockItem(cl) + self.addItem(item) + self.items[name] = item + + #self.timer = timer + #self.timer.timeout.connect(self.step) + + #def run(self, run): + #if not run: + #self.timer.stop() + #else: + #self.timer.start(self.dt) + + def restart(self): + for cl in self.items.values(): + cl.reset() + + def stepTo(self, t): + for i in self.items.values(): + i.stepTo(t) + + +class ClockItem(pg.ItemGroup): + def __init__(self, clock): + pg.ItemGroup.__init__(self) + self.size = clock.size + self.item = QtGui.QGraphicsEllipseItem(QtCore.QRectF(0, 0, self.size, self.size)) + self.item.translate(-self.size*0.5, -self.size*0.5) + self.item.setPen(pg.mkPen(100,100,100)) + self.item.setBrush(clock.brush) + self.hand = QtGui.QGraphicsLineItem(0, 0, 0, self.size*0.5) + self.hand.setPen(pg.mkPen('w')) + self.hand.setZValue(10) + self.flare = QtGui.QGraphicsPolygonItem(QtGui.QPolygonF([ + QtCore.QPointF(0, -self.size*0.25), + QtCore.QPointF(0, self.size*0.25), + QtCore.QPointF(self.size*1.5, 0), + QtCore.QPointF(0, -self.size*0.25), + ])) + self.flare.setPen(pg.mkPen('y')) + self.flare.setBrush(pg.mkBrush(255,150,0)) + self.flare.setZValue(-10) + self.addItem(self.hand) + self.addItem(self.item) + self.addItem(self.flare) + + self.clock = clock + self.i = 1 + + self._spaceline = None + + + def spaceline(self): + if self._spaceline is None: + self._spaceline = pg.InfiniteLine() + self._spaceline.setPen(self.clock.pen) + return self._spaceline + + def stepTo(self, t): + data = self.clock.refData + + while self.i < len(data)-1 and data['t'][self.i] < t: + self.i += 1 + while self.i > 1 and data['t'][self.i-1] >= t: + self.i -= 1 + + self.setPos(data['x'][self.i], self.clock.y0) + + t = data['pt'][self.i] + self.hand.setRotation(-0.25 * t * 360.) + + self.resetTransform() + v = data['v'][self.i] + gam = (1.0 - v**2)**0.5 + self.scale(gam, 1.0) + + f = data['f'][self.i] + self.flare.resetTransform() + if f < 0: + self.flare.translate(self.size*0.4, 0) + else: + self.flare.translate(-self.size*0.4, 0) + + self.flare.scale(-f * (0.5+np.random.random()*0.1), 1.0) + + if self._spaceline is not None: + self._spaceline.setPos(pg.Point(data['x'][self.i], data['t'][self.i])) + self._spaceline.setAngle(data['v'][self.i] * 45.) + + + def reset(self): + self.i = 1 + + +#class Spaceline(pg.InfiniteLine): + #def __init__(self, sim, frame): + #self.sim = sim + #self.frame = frame + #pg.InfiniteLine.__init__(self) + #self.setPen(sim.clocks[frame].pen) + + #def stepTo(self, t): + #self.setAngle(0) + + #pass + +if __name__ == '__main__': + pg.mkQApp() + #import pyqtgraph.console + #cw = pyqtgraph.console.ConsoleWidget() + #cw.show() + #cw.catchNextException() + win = RelativityGUI() + win.setWindowTitle("Relativity!") + win.show() + win.resize(1100,700) + + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() + + + #win.params.param('Objects').restoreState(state, removeChildren=False) + diff --git a/examples/relativity_demo.py b/examples/relativity_demo.py new file mode 100644 index 00000000..24a1f476 --- /dev/null +++ b/examples/relativity_demo.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" +Special relativity simulation + + + +""" +import initExample ## Add path to library (just for examples; you do not need this) +import pyqtgraph as pg +from relativity import RelativityGUI + +pg.mkQApp() +win = RelativityGUI() +win.setWindowTitle("Relativity!") +win.resize(1100,700) +win.show() +win.loadPreset(None, 'Twin Paradox (grid)') + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(pg.QtCore, 'PYQT_VERSION'): + pg.QtGui.QApplication.instance().exec_() diff --git a/examples/scrollingPlots.py b/examples/scrollingPlots.py new file mode 100644 index 00000000..623b9ab1 --- /dev/null +++ b/examples/scrollingPlots.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +""" +Various methods of drawing scrolling plots. +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +win = pg.GraphicsWindow() +win.setWindowTitle('pyqtgraph example: Scrolling Plots') + + +# 1) Simplest approach -- update data in the array such that plot appears to scroll +# In these examples, the array size is fixed. +p1 = win.addPlot() +p2 = win.addPlot() +data1 = np.random.normal(size=300) +curve1 = p1.plot(data1) +curve2 = p2.plot(data1) +ptr1 = 0 +def update1(): + global data1, curve1, ptr1 + data1[:-1] = data1[1:] # shift data in the array one sample left + # (see also: np.roll) + data1[-1] = np.random.normal() + curve1.setData(data1) + + ptr1 += 1 + curve2.setData(data1) + curve2.setPos(ptr1, 0) + + +# 2) Allow data to accumulate. In these examples, the array doubles in length +# whenever it is full. +win.nextRow() +p3 = win.addPlot() +p4 = win.addPlot() +# Use automatic downsampling and clipping to reduce the drawing load +p3.setDownsampling(mode='peak') +p4.setDownsampling(mode='peak') +p3.setClipToView(True) +p4.setClipToView(True) +p3.setRange(xRange=[-100, 0]) +p3.setLimits(xMax=0) +curve3 = p3.plot() +curve4 = p4.plot() + +data3 = np.empty(100) +ptr3 = 0 + +def update2(): + global data3, ptr3 + data3[ptr3] = np.random.normal() + ptr3 += 1 + if ptr3 >= data3.shape[0]: + tmp = data3 + data3 = np.empty(data3.shape[0] * 2) + data3[:tmp.shape[0]] = tmp + curve3.setData(data3[:ptr3]) + curve3.setPos(-ptr3, 0) + curve4.setData(data3[:ptr3]) + + +# 3) Plot in chunks, adding one new plot curve for every 100 samples +chunkSize = 100 +# Remove chunks after we have 10 +maxChunks = 10 +startTime = pg.ptime.time() +win.nextRow() +p5 = win.addPlot(colspan=2) +p5.setLabel('bottom', 'Time', 's') +p5.setXRange(-10, 0) +curves = [] +data5 = np.empty((chunkSize+1,2)) +ptr5 = 0 + +def update3(): + global p5, data5, ptr5, curves + now = pg.ptime.time() + for c in curves: + c.setPos(-(now-startTime), 0) + + i = ptr5 % chunkSize + if i == 0: + curve = p5.plot() + curves.append(curve) + last = data5[-1] + data5 = np.empty((chunkSize+1,2)) + data5[0] = last + while len(curves) > maxChunks: + c = curves.pop(0) + p5.removeItem(c) + else: + curve = curves[-1] + data5[i+1,0] = now - startTime + data5[i+1,1] = np.random.normal() + curve.setData(x=data5[:i+2, 0], y=data5[:i+2, 1]) + ptr5 += 1 + + +# update all plots +def update(): + update1() + update2() + update3() +timer = pg.QtCore.QTimer() +timer.timeout.connect(update) +timer.start(50) + + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/examples/verlet_chain/__init__.py b/examples/verlet_chain/__init__.py new file mode 100644 index 00000000..f473190f --- /dev/null +++ b/examples/verlet_chain/__init__.py @@ -0,0 +1 @@ +from .chain import ChainSim \ No newline at end of file diff --git a/examples/verlet_chain/chain.py b/examples/verlet_chain/chain.py new file mode 100644 index 00000000..6eb3501a --- /dev/null +++ b/examples/verlet_chain/chain.py @@ -0,0 +1,115 @@ +import pyqtgraph as pg +import numpy as np +import time +from . import relax + + +class ChainSim(pg.QtCore.QObject): + + stepped = pg.QtCore.Signal() + relaxed = pg.QtCore.Signal() + + def __init__(self): + pg.QtCore.QObject.__init__(self) + + self.damping = 0.1 # 0=full damping, 1=no damping + self.relaxPerStep = 10 + self.maxTimeStep = 0.01 + + self.pos = None # (Npts, 2) float + self.mass = None # (Npts) float + self.fixed = None # (Npts) bool + self.links = None # (Nlinks, 2), uint + self.lengths = None # (Nlinks), float + self.push = None # (Nlinks), bool + self.pull = None # (Nlinks), bool + + self.initialized = False + self.lasttime = None + self.lastpos = None + + def init(self): + if self.initialized: + return + + assert None not in [self.pos, self.mass, self.links, self.lengths] + + if self.fixed is None: + self.fixed = np.zeros(self.pos.shape[0], dtype=bool) + if self.push is None: + self.push = np.ones(self.links.shape[0], dtype=bool) + if self.pull is None: + self.pull = np.ones(self.links.shape[0], dtype=bool) + + + # precompute relative masses across links + l1 = self.links[:,0] + l2 = self.links[:,1] + m1 = self.mass[l1] + m2 = self.mass[l2] + self.mrel1 = (m1 / (m1+m2))[:,np.newaxis] + self.mrel1[self.fixed[l1]] = 1 # fixed point constraint + self.mrel1[self.fixed[l2]] = 0 + self.mrel2 = 1.0 - self.mrel1 + + for i in range(10): + self.relax(n=10) + + self.initialized = True + + def makeGraph(self): + #g1 = pg.GraphItem(pos=self.pos, adj=self.links[self.rope], pen=0.2, symbol=None) + brushes = np.where(self.fixed, pg.mkBrush(0,0,0,255), pg.mkBrush(50,50,200,255)) + g2 = pg.GraphItem(pos=self.pos, adj=self.links[self.push & self.pull], pen=0.5, brush=brushes, symbol='o', size=(self.mass**0.33), pxMode=False) + p = pg.ItemGroup() + #p.addItem(g1) + p.addItem(g2) + return p + + def update(self): + # approximate physics with verlet integration + + now = pg.ptime.time() + if self.lasttime is None: + dt = 0 + else: + dt = now - self.lasttime + self.lasttime = now + + # limit amount of work to be done between frames + if not relax.COMPILED: + dt = self.maxTimeStep + + if self.lastpos is None: + self.lastpos = self.pos + + # remember fixed positions + fixedpos = self.pos[self.fixed] + + while dt > 0: + dt1 = min(self.maxTimeStep, dt) + dt -= dt1 + + # compute motion since last timestep + dx = self.pos - self.lastpos + self.lastpos = self.pos + + # update positions for gravity and inertia + acc = np.array([[0, -5]]) * dt1 + inertia = dx * (self.damping**(dt1/self.mass))[:,np.newaxis] # with mass-dependent damping + self.pos = self.pos + inertia + acc + + self.pos[self.fixed] = fixedpos # fixed point constraint + + # correct for link constraints + self.relax(self.relaxPerStep) + self.stepped.emit() + + + def relax(self, n=50): + # speed up with C magic if possible + relax.relax(self.pos, self.links, self.mrel1, self.mrel2, self.lengths, self.push, self.pull, n) + self.relaxed.emit() + + + diff --git a/examples/verlet_chain/make b/examples/verlet_chain/make new file mode 100755 index 00000000..78503f2a --- /dev/null +++ b/examples/verlet_chain/make @@ -0,0 +1,3 @@ +gcc -fPIC -c relax.c +gcc -shared -o maths.so relax.o + diff --git a/examples/verlet_chain/relax.c b/examples/verlet_chain/relax.c new file mode 100644 index 00000000..a81ac70c --- /dev/null +++ b/examples/verlet_chain/relax.c @@ -0,0 +1,48 @@ +#include +#include + +void relax( + double* pos, + long* links, + double* mrel1, + double* mrel2, + double* lengths, + char* push, + char* pull, + int nlinks, + int iters) + { + int i, l, p1, p2; + double x1, x2, y1, y2, dx, dy, dist, change; +// printf("%d, %d\n", iters, nlinks); + for( i=0; i lengths[l] ) + dist = lengths[l]; + + change = (lengths[l]-dist) / dist; + dx *= change; + dy *= change; + + pos[p1] -= mrel2[l] * dx; + pos[p1+1] -= mrel2[l] * dy; + pos[p2] += mrel1[l] * dx; + pos[p2+1] += mrel1[l] * dy; + } + } +} \ No newline at end of file diff --git a/examples/verlet_chain/relax.py b/examples/verlet_chain/relax.py new file mode 100644 index 00000000..22c54d62 --- /dev/null +++ b/examples/verlet_chain/relax.py @@ -0,0 +1,70 @@ +import ctypes +import os + +so = os.path.join(os.path.dirname(__file__), 'maths.so') +try: + lib = ctypes.CDLL(so) + COMPILED = True +except OSError: + COMPILED = False + + +if COMPILED: + lib.relax.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_int, + ] + + def relax(pos, links, mrel1, mrel2, lengths, push, pull, iters): + nlinks = links.shape[0] + lib.relax(pos.ctypes, links.ctypes, mrel1.ctypes, mrel2.ctypes, lengths.ctypes, push.ctypes, pull.ctypes, nlinks, iters) + +else: + def relax(pos, links, mrel1, mrel2, lengths, push, pull, iters): + lengths2 = lengths**2 + for i in range(iters): + #p1 = links[:, 0] + #p2 = links[:, 1] + #x1 = pos[p1] + #x2 = pos[p2] + + #dx = x2 - x1 + + #dist = (dx**2).sum(axis=1)**0.5 + + #mask = (npush & (dist < lengths)) | (npull & (dist > lengths)) + ##dist[mask] = lengths[mask] + #change = (lengths-dist) / dist + #change[mask] = 0 + + #dx *= change[:, np.newaxis] + #print dx + + ##pos[p1] -= mrel2 * dx + ##pos[p2] += mrel1 * dx + #for j in range(links.shape[0]): + #pos[links[j,0]] -= mrel2[j] * dx[j] + #pos[links[j,1]] += mrel1[j] * dx[j] + + + for l in range(links.shape[0]): + p1, p2 = links[l]; + x1 = pos[p1] + x2 = pos[p2] + + dx = x2 - x1 + dist2 = (dx**2).sum() + + if (push[l] and dist2 < lengths2[l]) or (pull[l] and dist2 > lengths2[l]): + dist = dist2 ** 0.5 + change = (lengths[l]-dist) / dist + dx *= change + pos[p1] -= mrel2[l] * dx + pos[p2] += mrel1[l] * dx diff --git a/examples/verlet_chain_demo.py b/examples/verlet_chain_demo.py new file mode 100644 index 00000000..1197344d --- /dev/null +++ b/examples/verlet_chain_demo.py @@ -0,0 +1,126 @@ +""" +Mechanical simulation of a chain using verlet integration. + +Use the mouse to interact with one of the chains. + +By default, this uses a slow, pure-python integrator to solve the chain link +positions. Unix users may compile a small math library to speed this up by +running the `examples/verlet_chain/make` script. + +""" + +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +import verlet_chain + +sim = verlet_chain.ChainSim() + +if verlet_chain.relax.COMPILED: + # Use more complex chain if compiled mad library is available. + chlen1 = 80 + chlen2 = 60 + linklen = 1 +else: + chlen1 = 10 + chlen2 = 8 + linklen = 8 + +npts = chlen1 + chlen2 + +sim.mass = np.ones(npts) +sim.mass[int(chlen1 * 0.8)] = 100 +sim.mass[chlen1-1] = 500 +sim.mass[npts-1] = 200 + +sim.fixed = np.zeros(npts, dtype=bool) +sim.fixed[0] = True +sim.fixed[chlen1] = True + +sim.pos = np.empty((npts, 2)) +sim.pos[:chlen1, 0] = 0 +sim.pos[chlen1:, 0] = 10 +sim.pos[:chlen1, 1] = np.arange(chlen1) * linklen +sim.pos[chlen1:, 1] = np.arange(chlen2) * linklen +# to prevent miraculous balancing acts: +sim.pos += np.random.normal(size=sim.pos.shape, scale=1e-3) + +links1 = [(j, i+j+1) for i in range(chlen1) for j in range(chlen1-i-1)] +links2 = [(j, i+j+1) for i in range(chlen2) for j in range(chlen2-i-1)] +sim.links = np.concatenate([np.array(links1), np.array(links2)+chlen1, np.array([[chlen1-1, npts-1]])]) + +p1 = sim.pos[sim.links[:,0]] +p2 = sim.pos[sim.links[:,1]] +dif = p2-p1 +sim.lengths = (dif**2).sum(axis=1) ** 0.5 +sim.lengths[(chlen1-1):len(links1)] *= 1.05 # let auxiliary links stretch a little +sim.lengths[(len(links1)+chlen2-1):] *= 1.05 +sim.lengths[-1] = 7 + +push1 = np.ones(len(links1), dtype=bool) +push1[chlen1:] = False +push2 = np.ones(len(links2), dtype=bool) +push2[chlen2:] = False +sim.push = np.concatenate([push1, push2, np.array([True], dtype=bool)]) + +sim.pull = np.ones(sim.links.shape[0], dtype=bool) +sim.pull[-1] = False + +# move chain initially just to generate some motion if the mouse is not over the window +mousepos = np.array([30, 20]) + + +def display(): + global view, sim + view.clear() + view.addItem(sim.makeGraph()) + +def relaxed(): + global app + display() + app.processEvents() + +def mouse(pos): + global mousepos + pos = view.mapSceneToView(pos) + mousepos = np.array([pos.x(), pos.y()]) + +def update(): + global mousepos + #sim.pos[0] = sim.pos[0] * 0.9 + mousepos * 0.1 + s = 0.9 + sim.pos[0] = sim.pos[0] * s + mousepos * (1.0-s) + sim.update() + +app = pg.mkQApp() +win = pg.GraphicsLayoutWidget() +win.show() +view = win.addViewBox() +view.setAspectLocked(True) +view.setXRange(-100, 100) +#view.autoRange() + +view.scene().sigMouseMoved.connect(mouse) + +#display() +#app.processEvents() + +sim.relaxed.connect(relaxed) +sim.init() +sim.relaxed.disconnect(relaxed) + +sim.stepped.connect(display) + +timer = pg.QtCore.QTimer() +timer.timeout.connect(update) +timer.start(16) + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() diff --git a/pyqtgraph/GraphicsScene/GraphicsScene.py b/pyqtgraph/GraphicsScene/GraphicsScene.py index 8729d085..6f5354dc 100644 --- a/pyqtgraph/GraphicsScene/GraphicsScene.py +++ b/pyqtgraph/GraphicsScene/GraphicsScene.py @@ -1,19 +1,11 @@ -from pyqtgraph.Qt import QtCore, QtGui - -from pyqtgraph.python2_3 import sortList -#try: - #from PyQt4 import QtOpenGL - #HAVE_OPENGL = True -#except ImportError: - #HAVE_OPENGL = False - +from ..Qt import QtCore, QtGui +from ..python2_3 import sortList import weakref -from pyqtgraph.Point import Point -import pyqtgraph.functions as fn -import pyqtgraph.ptime as ptime +from ..Point import Point +from .. import functions as fn +from .. import ptime as ptime from .mouseEvents import * -import pyqtgraph.debug as debug -from . import exportDialog +from .. import debug as debug if hasattr(QtCore, 'PYQT_VERSION'): try: @@ -92,23 +84,19 @@ class GraphicsScene(QtGui.QGraphicsScene): cls._addressCache[sip.unwrapinstance(sip.cast(obj, QtGui.QGraphicsItem))] = obj - def __init__(self, clickRadius=2, moveDistance=5): - QtGui.QGraphicsScene.__init__(self) + def __init__(self, clickRadius=2, moveDistance=5, parent=None): + QtGui.QGraphicsScene.__init__(self, parent) self.setClickRadius(clickRadius) self.setMoveDistance(moveDistance) self.exportDirectory = None self.clickEvents = [] self.dragButtons = [] - self.prepItems = weakref.WeakKeyDictionary() ## set of items with prepareForPaintMethods self.mouseGrabber = None self.dragItem = None self.lastDrag = None self.hoverItems = weakref.WeakKeyDictionary() self.lastHoverEvent = None - #self.searchRect = QtGui.QGraphicsRectItem() - #self.searchRect.setPen(fn.mkPen(200,0,0)) - #self.addItem(self.searchRect) self.contextMenu = [QtGui.QAction("Export...", self)] self.contextMenu[0].triggered.connect(self.showExportDialog) @@ -147,8 +135,13 @@ class GraphicsScene(QtGui.QGraphicsScene): def mousePressEvent(self, ev): #print 'scenePress' QtGui.QGraphicsScene.mousePressEvent(self, ev) - #print "mouseGrabberItem: ", self.mouseGrabberItem() if self.mouseGrabberItem() is None: ## nobody claimed press; we are free to generate drag/click events + if self.lastHoverEvent is not None: + # If the mouse has moved since the last hover event, send a new one. + # This can happen if a context menu is open while the mouse is moving. + if ev.scenePos() != self.lastHoverEvent.scenePos(): + self.sendHoverEvents(ev) + self.clickEvents.append(MouseClickEvent(ev)) ## set focus on the topmost focusable item under this click @@ -157,10 +150,6 @@ class GraphicsScene(QtGui.QGraphicsScene): if i.isEnabled() and i.isVisible() and int(i.flags() & i.ItemIsFocusable) > 0: i.setFocus(QtCore.Qt.MouseFocusReason) break - #else: - #addr = sip.unwrapinstance(sip.cast(self.mouseGrabberItem(), QtGui.QGraphicsItem)) - #item = GraphicsScene._addressCache.get(addr, self.mouseGrabberItem()) - #print "click grabbed by:", item def mouseMoveEvent(self, ev): self.sigMouseMoved.emit(ev.scenePos()) @@ -201,7 +190,6 @@ class GraphicsScene(QtGui.QGraphicsScene): def mouseReleaseEvent(self, ev): #print 'sceneRelease' if self.mouseGrabberItem() is None: - #print "sending click/drag event" if ev.button() in self.dragButtons: if self.sendDragEvent(ev, final=True): #print "sent drag event" @@ -243,6 +231,8 @@ class GraphicsScene(QtGui.QGraphicsScene): prevItems = list(self.hoverItems.keys()) + #print "hover prev items:", prevItems + #print "hover test items:", items for item in items: if hasattr(item, 'hoverEvent'): event.currentItem = item @@ -260,6 +250,7 @@ class GraphicsScene(QtGui.QGraphicsScene): event.enter = False event.exit = True + #print "hover exit items:", prevItems for item in prevItems: event.currentItem = item try: @@ -269,9 +260,13 @@ class GraphicsScene(QtGui.QGraphicsScene): finally: del self.hoverItems[item] - if hasattr(ev, 'buttons') and int(ev.buttons()) == 0: + # Update last hover event unless: + # - mouse is dragging (move+buttons); in this case we want the dragged + # item to continue receiving events until the drag is over + # - event is not a mouse event (QEvent.Leave sometimes appears here) + if (ev.type() == ev.GraphicsSceneMousePress or + (ev.type() == ev.GraphicsSceneMouseMove and int(ev.buttons()) == 0)): self.lastHoverEvent = event ## save this so we can ask about accepted events later. - def sendDragEvent(self, ev, init=False, final=False): ## Send a MouseDragEvent to the current dragItem or to @@ -335,7 +330,6 @@ class GraphicsScene(QtGui.QGraphicsScene): acceptedItem = self.lastHoverEvent.clickItems().get(ev.button(), None) else: acceptedItem = None - if acceptedItem is not None: ev.currentItem = acceptedItem try: @@ -357,22 +351,9 @@ class GraphicsScene(QtGui.QGraphicsScene): if int(item.flags() & item.ItemIsFocusable) > 0: item.setFocus(QtCore.Qt.MouseFocusReason) break - #if not ev.isAccepted() and ev.button() is QtCore.Qt.RightButton: - #print "GraphicsScene emitting sigSceneContextMenu" - #self.sigMouseClicked.emit(ev) - #ev.accept() self.sigMouseClicked.emit(ev) return ev.isAccepted() - #def claimEvent(self, item, button, eventType): - #key = (button, eventType) - #if key in self.claimedEvents: - #return False - #self.claimedEvents[key] = item - #print "event", key, "claimed by", item - #return True - - def items(self, *args): #print 'args:', args items = QtGui.QGraphicsScene.items(self, *args) @@ -445,10 +426,10 @@ class GraphicsScene(QtGui.QGraphicsScene): for item in items: if hoverable and not hasattr(item, 'hoverEvent'): continue - shape = item.shape() + shape = item.shape() # Note: default shape() returns boundingRect() if shape is None: continue - if item.mapToScene(shape).contains(point): + if shape.contains(item.mapFromScene(point)): items2.append(item) ## Sort by descending Z-order (don't trust scene.itms() to do this either) @@ -489,7 +470,7 @@ class GraphicsScene(QtGui.QGraphicsScene): #return v #else: #return widget - + def addParentContextMenus(self, item, menu, event): """ Can be called by any item in the scene to expand its context menu to include parent context menus. @@ -519,30 +500,23 @@ class GraphicsScene(QtGui.QGraphicsScene): event The original event that triggered the menu to appear. ============== ================================================== """ - - #items = self.itemsNearEvent(ev) + menusToAdd = [] while item is not self: item = item.parentItem() - if item is None: item = self - if not hasattr(item, "getContextMenus"): continue - - subMenus = item.getContextMenus(event) - if subMenus is None: - continue - if type(subMenus) is not list: ## so that some items (like FlowchartViewBox) can return multiple menus - subMenus = [subMenus] - - for sm in subMenus: - menusToAdd.append(sm) - - if len(menusToAdd) > 0: + subMenus = item.getContextMenus(event) or [] + if isinstance(subMenus, list): ## so that some items (like FlowchartViewBox) can return multiple menus + menusToAdd.extend(subMenus) + else: + menusToAdd.append(subMenus) + + if menusToAdd: menu.addSeparator() - + for m in menusToAdd: if isinstance(m, QtGui.QMenu): menu.addMenu(m) @@ -559,6 +533,7 @@ class GraphicsScene(QtGui.QGraphicsScene): def showExportDialog(self): if self.exportDialog is None: + from . import exportDialog self.exportDialog = exportDialog.ExportDialog(self) self.exportDialog.show(self.contextMenuItem) diff --git a/pyqtgraph/GraphicsScene/exportDialog.py b/pyqtgraph/GraphicsScene/exportDialog.py index 436d5e42..5efb7c44 100644 --- a/pyqtgraph/GraphicsScene/exportDialog.py +++ b/pyqtgraph/GraphicsScene/exportDialog.py @@ -1,6 +1,8 @@ -from pyqtgraph.Qt import QtCore, QtGui, USE_PYSIDE -import pyqtgraph as pg -import pyqtgraph.exporters as exporters +from ..Qt import QtCore, QtGui, USE_PYSIDE +from .. import exporters as exporters +from .. import functions as fn +from ..graphicsItems.ViewBox import ViewBox +from ..graphicsItems.PlotItem import PlotItem if USE_PYSIDE: from . import exportDialogTemplate_pyside as exportDialogTemplate @@ -18,7 +20,7 @@ class ExportDialog(QtGui.QWidget): self.scene = scene self.selectBox = QtGui.QGraphicsRectItem() - self.selectBox.setPen(pg.mkPen('y', width=3, style=QtCore.Qt.DashLine)) + self.selectBox.setPen(fn.mkPen('y', width=3, style=QtCore.Qt.DashLine)) self.selectBox.hide() self.scene.addItem(self.selectBox) @@ -35,10 +37,10 @@ class ExportDialog(QtGui.QWidget): def show(self, item=None): if item is not None: ## Select next exportable parent of the item originally clicked on - while not isinstance(item, pg.ViewBox) and not isinstance(item, pg.PlotItem) and item is not None: + while not isinstance(item, ViewBox) and not isinstance(item, PlotItem) and item is not None: item = item.parentItem() ## if this is a ViewBox inside a PlotItem, select the parent instead. - if isinstance(item, pg.ViewBox) and isinstance(item.parentItem(), pg.PlotItem): + if isinstance(item, ViewBox) and isinstance(item.parentItem(), PlotItem): item = item.parentItem() self.updateItemList(select=item) self.setVisible(True) @@ -64,9 +66,9 @@ class ExportDialog(QtGui.QWidget): def updateItemTree(self, item, treeItem, select=None): si = None - if isinstance(item, pg.ViewBox): + if isinstance(item, ViewBox): si = QtGui.QTreeWidgetItem(['ViewBox']) - elif isinstance(item, pg.PlotItem): + elif isinstance(item, PlotItem): si = QtGui.QTreeWidgetItem(['Plot']) if si is not None: diff --git a/pyqtgraph/GraphicsScene/exportDialogTemplate.ui b/pyqtgraph/GraphicsScene/exportDialogTemplate.ui index c91fbc3f..eacacd88 100644 --- a/pyqtgraph/GraphicsScene/exportDialogTemplate.ui +++ b/pyqtgraph/GraphicsScene/exportDialogTemplate.ui @@ -92,7 +92,7 @@ ParameterTree QTreeWidget -
pyqtgraph.parametertree
+
..parametertree
diff --git a/pyqtgraph/GraphicsScene/exportDialogTemplate_pyqt.py b/pyqtgraph/GraphicsScene/exportDialogTemplate_pyqt.py index c3056d1c..ad7361ab 100644 --- a/pyqtgraph/GraphicsScene/exportDialogTemplate_pyqt.py +++ b/pyqtgraph/GraphicsScene/exportDialogTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './GraphicsScene/exportDialogTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/GraphicsScene/exportDialogTemplate.ui' # -# Created: Wed Jan 30 21:02:28 2013 -# by: PyQt4 UI code generator 4.9.3 +# Created: Mon Dec 23 10:10:52 2013 +# by: PyQt4 UI code generator 4.10 # # WARNING! All changes made in this file will be lost! @@ -12,7 +12,16 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_Form(object): def setupUi(self, Form): @@ -57,12 +66,12 @@ class Ui_Form(object): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Export", None, QtGui.QApplication.UnicodeUTF8)) - self.label.setText(QtGui.QApplication.translate("Form", "Item to export:", None, QtGui.QApplication.UnicodeUTF8)) - self.label_2.setText(QtGui.QApplication.translate("Form", "Export format", None, QtGui.QApplication.UnicodeUTF8)) - self.exportBtn.setText(QtGui.QApplication.translate("Form", "Export", None, QtGui.QApplication.UnicodeUTF8)) - self.closeBtn.setText(QtGui.QApplication.translate("Form", "Close", None, QtGui.QApplication.UnicodeUTF8)) - self.label_3.setText(QtGui.QApplication.translate("Form", "Export options", None, QtGui.QApplication.UnicodeUTF8)) - self.copyBtn.setText(QtGui.QApplication.translate("Form", "Copy", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(_translate("Form", "Export", None)) + self.label.setText(_translate("Form", "Item to export:", None)) + self.label_2.setText(_translate("Form", "Export format", None)) + self.exportBtn.setText(_translate("Form", "Export", None)) + self.closeBtn.setText(_translate("Form", "Close", None)) + self.label_3.setText(_translate("Form", "Export options", None)) + self.copyBtn.setText(_translate("Form", "Copy", None)) -from pyqtgraph.parametertree import ParameterTree +from ..parametertree import ParameterTree diff --git a/pyqtgraph/GraphicsScene/exportDialogTemplate_pyside.py b/pyqtgraph/GraphicsScene/exportDialogTemplate_pyside.py index cf27f60a..f2e8dc70 100644 --- a/pyqtgraph/GraphicsScene/exportDialogTemplate_pyside.py +++ b/pyqtgraph/GraphicsScene/exportDialogTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './GraphicsScene/exportDialogTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/GraphicsScene/exportDialogTemplate.ui' # -# Created: Wed Jan 30 21:02:28 2013 -# by: pyside-uic 0.2.13 running on PySide 1.1.1 +# Created: Mon Dec 23 10:10:53 2013 +# by: pyside-uic 0.2.14 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! @@ -60,4 +60,4 @@ class Ui_Form(object): self.label_3.setText(QtGui.QApplication.translate("Form", "Export options", None, QtGui.QApplication.UnicodeUTF8)) self.copyBtn.setText(QtGui.QApplication.translate("Form", "Copy", None, QtGui.QApplication.UnicodeUTF8)) -from pyqtgraph.parametertree import ParameterTree +from ..parametertree import ParameterTree diff --git a/pyqtgraph/GraphicsScene/mouseEvents.py b/pyqtgraph/GraphicsScene/mouseEvents.py index 0b71ac6f..2e472e04 100644 --- a/pyqtgraph/GraphicsScene/mouseEvents.py +++ b/pyqtgraph/GraphicsScene/mouseEvents.py @@ -1,7 +1,7 @@ -from pyqtgraph.Point import Point -from pyqtgraph.Qt import QtCore, QtGui +from ..Point import Point +from ..Qt import QtCore, QtGui import weakref -import pyqtgraph.ptime as ptime +from .. import ptime as ptime class MouseDragEvent(object): """ @@ -131,8 +131,12 @@ class MouseDragEvent(object): return self.finish def __repr__(self): - lp = self.lastPos() - p = self.pos() + if self.currentItem is None: + lp = self._lastScenePos + p = self._scenePos + else: + lp = self.lastPos() + p = self.pos() return "(%g,%g) buttons=%d start=%s finish=%s>" % (lp.x(), lp.y(), p.x(), p.y(), int(self.buttons()), str(self.isStart()), str(self.isFinish())) def modifiers(self): @@ -221,9 +225,15 @@ class MouseClickEvent(object): return self._modifiers def __repr__(self): - p = self.pos() - return "" % (p.x(), p.y(), int(self.button())) - + try: + if self.currentItem is None: + p = self._scenePos + else: + p = self.pos() + return "" % (p.x(), p.y(), int(self.button())) + except: + return "" % (int(self.button())) + def time(self): return self._time @@ -345,8 +355,15 @@ class HoverEvent(object): return Point(self.currentItem.mapFromScene(self._lastScenePos)) def __repr__(self): - lp = self.lastPos() - p = self.pos() + if self.exit: + return "" + + if self.currentItem is None: + lp = self._lastScenePos + p = self._scenePos + else: + lp = self.lastPos() + p = self.pos() return "(%g,%g) buttons=%d enter=%s exit=%s>" % (lp.x(), lp.y(), p.x(), p.y(), int(self.buttons()), str(self.isEnter()), str(self.isExit())) def modifiers(self): diff --git a/pyqtgraph/PIL_Fix/Image.py-1.6 b/pyqtgraph/PIL_Fix/Image.py-1.6 deleted file mode 100644 index 2b373059..00000000 --- a/pyqtgraph/PIL_Fix/Image.py-1.6 +++ /dev/null @@ -1,2099 +0,0 @@ -# -# The Python Imaging Library. -# $Id: Image.py 2933 2006-12-03 12:08:22Z fredrik $ -# -# the Image class wrapper -# -# partial release history: -# 1995-09-09 fl Created -# 1996-03-11 fl PIL release 0.0 (proof of concept) -# 1996-04-30 fl PIL release 0.1b1 -# 1999-07-28 fl PIL release 1.0 final -# 2000-06-07 fl PIL release 1.1 -# 2000-10-20 fl PIL release 1.1.1 -# 2001-05-07 fl PIL release 1.1.2 -# 2002-03-15 fl PIL release 1.1.3 -# 2003-05-10 fl PIL release 1.1.4 -# 2005-03-28 fl PIL release 1.1.5 -# 2006-12-02 fl PIL release 1.1.6 -# -# Copyright (c) 1997-2006 by Secret Labs AB. All rights reserved. -# Copyright (c) 1995-2006 by Fredrik Lundh. -# -# See the README file for information on usage and redistribution. -# - -VERSION = "1.1.6" - -try: - import warnings -except ImportError: - warnings = None - -class _imaging_not_installed: - # module placeholder - def __getattr__(self, id): - raise ImportError("The _imaging C module is not installed") - -try: - # give Tk a chance to set up the environment, in case we're - # using an _imaging module linked against libtcl/libtk (use - # __import__ to hide this from naive packagers; we don't really - # depend on Tk unless ImageTk is used, and that module already - # imports Tkinter) - __import__("FixTk") -except ImportError: - pass - -try: - # If the _imaging C module is not present, you can still use - # the "open" function to identify files, but you cannot load - # them. Note that other modules should not refer to _imaging - # directly; import Image and use the Image.core variable instead. - import _imaging - core = _imaging - del _imaging -except ImportError, v: - core = _imaging_not_installed() - if str(v)[:20] == "Module use of python" and warnings: - # The _imaging C module is present, but not compiled for - # the right version (windows only). Print a warning, if - # possible. - warnings.warn( - "The _imaging extension was built for another version " - "of Python; most PIL functions will be disabled", - RuntimeWarning - ) - -import ImageMode -import ImagePalette - -import os, string, sys - -# type stuff -from types import IntType, StringType, TupleType - -try: - UnicodeStringType = type(unicode("")) - ## - # (Internal) Checks if an object is a string. If the current - # Python version supports Unicode, this checks for both 8-bit - # and Unicode strings. - def isStringType(t): - return isinstance(t, StringType) or isinstance(t, UnicodeStringType) -except NameError: - def isStringType(t): - return isinstance(t, StringType) - -## -# (Internal) Checks if an object is a tuple. - -def isTupleType(t): - return isinstance(t, TupleType) - -## -# (Internal) Checks if an object is an image object. - -def isImageType(t): - return hasattr(t, "im") - -## -# (Internal) Checks if an object is a string, and that it points to a -# directory. - -def isDirectory(f): - return isStringType(f) and os.path.isdir(f) - -from operator import isNumberType, isSequenceType - -# -# Debug level - -DEBUG = 0 - -# -# Constants (also defined in _imagingmodule.c!) - -NONE = 0 - -# transpose -FLIP_LEFT_RIGHT = 0 -FLIP_TOP_BOTTOM = 1 -ROTATE_90 = 2 -ROTATE_180 = 3 -ROTATE_270 = 4 - -# transforms -AFFINE = 0 -EXTENT = 1 -PERSPECTIVE = 2 -QUAD = 3 -MESH = 4 - -# resampling filters -NONE = 0 -NEAREST = 0 -ANTIALIAS = 1 # 3-lobed lanczos -LINEAR = BILINEAR = 2 -CUBIC = BICUBIC = 3 - -# dithers -NONE = 0 -NEAREST = 0 -ORDERED = 1 # Not yet implemented -RASTERIZE = 2 # Not yet implemented -FLOYDSTEINBERG = 3 # default - -# palettes/quantizers -WEB = 0 -ADAPTIVE = 1 - -# categories -NORMAL = 0 -SEQUENCE = 1 -CONTAINER = 2 - -# -------------------------------------------------------------------- -# Registries - -ID = [] -OPEN = {} -MIME = {} -SAVE = {} -EXTENSION = {} - -# -------------------------------------------------------------------- -# Modes supported by this version - -_MODEINFO = { - # NOTE: this table will be removed in future versions. use - # getmode* functions or ImageMode descriptors instead. - - # official modes - "1": ("L", "L", ("1",)), - "L": ("L", "L", ("L",)), - "I": ("L", "I", ("I",)), - "F": ("L", "F", ("F",)), - "P": ("RGB", "L", ("P",)), - "RGB": ("RGB", "L", ("R", "G", "B")), - "RGBX": ("RGB", "L", ("R", "G", "B", "X")), - "RGBA": ("RGB", "L", ("R", "G", "B", "A")), - "CMYK": ("RGB", "L", ("C", "M", "Y", "K")), - "YCbCr": ("RGB", "L", ("Y", "Cb", "Cr")), - - # Experimental modes include I;16, I;16B, RGBa, BGR;15, - # and BGR;24. Use these modes only if you know exactly - # what you're doing... - -} - -if sys.byteorder == 'little': - _ENDIAN = '<' -else: - _ENDIAN = '>' - -_MODE_CONV = { - # official modes - "1": ('|b1', None), - "L": ('|u1', None), - "I": ('%si4' % _ENDIAN, None), # FIXME: is this correct? - "I;16": ('%su2' % _ENDIAN, None), # FIXME: is this correct? - "F": ('%sf4' % _ENDIAN, None), # FIXME: is this correct? - "P": ('|u1', None), - "RGB": ('|u1', 3), - "RGBX": ('|u1', 4), - "RGBA": ('|u1', 4), - "CMYK": ('|u1', 4), - "YCbCr": ('|u1', 4), -} - -def _conv_type_shape(im): - shape = im.size[::-1] - typ, extra = _MODE_CONV[im.mode] - if extra is None: - return shape, typ - else: - return shape+(extra,), typ - - -MODES = _MODEINFO.keys() -MODES.sort() - -# raw modes that may be memory mapped. NOTE: if you change this, you -# may have to modify the stride calculation in map.c too! -_MAPMODES = ("L", "P", "RGBX", "RGBA", "CMYK", "I;16", "I;16B") - -## -# Gets the "base" mode for given mode. This function returns "L" for -# images that contain grayscale data, and "RGB" for images that -# contain color data. -# -# @param mode Input mode. -# @return "L" or "RGB". -# @exception KeyError If the input mode was not a standard mode. - -def getmodebase(mode): - return ImageMode.getmode(mode).basemode - -## -# Gets the storage type mode. Given a mode, this function returns a -# single-layer mode suitable for storing individual bands. -# -# @param mode Input mode. -# @return "L", "I", or "F". -# @exception KeyError If the input mode was not a standard mode. - -def getmodetype(mode): - return ImageMode.getmode(mode).basetype - -## -# Gets a list of individual band names. Given a mode, this function -# returns a tuple containing the names of individual bands (use -# {@link #getmodetype} to get the mode used to store each individual -# band. -# -# @param mode Input mode. -# @return A tuple containing band names. The length of the tuple -# gives the number of bands in an image of the given mode. -# @exception KeyError If the input mode was not a standard mode. - -def getmodebandnames(mode): - return ImageMode.getmode(mode).bands - -## -# Gets the number of individual bands for this mode. -# -# @param mode Input mode. -# @return The number of bands in this mode. -# @exception KeyError If the input mode was not a standard mode. - -def getmodebands(mode): - return len(ImageMode.getmode(mode).bands) - -# -------------------------------------------------------------------- -# Helpers - -_initialized = 0 - -## -# Explicitly loads standard file format drivers. - -def preinit(): - "Load standard file format drivers." - - global _initialized - if _initialized >= 1: - return - - try: - import BmpImagePlugin - except ImportError: - pass - try: - import GifImagePlugin - except ImportError: - pass - try: - import JpegImagePlugin - except ImportError: - pass - try: - import PpmImagePlugin - except ImportError: - pass - try: - import PngImagePlugin - except ImportError: - pass -# try: -# import TiffImagePlugin -# except ImportError: -# pass - - _initialized = 1 - -## -# Explicitly initializes the Python Imaging Library. This function -# loads all available file format drivers. - -def init(): - "Load all file format drivers." - - global _initialized - if _initialized >= 2: - return - - visited = {} - - directories = sys.path - - try: - directories = directories + [os.path.dirname(__file__)] - except NameError: - pass - - # only check directories (including current, if present in the path) - for directory in filter(isDirectory, directories): - fullpath = os.path.abspath(directory) - if visited.has_key(fullpath): - continue - for file in os.listdir(directory): - if file[-14:] == "ImagePlugin.py": - f, e = os.path.splitext(file) - try: - sys.path.insert(0, directory) - try: - __import__(f, globals(), locals(), []) - finally: - del sys.path[0] - except ImportError: - if DEBUG: - print "Image: failed to import", - print f, ":", sys.exc_value - visited[fullpath] = None - - if OPEN or SAVE: - _initialized = 2 - - -# -------------------------------------------------------------------- -# Codec factories (used by tostring/fromstring and ImageFile.load) - -def _getdecoder(mode, decoder_name, args, extra=()): - - # tweak arguments - if args is None: - args = () - elif not isTupleType(args): - args = (args,) - - try: - # get decoder - decoder = getattr(core, decoder_name + "_decoder") - # print decoder, (mode,) + args + extra - return apply(decoder, (mode,) + args + extra) - except AttributeError: - raise IOError("decoder %s not available" % decoder_name) - -def _getencoder(mode, encoder_name, args, extra=()): - - # tweak arguments - if args is None: - args = () - elif not isTupleType(args): - args = (args,) - - try: - # get encoder - encoder = getattr(core, encoder_name + "_encoder") - # print encoder, (mode,) + args + extra - return apply(encoder, (mode,) + args + extra) - except AttributeError: - raise IOError("encoder %s not available" % encoder_name) - - -# -------------------------------------------------------------------- -# Simple expression analyzer - -class _E: - def __init__(self, data): self.data = data - def __coerce__(self, other): return self, _E(other) - def __add__(self, other): return _E((self.data, "__add__", other.data)) - def __mul__(self, other): return _E((self.data, "__mul__", other.data)) - -def _getscaleoffset(expr): - stub = ["stub"] - data = expr(_E(stub)).data - try: - (a, b, c) = data # simplified syntax - if (a is stub and b == "__mul__" and isNumberType(c)): - return c, 0.0 - if (a is stub and b == "__add__" and isNumberType(c)): - return 1.0, c - except TypeError: pass - try: - ((a, b, c), d, e) = data # full syntax - if (a is stub and b == "__mul__" and isNumberType(c) and - d == "__add__" and isNumberType(e)): - return c, e - except TypeError: pass - raise ValueError("illegal expression") - - -# -------------------------------------------------------------------- -# Implementation wrapper - -## -# This class represents an image object. To create Image objects, use -# the appropriate factory functions. There's hardly ever any reason -# to call the Image constructor directly. -# -# @see #open -# @see #new -# @see #fromstring - -class Image: - - format = None - format_description = None - - def __init__(self): - self.im = None - self.mode = "" - self.size = (0, 0) - self.palette = None - self.info = {} - self.category = NORMAL - self.readonly = 0 - - def _new(self, im): - new = Image() - new.im = im - new.mode = im.mode - new.size = im.size - new.palette = self.palette - if im.mode == "P": - new.palette = ImagePalette.ImagePalette() - try: - new.info = self.info.copy() - except AttributeError: - # fallback (pre-1.5.2) - new.info = {} - for k, v in self.info: - new.info[k] = v - return new - - _makeself = _new # compatibility - - def _copy(self): - self.load() - self.im = self.im.copy() - self.readonly = 0 - - def _dump(self, file=None, format=None): - import tempfile - if not file: - file = tempfile.mktemp() - self.load() - if not format or format == "PPM": - self.im.save_ppm(file) - else: - file = file + "." + format - self.save(file, format) - return file - - def __getattr__(self, name): - if name == "__array_interface__": - # numpy array interface support - new = {} - shape, typestr = _conv_type_shape(self) - new['shape'] = shape - new['typestr'] = typestr - new['data'] = self.tostring() - return new - raise AttributeError(name) - - ## - # Returns a string containing pixel data. - # - # @param encoder_name What encoder to use. The default is to - # use the standard "raw" encoder. - # @param *args Extra arguments to the encoder. - # @return An 8-bit string. - - def tostring(self, encoder_name="raw", *args): - "Return image as a binary string" - - # may pass tuple instead of argument list - if len(args) == 1 and isTupleType(args[0]): - args = args[0] - - if encoder_name == "raw" and args == (): - args = self.mode - - self.load() - - # unpack data - e = _getencoder(self.mode, encoder_name, args) - e.setimage(self.im) - - bufsize = max(65536, self.size[0] * 4) # see RawEncode.c - - data = [] - while 1: - l, s, d = e.encode(bufsize) - data.append(d) - if s: - break - if s < 0: - raise RuntimeError("encoder error %d in tostring" % s) - - return string.join(data, "") - - ## - # Returns the image converted to an X11 bitmap. This method - # only works for mode "1" images. - # - # @param name The name prefix to use for the bitmap variables. - # @return A string containing an X11 bitmap. - # @exception ValueError If the mode is not "1" - - def tobitmap(self, name="image"): - "Return image as an XBM bitmap" - - self.load() - if self.mode != "1": - raise ValueError("not a bitmap") - data = self.tostring("xbm") - return string.join(["#define %s_width %d\n" % (name, self.size[0]), - "#define %s_height %d\n"% (name, self.size[1]), - "static char %s_bits[] = {\n" % name, data, "};"], "") - - ## - # Loads this image with pixel data from a string. - #

- # This method is similar to the {@link #fromstring} function, but - # loads data into this image instead of creating a new image - # object. - - def fromstring(self, data, decoder_name="raw", *args): - "Load data to image from binary string" - - # may pass tuple instead of argument list - if len(args) == 1 and isTupleType(args[0]): - args = args[0] - - # default format - if decoder_name == "raw" and args == (): - args = self.mode - - # unpack data - d = _getdecoder(self.mode, decoder_name, args) - d.setimage(self.im) - s = d.decode(data) - - if s[0] >= 0: - raise ValueError("not enough image data") - if s[1] != 0: - raise ValueError("cannot decode image data") - - ## - # Allocates storage for the image and loads the pixel data. In - # normal cases, you don't need to call this method, since the - # Image class automatically loads an opened image when it is - # accessed for the first time. - # - # @return An image access object. - - def load(self): - "Explicitly load pixel data." - if self.im and self.palette and self.palette.dirty: - # realize palette - apply(self.im.putpalette, self.palette.getdata()) - self.palette.dirty = 0 - self.palette.mode = "RGB" - self.palette.rawmode = None - if self.info.has_key("transparency"): - self.im.putpalettealpha(self.info["transparency"], 0) - self.palette.mode = "RGBA" - if self.im: - return self.im.pixel_access(self.readonly) - - ## - # Verifies the contents of a file. For data read from a file, this - # method attempts to determine if the file is broken, without - # actually decoding the image data. If this method finds any - # problems, it raises suitable exceptions. If you need to load - # the image after using this method, you must reopen the image - # file. - - def verify(self): - "Verify file contents." - pass - - - ## - # Returns a converted copy of this image. For the "P" mode, this - # method translates pixels through the palette. If mode is - # omitted, a mode is chosen so that all information in the image - # and the palette can be represented without a palette. - #

- # The current version supports all possible conversions between - # "L", "RGB" and "CMYK." - #

- # When translating a colour image to black and white (mode "L"), - # the library uses the ITU-R 601-2 luma transform: - #

- # L = R * 299/1000 + G * 587/1000 + B * 114/1000 - #

- # When translating a greyscale image into a bilevel image (mode - # "1"), all non-zero values are set to 255 (white). To use other - # thresholds, use the {@link #Image.point} method. - # - # @def convert(mode, matrix=None) - # @param mode The requested mode. - # @param matrix An optional conversion matrix. If given, this - # should be 4- or 16-tuple containing floating point values. - # @return An Image object. - - def convert(self, mode=None, data=None, dither=None, - palette=WEB, colors=256): - "Convert to other pixel format" - - if not mode: - # determine default mode - if self.mode == "P": - self.load() - if self.palette: - mode = self.palette.mode - else: - mode = "RGB" - else: - return self.copy() - - self.load() - - if data: - # matrix conversion - if mode not in ("L", "RGB"): - raise ValueError("illegal conversion") - im = self.im.convert_matrix(mode, data) - return self._new(im) - - if mode == "P" and palette == ADAPTIVE: - im = self.im.quantize(colors) - return self._new(im) - - # colourspace conversion - if dither is None: - dither = FLOYDSTEINBERG - - try: - im = self.im.convert(mode, dither) - except ValueError: - try: - # normalize source image and try again - im = self.im.convert(getmodebase(self.mode)) - im = im.convert(mode, dither) - except KeyError: - raise ValueError("illegal conversion") - - return self._new(im) - - def quantize(self, colors=256, method=0, kmeans=0, palette=None): - - # methods: - # 0 = median cut - # 1 = maximum coverage - - # NOTE: this functionality will be moved to the extended - # quantizer interface in a later version of PIL. - - self.load() - - if palette: - # use palette from reference image - palette.load() - if palette.mode != "P": - raise ValueError("bad mode for palette image") - if self.mode != "RGB" and self.mode != "L": - raise ValueError( - "only RGB or L mode images can be quantized to a palette" - ) - im = self.im.convert("P", 1, palette.im) - return self._makeself(im) - - im = self.im.quantize(colors, method, kmeans) - return self._new(im) - - ## - # Copies this image. Use this method if you wish to paste things - # into an image, but still retain the original. - # - # @return An Image object. - - def copy(self): - "Copy raster data" - - self.load() - im = self.im.copy() - return self._new(im) - - ## - # Returns a rectangular region from this image. The box is a - # 4-tuple defining the left, upper, right, and lower pixel - # coordinate. - #

- # This is a lazy operation. Changes to the source image may or - # may not be reflected in the cropped image. To break the - # connection, call the {@link #Image.load} method on the cropped - # copy. - # - # @param The crop rectangle, as a (left, upper, right, lower)-tuple. - # @return An Image object. - - def crop(self, box=None): - "Crop region from image" - - self.load() - if box is None: - return self.copy() - - # lazy operation - return _ImageCrop(self, box) - - ## - # Configures the image file loader so it returns a version of the - # image that as closely as possible matches the given mode and - # size. For example, you can use this method to convert a colour - # JPEG to greyscale while loading it, or to extract a 128x192 - # version from a PCD file. - #

- # Note that this method modifies the Image object in place. If - # the image has already been loaded, this method has no effect. - # - # @param mode The requested mode. - # @param size The requested size. - - def draft(self, mode, size): - "Configure image decoder" - - pass - - def _expand(self, xmargin, ymargin=None): - if ymargin is None: - ymargin = xmargin - self.load() - return self._new(self.im.expand(xmargin, ymargin, 0)) - - ## - # Filters this image using the given filter. For a list of - # available filters, see the ImageFilter module. - # - # @param filter Filter kernel. - # @return An Image object. - # @see ImageFilter - - def filter(self, filter): - "Apply environment filter to image" - - self.load() - - from ImageFilter import Filter - if not isinstance(filter, Filter): - filter = filter() - - if self.im.bands == 1: - return self._new(filter.filter(self.im)) - # fix to handle multiband images since _imaging doesn't - ims = [] - for c in range(self.im.bands): - ims.append(self._new(filter.filter(self.im.getband(c)))) - return merge(self.mode, ims) - - ## - # Returns a tuple containing the name of each band in this image. - # For example, getbands on an RGB image returns ("R", "G", "B"). - # - # @return A tuple containing band names. - - def getbands(self): - "Get band names" - - return ImageMode.getmode(self.mode).bands - - ## - # Calculates the bounding box of the non-zero regions in the - # image. - # - # @return The bounding box is returned as a 4-tuple defining the - # left, upper, right, and lower pixel coordinate. If the image - # is completely empty, this method returns None. - - def getbbox(self): - "Get bounding box of actual data (non-zero pixels) in image" - - self.load() - return self.im.getbbox() - - ## - # Returns a list of colors used in this image. - # - # @param maxcolors Maximum number of colors. If this number is - # exceeded, this method returns None. The default limit is - # 256 colors. - # @return An unsorted list of (count, pixel) values. - - def getcolors(self, maxcolors=256): - "Get colors from image, up to given limit" - - self.load() - if self.mode in ("1", "L", "P"): - h = self.im.histogram() - out = [] - for i in range(256): - if h[i]: - out.append((h[i], i)) - if len(out) > maxcolors: - return None - return out - return self.im.getcolors(maxcolors) - - ## - # Returns the contents of this image as a sequence object - # containing pixel values. The sequence object is flattened, so - # that values for line one follow directly after the values of - # line zero, and so on. - #

- # Note that the sequence object returned by this method is an - # internal PIL data type, which only supports certain sequence - # operations. To convert it to an ordinary sequence (e.g. for - # printing), use list(im.getdata()). - # - # @param band What band to return. The default is to return - # all bands. To return a single band, pass in the index - # value (e.g. 0 to get the "R" band from an "RGB" image). - # @return A sequence-like object. - - def getdata(self, band = None): - "Get image data as sequence object." - - self.load() - if band is not None: - return self.im.getband(band) - return self.im # could be abused - - ## - # Gets the the minimum and maximum pixel values for each band in - # the image. - # - # @return For a single-band image, a 2-tuple containing the - # minimum and maximum pixel value. For a multi-band image, - # a tuple containing one 2-tuple for each band. - - def getextrema(self): - "Get min/max value" - - self.load() - if self.im.bands > 1: - extrema = [] - for i in range(self.im.bands): - extrema.append(self.im.getband(i).getextrema()) - return tuple(extrema) - return self.im.getextrema() - - ## - # Returns a PyCObject that points to the internal image memory. - # - # @return A PyCObject object. - - def getim(self): - "Get PyCObject pointer to internal image memory" - - self.load() - return self.im.ptr - - - ## - # Returns the image palette as a list. - # - # @return A list of color values [r, g, b, ...], or None if the - # image has no palette. - - def getpalette(self): - "Get palette contents." - - self.load() - try: - return map(ord, self.im.getpalette()) - except ValueError: - return None # no palette - - - ## - # Returns the pixel value at a given position. - # - # @param xy The coordinate, given as (x, y). - # @return The pixel value. If the image is a multi-layer image, - # this method returns a tuple. - - def getpixel(self, xy): - "Get pixel value" - - self.load() - return self.im.getpixel(xy) - - ## - # Returns the horizontal and vertical projection. - # - # @return Two sequences, indicating where there are non-zero - # pixels along the X-axis and the Y-axis, respectively. - - def getprojection(self): - "Get projection to x and y axes" - - self.load() - x, y = self.im.getprojection() - return map(ord, x), map(ord, y) - - ## - # Returns a histogram for the image. The histogram is returned as - # a list of pixel counts, one for each pixel value in the source - # image. If the image has more than one band, the histograms for - # all bands are concatenated (for example, the histogram for an - # "RGB" image contains 768 values). - #

- # A bilevel image (mode "1") is treated as a greyscale ("L") image - # by this method. - #

- # If a mask is provided, the method returns a histogram for those - # parts of the image where the mask image is non-zero. The mask - # image must have the same size as the image, and be either a - # bi-level image (mode "1") or a greyscale image ("L"). - # - # @def histogram(mask=None) - # @param mask An optional mask. - # @return A list containing pixel counts. - - def histogram(self, mask=None, extrema=None): - "Take histogram of image" - - self.load() - if mask: - mask.load() - return self.im.histogram((0, 0), mask.im) - if self.mode in ("I", "F"): - if extrema is None: - extrema = self.getextrema() - return self.im.histogram(extrema) - return self.im.histogram() - - ## - # (Deprecated) Returns a copy of the image where the data has been - # offset by the given distances. Data wraps around the edges. If - # yoffset is omitted, it is assumed to be equal to xoffset. - #

- # This method is deprecated. New code should use the offset - # function in the ImageChops module. - # - # @param xoffset The horizontal distance. - # @param yoffset The vertical distance. If omitted, both - # distances are set to the same value. - # @return An Image object. - - def offset(self, xoffset, yoffset=None): - "(deprecated) Offset image in horizontal and/or vertical direction" - if warnings: - warnings.warn( - "'offset' is deprecated; use 'ImageChops.offset' instead", - DeprecationWarning, stacklevel=2 - ) - import ImageChops - return ImageChops.offset(self, xoffset, yoffset) - - ## - # Pastes another image into this image. The box argument is either - # a 2-tuple giving the upper left corner, a 4-tuple defining the - # left, upper, right, and lower pixel coordinate, or None (same as - # (0, 0)). If a 4-tuple is given, the size of the pasted image - # must match the size of the region. - #

- # If the modes don't match, the pasted image is converted to the - # mode of this image (see the {@link #Image.convert} method for - # details). - #

- # Instead of an image, the source can be a integer or tuple - # containing pixel values. The method then fills the region - # with the given colour. When creating RGB images, you can - # also use colour strings as supported by the ImageColor module. - #

- # If a mask is given, this method updates only the regions - # indicated by the mask. You can use either "1", "L" or "RGBA" - # images (in the latter case, the alpha band is used as mask). - # Where the mask is 255, the given image is copied as is. Where - # the mask is 0, the current value is preserved. Intermediate - # values can be used for transparency effects. - #

- # Note that if you paste an "RGBA" image, the alpha band is - # ignored. You can work around this by using the same image as - # both source image and mask. - # - # @param im Source image or pixel value (integer or tuple). - # @param box An optional 4-tuple giving the region to paste into. - # If a 2-tuple is used instead, it's treated as the upper left - # corner. If omitted or None, the source is pasted into the - # upper left corner. - #

- # If an image is given as the second argument and there is no - # third, the box defaults to (0, 0), and the second argument - # is interpreted as a mask image. - # @param mask An optional mask image. - # @return An Image object. - - def paste(self, im, box=None, mask=None): - "Paste other image into region" - - if isImageType(box) and mask is None: - # abbreviated paste(im, mask) syntax - mask = box; box = None - - if box is None: - # cover all of self - box = (0, 0) + self.size - - if len(box) == 2: - # lower left corner given; get size from image or mask - if isImageType(im): - size = im.size - elif isImageType(mask): - size = mask.size - else: - # FIXME: use self.size here? - raise ValueError( - "cannot determine region size; use 4-item box" - ) - box = box + (box[0]+size[0], box[1]+size[1]) - - if isStringType(im): - import ImageColor - im = ImageColor.getcolor(im, self.mode) - - elif isImageType(im): - im.load() - if self.mode != im.mode: - if self.mode != "RGB" or im.mode not in ("RGBA", "RGBa"): - # should use an adapter for this! - im = im.convert(self.mode) - im = im.im - - self.load() - if self.readonly: - self._copy() - - if mask: - mask.load() - self.im.paste(im, box, mask.im) - else: - self.im.paste(im, box) - - ## - # Maps this image through a lookup table or function. - # - # @param lut A lookup table, containing 256 values per band in the - # image. A function can be used instead, it should take a single - # argument. The function is called once for each possible pixel - # value, and the resulting table is applied to all bands of the - # image. - # @param mode Output mode (default is same as input). In the - # current version, this can only be used if the source image - # has mode "L" or "P", and the output has mode "1". - # @return An Image object. - - def point(self, lut, mode=None): - "Map image through lookup table" - - self.load() - - if not isSequenceType(lut): - # if it isn't a list, it should be a function - if self.mode in ("I", "I;16", "F"): - # check if the function can be used with point_transform - scale, offset = _getscaleoffset(lut) - return self._new(self.im.point_transform(scale, offset)) - # for other modes, convert the function to a table - lut = map(lut, range(256)) * self.im.bands - - if self.mode == "F": - # FIXME: _imaging returns a confusing error message for this case - raise ValueError("point operation not supported for this mode") - - return self._new(self.im.point(lut, mode)) - - ## - # Adds or replaces the alpha layer in this image. If the image - # does not have an alpha layer, it's converted to "LA" or "RGBA". - # The new layer must be either "L" or "1". - # - # @param im The new alpha layer. This can either be an "L" or "1" - # image having the same size as this image, or an integer or - # other color value. - - def putalpha(self, alpha): - "Set alpha layer" - - self.load() - if self.readonly: - self._copy() - - if self.mode not in ("LA", "RGBA"): - # attempt to promote self to a matching alpha mode - try: - mode = getmodebase(self.mode) + "A" - try: - self.im.setmode(mode) - except (AttributeError, ValueError): - # do things the hard way - im = self.im.convert(mode) - if im.mode not in ("LA", "RGBA"): - raise ValueError # sanity check - self.im = im - self.mode = self.im.mode - except (KeyError, ValueError): - raise ValueError("illegal image mode") - - if self.mode == "LA": - band = 1 - else: - band = 3 - - if isImageType(alpha): - # alpha layer - if alpha.mode not in ("1", "L"): - raise ValueError("illegal image mode") - alpha.load() - if alpha.mode == "1": - alpha = alpha.convert("L") - else: - # constant alpha - try: - self.im.fillband(band, alpha) - except (AttributeError, ValueError): - # do things the hard way - alpha = new("L", self.size, alpha) - else: - return - - self.im.putband(alpha.im, band) - - ## - # Copies pixel data to this image. This method copies data from a - # sequence object into the image, starting at the upper left - # corner (0, 0), and continuing until either the image or the - # sequence ends. The scale and offset values are used to adjust - # the sequence values: pixel = value*scale + offset. - # - # @param data A sequence object. - # @param scale An optional scale value. The default is 1.0. - # @param offset An optional offset value. The default is 0.0. - - def putdata(self, data, scale=1.0, offset=0.0): - "Put data from a sequence object into an image." - - self.load() - if self.readonly: - self._copy() - - self.im.putdata(data, scale, offset) - - ## - # Attaches a palette to this image. The image must be a "P" or - # "L" image, and the palette sequence must contain 768 integer - # values, where each group of three values represent the red, - # green, and blue values for the corresponding pixel - # index. Instead of an integer sequence, you can use an 8-bit - # string. - # - # @def putpalette(data) - # @param data A palette sequence (either a list or a string). - - def putpalette(self, data, rawmode="RGB"): - "Put palette data into an image." - - self.load() - if self.mode not in ("L", "P"): - raise ValueError("illegal image mode") - if not isStringType(data): - data = string.join(map(chr, data), "") - self.mode = "P" - self.palette = ImagePalette.raw(rawmode, data) - self.palette.mode = "RGB" - self.load() # install new palette - - ## - # Modifies the pixel at the given position. The colour is given as - # a single numerical value for single-band images, and a tuple for - # multi-band images. - #

- # Note that this method is relatively slow. For more extensive - # changes, use {@link #Image.paste} or the ImageDraw module - # instead. - # - # @param xy The pixel coordinate, given as (x, y). - # @param value The pixel value. - # @see #Image.paste - # @see #Image.putdata - # @see ImageDraw - - def putpixel(self, xy, value): - "Set pixel value" - - self.load() - if self.readonly: - self._copy() - - return self.im.putpixel(xy, value) - - ## - # Returns a resized copy of this image. - # - # @def resize(size, filter=NEAREST) - # @param size The requested size in pixels, as a 2-tuple: - # (width, height). - # @param filter An optional resampling filter. This can be - # one of NEAREST (use nearest neighbour), BILINEAR - # (linear interpolation in a 2x2 environment), BICUBIC - # (cubic spline interpolation in a 4x4 environment), or - # ANTIALIAS (a high-quality downsampling filter). - # If omitted, or if the image has mode "1" or "P", it is - # set NEAREST. - # @return An Image object. - - def resize(self, size, resample=NEAREST): - "Resize image" - - if resample not in (NEAREST, BILINEAR, BICUBIC, ANTIALIAS): - raise ValueError("unknown resampling filter") - - self.load() - - if self.mode in ("1", "P"): - resample = NEAREST - - if resample == ANTIALIAS: - # requires stretch support (imToolkit & PIL 1.1.3) - try: - im = self.im.stretch(size, resample) - except AttributeError: - raise ValueError("unsupported resampling filter") - else: - im = self.im.resize(size, resample) - - return self._new(im) - - ## - # Returns a rotated copy of this image. This method returns a - # copy of this image, rotated the given number of degrees counter - # clockwise around its centre. - # - # @def rotate(angle, filter=NEAREST) - # @param angle In degrees counter clockwise. - # @param filter An optional resampling filter. This can be - # one of NEAREST (use nearest neighbour), BILINEAR - # (linear interpolation in a 2x2 environment), or BICUBIC - # (cubic spline interpolation in a 4x4 environment). - # If omitted, or if the image has mode "1" or "P", it is - # set NEAREST. - # @param expand Optional expansion flag. If true, expands the output - # image to make it large enough to hold the entire rotated image. - # If false or omitted, make the output image the same size as the - # input image. - # @return An Image object. - - def rotate(self, angle, resample=NEAREST, expand=0): - "Rotate image. Angle given as degrees counter-clockwise." - - if expand: - import math - angle = -angle * math.pi / 180 - matrix = [ - math.cos(angle), math.sin(angle), 0.0, - -math.sin(angle), math.cos(angle), 0.0 - ] - def transform(x, y, (a, b, c, d, e, f)=matrix): - return a*x + b*y + c, d*x + e*y + f - - # calculate output size - w, h = self.size - xx = [] - yy = [] - for x, y in ((0, 0), (w, 0), (w, h), (0, h)): - x, y = transform(x, y) - xx.append(x) - yy.append(y) - w = int(math.ceil(max(xx)) - math.floor(min(xx))) - h = int(math.ceil(max(yy)) - math.floor(min(yy))) - - # adjust center - x, y = transform(w / 2.0, h / 2.0) - matrix[2] = self.size[0] / 2.0 - x - matrix[5] = self.size[1] / 2.0 - y - - return self.transform((w, h), AFFINE, matrix) - - if resample not in (NEAREST, BILINEAR, BICUBIC): - raise ValueError("unknown resampling filter") - - self.load() - - if self.mode in ("1", "P"): - resample = NEAREST - - return self._new(self.im.rotate(angle, resample)) - - ## - # Saves this image under the given filename. If no format is - # specified, the format to use is determined from the filename - # extension, if possible. - #

- # Keyword options can be used to provide additional instructions - # to the writer. If a writer doesn't recognise an option, it is - # silently ignored. The available options are described later in - # this handbook. - #

- # You can use a file object instead of a filename. In this case, - # you must always specify the format. The file object must - # implement the seek, tell, and write - # methods, and be opened in binary mode. - # - # @def save(file, format=None, **options) - # @param file File name or file object. - # @param format Optional format override. If omitted, the - # format to use is determined from the filename extension. - # If a file object was used instead of a filename, this - # parameter should always be used. - # @param **options Extra parameters to the image writer. - # @return None - # @exception KeyError If the output format could not be determined - # from the file name. Use the format option to solve this. - # @exception IOError If the file could not be written. The file - # may have been created, and may contain partial data. - - def save(self, fp, format=None, **params): - "Save image to file or stream" - - if isStringType(fp): - filename = fp - else: - if hasattr(fp, "name") and isStringType(fp.name): - filename = fp.name - else: - filename = "" - - # may mutate self! - self.load() - - self.encoderinfo = params - self.encoderconfig = () - - preinit() - - ext = string.lower(os.path.splitext(filename)[1]) - - if not format: - try: - format = EXTENSION[ext] - except KeyError: - init() - try: - format = EXTENSION[ext] - except KeyError: - raise KeyError(ext) # unknown extension - - try: - save_handler = SAVE[string.upper(format)] - except KeyError: - init() - save_handler = SAVE[string.upper(format)] # unknown format - - if isStringType(fp): - import __builtin__ - fp = __builtin__.open(fp, "wb") - close = 1 - else: - close = 0 - - try: - save_handler(self, fp, filename) - finally: - # do what we can to clean up - if close: - fp.close() - - ## - # Seeks to the given frame in this sequence file. If you seek - # beyond the end of the sequence, the method raises an - # EOFError exception. When a sequence file is opened, the - # library automatically seeks to frame 0. - #

- # Note that in the current version of the library, most sequence - # formats only allows you to seek to the next frame. - # - # @param frame Frame number, starting at 0. - # @exception EOFError If the call attempts to seek beyond the end - # of the sequence. - # @see #Image.tell - - def seek(self, frame): - "Seek to given frame in sequence file" - - # overridden by file handlers - if frame != 0: - raise EOFError - - ## - # Displays this image. This method is mainly intended for - # debugging purposes. - #

- # On Unix platforms, this method saves the image to a temporary - # PPM file, and calls the xv utility. - #

- # On Windows, it saves the image to a temporary BMP file, and uses - # the standard BMP display utility to show it (usually Paint). - # - # @def show(title=None) - # @param title Optional title to use for the image window, - # where possible. - - def show(self, title=None, command=None): - "Display image (for debug purposes only)" - - _showxv(self, title, command) - - ## - # Split this image into individual bands. This method returns a - # tuple of individual image bands from an image. For example, - # splitting an "RGB" image creates three new images each - # containing a copy of one of the original bands (red, green, - # blue). - # - # @return A tuple containing bands. - - def split(self): - "Split image into bands" - - ims = [] - self.load() - for i in range(self.im.bands): - ims.append(self._new(self.im.getband(i))) - return tuple(ims) - - ## - # Returns the current frame number. - # - # @return Frame number, starting with 0. - # @see #Image.seek - - def tell(self): - "Return current frame number" - - return 0 - - ## - # Make this image into a thumbnail. This method modifies the - # image to contain a thumbnail version of itself, no larger than - # the given size. This method calculates an appropriate thumbnail - # size to preserve the aspect of the image, calls the {@link - # #Image.draft} method to configure the file reader (where - # applicable), and finally resizes the image. - #

- # Note that the bilinear and bicubic filters in the current - # version of PIL are not well-suited for thumbnail generation. - # You should use ANTIALIAS unless speed is much more - # important than quality. - #

- # Also note that this function modifies the Image object in place. - # If you need to use the full resolution image as well, apply this - # method to a {@link #Image.copy} of the original image. - # - # @param size Requested size. - # @param resample Optional resampling filter. This can be one - # of NEAREST, BILINEAR, BICUBIC, or - # ANTIALIAS (best quality). If omitted, it defaults - # to NEAREST (this will be changed to ANTIALIAS in a - # future version). - # @return None - - def thumbnail(self, size, resample=NEAREST): - "Create thumbnail representation (modifies image in place)" - - # FIXME: the default resampling filter will be changed - # to ANTIALIAS in future versions - - # preserve aspect ratio - x, y = self.size - if x > size[0]: y = max(y * size[0] / x, 1); x = size[0] - if y > size[1]: x = max(x * size[1] / y, 1); y = size[1] - size = x, y - - if size == self.size: - return - - self.draft(None, size) - - self.load() - - try: - im = self.resize(size, resample) - except ValueError: - if resample != ANTIALIAS: - raise - im = self.resize(size, NEAREST) # fallback - - self.im = im.im - self.mode = im.mode - self.size = size - - self.readonly = 0 - - # FIXME: the different tranform methods need further explanation - # instead of bloating the method docs, add a separate chapter. - - ## - # Transforms this image. This method creates a new image with the - # given size, and the same mode as the original, and copies data - # to the new image using the given transform. - #

- # @def transform(size, method, data, resample=NEAREST) - # @param size The output size. - # @param method The transformation method. This is one of - # EXTENT (cut out a rectangular subregion), AFFINE - # (affine transform), PERSPECTIVE (perspective - # transform), QUAD (map a quadrilateral to a - # rectangle), or MESH (map a number of source quadrilaterals - # in one operation). - # @param data Extra data to the transformation method. - # @param resample Optional resampling filter. It can be one of - # NEAREST (use nearest neighbour), BILINEAR - # (linear interpolation in a 2x2 environment), or - # BICUBIC (cubic spline interpolation in a 4x4 - # environment). If omitted, or if the image has mode - # "1" or "P", it is set to NEAREST. - # @return An Image object. - - def transform(self, size, method, data=None, resample=NEAREST, fill=1): - "Transform image" - - import ImageTransform - if isinstance(method, ImageTransform.Transform): - method, data = method.getdata() - if data is None: - raise ValueError("missing method data") - im = new(self.mode, size, None) - if method == MESH: - # list of quads - for box, quad in data: - im.__transformer(box, self, QUAD, quad, resample, fill) - else: - im.__transformer((0, 0)+size, self, method, data, resample, fill) - - return im - - def __transformer(self, box, image, method, data, - resample=NEAREST, fill=1): - - # FIXME: this should be turned into a lazy operation (?) - - w = box[2]-box[0] - h = box[3]-box[1] - - if method == AFFINE: - # change argument order to match implementation - data = (data[2], data[0], data[1], - data[5], data[3], data[4]) - elif method == EXTENT: - # convert extent to an affine transform - x0, y0, x1, y1 = data - xs = float(x1 - x0) / w - ys = float(y1 - y0) / h - method = AFFINE - data = (x0 + xs/2, xs, 0, y0 + ys/2, 0, ys) - elif method == PERSPECTIVE: - # change argument order to match implementation - data = (data[2], data[0], data[1], - data[5], data[3], data[4], - data[6], data[7]) - elif method == QUAD: - # quadrilateral warp. data specifies the four corners - # given as NW, SW, SE, and NE. - nw = data[0:2]; sw = data[2:4]; se = data[4:6]; ne = data[6:8] - x0, y0 = nw; As = 1.0 / w; At = 1.0 / h - data = (x0, (ne[0]-x0)*As, (sw[0]-x0)*At, - (se[0]-sw[0]-ne[0]+x0)*As*At, - y0, (ne[1]-y0)*As, (sw[1]-y0)*At, - (se[1]-sw[1]-ne[1]+y0)*As*At) - else: - raise ValueError("unknown transformation method") - - if resample not in (NEAREST, BILINEAR, BICUBIC): - raise ValueError("unknown resampling filter") - - image.load() - - self.load() - - if image.mode in ("1", "P"): - resample = NEAREST - - self.im.transform2(box, image.im, method, data, resample, fill) - - ## - # Returns a flipped or rotated copy of this image. - # - # @param method One of FLIP_LEFT_RIGHT, FLIP_TOP_BOTTOM, - # ROTATE_90, ROTATE_180, or ROTATE_270. - - def transpose(self, method): - "Transpose image (flip or rotate in 90 degree steps)" - - self.load() - im = self.im.transpose(method) - return self._new(im) - -# -------------------------------------------------------------------- -# Lazy operations - -class _ImageCrop(Image): - - def __init__(self, im, box): - - Image.__init__(self) - - x0, y0, x1, y1 = box - if x1 < x0: - x1 = x0 - if y1 < y0: - y1 = y0 - - self.mode = im.mode - self.size = x1-x0, y1-y0 - - self.__crop = x0, y0, x1, y1 - - self.im = im.im - - def load(self): - - # lazy evaluation! - if self.__crop: - self.im = self.im.crop(self.__crop) - self.__crop = None - - # FIXME: future versions should optimize crop/paste - # sequences! - -# -------------------------------------------------------------------- -# Factories - -# -# Debugging - -def _wedge(): - "Create greyscale wedge (for debugging only)" - - return Image()._new(core.wedge("L")) - -## -# Creates a new image with the given mode and size. -# -# @param mode The mode to use for the new image. -# @param size A 2-tuple, containing (width, height) in pixels. -# @param color What colour to use for the image. Default is black. -# If given, this should be a single integer or floating point value -# for single-band modes, and a tuple for multi-band modes (one value -# per band). When creating RGB images, you can also use colour -# strings as supported by the ImageColor module. If the colour is -# None, the image is not initialised. -# @return An Image object. - -def new(mode, size, color=0): - "Create a new image" - - if color is None: - # don't initialize - return Image()._new(core.new(mode, size)) - - if isStringType(color): - # css3-style specifier - - import ImageColor - color = ImageColor.getcolor(color, mode) - - return Image()._new(core.fill(mode, size, color)) - -## -# Creates an image memory from pixel data in a string. -#

-# In its simplest form, this function takes three arguments -# (mode, size, and unpacked pixel data). -#

-# You can also use any pixel decoder supported by PIL. For more -# information on available decoders, see the section Writing Your Own File Decoder. -#

-# Note that this function decodes pixel data only, not entire images. -# If you have an entire image in a string, wrap it in a -# StringIO object, and use {@link #open} to load it. -# -# @param mode The image mode. -# @param size The image size. -# @param data An 8-bit string containing raw data for the given mode. -# @param decoder_name What decoder to use. -# @param *args Additional parameters for the given decoder. -# @return An Image object. - -def fromstring(mode, size, data, decoder_name="raw", *args): - "Load image from string" - - # may pass tuple instead of argument list - if len(args) == 1 and isTupleType(args[0]): - args = args[0] - - if decoder_name == "raw" and args == (): - args = mode - - im = new(mode, size) - im.fromstring(data, decoder_name, args) - return im - -## -# (New in 1.1.4) Creates an image memory from pixel data in a string -# or byte buffer. -#

-# This function is similar to {@link #fromstring}, but uses data in -# the byte buffer, where possible. This means that changes to the -# original buffer object are reflected in this image). Not all modes -# can share memory; supported modes include "L", "RGBX", "RGBA", and -# "CMYK". -#

-# Note that this function decodes pixel data only, not entire images. -# If you have an entire image file in a string, wrap it in a -# StringIO object, and use {@link #open} to load it. -#

-# In the current version, the default parameters used for the "raw" -# decoder differs from that used for {@link fromstring}. This is a -# bug, and will probably be fixed in a future release. The current -# release issues a warning if you do this; to disable the warning, -# you should provide the full set of parameters. See below for -# details. -# -# @param mode The image mode. -# @param size The image size. -# @param data An 8-bit string or other buffer object containing raw -# data for the given mode. -# @param decoder_name What decoder to use. -# @param *args Additional parameters for the given decoder. For the -# default encoder ("raw"), it's recommended that you provide the -# full set of parameters: -# frombuffer(mode, size, data, "raw", mode, 0, 1). -# @return An Image object. -# @since 1.1.4 - -def frombuffer(mode, size, data, decoder_name="raw", *args): - "Load image from string or buffer" - - # may pass tuple instead of argument list - if len(args) == 1 and isTupleType(args[0]): - args = args[0] - - if decoder_name == "raw": - if args == (): - if warnings: - warnings.warn( - "the frombuffer defaults may change in a future release; " - "for portability, change the call to read:\n" - " frombuffer(mode, size, data, 'raw', mode, 0, 1)", - RuntimeWarning, stacklevel=2 - ) - args = mode, 0, -1 # may change to (mode, 0, 1) post-1.1.6 - if args[0] in _MAPMODES: - im = new(mode, (1,1)) - im = im._new( - core.map_buffer(data, size, decoder_name, None, 0, args) - ) - im.readonly = 1 - return im - - return apply(fromstring, (mode, size, data, decoder_name, args)) - - -## -# (New in 1.1.6) Create an image memory from an object exporting -# the array interface (using the buffer protocol). -# -# If obj is not contiguous, then the tostring method is called -# and {@link frombuffer} is used. -# -# @param obj Object with array interface -# @param mode Mode to use (will be determined from type if None) -# @return An image memory. - -def fromarray(obj, mode=None): - arr = obj.__array_interface__ - shape = arr['shape'] - ndim = len(shape) - try: - strides = arr['strides'] - except KeyError: - strides = None - if mode is None: - typestr = arr['typestr'] - if not (typestr[0] == '|' or typestr[0] == _ENDIAN or - typestr[1:] not in ['u1', 'b1', 'i4', 'f4']): - raise TypeError("cannot handle data-type") - if typestr[0] == _ENDIAN: - typestr = typestr[1:3] - else: - typestr = typestr[:2] - if typestr == 'i4': - mode = 'I' - if typestr == 'u2': - mode = 'I;16' - elif typestr == 'f4': - mode = 'F' - elif typestr == 'b1': - mode = '1' - elif ndim == 2: - mode = 'L' - elif ndim == 3: - mode = 'RGB' - elif ndim == 4: - mode = 'RGBA' - else: - raise TypeError("Do not understand data.") - ndmax = 4 - bad_dims=0 - if mode in ['1','L','I','P','F']: - ndmax = 2 - elif mode == 'RGB': - ndmax = 3 - if ndim > ndmax: - raise ValueError("Too many dimensions.") - - size = shape[:2][::-1] - if strides is not None: - obj = obj.tostring() - - return frombuffer(mode, size, obj, "raw", mode, 0, 1) - -## -# Opens and identifies the given image file. -#

-# This is a lazy operation; this function identifies the file, but the -# actual image data is not read from the file until you try to process -# the data (or call the {@link #Image.load} method). -# -# @def open(file, mode="r") -# @param file A filename (string) or a file object. The file object -# must implement read, seek, and tell methods, -# and be opened in binary mode. -# @param mode The mode. If given, this argument must be "r". -# @return An Image object. -# @exception IOError If the file cannot be found, or the image cannot be -# opened and identified. -# @see #new - -def open(fp, mode="r"): - "Open an image file, without loading the raster data" - - if mode != "r": - raise ValueError("bad mode") - - if isStringType(fp): - import __builtin__ - filename = fp - fp = __builtin__.open(fp, "rb") - else: - filename = "" - - prefix = fp.read(16) - - preinit() - - for i in ID: - try: - factory, accept = OPEN[i] - if not accept or accept(prefix): - fp.seek(0) - return factory(fp, filename) - except (SyntaxError, IndexError, TypeError): - pass - - init() - - for i in ID: - try: - factory, accept = OPEN[i] - if not accept or accept(prefix): - fp.seek(0) - return factory(fp, filename) - except (SyntaxError, IndexError, TypeError): - pass - - raise IOError("cannot identify image file") - -# -# Image processing. - -## -# Creates a new image by interpolating between two input images, using -# a constant alpha. -# -#

-#    out = image1 * (1.0 - alpha) + image2 * alpha
-# 
-# -# @param im1 The first image. -# @param im2 The second image. Must have the same mode and size as -# the first image. -# @param alpha The interpolation alpha factor. If alpha is 0.0, a -# copy of the first image is returned. If alpha is 1.0, a copy of -# the second image is returned. There are no restrictions on the -# alpha value. If necessary, the result is clipped to fit into -# the allowed output range. -# @return An Image object. - -def blend(im1, im2, alpha): - "Interpolate between images." - - im1.load() - im2.load() - return im1._new(core.blend(im1.im, im2.im, alpha)) - -## -# Creates a new image by interpolating between two input images, -# using the mask as alpha. -# -# @param image1 The first image. -# @param image2 The second image. Must have the same mode and -# size as the first image. -# @param mask A mask image. This image can can have mode -# "1", "L", or "RGBA", and must have the same size as the -# other two images. - -def composite(image1, image2, mask): - "Create composite image by blending images using a transparency mask" - - image = image2.copy() - image.paste(image1, None, mask) - return image - -## -# Applies the function (which should take one argument) to each pixel -# in the given image. If the image has more than one band, the same -# function is applied to each band. Note that the function is -# evaluated once for each possible pixel value, so you cannot use -# random components or other generators. -# -# @def eval(image, function) -# @param image The input image. -# @param function A function object, taking one integer argument. -# @return An Image object. - -def eval(image, *args): - "Evaluate image expression" - - return image.point(args[0]) - -## -# Creates a new image from a number of single-band images. -# -# @param mode The mode to use for the output image. -# @param bands A sequence containing one single-band image for -# each band in the output image. All bands must have the -# same size. -# @return An Image object. - -def merge(mode, bands): - "Merge a set of single band images into a new multiband image." - - if getmodebands(mode) != len(bands) or "*" in mode: - raise ValueError("wrong number of bands") - for im in bands[1:]: - if im.mode != getmodetype(mode): - raise ValueError("mode mismatch") - if im.size != bands[0].size: - raise ValueError("size mismatch") - im = core.new(mode, bands[0].size) - for i in range(getmodebands(mode)): - bands[i].load() - im.putband(bands[i].im, i) - return bands[0]._new(im) - -# -------------------------------------------------------------------- -# Plugin registry - -## -# Register an image file plugin. This function should not be used -# in application code. -# -# @param id An image format identifier. -# @param factory An image file factory method. -# @param accept An optional function that can be used to quickly -# reject images having another format. - -def register_open(id, factory, accept=None): - id = string.upper(id) - ID.append(id) - OPEN[id] = factory, accept - -## -# Registers an image MIME type. This function should not be used -# in application code. -# -# @param id An image format identifier. -# @param mimetype The image MIME type for this format. - -def register_mime(id, mimetype): - MIME[string.upper(id)] = mimetype - -## -# Registers an image save function. This function should not be -# used in application code. -# -# @param id An image format identifier. -# @param driver A function to save images in this format. - -def register_save(id, driver): - SAVE[string.upper(id)] = driver - -## -# Registers an image extension. This function should not be -# used in application code. -# -# @param id An image format identifier. -# @param extension An extension used for this format. - -def register_extension(id, extension): - EXTENSION[string.lower(extension)] = string.upper(id) - - -# -------------------------------------------------------------------- -# Simple display support - -def _showxv(image, title=None, command=None): - - if os.name == "nt": - format = "BMP" - elif sys.platform == "darwin": - format = "JPEG" - if not command: - command = "open -a /Applications/Preview.app" - else: - format = None - if not command: - command = "xv" - if title: - command = command + " -name \"%s\"" % title - - if image.mode == "I;16": - # @PIL88 @PIL101 - # "I;16" isn't an 'official' mode, but we still want to - # provide a simple way to show 16-bit images. - base = "L" - else: - base = getmodebase(image.mode) - if base != image.mode and image.mode != "1": - file = image.convert(base)._dump(format=format) - else: - file = image._dump(format=format) - - if os.name == "nt": - command = "start /wait %s && del /f %s" % (file, file) - elif sys.platform == "darwin": - # on darwin open returns immediately resulting in the temp - # file removal while app is opening - command = "(%s %s; sleep 20; rm -f %s)&" % (command, file, file) - else: - command = "(%s %s; rm -f %s)&" % (command, file, file) - - os.system(command) diff --git a/pyqtgraph/PIL_Fix/Image.py-1.7 b/pyqtgraph/PIL_Fix/Image.py-1.7 deleted file mode 100644 index cacbcc64..00000000 --- a/pyqtgraph/PIL_Fix/Image.py-1.7 +++ /dev/null @@ -1,2129 +0,0 @@ -# -# The Python Imaging Library. -# $Id$ -# -# the Image class wrapper -# -# partial release history: -# 1995-09-09 fl Created -# 1996-03-11 fl PIL release 0.0 (proof of concept) -# 1996-04-30 fl PIL release 0.1b1 -# 1999-07-28 fl PIL release 1.0 final -# 2000-06-07 fl PIL release 1.1 -# 2000-10-20 fl PIL release 1.1.1 -# 2001-05-07 fl PIL release 1.1.2 -# 2002-03-15 fl PIL release 1.1.3 -# 2003-05-10 fl PIL release 1.1.4 -# 2005-03-28 fl PIL release 1.1.5 -# 2006-12-02 fl PIL release 1.1.6 -# 2009-11-15 fl PIL release 1.1.7 -# -# Copyright (c) 1997-2009 by Secret Labs AB. All rights reserved. -# Copyright (c) 1995-2009 by Fredrik Lundh. -# -# See the README file for information on usage and redistribution. -# - -VERSION = "1.1.7" - -try: - import warnings -except ImportError: - warnings = None - -class _imaging_not_installed: - # module placeholder - def __getattr__(self, id): - raise ImportError("The _imaging C module is not installed") - -try: - # give Tk a chance to set up the environment, in case we're - # using an _imaging module linked against libtcl/libtk (use - # __import__ to hide this from naive packagers; we don't really - # depend on Tk unless ImageTk is used, and that module already - # imports Tkinter) - __import__("FixTk") -except ImportError: - pass - -try: - # If the _imaging C module is not present, you can still use - # the "open" function to identify files, but you cannot load - # them. Note that other modules should not refer to _imaging - # directly; import Image and use the Image.core variable instead. - import _imaging - core = _imaging - del _imaging -except ImportError, v: - core = _imaging_not_installed() - if str(v)[:20] == "Module use of python" and warnings: - # The _imaging C module is present, but not compiled for - # the right version (windows only). Print a warning, if - # possible. - warnings.warn( - "The _imaging extension was built for another version " - "of Python; most PIL functions will be disabled", - RuntimeWarning - ) - -import ImageMode -import ImagePalette - -import os, string, sys - -# type stuff -from types import IntType, StringType, TupleType - -try: - UnicodeStringType = type(unicode("")) - ## - # (Internal) Checks if an object is a string. If the current - # Python version supports Unicode, this checks for both 8-bit - # and Unicode strings. - def isStringType(t): - return isinstance(t, StringType) or isinstance(t, UnicodeStringType) -except NameError: - def isStringType(t): - return isinstance(t, StringType) - -## -# (Internal) Checks if an object is a tuple. - -def isTupleType(t): - return isinstance(t, TupleType) - -## -# (Internal) Checks if an object is an image object. - -def isImageType(t): - return hasattr(t, "im") - -## -# (Internal) Checks if an object is a string, and that it points to a -# directory. - -def isDirectory(f): - return isStringType(f) and os.path.isdir(f) - -from operator import isNumberType, isSequenceType - -# -# Debug level - -DEBUG = 0 - -# -# Constants (also defined in _imagingmodule.c!) - -NONE = 0 - -# transpose -FLIP_LEFT_RIGHT = 0 -FLIP_TOP_BOTTOM = 1 -ROTATE_90 = 2 -ROTATE_180 = 3 -ROTATE_270 = 4 - -# transforms -AFFINE = 0 -EXTENT = 1 -PERSPECTIVE = 2 -QUAD = 3 -MESH = 4 - -# resampling filters -NONE = 0 -NEAREST = 0 -ANTIALIAS = 1 # 3-lobed lanczos -LINEAR = BILINEAR = 2 -CUBIC = BICUBIC = 3 - -# dithers -NONE = 0 -NEAREST = 0 -ORDERED = 1 # Not yet implemented -RASTERIZE = 2 # Not yet implemented -FLOYDSTEINBERG = 3 # default - -# palettes/quantizers -WEB = 0 -ADAPTIVE = 1 - -# categories -NORMAL = 0 -SEQUENCE = 1 -CONTAINER = 2 - -# -------------------------------------------------------------------- -# Registries - -ID = [] -OPEN = {} -MIME = {} -SAVE = {} -EXTENSION = {} - -# -------------------------------------------------------------------- -# Modes supported by this version - -_MODEINFO = { - # NOTE: this table will be removed in future versions. use - # getmode* functions or ImageMode descriptors instead. - - # official modes - "1": ("L", "L", ("1",)), - "L": ("L", "L", ("L",)), - "I": ("L", "I", ("I",)), - "F": ("L", "F", ("F",)), - "P": ("RGB", "L", ("P",)), - "RGB": ("RGB", "L", ("R", "G", "B")), - "RGBX": ("RGB", "L", ("R", "G", "B", "X")), - "RGBA": ("RGB", "L", ("R", "G", "B", "A")), - "CMYK": ("RGB", "L", ("C", "M", "Y", "K")), - "YCbCr": ("RGB", "L", ("Y", "Cb", "Cr")), - - # Experimental modes include I;16, I;16L, I;16B, RGBa, BGR;15, and - # BGR;24. Use these modes only if you know exactly what you're - # doing... - -} - -try: - byteorder = sys.byteorder -except AttributeError: - import struct - if struct.unpack("h", "\0\1")[0] == 1: - byteorder = "big" - else: - byteorder = "little" - -if byteorder == 'little': - _ENDIAN = '<' -else: - _ENDIAN = '>' - -_MODE_CONV = { - # official modes - "1": ('|b1', None), # broken - "L": ('|u1', None), - "I": (_ENDIAN + 'i4', None), - "I;16": ('%su2' % _ENDIAN, None), - "F": (_ENDIAN + 'f4', None), - "P": ('|u1', None), - "RGB": ('|u1', 3), - "RGBX": ('|u1', 4), - "RGBA": ('|u1', 4), - "CMYK": ('|u1', 4), - "YCbCr": ('|u1', 4), -} - -def _conv_type_shape(im): - shape = im.size[1], im.size[0] - typ, extra = _MODE_CONV[im.mode] - if extra is None: - return shape, typ - else: - return shape+(extra,), typ - - -MODES = _MODEINFO.keys() -MODES.sort() - -# raw modes that may be memory mapped. NOTE: if you change this, you -# may have to modify the stride calculation in map.c too! -_MAPMODES = ("L", "P", "RGBX", "RGBA", "CMYK", "I;16", "I;16L", "I;16B") - -## -# Gets the "base" mode for given mode. This function returns "L" for -# images that contain grayscale data, and "RGB" for images that -# contain color data. -# -# @param mode Input mode. -# @return "L" or "RGB". -# @exception KeyError If the input mode was not a standard mode. - -def getmodebase(mode): - return ImageMode.getmode(mode).basemode - -## -# Gets the storage type mode. Given a mode, this function returns a -# single-layer mode suitable for storing individual bands. -# -# @param mode Input mode. -# @return "L", "I", or "F". -# @exception KeyError If the input mode was not a standard mode. - -def getmodetype(mode): - return ImageMode.getmode(mode).basetype - -## -# Gets a list of individual band names. Given a mode, this function -# returns a tuple containing the names of individual bands (use -# {@link #getmodetype} to get the mode used to store each individual -# band. -# -# @param mode Input mode. -# @return A tuple containing band names. The length of the tuple -# gives the number of bands in an image of the given mode. -# @exception KeyError If the input mode was not a standard mode. - -def getmodebandnames(mode): - return ImageMode.getmode(mode).bands - -## -# Gets the number of individual bands for this mode. -# -# @param mode Input mode. -# @return The number of bands in this mode. -# @exception KeyError If the input mode was not a standard mode. - -def getmodebands(mode): - return len(ImageMode.getmode(mode).bands) - -# -------------------------------------------------------------------- -# Helpers - -_initialized = 0 - -## -# Explicitly loads standard file format drivers. - -def preinit(): - "Load standard file format drivers." - - global _initialized - if _initialized >= 1: - return - - try: - import BmpImagePlugin - except ImportError: - pass - try: - import GifImagePlugin - except ImportError: - pass - try: - import JpegImagePlugin - except ImportError: - pass - try: - import PpmImagePlugin - except ImportError: - pass - try: - import PngImagePlugin - except ImportError: - pass -# try: -# import TiffImagePlugin -# except ImportError: -# pass - - _initialized = 1 - -## -# Explicitly initializes the Python Imaging Library. This function -# loads all available file format drivers. - -def init(): - "Load all file format drivers." - - global _initialized - if _initialized >= 2: - return 0 - - visited = {} - - directories = sys.path - - try: - directories = directories + [os.path.dirname(__file__)] - except NameError: - pass - - # only check directories (including current, if present in the path) - for directory in filter(isDirectory, directories): - fullpath = os.path.abspath(directory) - if visited.has_key(fullpath): - continue - for file in os.listdir(directory): - if file[-14:] == "ImagePlugin.py": - f, e = os.path.splitext(file) - try: - sys.path.insert(0, directory) - try: - __import__(f, globals(), locals(), []) - finally: - del sys.path[0] - except ImportError: - if DEBUG: - print "Image: failed to import", - print f, ":", sys.exc_value - visited[fullpath] = None - - if OPEN or SAVE: - _initialized = 2 - return 1 - -# -------------------------------------------------------------------- -# Codec factories (used by tostring/fromstring and ImageFile.load) - -def _getdecoder(mode, decoder_name, args, extra=()): - - # tweak arguments - if args is None: - args = () - elif not isTupleType(args): - args = (args,) - - try: - # get decoder - decoder = getattr(core, decoder_name + "_decoder") - # print decoder, (mode,) + args + extra - return apply(decoder, (mode,) + args + extra) - except AttributeError: - raise IOError("decoder %s not available" % decoder_name) - -def _getencoder(mode, encoder_name, args, extra=()): - - # tweak arguments - if args is None: - args = () - elif not isTupleType(args): - args = (args,) - - try: - # get encoder - encoder = getattr(core, encoder_name + "_encoder") - # print encoder, (mode,) + args + extra - return apply(encoder, (mode,) + args + extra) - except AttributeError: - raise IOError("encoder %s not available" % encoder_name) - - -# -------------------------------------------------------------------- -# Simple expression analyzer - -class _E: - def __init__(self, data): self.data = data - def __coerce__(self, other): return self, _E(other) - def __add__(self, other): return _E((self.data, "__add__", other.data)) - def __mul__(self, other): return _E((self.data, "__mul__", other.data)) - -def _getscaleoffset(expr): - stub = ["stub"] - data = expr(_E(stub)).data - try: - (a, b, c) = data # simplified syntax - if (a is stub and b == "__mul__" and isNumberType(c)): - return c, 0.0 - if (a is stub and b == "__add__" and isNumberType(c)): - return 1.0, c - except TypeError: pass - try: - ((a, b, c), d, e) = data # full syntax - if (a is stub and b == "__mul__" and isNumberType(c) and - d == "__add__" and isNumberType(e)): - return c, e - except TypeError: pass - raise ValueError("illegal expression") - - -# -------------------------------------------------------------------- -# Implementation wrapper - -## -# This class represents an image object. To create Image objects, use -# the appropriate factory functions. There's hardly ever any reason -# to call the Image constructor directly. -# -# @see #open -# @see #new -# @see #fromstring - -class Image: - - format = None - format_description = None - - def __init__(self): - # FIXME: take "new" parameters / other image? - # FIXME: turn mode and size into delegating properties? - self.im = None - self.mode = "" - self.size = (0, 0) - self.palette = None - self.info = {} - self.category = NORMAL - self.readonly = 0 - - def _new(self, im): - new = Image() - new.im = im - new.mode = im.mode - new.size = im.size - new.palette = self.palette - if im.mode == "P": - new.palette = ImagePalette.ImagePalette() - try: - new.info = self.info.copy() - except AttributeError: - # fallback (pre-1.5.2) - new.info = {} - for k, v in self.info: - new.info[k] = v - return new - - _makeself = _new # compatibility - - def _copy(self): - self.load() - self.im = self.im.copy() - self.readonly = 0 - - def _dump(self, file=None, format=None): - import tempfile - if not file: - file = tempfile.mktemp() - self.load() - if not format or format == "PPM": - self.im.save_ppm(file) - else: - file = file + "." + format - self.save(file, format) - return file - - def __repr__(self): - return "<%s.%s image mode=%s size=%dx%d at 0x%X>" % ( - self.__class__.__module__, self.__class__.__name__, - self.mode, self.size[0], self.size[1], - id(self) - ) - - def __getattr__(self, name): - if name == "__array_interface__": - # numpy array interface support - new = {} - shape, typestr = _conv_type_shape(self) - new['shape'] = shape - new['typestr'] = typestr - new['data'] = self.tostring() - return new - raise AttributeError(name) - - ## - # Returns a string containing pixel data. - # - # @param encoder_name What encoder to use. The default is to - # use the standard "raw" encoder. - # @param *args Extra arguments to the encoder. - # @return An 8-bit string. - - def tostring(self, encoder_name="raw", *args): - "Return image as a binary string" - - # may pass tuple instead of argument list - if len(args) == 1 and isTupleType(args[0]): - args = args[0] - - if encoder_name == "raw" and args == (): - args = self.mode - - self.load() - - # unpack data - e = _getencoder(self.mode, encoder_name, args) - e.setimage(self.im) - - bufsize = max(65536, self.size[0] * 4) # see RawEncode.c - - data = [] - while 1: - l, s, d = e.encode(bufsize) - data.append(d) - if s: - break - if s < 0: - raise RuntimeError("encoder error %d in tostring" % s) - - return string.join(data, "") - - ## - # Returns the image converted to an X11 bitmap. This method - # only works for mode "1" images. - # - # @param name The name prefix to use for the bitmap variables. - # @return A string containing an X11 bitmap. - # @exception ValueError If the mode is not "1" - - def tobitmap(self, name="image"): - "Return image as an XBM bitmap" - - self.load() - if self.mode != "1": - raise ValueError("not a bitmap") - data = self.tostring("xbm") - return string.join(["#define %s_width %d\n" % (name, self.size[0]), - "#define %s_height %d\n"% (name, self.size[1]), - "static char %s_bits[] = {\n" % name, data, "};"], "") - - ## - # Loads this image with pixel data from a string. - #

- # This method is similar to the {@link #fromstring} function, but - # loads data into this image instead of creating a new image - # object. - - def fromstring(self, data, decoder_name="raw", *args): - "Load data to image from binary string" - - # may pass tuple instead of argument list - if len(args) == 1 and isTupleType(args[0]): - args = args[0] - - # default format - if decoder_name == "raw" and args == (): - args = self.mode - - # unpack data - d = _getdecoder(self.mode, decoder_name, args) - d.setimage(self.im) - s = d.decode(data) - - if s[0] >= 0: - raise ValueError("not enough image data") - if s[1] != 0: - raise ValueError("cannot decode image data") - - ## - # Allocates storage for the image and loads the pixel data. In - # normal cases, you don't need to call this method, since the - # Image class automatically loads an opened image when it is - # accessed for the first time. - # - # @return An image access object. - - def load(self): - "Explicitly load pixel data." - if self.im and self.palette and self.palette.dirty: - # realize palette - apply(self.im.putpalette, self.palette.getdata()) - self.palette.dirty = 0 - self.palette.mode = "RGB" - self.palette.rawmode = None - if self.info.has_key("transparency"): - self.im.putpalettealpha(self.info["transparency"], 0) - self.palette.mode = "RGBA" - if self.im: - return self.im.pixel_access(self.readonly) - - ## - # Verifies the contents of a file. For data read from a file, this - # method attempts to determine if the file is broken, without - # actually decoding the image data. If this method finds any - # problems, it raises suitable exceptions. If you need to load - # the image after using this method, you must reopen the image - # file. - - def verify(self): - "Verify file contents." - pass - - ## - # Returns a converted copy of this image. For the "P" mode, this - # method translates pixels through the palette. If mode is - # omitted, a mode is chosen so that all information in the image - # and the palette can be represented without a palette. - #

- # The current version supports all possible conversions between - # "L", "RGB" and "CMYK." - #

- # When translating a colour image to black and white (mode "L"), - # the library uses the ITU-R 601-2 luma transform: - #

- # L = R * 299/1000 + G * 587/1000 + B * 114/1000 - #

- # When translating a greyscale image into a bilevel image (mode - # "1"), all non-zero values are set to 255 (white). To use other - # thresholds, use the {@link #Image.point} method. - # - # @def convert(mode, matrix=None, **options) - # @param mode The requested mode. - # @param matrix An optional conversion matrix. If given, this - # should be 4- or 16-tuple containing floating point values. - # @param options Additional options, given as keyword arguments. - # @keyparam dither Dithering method, used when converting from - # mode "RGB" to "P". - # Available methods are NONE or FLOYDSTEINBERG (default). - # @keyparam palette Palette to use when converting from mode "RGB" - # to "P". Available palettes are WEB or ADAPTIVE. - # @keyparam colors Number of colors to use for the ADAPTIVE palette. - # Defaults to 256. - # @return An Image object. - - def convert(self, mode=None, data=None, dither=None, - palette=WEB, colors=256): - "Convert to other pixel format" - - if not mode: - # determine default mode - if self.mode == "P": - self.load() - if self.palette: - mode = self.palette.mode - else: - mode = "RGB" - else: - return self.copy() - - self.load() - - if data: - # matrix conversion - if mode not in ("L", "RGB"): - raise ValueError("illegal conversion") - im = self.im.convert_matrix(mode, data) - return self._new(im) - - if mode == "P" and palette == ADAPTIVE: - im = self.im.quantize(colors) - return self._new(im) - - # colourspace conversion - if dither is None: - dither = FLOYDSTEINBERG - - try: - im = self.im.convert(mode, dither) - except ValueError: - try: - # normalize source image and try again - im = self.im.convert(getmodebase(self.mode)) - im = im.convert(mode, dither) - except KeyError: - raise ValueError("illegal conversion") - - return self._new(im) - - def quantize(self, colors=256, method=0, kmeans=0, palette=None): - - # methods: - # 0 = median cut - # 1 = maximum coverage - - # NOTE: this functionality will be moved to the extended - # quantizer interface in a later version of PIL. - - self.load() - - if palette: - # use palette from reference image - palette.load() - if palette.mode != "P": - raise ValueError("bad mode for palette image") - if self.mode != "RGB" and self.mode != "L": - raise ValueError( - "only RGB or L mode images can be quantized to a palette" - ) - im = self.im.convert("P", 1, palette.im) - return self._makeself(im) - - im = self.im.quantize(colors, method, kmeans) - return self._new(im) - - ## - # Copies this image. Use this method if you wish to paste things - # into an image, but still retain the original. - # - # @return An Image object. - - def copy(self): - "Copy raster data" - - self.load() - im = self.im.copy() - return self._new(im) - - ## - # Returns a rectangular region from this image. The box is a - # 4-tuple defining the left, upper, right, and lower pixel - # coordinate. - #

- # This is a lazy operation. Changes to the source image may or - # may not be reflected in the cropped image. To break the - # connection, call the {@link #Image.load} method on the cropped - # copy. - # - # @param The crop rectangle, as a (left, upper, right, lower)-tuple. - # @return An Image object. - - def crop(self, box=None): - "Crop region from image" - - self.load() - if box is None: - return self.copy() - - # lazy operation - return _ImageCrop(self, box) - - ## - # Configures the image file loader so it returns a version of the - # image that as closely as possible matches the given mode and - # size. For example, you can use this method to convert a colour - # JPEG to greyscale while loading it, or to extract a 128x192 - # version from a PCD file. - #

- # Note that this method modifies the Image object in place. If - # the image has already been loaded, this method has no effect. - # - # @param mode The requested mode. - # @param size The requested size. - - def draft(self, mode, size): - "Configure image decoder" - - pass - - def _expand(self, xmargin, ymargin=None): - if ymargin is None: - ymargin = xmargin - self.load() - return self._new(self.im.expand(xmargin, ymargin, 0)) - - ## - # Filters this image using the given filter. For a list of - # available filters, see the ImageFilter module. - # - # @param filter Filter kernel. - # @return An Image object. - # @see ImageFilter - - def filter(self, filter): - "Apply environment filter to image" - - self.load() - - if callable(filter): - filter = filter() - if not hasattr(filter, "filter"): - raise TypeError("filter argument should be ImageFilter.Filter instance or class") - - if self.im.bands == 1: - return self._new(filter.filter(self.im)) - # fix to handle multiband images since _imaging doesn't - ims = [] - for c in range(self.im.bands): - ims.append(self._new(filter.filter(self.im.getband(c)))) - return merge(self.mode, ims) - - ## - # Returns a tuple containing the name of each band in this image. - # For example, getbands on an RGB image returns ("R", "G", "B"). - # - # @return A tuple containing band names. - - def getbands(self): - "Get band names" - - return ImageMode.getmode(self.mode).bands - - ## - # Calculates the bounding box of the non-zero regions in the - # image. - # - # @return The bounding box is returned as a 4-tuple defining the - # left, upper, right, and lower pixel coordinate. If the image - # is completely empty, this method returns None. - - def getbbox(self): - "Get bounding box of actual data (non-zero pixels) in image" - - self.load() - return self.im.getbbox() - - ## - # Returns a list of colors used in this image. - # - # @param maxcolors Maximum number of colors. If this number is - # exceeded, this method returns None. The default limit is - # 256 colors. - # @return An unsorted list of (count, pixel) values. - - def getcolors(self, maxcolors=256): - "Get colors from image, up to given limit" - - self.load() - if self.mode in ("1", "L", "P"): - h = self.im.histogram() - out = [] - for i in range(256): - if h[i]: - out.append((h[i], i)) - if len(out) > maxcolors: - return None - return out - return self.im.getcolors(maxcolors) - - ## - # Returns the contents of this image as a sequence object - # containing pixel values. The sequence object is flattened, so - # that values for line one follow directly after the values of - # line zero, and so on. - #

- # Note that the sequence object returned by this method is an - # internal PIL data type, which only supports certain sequence - # operations. To convert it to an ordinary sequence (e.g. for - # printing), use list(im.getdata()). - # - # @param band What band to return. The default is to return - # all bands. To return a single band, pass in the index - # value (e.g. 0 to get the "R" band from an "RGB" image). - # @return A sequence-like object. - - def getdata(self, band = None): - "Get image data as sequence object." - - self.load() - if band is not None: - return self.im.getband(band) - return self.im # could be abused - - ## - # Gets the the minimum and maximum pixel values for each band in - # the image. - # - # @return For a single-band image, a 2-tuple containing the - # minimum and maximum pixel value. For a multi-band image, - # a tuple containing one 2-tuple for each band. - - def getextrema(self): - "Get min/max value" - - self.load() - if self.im.bands > 1: - extrema = [] - for i in range(self.im.bands): - extrema.append(self.im.getband(i).getextrema()) - return tuple(extrema) - return self.im.getextrema() - - ## - # Returns a PyCObject that points to the internal image memory. - # - # @return A PyCObject object. - - def getim(self): - "Get PyCObject pointer to internal image memory" - - self.load() - return self.im.ptr - - - ## - # Returns the image palette as a list. - # - # @return A list of color values [r, g, b, ...], or None if the - # image has no palette. - - def getpalette(self): - "Get palette contents." - - self.load() - try: - return map(ord, self.im.getpalette()) - except ValueError: - return None # no palette - - - ## - # Returns the pixel value at a given position. - # - # @param xy The coordinate, given as (x, y). - # @return The pixel value. If the image is a multi-layer image, - # this method returns a tuple. - - def getpixel(self, xy): - "Get pixel value" - - self.load() - return self.im.getpixel(xy) - - ## - # Returns the horizontal and vertical projection. - # - # @return Two sequences, indicating where there are non-zero - # pixels along the X-axis and the Y-axis, respectively. - - def getprojection(self): - "Get projection to x and y axes" - - self.load() - x, y = self.im.getprojection() - return map(ord, x), map(ord, y) - - ## - # Returns a histogram for the image. The histogram is returned as - # a list of pixel counts, one for each pixel value in the source - # image. If the image has more than one band, the histograms for - # all bands are concatenated (for example, the histogram for an - # "RGB" image contains 768 values). - #

- # A bilevel image (mode "1") is treated as a greyscale ("L") image - # by this method. - #

- # If a mask is provided, the method returns a histogram for those - # parts of the image where the mask image is non-zero. The mask - # image must have the same size as the image, and be either a - # bi-level image (mode "1") or a greyscale image ("L"). - # - # @def histogram(mask=None) - # @param mask An optional mask. - # @return A list containing pixel counts. - - def histogram(self, mask=None, extrema=None): - "Take histogram of image" - - self.load() - if mask: - mask.load() - return self.im.histogram((0, 0), mask.im) - if self.mode in ("I", "F"): - if extrema is None: - extrema = self.getextrema() - return self.im.histogram(extrema) - return self.im.histogram() - - ## - # (Deprecated) Returns a copy of the image where the data has been - # offset by the given distances. Data wraps around the edges. If - # yoffset is omitted, it is assumed to be equal to xoffset. - #

- # This method is deprecated. New code should use the offset - # function in the ImageChops module. - # - # @param xoffset The horizontal distance. - # @param yoffset The vertical distance. If omitted, both - # distances are set to the same value. - # @return An Image object. - - def offset(self, xoffset, yoffset=None): - "(deprecated) Offset image in horizontal and/or vertical direction" - if warnings: - warnings.warn( - "'offset' is deprecated; use 'ImageChops.offset' instead", - DeprecationWarning, stacklevel=2 - ) - import ImageChops - return ImageChops.offset(self, xoffset, yoffset) - - ## - # Pastes another image into this image. The box argument is either - # a 2-tuple giving the upper left corner, a 4-tuple defining the - # left, upper, right, and lower pixel coordinate, or None (same as - # (0, 0)). If a 4-tuple is given, the size of the pasted image - # must match the size of the region. - #

- # If the modes don't match, the pasted image is converted to the - # mode of this image (see the {@link #Image.convert} method for - # details). - #

- # Instead of an image, the source can be a integer or tuple - # containing pixel values. The method then fills the region - # with the given colour. When creating RGB images, you can - # also use colour strings as supported by the ImageColor module. - #

- # If a mask is given, this method updates only the regions - # indicated by the mask. You can use either "1", "L" or "RGBA" - # images (in the latter case, the alpha band is used as mask). - # Where the mask is 255, the given image is copied as is. Where - # the mask is 0, the current value is preserved. Intermediate - # values can be used for transparency effects. - #

- # Note that if you paste an "RGBA" image, the alpha band is - # ignored. You can work around this by using the same image as - # both source image and mask. - # - # @param im Source image or pixel value (integer or tuple). - # @param box An optional 4-tuple giving the region to paste into. - # If a 2-tuple is used instead, it's treated as the upper left - # corner. If omitted or None, the source is pasted into the - # upper left corner. - #

- # If an image is given as the second argument and there is no - # third, the box defaults to (0, 0), and the second argument - # is interpreted as a mask image. - # @param mask An optional mask image. - # @return An Image object. - - def paste(self, im, box=None, mask=None): - "Paste other image into region" - - if isImageType(box) and mask is None: - # abbreviated paste(im, mask) syntax - mask = box; box = None - - if box is None: - # cover all of self - box = (0, 0) + self.size - - if len(box) == 2: - # lower left corner given; get size from image or mask - if isImageType(im): - size = im.size - elif isImageType(mask): - size = mask.size - else: - # FIXME: use self.size here? - raise ValueError( - "cannot determine region size; use 4-item box" - ) - box = box + (box[0]+size[0], box[1]+size[1]) - - if isStringType(im): - import ImageColor - im = ImageColor.getcolor(im, self.mode) - - elif isImageType(im): - im.load() - if self.mode != im.mode: - if self.mode != "RGB" or im.mode not in ("RGBA", "RGBa"): - # should use an adapter for this! - im = im.convert(self.mode) - im = im.im - - self.load() - if self.readonly: - self._copy() - - if mask: - mask.load() - self.im.paste(im, box, mask.im) - else: - self.im.paste(im, box) - - ## - # Maps this image through a lookup table or function. - # - # @param lut A lookup table, containing 256 values per band in the - # image. A function can be used instead, it should take a single - # argument. The function is called once for each possible pixel - # value, and the resulting table is applied to all bands of the - # image. - # @param mode Output mode (default is same as input). In the - # current version, this can only be used if the source image - # has mode "L" or "P", and the output has mode "1". - # @return An Image object. - - def point(self, lut, mode=None): - "Map image through lookup table" - - self.load() - - if isinstance(lut, ImagePointHandler): - return lut.point(self) - - if not isSequenceType(lut): - # if it isn't a list, it should be a function - if self.mode in ("I", "I;16", "F"): - # check if the function can be used with point_transform - scale, offset = _getscaleoffset(lut) - return self._new(self.im.point_transform(scale, offset)) - # for other modes, convert the function to a table - lut = map(lut, range(256)) * self.im.bands - - if self.mode == "F": - # FIXME: _imaging returns a confusing error message for this case - raise ValueError("point operation not supported for this mode") - - return self._new(self.im.point(lut, mode)) - - ## - # Adds or replaces the alpha layer in this image. If the image - # does not have an alpha layer, it's converted to "LA" or "RGBA". - # The new layer must be either "L" or "1". - # - # @param im The new alpha layer. This can either be an "L" or "1" - # image having the same size as this image, or an integer or - # other color value. - - def putalpha(self, alpha): - "Set alpha layer" - - self.load() - if self.readonly: - self._copy() - - if self.mode not in ("LA", "RGBA"): - # attempt to promote self to a matching alpha mode - try: - mode = getmodebase(self.mode) + "A" - try: - self.im.setmode(mode) - except (AttributeError, ValueError): - # do things the hard way - im = self.im.convert(mode) - if im.mode not in ("LA", "RGBA"): - raise ValueError # sanity check - self.im = im - self.mode = self.im.mode - except (KeyError, ValueError): - raise ValueError("illegal image mode") - - if self.mode == "LA": - band = 1 - else: - band = 3 - - if isImageType(alpha): - # alpha layer - if alpha.mode not in ("1", "L"): - raise ValueError("illegal image mode") - alpha.load() - if alpha.mode == "1": - alpha = alpha.convert("L") - else: - # constant alpha - try: - self.im.fillband(band, alpha) - except (AttributeError, ValueError): - # do things the hard way - alpha = new("L", self.size, alpha) - else: - return - - self.im.putband(alpha.im, band) - - ## - # Copies pixel data to this image. This method copies data from a - # sequence object into the image, starting at the upper left - # corner (0, 0), and continuing until either the image or the - # sequence ends. The scale and offset values are used to adjust - # the sequence values: pixel = value*scale + offset. - # - # @param data A sequence object. - # @param scale An optional scale value. The default is 1.0. - # @param offset An optional offset value. The default is 0.0. - - def putdata(self, data, scale=1.0, offset=0.0): - "Put data from a sequence object into an image." - - self.load() - if self.readonly: - self._copy() - - self.im.putdata(data, scale, offset) - - ## - # Attaches a palette to this image. The image must be a "P" or - # "L" image, and the palette sequence must contain 768 integer - # values, where each group of three values represent the red, - # green, and blue values for the corresponding pixel - # index. Instead of an integer sequence, you can use an 8-bit - # string. - # - # @def putpalette(data) - # @param data A palette sequence (either a list or a string). - - def putpalette(self, data, rawmode="RGB"): - "Put palette data into an image." - - if self.mode not in ("L", "P"): - raise ValueError("illegal image mode") - self.load() - if isinstance(data, ImagePalette.ImagePalette): - palette = ImagePalette.raw(data.rawmode, data.palette) - else: - if not isStringType(data): - data = string.join(map(chr, data), "") - palette = ImagePalette.raw(rawmode, data) - self.mode = "P" - self.palette = palette - self.palette.mode = "RGB" - self.load() # install new palette - - ## - # Modifies the pixel at the given position. The colour is given as - # a single numerical value for single-band images, and a tuple for - # multi-band images. - #

- # Note that this method is relatively slow. For more extensive - # changes, use {@link #Image.paste} or the ImageDraw module - # instead. - # - # @param xy The pixel coordinate, given as (x, y). - # @param value The pixel value. - # @see #Image.paste - # @see #Image.putdata - # @see ImageDraw - - def putpixel(self, xy, value): - "Set pixel value" - - self.load() - if self.readonly: - self._copy() - - return self.im.putpixel(xy, value) - - ## - # Returns a resized copy of this image. - # - # @def resize(size, filter=NEAREST) - # @param size The requested size in pixels, as a 2-tuple: - # (width, height). - # @param filter An optional resampling filter. This can be - # one of NEAREST (use nearest neighbour), BILINEAR - # (linear interpolation in a 2x2 environment), BICUBIC - # (cubic spline interpolation in a 4x4 environment), or - # ANTIALIAS (a high-quality downsampling filter). - # If omitted, or if the image has mode "1" or "P", it is - # set NEAREST. - # @return An Image object. - - def resize(self, size, resample=NEAREST): - "Resize image" - - if resample not in (NEAREST, BILINEAR, BICUBIC, ANTIALIAS): - raise ValueError("unknown resampling filter") - - self.load() - - if self.mode in ("1", "P"): - resample = NEAREST - - if resample == ANTIALIAS: - # requires stretch support (imToolkit & PIL 1.1.3) - try: - im = self.im.stretch(size, resample) - except AttributeError: - raise ValueError("unsupported resampling filter") - else: - im = self.im.resize(size, resample) - - return self._new(im) - - ## - # Returns a rotated copy of this image. This method returns a - # copy of this image, rotated the given number of degrees counter - # clockwise around its centre. - # - # @def rotate(angle, filter=NEAREST) - # @param angle In degrees counter clockwise. - # @param filter An optional resampling filter. This can be - # one of NEAREST (use nearest neighbour), BILINEAR - # (linear interpolation in a 2x2 environment), or BICUBIC - # (cubic spline interpolation in a 4x4 environment). - # If omitted, or if the image has mode "1" or "P", it is - # set NEAREST. - # @param expand Optional expansion flag. If true, expands the output - # image to make it large enough to hold the entire rotated image. - # If false or omitted, make the output image the same size as the - # input image. - # @return An Image object. - - def rotate(self, angle, resample=NEAREST, expand=0): - "Rotate image. Angle given as degrees counter-clockwise." - - if expand: - import math - angle = -angle * math.pi / 180 - matrix = [ - math.cos(angle), math.sin(angle), 0.0, - -math.sin(angle), math.cos(angle), 0.0 - ] - def transform(x, y, (a, b, c, d, e, f)=matrix): - return a*x + b*y + c, d*x + e*y + f - - # calculate output size - w, h = self.size - xx = [] - yy = [] - for x, y in ((0, 0), (w, 0), (w, h), (0, h)): - x, y = transform(x, y) - xx.append(x) - yy.append(y) - w = int(math.ceil(max(xx)) - math.floor(min(xx))) - h = int(math.ceil(max(yy)) - math.floor(min(yy))) - - # adjust center - x, y = transform(w / 2.0, h / 2.0) - matrix[2] = self.size[0] / 2.0 - x - matrix[5] = self.size[1] / 2.0 - y - - return self.transform((w, h), AFFINE, matrix, resample) - - if resample not in (NEAREST, BILINEAR, BICUBIC): - raise ValueError("unknown resampling filter") - - self.load() - - if self.mode in ("1", "P"): - resample = NEAREST - - return self._new(self.im.rotate(angle, resample)) - - ## - # Saves this image under the given filename. If no format is - # specified, the format to use is determined from the filename - # extension, if possible. - #

- # Keyword options can be used to provide additional instructions - # to the writer. If a writer doesn't recognise an option, it is - # silently ignored. The available options are described later in - # this handbook. - #

- # You can use a file object instead of a filename. In this case, - # you must always specify the format. The file object must - # implement the seek, tell, and write - # methods, and be opened in binary mode. - # - # @def save(file, format=None, **options) - # @param file File name or file object. - # @param format Optional format override. If omitted, the - # format to use is determined from the filename extension. - # If a file object was used instead of a filename, this - # parameter should always be used. - # @param **options Extra parameters to the image writer. - # @return None - # @exception KeyError If the output format could not be determined - # from the file name. Use the format option to solve this. - # @exception IOError If the file could not be written. The file - # may have been created, and may contain partial data. - - def save(self, fp, format=None, **params): - "Save image to file or stream" - - if isStringType(fp): - filename = fp - else: - if hasattr(fp, "name") and isStringType(fp.name): - filename = fp.name - else: - filename = "" - - # may mutate self! - self.load() - - self.encoderinfo = params - self.encoderconfig = () - - preinit() - - ext = string.lower(os.path.splitext(filename)[1]) - - if not format: - try: - format = EXTENSION[ext] - except KeyError: - init() - try: - format = EXTENSION[ext] - except KeyError: - raise KeyError(ext) # unknown extension - - try: - save_handler = SAVE[string.upper(format)] - except KeyError: - init() - save_handler = SAVE[string.upper(format)] # unknown format - - if isStringType(fp): - import __builtin__ - fp = __builtin__.open(fp, "wb") - close = 1 - else: - close = 0 - - try: - save_handler(self, fp, filename) - finally: - # do what we can to clean up - if close: - fp.close() - - ## - # Seeks to the given frame in this sequence file. If you seek - # beyond the end of the sequence, the method raises an - # EOFError exception. When a sequence file is opened, the - # library automatically seeks to frame 0. - #

- # Note that in the current version of the library, most sequence - # formats only allows you to seek to the next frame. - # - # @param frame Frame number, starting at 0. - # @exception EOFError If the call attempts to seek beyond the end - # of the sequence. - # @see #Image.tell - - def seek(self, frame): - "Seek to given frame in sequence file" - - # overridden by file handlers - if frame != 0: - raise EOFError - - ## - # Displays this image. This method is mainly intended for - # debugging purposes. - #

- # On Unix platforms, this method saves the image to a temporary - # PPM file, and calls the xv utility. - #

- # On Windows, it saves the image to a temporary BMP file, and uses - # the standard BMP display utility to show it (usually Paint). - # - # @def show(title=None) - # @param title Optional title to use for the image window, - # where possible. - - def show(self, title=None, command=None): - "Display image (for debug purposes only)" - - _show(self, title=title, command=command) - - ## - # Split this image into individual bands. This method returns a - # tuple of individual image bands from an image. For example, - # splitting an "RGB" image creates three new images each - # containing a copy of one of the original bands (red, green, - # blue). - # - # @return A tuple containing bands. - - def split(self): - "Split image into bands" - - if self.im.bands == 1: - ims = [self.copy()] - else: - ims = [] - self.load() - for i in range(self.im.bands): - ims.append(self._new(self.im.getband(i))) - return tuple(ims) - - ## - # Returns the current frame number. - # - # @return Frame number, starting with 0. - # @see #Image.seek - - def tell(self): - "Return current frame number" - - return 0 - - ## - # Make this image into a thumbnail. This method modifies the - # image to contain a thumbnail version of itself, no larger than - # the given size. This method calculates an appropriate thumbnail - # size to preserve the aspect of the image, calls the {@link - # #Image.draft} method to configure the file reader (where - # applicable), and finally resizes the image. - #

- # Note that the bilinear and bicubic filters in the current - # version of PIL are not well-suited for thumbnail generation. - # You should use ANTIALIAS unless speed is much more - # important than quality. - #

- # Also note that this function modifies the Image object in place. - # If you need to use the full resolution image as well, apply this - # method to a {@link #Image.copy} of the original image. - # - # @param size Requested size. - # @param resample Optional resampling filter. This can be one - # of NEAREST, BILINEAR, BICUBIC, or - # ANTIALIAS (best quality). If omitted, it defaults - # to NEAREST (this will be changed to ANTIALIAS in a - # future version). - # @return None - - def thumbnail(self, size, resample=NEAREST): - "Create thumbnail representation (modifies image in place)" - - # FIXME: the default resampling filter will be changed - # to ANTIALIAS in future versions - - # preserve aspect ratio - x, y = self.size - if x > size[0]: y = max(y * size[0] / x, 1); x = size[0] - if y > size[1]: x = max(x * size[1] / y, 1); y = size[1] - size = x, y - - if size == self.size: - return - - self.draft(None, size) - - self.load() - - try: - im = self.resize(size, resample) - except ValueError: - if resample != ANTIALIAS: - raise - im = self.resize(size, NEAREST) # fallback - - self.im = im.im - self.mode = im.mode - self.size = size - - self.readonly = 0 - - # FIXME: the different tranform methods need further explanation - # instead of bloating the method docs, add a separate chapter. - - ## - # Transforms this image. This method creates a new image with the - # given size, and the same mode as the original, and copies data - # to the new image using the given transform. - #

- # @def transform(size, method, data, resample=NEAREST) - # @param size The output size. - # @param method The transformation method. This is one of - # EXTENT (cut out a rectangular subregion), AFFINE - # (affine transform), PERSPECTIVE (perspective - # transform), QUAD (map a quadrilateral to a - # rectangle), or MESH (map a number of source quadrilaterals - # in one operation). - # @param data Extra data to the transformation method. - # @param resample Optional resampling filter. It can be one of - # NEAREST (use nearest neighbour), BILINEAR - # (linear interpolation in a 2x2 environment), or - # BICUBIC (cubic spline interpolation in a 4x4 - # environment). If omitted, or if the image has mode - # "1" or "P", it is set to NEAREST. - # @return An Image object. - - def transform(self, size, method, data=None, resample=NEAREST, fill=1): - "Transform image" - - if isinstance(method, ImageTransformHandler): - return method.transform(size, self, resample=resample, fill=fill) - if hasattr(method, "getdata"): - # compatibility w. old-style transform objects - method, data = method.getdata() - if data is None: - raise ValueError("missing method data") - im = new(self.mode, size, None) - if method == MESH: - # list of quads - for box, quad in data: - im.__transformer(box, self, QUAD, quad, resample, fill) - else: - im.__transformer((0, 0)+size, self, method, data, resample, fill) - - return im - - def __transformer(self, box, image, method, data, - resample=NEAREST, fill=1): - - # FIXME: this should be turned into a lazy operation (?) - - w = box[2]-box[0] - h = box[3]-box[1] - - if method == AFFINE: - # change argument order to match implementation - data = (data[2], data[0], data[1], - data[5], data[3], data[4]) - elif method == EXTENT: - # convert extent to an affine transform - x0, y0, x1, y1 = data - xs = float(x1 - x0) / w - ys = float(y1 - y0) / h - method = AFFINE - data = (x0 + xs/2, xs, 0, y0 + ys/2, 0, ys) - elif method == PERSPECTIVE: - # change argument order to match implementation - data = (data[2], data[0], data[1], - data[5], data[3], data[4], - data[6], data[7]) - elif method == QUAD: - # quadrilateral warp. data specifies the four corners - # given as NW, SW, SE, and NE. - nw = data[0:2]; sw = data[2:4]; se = data[4:6]; ne = data[6:8] - x0, y0 = nw; As = 1.0 / w; At = 1.0 / h - data = (x0, (ne[0]-x0)*As, (sw[0]-x0)*At, - (se[0]-sw[0]-ne[0]+x0)*As*At, - y0, (ne[1]-y0)*As, (sw[1]-y0)*At, - (se[1]-sw[1]-ne[1]+y0)*As*At) - else: - raise ValueError("unknown transformation method") - - if resample not in (NEAREST, BILINEAR, BICUBIC): - raise ValueError("unknown resampling filter") - - image.load() - - self.load() - - if image.mode in ("1", "P"): - resample = NEAREST - - self.im.transform2(box, image.im, method, data, resample, fill) - - ## - # Returns a flipped or rotated copy of this image. - # - # @param method One of FLIP_LEFT_RIGHT, FLIP_TOP_BOTTOM, - # ROTATE_90, ROTATE_180, or ROTATE_270. - - def transpose(self, method): - "Transpose image (flip or rotate in 90 degree steps)" - - self.load() - im = self.im.transpose(method) - return self._new(im) - -# -------------------------------------------------------------------- -# Lazy operations - -class _ImageCrop(Image): - - def __init__(self, im, box): - - Image.__init__(self) - - x0, y0, x1, y1 = box - if x1 < x0: - x1 = x0 - if y1 < y0: - y1 = y0 - - self.mode = im.mode - self.size = x1-x0, y1-y0 - - self.__crop = x0, y0, x1, y1 - - self.im = im.im - - def load(self): - - # lazy evaluation! - if self.__crop: - self.im = self.im.crop(self.__crop) - self.__crop = None - - if self.im: - return self.im.pixel_access(self.readonly) - - # FIXME: future versions should optimize crop/paste - # sequences! - -# -------------------------------------------------------------------- -# Abstract handlers. - -class ImagePointHandler: - # used as a mixin by point transforms (for use with im.point) - pass - -class ImageTransformHandler: - # used as a mixin by geometry transforms (for use with im.transform) - pass - -# -------------------------------------------------------------------- -# Factories - -# -# Debugging - -def _wedge(): - "Create greyscale wedge (for debugging only)" - - return Image()._new(core.wedge("L")) - -## -# Creates a new image with the given mode and size. -# -# @param mode The mode to use for the new image. -# @param size A 2-tuple, containing (width, height) in pixels. -# @param color What colour to use for the image. Default is black. -# If given, this should be a single integer or floating point value -# for single-band modes, and a tuple for multi-band modes (one value -# per band). When creating RGB images, you can also use colour -# strings as supported by the ImageColor module. If the colour is -# None, the image is not initialised. -# @return An Image object. - -def new(mode, size, color=0): - "Create a new image" - - if color is None: - # don't initialize - return Image()._new(core.new(mode, size)) - - if isStringType(color): - # css3-style specifier - - import ImageColor - color = ImageColor.getcolor(color, mode) - - return Image()._new(core.fill(mode, size, color)) - -## -# Creates an image memory from pixel data in a string. -#

-# In its simplest form, this function takes three arguments -# (mode, size, and unpacked pixel data). -#

-# You can also use any pixel decoder supported by PIL. For more -# information on available decoders, see the section Writing Your Own File Decoder. -#

-# Note that this function decodes pixel data only, not entire images. -# If you have an entire image in a string, wrap it in a -# StringIO object, and use {@link #open} to load it. -# -# @param mode The image mode. -# @param size The image size. -# @param data An 8-bit string containing raw data for the given mode. -# @param decoder_name What decoder to use. -# @param *args Additional parameters for the given decoder. -# @return An Image object. - -def fromstring(mode, size, data, decoder_name="raw", *args): - "Load image from string" - - # may pass tuple instead of argument list - if len(args) == 1 and isTupleType(args[0]): - args = args[0] - - if decoder_name == "raw" and args == (): - args = mode - - im = new(mode, size) - im.fromstring(data, decoder_name, args) - return im - -## -# (New in 1.1.4) Creates an image memory from pixel data in a string -# or byte buffer. -#

-# This function is similar to {@link #fromstring}, but uses data in -# the byte buffer, where possible. This means that changes to the -# original buffer object are reflected in this image). Not all modes -# can share memory; supported modes include "L", "RGBX", "RGBA", and -# "CMYK". -#

-# Note that this function decodes pixel data only, not entire images. -# If you have an entire image file in a string, wrap it in a -# StringIO object, and use {@link #open} to load it. -#

-# In the current version, the default parameters used for the "raw" -# decoder differs from that used for {@link fromstring}. This is a -# bug, and will probably be fixed in a future release. The current -# release issues a warning if you do this; to disable the warning, -# you should provide the full set of parameters. See below for -# details. -# -# @param mode The image mode. -# @param size The image size. -# @param data An 8-bit string or other buffer object containing raw -# data for the given mode. -# @param decoder_name What decoder to use. -# @param *args Additional parameters for the given decoder. For the -# default encoder ("raw"), it's recommended that you provide the -# full set of parameters: -# frombuffer(mode, size, data, "raw", mode, 0, 1). -# @return An Image object. -# @since 1.1.4 - -def frombuffer(mode, size, data, decoder_name="raw", *args): - "Load image from string or buffer" - - # may pass tuple instead of argument list - if len(args) == 1 and isTupleType(args[0]): - args = args[0] - - if decoder_name == "raw": - if args == (): - if warnings: - warnings.warn( - "the frombuffer defaults may change in a future release; " - "for portability, change the call to read:\n" - " frombuffer(mode, size, data, 'raw', mode, 0, 1)", - RuntimeWarning, stacklevel=2 - ) - args = mode, 0, -1 # may change to (mode, 0, 1) post-1.1.6 - if args[0] in _MAPMODES: - im = new(mode, (1,1)) - im = im._new( - core.map_buffer(data, size, decoder_name, None, 0, args) - ) - im.readonly = 1 - return im - - return fromstring(mode, size, data, decoder_name, args) - - -## -# (New in 1.1.6) Creates an image memory from an object exporting -# the array interface (using the buffer protocol). -# -# If obj is not contiguous, then the tostring method is called -# and {@link frombuffer} is used. -# -# @param obj Object with array interface -# @param mode Mode to use (will be determined from type if None) -# @return An image memory. - -def fromarray(obj, mode=None): - arr = obj.__array_interface__ - shape = arr['shape'] - ndim = len(shape) - try: - strides = arr['strides'] - except KeyError: - strides = None - if mode is None: - try: - typekey = (1, 1) + shape[2:], arr['typestr'] - mode, rawmode = _fromarray_typemap[typekey] - except KeyError: - # print typekey - raise TypeError("Cannot handle this data type") - else: - rawmode = mode - if mode in ["1", "L", "I", "P", "F"]: - ndmax = 2 - elif mode == "RGB": - ndmax = 3 - else: - ndmax = 4 - if ndim > ndmax: - raise ValueError("Too many dimensions.") - - size = shape[1], shape[0] - if strides is not None: - obj = obj.tostring() - - return frombuffer(mode, size, obj, "raw", rawmode, 0, 1) - -_fromarray_typemap = { - # (shape, typestr) => mode, rawmode - # first two members of shape are set to one - # ((1, 1), "|b1"): ("1", "1"), # broken - ((1, 1), "|u1"): ("L", "L"), - ((1, 1), "|i1"): ("I", "I;8"), - ((1, 1), "i2"): ("I", "I;16B"), - ((1, 1), "i4"): ("I", "I;32B"), - ((1, 1), "f4"): ("F", "F;32BF"), - ((1, 1), "f8"): ("F", "F;64BF"), - ((1, 1, 3), "|u1"): ("RGB", "RGB"), - ((1, 1, 4), "|u1"): ("RGBA", "RGBA"), - } - -# shortcuts -_fromarray_typemap[((1, 1), _ENDIAN + "i4")] = ("I", "I") -_fromarray_typemap[((1, 1), _ENDIAN + "f4")] = ("F", "F") - -## -# Opens and identifies the given image file. -#

-# This is a lazy operation; this function identifies the file, but the -# actual image data is not read from the file until you try to process -# the data (or call the {@link #Image.load} method). -# -# @def open(file, mode="r") -# @param file A filename (string) or a file object. The file object -# must implement read, seek, and tell methods, -# and be opened in binary mode. -# @param mode The mode. If given, this argument must be "r". -# @return An Image object. -# @exception IOError If the file cannot be found, or the image cannot be -# opened and identified. -# @see #new - -def open(fp, mode="r"): - "Open an image file, without loading the raster data" - - if mode != "r": - raise ValueError("bad mode") - - if isStringType(fp): - import __builtin__ - filename = fp - fp = __builtin__.open(fp, "rb") - else: - filename = "" - - prefix = fp.read(16) - - preinit() - - for i in ID: - try: - factory, accept = OPEN[i] - if not accept or accept(prefix): - fp.seek(0) - return factory(fp, filename) - except (SyntaxError, IndexError, TypeError): - pass - - if init(): - - for i in ID: - try: - factory, accept = OPEN[i] - if not accept or accept(prefix): - fp.seek(0) - return factory(fp, filename) - except (SyntaxError, IndexError, TypeError): - pass - - raise IOError("cannot identify image file") - -# -# Image processing. - -## -# Creates a new image by interpolating between two input images, using -# a constant alpha. -# -#

-#    out = image1 * (1.0 - alpha) + image2 * alpha
-# 
-# -# @param im1 The first image. -# @param im2 The second image. Must have the same mode and size as -# the first image. -# @param alpha The interpolation alpha factor. If alpha is 0.0, a -# copy of the first image is returned. If alpha is 1.0, a copy of -# the second image is returned. There are no restrictions on the -# alpha value. If necessary, the result is clipped to fit into -# the allowed output range. -# @return An Image object. - -def blend(im1, im2, alpha): - "Interpolate between images." - - im1.load() - im2.load() - return im1._new(core.blend(im1.im, im2.im, alpha)) - -## -# Creates a new image by interpolating between two input images, -# using the mask as alpha. -# -# @param image1 The first image. -# @param image2 The second image. Must have the same mode and -# size as the first image. -# @param mask A mask image. This image can can have mode -# "1", "L", or "RGBA", and must have the same size as the -# other two images. - -def composite(image1, image2, mask): - "Create composite image by blending images using a transparency mask" - - image = image2.copy() - image.paste(image1, None, mask) - return image - -## -# Applies the function (which should take one argument) to each pixel -# in the given image. If the image has more than one band, the same -# function is applied to each band. Note that the function is -# evaluated once for each possible pixel value, so you cannot use -# random components or other generators. -# -# @def eval(image, function) -# @param image The input image. -# @param function A function object, taking one integer argument. -# @return An Image object. - -def eval(image, *args): - "Evaluate image expression" - - return image.point(args[0]) - -## -# Creates a new image from a number of single-band images. -# -# @param mode The mode to use for the output image. -# @param bands A sequence containing one single-band image for -# each band in the output image. All bands must have the -# same size. -# @return An Image object. - -def merge(mode, bands): - "Merge a set of single band images into a new multiband image." - - if getmodebands(mode) != len(bands) or "*" in mode: - raise ValueError("wrong number of bands") - for im in bands[1:]: - if im.mode != getmodetype(mode): - raise ValueError("mode mismatch") - if im.size != bands[0].size: - raise ValueError("size mismatch") - im = core.new(mode, bands[0].size) - for i in range(getmodebands(mode)): - bands[i].load() - im.putband(bands[i].im, i) - return bands[0]._new(im) - -# -------------------------------------------------------------------- -# Plugin registry - -## -# Register an image file plugin. This function should not be used -# in application code. -# -# @param id An image format identifier. -# @param factory An image file factory method. -# @param accept An optional function that can be used to quickly -# reject images having another format. - -def register_open(id, factory, accept=None): - id = string.upper(id) - ID.append(id) - OPEN[id] = factory, accept - -## -# Registers an image MIME type. This function should not be used -# in application code. -# -# @param id An image format identifier. -# @param mimetype The image MIME type for this format. - -def register_mime(id, mimetype): - MIME[string.upper(id)] = mimetype - -## -# Registers an image save function. This function should not be -# used in application code. -# -# @param id An image format identifier. -# @param driver A function to save images in this format. - -def register_save(id, driver): - SAVE[string.upper(id)] = driver - -## -# Registers an image extension. This function should not be -# used in application code. -# -# @param id An image format identifier. -# @param extension An extension used for this format. - -def register_extension(id, extension): - EXTENSION[string.lower(extension)] = string.upper(id) - - -# -------------------------------------------------------------------- -# Simple display support. User code may override this. - -def _show(image, **options): - # override me, as necessary - apply(_showxv, (image,), options) - -def _showxv(image, title=None, **options): - import ImageShow - apply(ImageShow.show, (image, title), options) diff --git a/pyqtgraph/PIL_Fix/README b/pyqtgraph/PIL_Fix/README deleted file mode 100644 index 3711e113..00000000 --- a/pyqtgraph/PIL_Fix/README +++ /dev/null @@ -1,11 +0,0 @@ -The file Image.py is a drop-in replacement for the same file in PIL 1.1.6. -It adds support for reading 16-bit TIFF files and converting then to numpy arrays. -(I submitted the changes to the PIL folks long ago, but to my knowledge the code -is not being used by them.) - -To use, copy this file into - /usr/lib/python2.6/dist-packages/PIL/ -or - C:\Python26\lib\site-packages\PIL\ - -..or wherever your system keeps its python modules. diff --git a/pyqtgraph/Qt.py b/pyqtgraph/Qt.py index e584a381..efbe66c4 100644 --- a/pyqtgraph/Qt.py +++ b/pyqtgraph/Qt.py @@ -1,6 +1,18 @@ -## Do all Qt imports from here to allow easier PyQt / PySide compatibility +""" +This module exists to smooth out some of the differences between PySide and PyQt4: + +* Automatically import either PyQt4 or PySide depending on availability +* Allow to import QtCore/QtGui pyqtgraph.Qt without specifying which Qt wrapper + you want to use. +* Declare QtCore.Signal, .Slot in PyQt4 +* Declare loadUiType function for Pyside + +""" + import sys, re +from .python2_3 import asUnicode + ## Automatically determine whether to use PyQt or PySide. ## This is done by first checking to see whether one of the libraries ## is already imported. If not, then attempt to import PyQt4, then PySide. @@ -21,10 +33,75 @@ else: if USE_PYSIDE: from PySide import QtGui, QtCore, QtOpenGL, QtSvg + try: + from PySide import QtTest + except ImportError: + pass import PySide + try: + from PySide import shiboken + isQObjectAlive = shiboken.isValid + except ImportError: + def isQObjectAlive(obj): + try: + if hasattr(obj, 'parent'): + obj.parent() + elif hasattr(obj, 'parentItem'): + obj.parentItem() + else: + raise Exception("Cannot determine whether Qt object %s is still alive." % obj) + except RuntimeError: + return False + else: + return True + VERSION_INFO = 'PySide ' + PySide.__version__ + + # Make a loadUiType function like PyQt has + + # Credit: + # http://stackoverflow.com/questions/4442286/python-code-genration-with-pyside-uic/14195313#14195313 + + class StringIO(object): + """Alternative to built-in StringIO needed to circumvent unicode/ascii issues""" + def __init__(self): + self.data = [] + + def write(self, data): + self.data.append(data) + + def getvalue(self): + return ''.join(map(asUnicode, self.data)).encode('utf8') + + def loadUiType(uiFile): + """ + Pyside "loadUiType" command like PyQt4 has one, so we have to convert the ui file to py code in-memory first and then execute it in a special frame to retrieve the form_class. + """ + import pysideuic + import xml.etree.ElementTree as xml + #from io import StringIO + + parsed = xml.parse(uiFile) + widget_class = parsed.find('widget').get('class') + form_class = parsed.find('class').text + + with open(uiFile, 'r') as f: + o = StringIO() + frame = {} + + pysideuic.compileUi(f, o, indent=0) + pyc = compile(o.getvalue(), '', 'exec') + exec(pyc, frame) + + #Fetch the base_class and form class based on their type in the xml from designer + form_class = frame['Ui_%s'%form_class] + base_class = eval('QtGui.%s'%widget_class) + + return form_class, base_class + + else: - from PyQt4 import QtGui, QtCore + from PyQt4 import QtGui, QtCore, uic try: from PyQt4 import QtSvg except ImportError: @@ -33,6 +110,16 @@ else: from PyQt4 import QtOpenGL except ImportError: pass + try: + from PyQt4 import QtTest + except ImportError: + pass + + + import sip + def isQObjectAlive(obj): + return not sip.isdeleted(obj) + loadUiType = uic.loadUiType QtCore.Signal = QtCore.pyqtSignal VERSION_INFO = 'PyQt4 ' + QtCore.PYQT_VERSION_STR + ' Qt ' + QtCore.QT_VERSION_STR @@ -43,6 +130,6 @@ versionReq = [4, 7] QtVersion = PySide.QtCore.__version__ if USE_PYSIDE else QtCore.QT_VERSION_STR m = re.match(r'(\d+)\.(\d+).*', QtVersion) if m is not None and list(map(int, m.groups())) < versionReq: - print(map(int, m.groups())) + print(list(map(int, m.groups()))) raise Exception('pyqtgraph requires Qt version >= %d.%d (your version is %s)' % (versionReq[0], versionReq[1], QtVersion)) diff --git a/pyqtgraph/SRTTransform.py b/pyqtgraph/SRTTransform.py index efb24f60..23281343 100644 --- a/pyqtgraph/SRTTransform.py +++ b/pyqtgraph/SRTTransform.py @@ -2,7 +2,6 @@ from .Qt import QtCore, QtGui from .Point import Point import numpy as np -import pyqtgraph as pg class SRTTransform(QtGui.QTransform): """Transform that can always be represented as a combination of 3 matrices: scale * rotate * translate @@ -77,7 +76,7 @@ class SRTTransform(QtGui.QTransform): self.update() def setFromMatrix4x4(self, m): - m = pg.SRTTransform3D(m) + m = SRTTransform3D(m) angle, axis = m.getRotation() if angle != 0 and (axis[0] != 0 or axis[1] != 0 or axis[2] != 1): print("angle: %s axis: %s" % (str(angle), str(axis))) @@ -256,4 +255,4 @@ if __name__ == '__main__': w1.sigRegionChanged.connect(update) #w2.sigRegionChanged.connect(update2) - \ No newline at end of file +from .SRTTransform3D import SRTTransform3D diff --git a/pyqtgraph/SRTTransform3D.py b/pyqtgraph/SRTTransform3D.py index 7d87dcb8..9b54843b 100644 --- a/pyqtgraph/SRTTransform3D.py +++ b/pyqtgraph/SRTTransform3D.py @@ -1,17 +1,16 @@ # -*- coding: utf-8 -*- from .Qt import QtCore, QtGui from .Vector import Vector -from .SRTTransform import SRTTransform -import pyqtgraph as pg +from .Transform3D import Transform3D +from .Vector import Vector import numpy as np -import scipy.linalg -class SRTTransform3D(pg.Transform3D): +class SRTTransform3D(Transform3D): """4x4 Transform matrix that can always be represented as a combination of 3 matrices: scale * rotate * translate This transform has no shear; angles are always preserved. """ def __init__(self, init=None): - pg.Transform3D.__init__(self) + Transform3D.__init__(self) self.reset() if init is None: return @@ -44,14 +43,14 @@ class SRTTransform3D(pg.Transform3D): def getScale(self): - return pg.Vector(self._state['scale']) + return Vector(self._state['scale']) def getRotation(self): """Return (angle, axis) of rotation""" - return self._state['angle'], pg.Vector(self._state['axis']) + return self._state['angle'], Vector(self._state['axis']) def getTranslation(self): - return pg.Vector(self._state['pos']) + return Vector(self._state['pos']) def reset(self): self._state = { @@ -118,11 +117,13 @@ class SRTTransform3D(pg.Transform3D): The input matrix must be affine AND have no shear, otherwise the conversion will most likely fail. """ + import numpy.linalg for i in range(4): self.setRow(i, m.row(i)) m = self.matrix().reshape(4,4) ## translation is 4th column - self._state['pos'] = m[:3,3] + self._state['pos'] = m[:3,3] + ## scale is vector-length of first three columns scale = (m[:3,:3]**2).sum(axis=0)**0.5 ## see whether there is an inversion @@ -132,9 +133,9 @@ class SRTTransform3D(pg.Transform3D): self._state['scale'] = scale ## rotation axis is the eigenvector with eigenvalue=1 - r = m[:3, :3] / scale[:, np.newaxis] + r = m[:3, :3] / scale[np.newaxis, :] try: - evals, evecs = scipy.linalg.eig(r) + evals, evecs = numpy.linalg.eig(r) except: print("Rotation matrix: %s" % str(r)) print("Scale: %s" % str(scale)) @@ -169,7 +170,7 @@ class SRTTransform3D(pg.Transform3D): def as2D(self): """Return a QTransform representing the x,y portion of this transform (if possible)""" - return pg.SRTTransform(self) + return SRTTransform(self) #def __div__(self, t): #"""A / B == B^-1 * A""" @@ -202,11 +203,11 @@ class SRTTransform3D(pg.Transform3D): self.update() def update(self): - pg.Transform3D.setToIdentity(self) + Transform3D.setToIdentity(self) ## modifications to the transform are multiplied on the right, so we need to reverse order here. - pg.Transform3D.translate(self, *self._state['pos']) - pg.Transform3D.rotate(self, self._state['angle'], *self._state['axis']) - pg.Transform3D.scale(self, *self._state['scale']) + Transform3D.translate(self, *self._state['pos']) + Transform3D.rotate(self, self._state['angle'], *self._state['axis']) + Transform3D.scale(self, *self._state['scale']) def __repr__(self): return str(self.saveState()) @@ -311,4 +312,4 @@ if __name__ == '__main__': w1.sigRegionChanged.connect(update) #w2.sigRegionChanged.connect(update2) - \ No newline at end of file +from .SRTTransform import SRTTransform diff --git a/pyqtgraph/SignalProxy.py b/pyqtgraph/SignalProxy.py index 6f9b9112..d36282fa 100644 --- a/pyqtgraph/SignalProxy.py +++ b/pyqtgraph/SignalProxy.py @@ -2,6 +2,7 @@ from .Qt import QtCore from .ptime import time from . import ThreadsafeTimer +import weakref __all__ = ['SignalProxy'] @@ -34,7 +35,7 @@ class SignalProxy(QtCore.QObject): self.timer = ThreadsafeTimer.ThreadsafeTimer() self.timer.timeout.connect(self.flush) self.block = False - self.slot = slot + self.slot = weakref.ref(slot) self.lastFlushTime = None if slot is not None: self.sigDelayed.connect(slot) @@ -80,7 +81,7 @@ class SignalProxy(QtCore.QObject): except: pass try: - self.sigDelayed.disconnect(self.slot) + self.sigDelayed.disconnect(self.slot()) except: pass diff --git a/pyqtgraph/ThreadsafeTimer.py b/pyqtgraph/ThreadsafeTimer.py index f2de9791..201469de 100644 --- a/pyqtgraph/ThreadsafeTimer.py +++ b/pyqtgraph/ThreadsafeTimer.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtCore, QtGui +from .Qt import QtCore, QtGui class ThreadsafeTimer(QtCore.QObject): """ diff --git a/pyqtgraph/Transform3D.py b/pyqtgraph/Transform3D.py index aa948e28..43b12de3 100644 --- a/pyqtgraph/Transform3D.py +++ b/pyqtgraph/Transform3D.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- from .Qt import QtCore, QtGui -import pyqtgraph as pg +from . import functions as fn import numpy as np class Transform3D(QtGui.QMatrix4x4): @@ -26,7 +26,7 @@ class Transform3D(QtGui.QMatrix4x4): Extends QMatrix4x4.map() to allow mapping (3, ...) arrays of coordinates """ if isinstance(obj, np.ndarray) and obj.ndim >= 2 and obj.shape[0] in (2,3): - return pg.transformCoordinates(self, obj) + return fn.transformCoordinates(self, obj) else: return QtGui.QMatrix4x4.map(self, obj) diff --git a/pyqtgraph/Vector.py b/pyqtgraph/Vector.py index 4b4fb02f..f2898e80 100644 --- a/pyqtgraph/Vector.py +++ b/pyqtgraph/Vector.py @@ -67,4 +67,21 @@ class Vector(QtGui.QVector3D): yield(self.x()) yield(self.y()) yield(self.z()) + + def angle(self, a): + """Returns the angle in degrees between this vector and the vector a.""" + n1 = self.length() + n2 = a.length() + if n1 == 0. or n2 == 0.: + return None + ## Probably this should be done with arctan2 instead.. + ang = np.arccos(np.clip(QtGui.QVector3D.dotProduct(self, a) / (n1 * n2), -1.0, 1.0)) ### in radians +# c = self.crossProduct(a) +# if c > 0: +# ang *= -1. + return ang * 180. / np.pi + + def __abs__(self): + return Vector(abs(self.x()), abs(self.y()), abs(self.z())) + \ No newline at end of file diff --git a/pyqtgraph/__init__.py b/pyqtgraph/__init__.py index 11e281a4..0f5333f0 100644 --- a/pyqtgraph/__init__.py +++ b/pyqtgraph/__init__.py @@ -4,7 +4,7 @@ PyQtGraph - Scientific Graphics and GUI Library for Python www.pyqtgraph.org """ -__version__ = None +__version__ = '0.9.9' ### import all the goodies and add some helper functions for easy CLI use @@ -48,14 +48,15 @@ else: CONFIG_OPTIONS = { 'useOpenGL': useOpenGL, ## by default, this is platform-dependent (see widgets/GraphicsView). Set to True or False to explicitly enable/disable opengl. 'leftButtonPan': True, ## if false, left button drags a rubber band for zooming in viewbox - 'foreground': (150, 150, 150), ## default foreground color for axes, labels, etc. - 'background': (0, 0, 0), ## default background for GraphicsWidget + 'foreground': 'd', ## default foreground color for axes, labels, etc. + 'background': 'k', ## default background for GraphicsWidget 'antialias': False, 'editorCommand': None, ## command used to invoke code editor from ConsoleWidgets - 'useWeave': True, ## Use weave to speed up some operations, if it is available + 'useWeave': False, ## Use weave to speed up some operations, if it is available 'weaveDebug': False, ## Print full error message if weave compile fails 'exitCleanup': True, ## Attempt to work around some exit crash bugs in PyQt and PySide 'enableExperimental': False, ## Enable experimental features (the curious can search for this key in the code) + 'crashWarning': False, # If True, print warnings about situations that may result in a crash } @@ -130,56 +131,119 @@ if __version__ is None and not hasattr(sys, 'frozen') and sys.version_info[0] == ## Import almost everything to make it available from a single namespace ## don't import the more complex systems--canvas, parametertree, flowchart, dockarea ## these must be imported separately. -from . import frozenSupport -def importModules(path, globals, locals, excludes=()): - """Import all modules residing within *path*, return a dict of name: module pairs. +#from . import frozenSupport +#def importModules(path, globals, locals, excludes=()): + #"""Import all modules residing within *path*, return a dict of name: module pairs. - Note that *path* MUST be relative to the module doing the import. - """ - d = os.path.join(os.path.split(globals['__file__'])[0], path) - files = set() - for f in frozenSupport.listdir(d): - if frozenSupport.isdir(os.path.join(d, f)) and f not in ['__pycache__', 'tests']: - files.add(f) - elif f[-3:] == '.py' and f != '__init__.py': - files.add(f[:-3]) - elif f[-4:] == '.pyc' and f != '__init__.pyc': - files.add(f[:-4]) + #Note that *path* MUST be relative to the module doing the import. + #""" + #d = os.path.join(os.path.split(globals['__file__'])[0], path) + #files = set() + #for f in frozenSupport.listdir(d): + #if frozenSupport.isdir(os.path.join(d, f)) and f not in ['__pycache__', 'tests']: + #files.add(f) + #elif f[-3:] == '.py' and f != '__init__.py': + #files.add(f[:-3]) + #elif f[-4:] == '.pyc' and f != '__init__.pyc': + #files.add(f[:-4]) - mods = {} - path = path.replace(os.sep, '.') - for modName in files: - if modName in excludes: - continue - try: - if len(path) > 0: - modName = path + '.' + modName - #mod = __import__(modName, globals, locals, fromlist=['*']) - mod = __import__(modName, globals, locals, ['*'], 1) - mods[modName] = mod - except: - import traceback - traceback.print_stack() - sys.excepthook(*sys.exc_info()) - print("[Error importing module: %s]" % modName) + #mods = {} + #path = path.replace(os.sep, '.') + #for modName in files: + #if modName in excludes: + #continue + #try: + #if len(path) > 0: + #modName = path + '.' + modName + #print( "from .%s import * " % modName) + #mod = __import__(modName, globals, locals, ['*'], 1) + #mods[modName] = mod + #except: + #import traceback + #traceback.print_stack() + #sys.excepthook(*sys.exc_info()) + #print("[Error importing module: %s]" % modName) - return mods + #return mods -def importAll(path, globals, locals, excludes=()): - """Given a list of modules, import all names from each module into the global namespace.""" - mods = importModules(path, globals, locals, excludes) - for mod in mods.values(): - if hasattr(mod, '__all__'): - names = mod.__all__ - else: - names = [n for n in dir(mod) if n[0] != '_'] - for k in names: - if hasattr(mod, k): - globals[k] = getattr(mod, k) +#def importAll(path, globals, locals, excludes=()): + #"""Given a list of modules, import all names from each module into the global namespace.""" + #mods = importModules(path, globals, locals, excludes) + #for mod in mods.values(): + #if hasattr(mod, '__all__'): + #names = mod.__all__ + #else: + #names = [n for n in dir(mod) if n[0] != '_'] + #for k in names: + #if hasattr(mod, k): + #globals[k] = getattr(mod, k) -importAll('graphicsItems', globals(), locals()) -importAll('widgets', globals(), locals(), - excludes=['MatplotlibWidget', 'RawImageWidget', 'RemoteGraphicsView']) +# Dynamic imports are disabled. This causes too many problems. +#importAll('graphicsItems', globals(), locals()) +#importAll('widgets', globals(), locals(), + #excludes=['MatplotlibWidget', 'RawImageWidget', 'RemoteGraphicsView']) + +from .graphicsItems.VTickGroup import * +from .graphicsItems.GraphicsWidget import * +from .graphicsItems.ScaleBar import * +from .graphicsItems.PlotDataItem import * +from .graphicsItems.GraphItem import * +from .graphicsItems.TextItem import * +from .graphicsItems.GraphicsLayout import * +from .graphicsItems.UIGraphicsItem import * +from .graphicsItems.GraphicsObject import * +from .graphicsItems.PlotItem import * +from .graphicsItems.ROI import * +from .graphicsItems.InfiniteLine import * +from .graphicsItems.HistogramLUTItem import * +from .graphicsItems.GridItem import * +from .graphicsItems.GradientLegend import * +from .graphicsItems.GraphicsItem import * +from .graphicsItems.BarGraphItem import * +from .graphicsItems.ViewBox import * +from .graphicsItems.ArrowItem import * +from .graphicsItems.ImageItem import * +from .graphicsItems.AxisItem import * +from .graphicsItems.LabelItem import * +from .graphicsItems.CurvePoint import * +from .graphicsItems.GraphicsWidgetAnchor import * +from .graphicsItems.PlotCurveItem import * +from .graphicsItems.ButtonItem import * +from .graphicsItems.GradientEditorItem import * +from .graphicsItems.MultiPlotItem import * +from .graphicsItems.ErrorBarItem import * +from .graphicsItems.IsocurveItem import * +from .graphicsItems.LinearRegionItem import * +from .graphicsItems.FillBetweenItem import * +from .graphicsItems.LegendItem import * +from .graphicsItems.ScatterPlotItem import * +from .graphicsItems.ItemGroup import * + +from .widgets.MultiPlotWidget import * +from .widgets.ScatterPlotWidget import * +from .widgets.ColorMapWidget import * +from .widgets.FileDialog import * +from .widgets.ValueLabel import * +from .widgets.HistogramLUTWidget import * +from .widgets.CheckTable import * +from .widgets.BusyCursor import * +from .widgets.PlotWidget import * +from .widgets.ComboBox import * +from .widgets.GradientWidget import * +from .widgets.DataFilterWidget import * +from .widgets.SpinBox import * +from .widgets.JoystickButton import * +from .widgets.GraphicsLayoutWidget import * +from .widgets.TreeWidget import * +from .widgets.PathButton import * +from .widgets.VerticalLabel import * +from .widgets.FeedbackButton import * +from .widgets.ColorButton import * +from .widgets.DataTreeWidget import * +from .widgets.GraphicsView import * +from .widgets.LayoutWidget import * +from .widgets.TableWidget import * +from .widgets.ProgressDialog import * from .imageview import * from .WidgetGroup import * @@ -193,6 +257,8 @@ from .graphicsWindows import * from .SignalProxy import * from .colormap import * from .ptime import time +from .Qt import isQObjectAlive + ############################################################## ## PyQt and PySide both are prone to crashing on exit. @@ -204,7 +270,12 @@ from .ptime import time ## Attempts to work around exit crashes: import atexit +_cleanupCalled = False def cleanup(): + global _cleanupCalled + if _cleanupCalled: + return + if not getConfigOption('exitCleanup'): return @@ -220,12 +291,31 @@ def cleanup(): s = QtGui.QGraphicsScene() for o in gc.get_objects(): try: - if isinstance(o, QtGui.QGraphicsItem) and o.scene() is None: + if isinstance(o, QtGui.QGraphicsItem) and isQObjectAlive(o) and o.scene() is None: + if getConfigOption('crashWarning'): + sys.stderr.write('Error: graphics item without scene. ' + 'Make sure ViewBox.close() and GraphicsView.close() ' + 'are properly called before app shutdown (%s)\n' % (o,)) + s.addItem(o) except RuntimeError: ## occurs if a python wrapper no longer has its underlying C++ object continue + _cleanupCalled = True + atexit.register(cleanup) +# Call cleanup when QApplication quits. This is necessary because sometimes +# the QApplication will quit before the atexit callbacks are invoked. +# Note: cannot connect this function until QApplication has been created, so +# instead we have GraphicsView.__init__ call this for us. +_cleanupConnected = False +def _connectCleanup(): + global _cleanupConnected + if _cleanupConnected: + return + QtGui.QApplication.instance().aboutToQuit.connect(cleanup) + _cleanupConnected = True + ## Optional function for exiting immediately (with some manual teardown) def exit(): @@ -254,8 +344,13 @@ def exit(): atexit._run_exitfuncs() ## close file handles - os.closerange(3, 4096) ## just guessing on the maximum descriptor count.. - + if sys.platform == 'darwin': + for fd in xrange(3, 4096): + if fd not in [7]: # trying to close 7 produces an illegal instruction on the Mac. + os.close(fd) + else: + os.closerange(3, 4096) ## just guessing on the maximum descriptor count.. + os._exit(0) @@ -292,7 +387,8 @@ def plot(*args, **kargs): dataArgs[k] = kargs[k] w = PlotWindow(**pwArgs) - w.plot(*args, **dataArgs) + if len(args) > 0 or len(dataArgs) > 0: + w.plot(*args, **dataArgs) plots.append(w) w.show() return w @@ -328,6 +424,7 @@ def dbg(*args, **kwds): consoles.append(c) except NameError: consoles = [c] + return c def mkQApp(): diff --git a/pyqtgraph/canvas/Canvas.py b/pyqtgraph/canvas/Canvas.py index 17a39c2b..4de891f7 100644 --- a/pyqtgraph/canvas/Canvas.py +++ b/pyqtgraph/canvas/Canvas.py @@ -3,29 +3,21 @@ if __name__ == '__main__': import sys, os md = os.path.dirname(os.path.abspath(__file__)) sys.path = [os.path.dirname(md), os.path.join(md, '..', '..', '..')] + sys.path - #print md - -#from pyqtgraph.GraphicsView import GraphicsView -#import pyqtgraph.graphicsItems as graphicsItems -#from pyqtgraph.PlotWidget import PlotWidget -from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE -from pyqtgraph.graphicsItems.ROI import ROI -from pyqtgraph.graphicsItems.ViewBox import ViewBox -from pyqtgraph.graphicsItems.GridItem import GridItem +from ..Qt import QtGui, QtCore, USE_PYSIDE +from ..graphicsItems.ROI import ROI +from ..graphicsItems.ViewBox import ViewBox +from ..graphicsItems.GridItem import GridItem if USE_PYSIDE: from .CanvasTemplate_pyside import * else: from .CanvasTemplate_pyqt import * -#import DataManager import numpy as np -from pyqtgraph import debug -#import pyqtgraph as pg +from .. import debug import weakref from .CanvasManager import CanvasManager -#import items from .CanvasItem import CanvasItem, GroupCanvasItem class Canvas(QtGui.QWidget): @@ -75,8 +67,8 @@ class Canvas(QtGui.QWidget): self.ui.itemList.sigItemMoved.connect(self.treeItemMoved) self.ui.itemList.itemSelectionChanged.connect(self.treeItemSelected) self.ui.autoRangeBtn.clicked.connect(self.autoRange) - self.ui.storeSvgBtn.clicked.connect(self.storeSvg) - self.ui.storePngBtn.clicked.connect(self.storePng) + #self.ui.storeSvgBtn.clicked.connect(self.storeSvg) + #self.ui.storePngBtn.clicked.connect(self.storePng) self.ui.redirectCheck.toggled.connect(self.updateRedirect) self.ui.redirectCombo.currentIndexChanged.connect(self.updateRedirect) self.multiSelectBox.sigRegionChanged.connect(self.multiSelectBoxChanged) @@ -102,11 +94,13 @@ class Canvas(QtGui.QWidget): self.ui.itemList.contextMenuEvent = self.itemListContextMenuEvent - def storeSvg(self): - self.ui.view.writeSvg() + #def storeSvg(self): + #from pyqtgraph.GraphicsScene.exportDialog import ExportDialog + #ex = ExportDialog(self.ui.view) + #ex.show() - def storePng(self): - self.ui.view.writeImage() + #def storePng(self): + #self.ui.view.writeImage() def splitterMoved(self): self.resizeEvent() @@ -579,7 +573,9 @@ class Canvas(QtGui.QWidget): self.menu.popup(ev.globalPos()) def removeClicked(self): - self.removeItem(self.menuItem) + #self.removeItem(self.menuItem) + for item in self.selectedItems(): + self.removeItem(item) self.menuItem = None import gc gc.collect() @@ -605,4 +601,4 @@ class SelectBox(ROI): - \ No newline at end of file + diff --git a/pyqtgraph/canvas/CanvasItem.py b/pyqtgraph/canvas/CanvasItem.py index 81388cb6..b6ecbb39 100644 --- a/pyqtgraph/canvas/CanvasItem.py +++ b/pyqtgraph/canvas/CanvasItem.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore, QtSvg, USE_PYSIDE -from pyqtgraph.graphicsItems.ROI import ROI -import pyqtgraph as pg +from ..Qt import QtGui, QtCore, QtSvg, USE_PYSIDE +from ..graphicsItems.ROI import ROI +from .. import SRTTransform, ItemGroup if USE_PYSIDE: from . import TransformGuiTemplate_pyside as TransformGuiTemplate else: from . import TransformGuiTemplate_pyqt as TransformGuiTemplate -from pyqtgraph import debug +from .. import debug class SelectBox(ROI): def __init__(self, scalable=False, rotatable=True): @@ -96,7 +96,7 @@ class CanvasItem(QtCore.QObject): if 'transform' in self.opts: self.baseTransform = self.opts['transform'] else: - self.baseTransform = pg.SRTTransform() + self.baseTransform = SRTTransform() if 'pos' in self.opts and self.opts['pos'] is not None: self.baseTransform.translate(self.opts['pos']) if 'angle' in self.opts and self.opts['angle'] is not None: @@ -124,8 +124,8 @@ class CanvasItem(QtCore.QObject): self.itemScale = QtGui.QGraphicsScale() self._graphicsItem.setTransformations([self.itemRotation, self.itemScale]) - self.tempTransform = pg.SRTTransform() ## holds the additional transform that happens during a move - gets added to the userTransform when move is done. - self.userTransform = pg.SRTTransform() ## stores the total transform of the object + self.tempTransform = SRTTransform() ## holds the additional transform that happens during a move - gets added to the userTransform when move is done. + self.userTransform = SRTTransform() ## stores the total transform of the object self.resetUserTransform() ## now happens inside resetUserTransform -> selectBoxToItem @@ -200,7 +200,7 @@ class CanvasItem(QtCore.QObject): #flip = self.transformGui.mirrorImageCheck.isChecked() #tr = self.userTransform.saveState() - inv = pg.SRTTransform() + inv = SRTTransform() inv.scale(-1, 1) self.userTransform = self.userTransform * inv self.updateTransform() @@ -231,7 +231,7 @@ class CanvasItem(QtCore.QObject): if not self.isMovable(): return self.rotate(180.) - # inv = pg.SRTTransform() + # inv = SRTTransform() # inv.scale(-1, -1) # self.userTransform = self.userTransform * inv #flip lr/ud # s=self.updateTransform() @@ -316,7 +316,7 @@ class CanvasItem(QtCore.QObject): def resetTemporaryTransform(self): - self.tempTransform = pg.SRTTransform() ## don't use Transform.reset()--this transform might be used elsewhere. + self.tempTransform = SRTTransform() ## don't use Transform.reset()--this transform might be used elsewhere. self.updateTransform() def transform(self): @@ -368,7 +368,7 @@ class CanvasItem(QtCore.QObject): try: #self.userTranslate = pg.Point(tr['trans']) #self.userRotate = tr['rot'] - self.userTransform = pg.SRTTransform(tr) + self.userTransform = SRTTransform(tr) self.updateTransform() self.selectBoxFromUser() ## move select box to match @@ -377,7 +377,7 @@ class CanvasItem(QtCore.QObject): except: #self.userTranslate = pg.Point([0,0]) #self.userRotate = 0 - self.userTransform = pg.SRTTransform() + self.userTransform = SRTTransform() debug.printExc("Failed to load transform:") #print "set transform", self, self.userTranslate @@ -431,9 +431,12 @@ class CanvasItem(QtCore.QObject): def selectionChanged(self, sel, multi): """ Inform the item that its selection state has changed. - Arguments: - sel: bool, whether the item is currently selected - multi: bool, whether there are multiple items currently selected + ============== ========================================================= + **Arguments:** + sel (bool) whether the item is currently selected + multi (bool) whether there are multiple items currently + selected + ============== ========================================================= """ self.selectedAlone = sel and not multi self.showSelectBox() @@ -504,6 +507,6 @@ class GroupCanvasItem(CanvasItem): def __init__(self, **opts): defOpts = {'movable': False, 'scalable': False} defOpts.update(opts) - item = pg.ItemGroup() + item = ItemGroup() CanvasItem.__init__(self, item, **defOpts) diff --git a/pyqtgraph/canvas/CanvasManager.py b/pyqtgraph/canvas/CanvasManager.py index e89ec00f..28188039 100644 --- a/pyqtgraph/canvas/CanvasManager.py +++ b/pyqtgraph/canvas/CanvasManager.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui if not hasattr(QtCore, 'Signal'): QtCore.Signal = QtCore.pyqtSignal import weakref diff --git a/pyqtgraph/canvas/CanvasTemplate.ui b/pyqtgraph/canvas/CanvasTemplate.ui index da032906..9bea8f89 100644 --- a/pyqtgraph/canvas/CanvasTemplate.ui +++ b/pyqtgraph/canvas/CanvasTemplate.ui @@ -28,21 +28,7 @@ - - - - Store SVG - - - - - - - Store PNG - - - - + @@ -55,7 +41,7 @@ - + 0 @@ -75,7 +61,7 @@
- + @@ -93,28 +79,28 @@ - + 0 - + Reset Transforms - + Mirror Selection - + MirrorXY @@ -131,12 +117,12 @@ TreeWidget QTreeWidget -
pyqtgraph.widgets.TreeWidget
+
..widgets.TreeWidget
GraphicsView QGraphicsView -
pyqtgraph.widgets.GraphicsView
+
..widgets.GraphicsView
CanvasCombo diff --git a/pyqtgraph/canvas/CanvasTemplate_pyqt.py b/pyqtgraph/canvas/CanvasTemplate_pyqt.py index 4d1d8208..557354e0 100644 --- a/pyqtgraph/canvas/CanvasTemplate_pyqt.py +++ b/pyqtgraph/canvas/CanvasTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './canvas/CanvasTemplate.ui' +# Form implementation generated from reading ui file 'acq4/pyqtgraph/canvas/CanvasTemplate.ui' # -# Created: Sun Sep 9 14:41:30 2012 -# by: PyQt4 UI code generator 4.9.1 +# Created: Thu Jan 2 11:13:07 2014 +# by: PyQt4 UI code generator 4.9 # # WARNING! All changes made in this file will be lost! @@ -32,12 +32,6 @@ class Ui_Form(object): self.gridLayout_2 = QtGui.QGridLayout(self.layoutWidget) self.gridLayout_2.setMargin(0) self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) - self.storeSvgBtn = QtGui.QPushButton(self.layoutWidget) - self.storeSvgBtn.setObjectName(_fromUtf8("storeSvgBtn")) - self.gridLayout_2.addWidget(self.storeSvgBtn, 1, 0, 1, 1) - self.storePngBtn = QtGui.QPushButton(self.layoutWidget) - self.storePngBtn.setObjectName(_fromUtf8("storePngBtn")) - self.gridLayout_2.addWidget(self.storePngBtn, 1, 1, 1, 1) self.autoRangeBtn = QtGui.QPushButton(self.layoutWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) @@ -45,7 +39,7 @@ class Ui_Form(object): sizePolicy.setHeightForWidth(self.autoRangeBtn.sizePolicy().hasHeightForWidth()) self.autoRangeBtn.setSizePolicy(sizePolicy) self.autoRangeBtn.setObjectName(_fromUtf8("autoRangeBtn")) - self.gridLayout_2.addWidget(self.autoRangeBtn, 3, 0, 1, 2) + self.gridLayout_2.addWidget(self.autoRangeBtn, 2, 0, 1, 2) self.horizontalLayout = QtGui.QHBoxLayout() self.horizontalLayout.setSpacing(0) self.horizontalLayout.setObjectName(_fromUtf8("horizontalLayout")) @@ -55,7 +49,7 @@ class Ui_Form(object): self.redirectCombo = CanvasCombo(self.layoutWidget) self.redirectCombo.setObjectName(_fromUtf8("redirectCombo")) self.horizontalLayout.addWidget(self.redirectCombo) - self.gridLayout_2.addLayout(self.horizontalLayout, 6, 0, 1, 2) + self.gridLayout_2.addLayout(self.horizontalLayout, 5, 0, 1, 2) self.itemList = TreeWidget(self.layoutWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) @@ -65,20 +59,20 @@ class Ui_Form(object): self.itemList.setHeaderHidden(True) self.itemList.setObjectName(_fromUtf8("itemList")) self.itemList.headerItem().setText(0, _fromUtf8("1")) - self.gridLayout_2.addWidget(self.itemList, 7, 0, 1, 2) + self.gridLayout_2.addWidget(self.itemList, 6, 0, 1, 2) self.ctrlLayout = QtGui.QGridLayout() self.ctrlLayout.setSpacing(0) self.ctrlLayout.setObjectName(_fromUtf8("ctrlLayout")) - self.gridLayout_2.addLayout(self.ctrlLayout, 11, 0, 1, 2) + self.gridLayout_2.addLayout(self.ctrlLayout, 10, 0, 1, 2) self.resetTransformsBtn = QtGui.QPushButton(self.layoutWidget) self.resetTransformsBtn.setObjectName(_fromUtf8("resetTransformsBtn")) - self.gridLayout_2.addWidget(self.resetTransformsBtn, 8, 0, 1, 1) + self.gridLayout_2.addWidget(self.resetTransformsBtn, 7, 0, 1, 1) self.mirrorSelectionBtn = QtGui.QPushButton(self.layoutWidget) self.mirrorSelectionBtn.setObjectName(_fromUtf8("mirrorSelectionBtn")) - self.gridLayout_2.addWidget(self.mirrorSelectionBtn, 4, 0, 1, 1) + self.gridLayout_2.addWidget(self.mirrorSelectionBtn, 3, 0, 1, 1) self.reflectSelectionBtn = QtGui.QPushButton(self.layoutWidget) self.reflectSelectionBtn.setObjectName(_fromUtf8("reflectSelectionBtn")) - self.gridLayout_2.addWidget(self.reflectSelectionBtn, 4, 1, 1, 1) + self.gridLayout_2.addWidget(self.reflectSelectionBtn, 3, 1, 1, 1) self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) self.retranslateUi(Form) @@ -86,8 +80,6 @@ class Ui_Form(object): def retranslateUi(self, Form): Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) - self.storeSvgBtn.setText(QtGui.QApplication.translate("Form", "Store SVG", None, QtGui.QApplication.UnicodeUTF8)) - self.storePngBtn.setText(QtGui.QApplication.translate("Form", "Store PNG", None, QtGui.QApplication.UnicodeUTF8)) self.autoRangeBtn.setText(QtGui.QApplication.translate("Form", "Auto Range", None, QtGui.QApplication.UnicodeUTF8)) self.redirectCheck.setToolTip(QtGui.QApplication.translate("Form", "Check to display all local items in a remote canvas.", None, QtGui.QApplication.UnicodeUTF8)) self.redirectCheck.setText(QtGui.QApplication.translate("Form", "Redirect", None, QtGui.QApplication.UnicodeUTF8)) @@ -95,6 +87,6 @@ class Ui_Form(object): self.mirrorSelectionBtn.setText(QtGui.QApplication.translate("Form", "Mirror Selection", None, QtGui.QApplication.UnicodeUTF8)) self.reflectSelectionBtn.setText(QtGui.QApplication.translate("Form", "MirrorXY", None, QtGui.QApplication.UnicodeUTF8)) -from pyqtgraph.widgets.GraphicsView import GraphicsView +from ..widgets.TreeWidget import TreeWidget from CanvasManager import CanvasCombo -from pyqtgraph.widgets.TreeWidget import TreeWidget +from ..widgets.GraphicsView import GraphicsView diff --git a/pyqtgraph/canvas/CanvasTemplate_pyside.py b/pyqtgraph/canvas/CanvasTemplate_pyside.py index 12afdf25..56d1ff47 100644 --- a/pyqtgraph/canvas/CanvasTemplate_pyside.py +++ b/pyqtgraph/canvas/CanvasTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './canvas/CanvasTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/canvas/CanvasTemplate.ui' # -# Created: Sun Sep 9 14:41:30 2012 -# by: pyside-uic 0.2.13 running on PySide 1.1.0 +# Created: Mon Dec 23 10:10:52 2013 +# by: pyside-uic 0.2.14 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! @@ -90,6 +90,6 @@ class Ui_Form(object): self.mirrorSelectionBtn.setText(QtGui.QApplication.translate("Form", "Mirror Selection", None, QtGui.QApplication.UnicodeUTF8)) self.reflectSelectionBtn.setText(QtGui.QApplication.translate("Form", "MirrorXY", None, QtGui.QApplication.UnicodeUTF8)) -from pyqtgraph.widgets.GraphicsView import GraphicsView +from ..widgets.TreeWidget import TreeWidget from CanvasManager import CanvasCombo -from pyqtgraph.widgets.TreeWidget import TreeWidget +from ..widgets.GraphicsView import GraphicsView diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyqt.py b/pyqtgraph/canvas/TransformGuiTemplate_pyqt.py index 1fb86d24..75c694c0 100644 --- a/pyqtgraph/canvas/TransformGuiTemplate_pyqt.py +++ b/pyqtgraph/canvas/TransformGuiTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './canvas/TransformGuiTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/canvas/TransformGuiTemplate.ui' # -# Created: Sun Sep 9 14:41:30 2012 -# by: PyQt4 UI code generator 4.9.1 +# Created: Mon Dec 23 10:10:52 2013 +# by: PyQt4 UI code generator 4.10 # # WARNING! All changes made in this file will be lost! @@ -12,7 +12,16 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_Form(object): def setupUi(self, Form): @@ -51,10 +60,10 @@ class Ui_Form(object): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) - self.translateLabel.setText(QtGui.QApplication.translate("Form", "Translate:", None, QtGui.QApplication.UnicodeUTF8)) - self.rotateLabel.setText(QtGui.QApplication.translate("Form", "Rotate:", None, QtGui.QApplication.UnicodeUTF8)) - self.scaleLabel.setText(QtGui.QApplication.translate("Form", "Scale:", None, QtGui.QApplication.UnicodeUTF8)) - self.mirrorImageBtn.setText(QtGui.QApplication.translate("Form", "Mirror", None, QtGui.QApplication.UnicodeUTF8)) - self.reflectImageBtn.setText(QtGui.QApplication.translate("Form", "Reflect", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(_translate("Form", "Form", None)) + self.translateLabel.setText(_translate("Form", "Translate:", None)) + self.rotateLabel.setText(_translate("Form", "Rotate:", None)) + self.scaleLabel.setText(_translate("Form", "Scale:", None)) + self.mirrorImageBtn.setText(_translate("Form", "Mirror", None)) + self.reflectImageBtn.setText(_translate("Form", "Reflect", None)) diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyside.py b/pyqtgraph/canvas/TransformGuiTemplate_pyside.py index 47b23faa..bce7b511 100644 --- a/pyqtgraph/canvas/TransformGuiTemplate_pyside.py +++ b/pyqtgraph/canvas/TransformGuiTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './canvas/TransformGuiTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/canvas/TransformGuiTemplate.ui' # -# Created: Sun Sep 9 14:41:30 2012 -# by: pyside-uic 0.2.13 running on PySide 1.1.0 +# Created: Mon Dec 23 10:10:52 2013 +# by: pyside-uic 0.2.14 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! diff --git a/pyqtgraph/colormap.py b/pyqtgraph/colormap.py index d6169209..c0033708 100644 --- a/pyqtgraph/colormap.py +++ b/pyqtgraph/colormap.py @@ -1,6 +1,5 @@ import numpy as np -import scipy.interpolate -from pyqtgraph.Qt import QtGui, QtCore +from .Qt import QtGui, QtCore class ColorMap(object): """ @@ -52,20 +51,20 @@ class ColorMap(object): def __init__(self, pos, color, mode=None): """ - ========= ============================================================== - Arguments - pos Array of positions where each color is defined - color Array of RGBA colors. - Integer data types are interpreted as 0-255; float data types - are interpreted as 0.0-1.0 - mode Array of color modes (ColorMap.RGB, HSV_POS, or HSV_NEG) - indicating the color space that should be used when - interpolating between stops. Note that the last mode value is - ignored. By default, the mode is entirely RGB. - ========= ============================================================== + =============== ============================================================== + **Arguments:** + pos Array of positions where each color is defined + color Array of RGBA colors. + Integer data types are interpreted as 0-255; float data types + are interpreted as 0.0-1.0 + mode Array of color modes (ColorMap.RGB, HSV_POS, or HSV_NEG) + indicating the color space that should be used when + interpolating between stops. Note that the last mode value is + ignored. By default, the mode is entirely RGB. + =============== ============================================================== """ - self.pos = pos - self.color = color + self.pos = np.array(pos) + self.color = np.array(color) if mode is None: mode = np.ones(len(pos)) self.mode = mode @@ -92,15 +91,24 @@ class ColorMap(object): else: pos, color = self.getStops(mode) - data = np.clip(data, pos.min(), pos.max()) + # don't need this--np.interp takes care of it. + #data = np.clip(data, pos.min(), pos.max()) - if not isinstance(data, np.ndarray): - interp = scipy.interpolate.griddata(pos, color, np.array([data]))[0] + # Interpolate + # TODO: is griddata faster? + # interp = scipy.interpolate.griddata(pos, color, data) + if np.isscalar(data): + interp = np.empty((color.shape[1],), dtype=color.dtype) else: - interp = scipy.interpolate.griddata(pos, color, data) - - if mode == self.QCOLOR: if not isinstance(data, np.ndarray): + data = np.array(data) + interp = np.empty(data.shape + (color.shape[1],), dtype=color.dtype) + for i in range(color.shape[1]): + interp[...,i] = np.interp(data, pos, color[:,i]) + + # Convert to QColor if requested + if mode == self.QCOLOR: + if np.isscalar(data): return QtGui.QColor(*interp) else: return [QtGui.QColor(*x) for x in interp] @@ -193,16 +201,16 @@ class ColorMap(object): """ Return an RGB(A) lookup table (ndarray). - ============= ============================================================================ - **Arguments** - start The starting value in the lookup table (default=0.0) - stop The final value in the lookup table (default=1.0) - nPts The number of points in the returned lookup table. - alpha True, False, or None - Specifies whether or not alpha values are included - in the table. If alpha is None, it will be automatically determined. - mode Determines return type: 'byte' (0-255), 'float' (0.0-1.0), or 'qcolor'. - See :func:`map() `. - ============= ============================================================================ + =============== ============================================================================= + **Arguments:** + start The starting value in the lookup table (default=0.0) + stop The final value in the lookup table (default=1.0) + nPts The number of points in the returned lookup table. + alpha True, False, or None - Specifies whether or not alpha values are included + in the table. If alpha is None, it will be automatically determined. + mode Determines return type: 'byte' (0-255), 'float' (0.0-1.0), or 'qcolor'. + See :func:`map() `. + =============== ============================================================================= """ if isinstance(mode, basestring): mode = self.enumMap[mode.lower()] @@ -236,4 +244,7 @@ class ColorMap(object): else: return np.all(self.color == np.array([[0,0,0,255], [255,255,255,255]])) - + def __repr__(self): + pos = repr(self.pos).replace('\n', '') + color = repr(self.color).replace('\n', '') + return "ColorMap(%s, %s)" % (pos, color) diff --git a/pyqtgraph/configfile.py b/pyqtgraph/configfile.py index f709c786..c095bba3 100644 --- a/pyqtgraph/configfile.py +++ b/pyqtgraph/configfile.py @@ -14,6 +14,10 @@ from .pgcollections import OrderedDict GLOBAL_PATH = None # so not thread safe. from . import units from .python2_3 import asUnicode +from .Qt import QtCore +from .Point import Point +from .colormap import ColorMap +import numpy class ParseError(Exception): def __init__(self, message, lineNum, line, fileName=None): @@ -46,7 +50,7 @@ def readConfigFile(fname): fname2 = os.path.join(GLOBAL_PATH, fname) if os.path.exists(fname2): fname = fname2 - + GLOBAL_PATH = os.path.dirname(os.path.abspath(fname)) try: @@ -135,6 +139,17 @@ def parseString(lines, start=0): local = units.allUnits.copy() local['OrderedDict'] = OrderedDict local['readConfigFile'] = readConfigFile + local['Point'] = Point + local['QtCore'] = QtCore + local['ColorMap'] = ColorMap + # Needed for reconstructing numpy arrays + local['array'] = numpy.array + for dtype in ['int8', 'uint8', + 'int16', 'uint16', 'float16', + 'int32', 'uint32', 'float32', + 'int64', 'uint64', 'float64']: + local[dtype] = getattr(numpy, dtype) + if len(k) < 1: raise ParseError('Missing name preceding colon', ln+1, l) if k[0] == '(' and k[-1] == ')': ## If the key looks like a tuple, try evaluating it. diff --git a/pyqtgraph/console/CmdInput.py b/pyqtgraph/console/CmdInput.py index 3e9730d6..24a01e89 100644 --- a/pyqtgraph/console/CmdInput.py +++ b/pyqtgraph/console/CmdInput.py @@ -1,5 +1,5 @@ -from pyqtgraph.Qt import QtCore, QtGui -from pyqtgraph.python2_3 import asUnicode +from ..Qt import QtCore, QtGui +from ..python2_3 import asUnicode class CmdInput(QtGui.QLineEdit): diff --git a/pyqtgraph/console/Console.py b/pyqtgraph/console/Console.py index 982c2424..896de924 100644 --- a/pyqtgraph/console/Console.py +++ b/pyqtgraph/console/Console.py @@ -1,14 +1,14 @@ -from pyqtgraph.Qt import QtCore, QtGui, USE_PYSIDE +from ..Qt import QtCore, QtGui, USE_PYSIDE import sys, re, os, time, traceback, subprocess -import pyqtgraph as pg if USE_PYSIDE: from . import template_pyside as template else: from . import template_pyqt as template -import pyqtgraph.exceptionHandling as exceptionHandling +from .. import exceptionHandling as exceptionHandling import pickle +from .. import getConfigOption class ConsoleWidget(QtGui.QWidget): """ @@ -31,16 +31,16 @@ class ConsoleWidget(QtGui.QWidget): def __init__(self, parent=None, namespace=None, historyFile=None, text=None, editor=None): """ - ============ ============================================================================ - Arguments: - namespace dictionary containing the initial variables present in the default namespace - historyFile optional file for storing command history - text initial text to display in the console window - editor optional string for invoking code editor (called when stack trace entries are - double-clicked). May contain {fileName} and {lineNum} format keys. Example:: + ============== ============================================================================ + **Arguments:** + namespace dictionary containing the initial variables present in the default namespace + historyFile optional file for storing command history + text initial text to display in the console window + editor optional string for invoking code editor (called when stack trace entries are + double-clicked). May contain {fileName} and {lineNum} format keys. Example:: - editorCommand --loadfile {fileName} --gotoline {lineNum} - ============ ============================================================================= + editorCommand --loadfile {fileName} --gotoline {lineNum} + ============== ============================================================================= """ QtGui.QWidget.__init__(self, parent) if namespace is None: @@ -281,7 +281,7 @@ class ConsoleWidget(QtGui.QWidget): def stackItemDblClicked(self, item): editor = self.editor if editor is None: - editor = pg.getConfigOption('editorCommand') + editor = getConfigOption('editorCommand') if editor is None: return tb = self.currentFrame() @@ -341,6 +341,17 @@ class ConsoleWidget(QtGui.QWidget): filename = tb.tb_frame.f_code.co_filename function = tb.tb_frame.f_code.co_name + filterStr = str(self.ui.filterText.text()) + if filterStr != '': + if isinstance(exc, Exception): + msg = exc.message + elif isinstance(exc, basestring): + msg = exc + else: + msg = repr(exc) + match = re.search(filterStr, "%s:%s:%s" % (filename, function, msg)) + return match is not None + ## Go through a list of common exception points we like to ignore: if excType is GeneratorExit or excType is StopIteration: return False diff --git a/pyqtgraph/console/template.ui b/pyqtgraph/console/template.ui index 6e5c5be3..1a672c5e 100644 --- a/pyqtgraph/console/template.ui +++ b/pyqtgraph/console/template.ui @@ -6,7 +6,7 @@ 0 0 - 710 + 694 497
@@ -89,6 +89,16 @@ 0 + + + + false + + + Clear Exception + + + @@ -109,7 +119,7 @@ - + Only Uncaught Exceptions @@ -119,14 +129,14 @@ - + true - + Run commands in selected stack frame @@ -136,24 +146,14 @@ - + Exception Info - - - - false - - - Clear Exception - - - - + Qt::Horizontal @@ -166,6 +166,16 @@ + + + + Filter (regex): + + + + + +
diff --git a/pyqtgraph/console/template_pyqt.py b/pyqtgraph/console/template_pyqt.py index 89ee6cff..354fb1d6 100644 --- a/pyqtgraph/console/template_pyqt.py +++ b/pyqtgraph/console/template_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './console/template.ui' +# Form implementation generated from reading ui file 'template.ui' # -# Created: Sun Sep 9 14:41:30 2012 -# by: PyQt4 UI code generator 4.9.1 +# Created: Fri May 02 18:55:28 2014 +# by: PyQt4 UI code generator 4.10.4 # # WARNING! All changes made in this file will be lost! @@ -12,12 +12,21 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_Form(object): def setupUi(self, Form): Form.setObjectName(_fromUtf8("Form")) - Form.resize(710, 497) + Form.resize(694, 497) self.gridLayout = QtGui.QGridLayout(Form) self.gridLayout.setMargin(0) self.gridLayout.setSpacing(0) @@ -62,6 +71,10 @@ class Ui_Form(object): self.gridLayout_2.setSpacing(0) self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) + self.clearExceptionBtn = QtGui.QPushButton(self.exceptionGroup) + self.clearExceptionBtn.setEnabled(False) + self.clearExceptionBtn.setObjectName(_fromUtf8("clearExceptionBtn")) + self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) self.catchAllExceptionsBtn = QtGui.QPushButton(self.exceptionGroup) self.catchAllExceptionsBtn.setCheckable(True) self.catchAllExceptionsBtn.setObjectName(_fromUtf8("catchAllExceptionsBtn")) @@ -73,39 +86,42 @@ class Ui_Form(object): self.onlyUncaughtCheck = QtGui.QCheckBox(self.exceptionGroup) self.onlyUncaughtCheck.setChecked(True) self.onlyUncaughtCheck.setObjectName(_fromUtf8("onlyUncaughtCheck")) - self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 2, 1, 1) + self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) self.exceptionStackList = QtGui.QListWidget(self.exceptionGroup) self.exceptionStackList.setAlternatingRowColors(True) self.exceptionStackList.setObjectName(_fromUtf8("exceptionStackList")) - self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 5) + self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 7) self.runSelectedFrameCheck = QtGui.QCheckBox(self.exceptionGroup) self.runSelectedFrameCheck.setChecked(True) self.runSelectedFrameCheck.setObjectName(_fromUtf8("runSelectedFrameCheck")) - self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 5) + self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) self.exceptionInfoLabel = QtGui.QLabel(self.exceptionGroup) self.exceptionInfoLabel.setObjectName(_fromUtf8("exceptionInfoLabel")) - self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 5) - self.clearExceptionBtn = QtGui.QPushButton(self.exceptionGroup) - self.clearExceptionBtn.setEnabled(False) - self.clearExceptionBtn.setObjectName(_fromUtf8("clearExceptionBtn")) - self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 4, 1, 1) + self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) spacerItem = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) - self.gridLayout_2.addItem(spacerItem, 0, 3, 1, 1) + self.gridLayout_2.addItem(spacerItem, 0, 5, 1, 1) + self.label = QtGui.QLabel(self.exceptionGroup) + self.label.setObjectName(_fromUtf8("label")) + self.gridLayout_2.addWidget(self.label, 0, 2, 1, 1) + self.filterText = QtGui.QLineEdit(self.exceptionGroup) + self.filterText.setObjectName(_fromUtf8("filterText")) + self.gridLayout_2.addWidget(self.filterText, 0, 3, 1, 1) self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) self.retranslateUi(Form) QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Console", None, QtGui.QApplication.UnicodeUTF8)) - self.historyBtn.setText(QtGui.QApplication.translate("Form", "History..", None, QtGui.QApplication.UnicodeUTF8)) - self.exceptionBtn.setText(QtGui.QApplication.translate("Form", "Exceptions..", None, QtGui.QApplication.UnicodeUTF8)) - self.exceptionGroup.setTitle(QtGui.QApplication.translate("Form", "Exception Handling", None, QtGui.QApplication.UnicodeUTF8)) - self.catchAllExceptionsBtn.setText(QtGui.QApplication.translate("Form", "Show All Exceptions", None, QtGui.QApplication.UnicodeUTF8)) - self.catchNextExceptionBtn.setText(QtGui.QApplication.translate("Form", "Show Next Exception", None, QtGui.QApplication.UnicodeUTF8)) - self.onlyUncaughtCheck.setText(QtGui.QApplication.translate("Form", "Only Uncaught Exceptions", None, QtGui.QApplication.UnicodeUTF8)) - self.runSelectedFrameCheck.setText(QtGui.QApplication.translate("Form", "Run commands in selected stack frame", None, QtGui.QApplication.UnicodeUTF8)) - self.exceptionInfoLabel.setText(QtGui.QApplication.translate("Form", "Exception Info", None, QtGui.QApplication.UnicodeUTF8)) - self.clearExceptionBtn.setText(QtGui.QApplication.translate("Form", "Clear Exception", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(_translate("Form", "Console", None)) + self.historyBtn.setText(_translate("Form", "History..", None)) + self.exceptionBtn.setText(_translate("Form", "Exceptions..", None)) + self.exceptionGroup.setTitle(_translate("Form", "Exception Handling", None)) + self.clearExceptionBtn.setText(_translate("Form", "Clear Exception", None)) + self.catchAllExceptionsBtn.setText(_translate("Form", "Show All Exceptions", None)) + self.catchNextExceptionBtn.setText(_translate("Form", "Show Next Exception", None)) + self.onlyUncaughtCheck.setText(_translate("Form", "Only Uncaught Exceptions", None)) + self.runSelectedFrameCheck.setText(_translate("Form", "Run commands in selected stack frame", None)) + self.exceptionInfoLabel.setText(_translate("Form", "Exception Info", None)) + self.label.setText(_translate("Form", "Filter (regex):", None)) from .CmdInput import CmdInput diff --git a/pyqtgraph/console/template_pyside.py b/pyqtgraph/console/template_pyside.py index 0493a0fe..2db8ed95 100644 --- a/pyqtgraph/console/template_pyside.py +++ b/pyqtgraph/console/template_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './console/template.ui' +# Form implementation generated from reading ui file './pyqtgraph/console/template.ui' # -# Created: Sun Sep 9 14:41:30 2012 -# by: pyside-uic 0.2.13 running on PySide 1.1.0 +# Created: Mon Dec 23 10:10:53 2013 +# by: pyside-uic 0.2.14 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! diff --git a/pyqtgraph/debug.py b/pyqtgraph/debug.py index a175be9c..57c71bc8 100644 --- a/pyqtgraph/debug.py +++ b/pyqtgraph/debug.py @@ -5,10 +5,14 @@ Copyright 2010 Luke Campagnola Distributed under MIT/X11 license. See license.txt for more infomation. """ -import sys, traceback, time, gc, re, types, weakref, inspect, os, cProfile +from __future__ import print_function + +import sys, traceback, time, gc, re, types, weakref, inspect, os, cProfile, threading from . import ptime from numpy import ndarray from .Qt import QtCore, QtGui +from .util.mutex import Mutex +from .util import cprint __ftraceDepth = 0 def ftrace(func): @@ -28,6 +32,57 @@ def ftrace(func): return rv return w + +class Tracer(object): + """ + Prints every function enter/exit. Useful for debugging crashes / lockups. + """ + def __init__(self): + self.count = 0 + self.stack = [] + + def trace(self, frame, event, arg): + self.count += 1 + # If it has been a long time since we saw the top of the stack, + # print a reminder + if self.count % 1000 == 0: + print("----- current stack: -----") + for line in self.stack: + print(line) + if event == 'call': + line = " " * len(self.stack) + ">> " + self.frameInfo(frame) + print(line) + self.stack.append(line) + elif event == 'return': + self.stack.pop() + line = " " * len(self.stack) + "<< " + self.frameInfo(frame) + print(line) + if len(self.stack) == 0: + self.count = 0 + + return self.trace + + def stop(self): + sys.settrace(None) + + def start(self): + sys.settrace(self.trace) + + def frameInfo(self, fr): + filename = fr.f_code.co_filename + funcname = fr.f_code.co_name + lineno = fr.f_lineno + callfr = sys._getframe(3) + callline = "%s %d" % (callfr.f_code.co_name, callfr.f_lineno) + args, _, _, value_dict = inspect.getargvalues(fr) + if len(args) and args[0] == 'self': + instance = value_dict.get('self', None) + if instance is not None: + cls = getattr(instance, '__class__', None) + if cls is not None: + funcname = cls.__name__ + "." + funcname + return "%s: %s %s: %s" % (callline, filename, lineno, funcname) + def warnOnException(func): """Decorator which catches/ignores exceptions and prints a stack trace.""" def w(*args, **kwds): @@ -37,17 +92,22 @@ def warnOnException(func): printExc('Ignored exception:') return w -def getExc(indent=4, prefix='| '): - tb = traceback.format_exc() - lines = [] - for l in tb.split('\n'): - lines.append(" "*indent + prefix + l) - return '\n'.join(lines) +def getExc(indent=4, prefix='| ', skip=1): + lines = (traceback.format_stack()[:-skip] + + [" ---- exception caught ---->\n"] + + traceback.format_tb(sys.exc_info()[2]) + + traceback.format_exception_only(*sys.exc_info()[:2])) + lines2 = [] + for l in lines: + lines2.extend(l.strip('\n').split('\n')) + lines3 = [" "*indent + prefix + l for l in lines2] + return '\n'.join(lines3) + def printExc(msg='', indent=4, prefix='|'): """Print an error message followed by an indented exception backtrace (This function is intended to be called within except: blocks)""" - exc = getExc(indent, prefix + ' ') + exc = getExc(indent, prefix + ' ', skip=2) print("[%s] %s\n" % (time.strftime("%H:%M:%S"), msg)) print(" "*indent + prefix + '='*30 + '>>') print(exc) @@ -236,7 +296,8 @@ def refPathString(chain): def objectSize(obj, ignore=None, verbose=False, depth=0, recursive=False): """Guess how much memory an object is using""" - ignoreTypes = [types.MethodType, types.UnboundMethodType, types.BuiltinMethodType, types.FunctionType, types.BuiltinFunctionType] + ignoreTypes = ['MethodType', 'UnboundMethodType', 'BuiltinMethodType', 'FunctionType', 'BuiltinFunctionType'] + ignoreTypes = [getattr(types, key) for key in ignoreTypes if hasattr(types, key)] ignoreRegex = re.compile('(method-wrapper|Flag|ItemChange|Option|Mode)') @@ -365,84 +426,130 @@ class GarbageWatcher(object): return self.objs[item] -class Profiler: + + +class Profiler(object): """Simple profiler allowing measurement of multiple time intervals. - Arguments: - msg: message to print at start and finish of profiling - disabled: If true, profiler does nothing (so you can leave it in place) - delayed: If true, all messages are printed after call to finish() - (this can result in more accurate time step measurements) - globalDelay: if True, all nested profilers delay printing until the top level finishes - + + By default, profilers are disabled. To enable profiling, set the + environment variable `PYQTGRAPHPROFILE` to a comma-separated list of + fully-qualified names of profiled functions. + + Calling a profiler registers a message (defaulting to an increasing + counter) that contains the time elapsed since the last call. When the + profiler is about to be garbage-collected, the messages are passed to the + outer profiler if one is running, or printed to stdout otherwise. + + If `delayed` is set to False, messages are immediately printed instead. + Example: - prof = Profiler('Function') - ... do stuff ... - prof.mark('did stuff') - ... do other stuff ... - prof.mark('did other stuff') - prof.finish() + def function(...): + profiler = Profiler() + ... do stuff ... + profiler('did stuff') + ... do other stuff ... + profiler('did other stuff') + # profiler is garbage-collected and flushed at function end + + If this function is a method of class C, setting `PYQTGRAPHPROFILE` to + "C.function" (without the module name) will enable this profiler. + + For regular functions, use the qualified name of the function, stripping + only the initial "pyqtgraph." prefix from the module. """ - depth = 0 - msgs = [] + + _profilers = os.environ.get("PYQTGRAPHPROFILE", None) + _profilers = _profilers.split(",") if _profilers is not None else [] - def __init__(self, msg="Profiler", disabled=False, delayed=True, globalDelay=True): - self.disabled = disabled - if disabled: - return - - self.markCount = 0 - self.finished = False - self.depth = Profiler.depth - Profiler.depth += 1 - if not globalDelay: - self.msgs = [] - self.delayed = delayed - self.msg = " "*self.depth + msg - msg2 = self.msg + " >>> Started" - if self.delayed: - self.msgs.append(msg2) - else: - print(msg2) - self.t0 = ptime.time() - self.t1 = self.t0 + _depth = 0 + _msgs = [] + disable = False # set this flag to disable all or individual profilers at runtime - def mark(self, msg=None): - if self.disabled: - return + class DisabledProfiler(object): + def __init__(self, *args, **kwds): + pass + def __call__(self, *args): + pass + def finish(self): + pass + def mark(self, msg=None): + pass + _disabledProfiler = DisabledProfiler() + def __new__(cls, msg=None, disabled='env', delayed=True): + """Optionally create a new profiler based on caller's qualname. + """ + if disabled is True or (disabled == 'env' and len(cls._profilers) == 0): + return cls._disabledProfiler + + # determine the qualified name of the caller function + caller_frame = sys._getframe(1) + try: + caller_object_type = type(caller_frame.f_locals["self"]) + except KeyError: # we are in a regular function + qualifier = caller_frame.f_globals["__name__"].split(".", 1)[1] + else: # we are in a method + qualifier = caller_object_type.__name__ + func_qualname = qualifier + "." + caller_frame.f_code.co_name + if disabled == 'env' and func_qualname not in cls._profilers: # don't do anything + return cls._disabledProfiler + # create an actual profiling object + cls._depth += 1 + obj = super(Profiler, cls).__new__(cls) + obj._name = msg or func_qualname + obj._delayed = delayed + obj._markCount = 0 + obj._finished = False + obj._firstTime = obj._lastTime = ptime.time() + obj._newMsg("> Entering " + obj._name) + return obj + + def __call__(self, msg=None): + """Register or print a new message with timing information. + """ + if self.disable: + return if msg is None: - msg = str(self.markCount) - self.markCount += 1 + msg = str(self._markCount) + self._markCount += 1 + newTime = ptime.time() + self._newMsg(" %s: %0.4f ms", + msg, (newTime - self._lastTime) * 1000) + self._lastTime = newTime - t1 = ptime.time() - msg2 = " "+self.msg+" "+msg+" "+"%gms" % ((t1-self.t1)*1000) - if self.delayed: - self.msgs.append(msg2) + def mark(self, msg=None): + self(msg) + + def _newMsg(self, msg, *args): + msg = " " * (self._depth - 1) + msg + if self._delayed: + self._msgs.append((msg, args)) else: - print(msg2) - self.t1 = ptime.time() ## don't measure time it took to print - + self.flush() + print(msg % args) + + def __del__(self): + self.finish() + def finish(self, msg=None): - if self.disabled or self.finished: - return - + """Add a final message; flush the message list if no parent profiler. + """ + if self._finished or self.disable: + return + self._finished = True if msg is not None: - self.mark(msg) - t1 = ptime.time() - msg = self.msg + ' <<< Finished, total time: %gms' % ((t1-self.t0)*1000) - if self.delayed: - self.msgs.append(msg) - if self.depth == 0: - for line in self.msgs: - print(line) - Profiler.msgs = [] - else: - print(msg) - Profiler.depth = self.depth - self.finished = True - - + self(msg) + self._newMsg("< Exiting %s, total time: %0.4f ms", + self._name, (ptime.time() - self._firstTime) * 1000) + type(self)._depth -= 1 + if self._depth < 1: + self.flush() + def flush(self): + if self._msgs: + print("\n".join([m[0]%m[1] for m in self._msgs])) + type(self)._msgs = [] + def profile(code, name='profile_run', sort='cumulative', num=30): """Common-use for cProfile""" @@ -573,12 +680,12 @@ class ObjTracker(object): ## Which refs have disappeared since call to start() (these are only displayed once, then forgotten.) delRefs = {} - for i in self.startRefs.keys(): + for i in list(self.startRefs.keys()): if i not in refs: delRefs[i] = self.startRefs[i] del self.startRefs[i] self.forgetRef(delRefs[i]) - for i in self.newRefs.keys(): + for i in list(self.newRefs.keys()): if i not in refs: delRefs[i] = self.newRefs[i] del self.newRefs[i] @@ -616,7 +723,8 @@ class ObjTracker(object): for k in self.startCount: c1[k] = c1.get(k, 0) - self.startCount[k] typs = list(c1.keys()) - typs.sort(lambda a,b: cmp(c1[a], c1[b])) + #typs.sort(lambda a,b: cmp(c1[a], c1[b])) + typs.sort(key=lambda a: c1[a]) for t in typs: if c1[t] == 0: continue @@ -716,7 +824,8 @@ class ObjTracker(object): c = count.get(typ, [0,0]) count[typ] = [c[0]+1, c[1]+objectSize(obj)] typs = list(count.keys()) - typs.sort(lambda a,b: cmp(count[a][1], count[b][1])) + #typs.sort(lambda a,b: cmp(count[a][1], count[b][1])) + typs.sort(key=lambda a: count[a][1]) for t in typs: line = " %d\t%d\t%s" % (count[t][0], count[t][1], t) @@ -776,14 +885,15 @@ def describeObj(obj, depth=4, path=None, ignore=None): def typeStr(obj): """Create a more useful type string by making types report their class.""" typ = type(obj) - if typ == types.InstanceType: + if typ == getattr(types, 'InstanceType', None): return "" % obj.__class__.__name__ else: return str(typ) def searchRefs(obj, *args): """Pseudo-interactive function for tracing references backward. - Arguments: + **Arguments:** + obj: The initial object from which to start searching args: A set of string or int arguments. each integer selects one of obj's referrers to be the new 'obj' @@ -795,7 +905,8 @@ def searchRefs(obj, *args): ro: return obj rr: return list of obj's referrers - Examples: + Examples:: + searchRefs(obj, 't') ## Print types of all objects referring to obj searchRefs(obj, 't', 0, 't') ## ..then select the first referrer and print the types of its referrers searchRefs(obj, 't', 0, 't', 'l') ## ..also print lengths of the last set of referrers @@ -928,6 +1039,7 @@ def qObjectReport(verbose=False): class PrintDetector(object): + """Find code locations that print to stdout.""" def __init__(self): self.stdout = sys.stdout sys.stdout = self @@ -943,4 +1055,115 @@ class PrintDetector(object): traceback.print_stack() def flush(self): - self.stdout.flush() \ No newline at end of file + self.stdout.flush() + + +def listQThreads(): + """Prints Thread IDs (Qt's, not OS's) for all QThreads.""" + thr = findObj('[Tt]hread') + thr = [t for t in thr if isinstance(t, QtCore.QThread)] + import sip + for t in thr: + print("--> ", t) + print(" Qt ID: 0x%x" % sip.unwrapinstance(t)) + + +def pretty(data, indent=''): + """Format nested dict/list/tuple structures into a more human-readable string + This function is a bit better than pprint for displaying OrderedDicts. + """ + ret = "" + ind2 = indent + " " + if isinstance(data, dict): + ret = indent+"{\n" + for k, v in data.iteritems(): + ret += ind2 + repr(k) + ": " + pretty(v, ind2).strip() + "\n" + ret += indent+"}\n" + elif isinstance(data, list) or isinstance(data, tuple): + s = repr(data) + if len(s) < 40: + ret += indent + s + else: + if isinstance(data, list): + d = '[]' + else: + d = '()' + ret = indent+d[0]+"\n" + for i, v in enumerate(data): + ret += ind2 + str(i) + ": " + pretty(v, ind2).strip() + "\n" + ret += indent+d[1]+"\n" + else: + ret += indent + repr(data) + return ret + + +class PeriodicTrace(object): + """ + Used to debug freezing by starting a new thread that reports on the + location of the main thread periodically. + """ + class ReportThread(QtCore.QThread): + def __init__(self): + self.frame = None + self.ind = 0 + self.lastInd = None + self.lock = Mutex() + QtCore.QThread.__init__(self) + + def notify(self, frame): + with self.lock: + self.frame = frame + self.ind += 1 + + def run(self): + while True: + time.sleep(1) + with self.lock: + if self.lastInd != self.ind: + print("== Trace %d: ==" % self.ind) + traceback.print_stack(self.frame) + self.lastInd = self.ind + + def __init__(self): + self.mainThread = threading.current_thread() + self.thread = PeriodicTrace.ReportThread() + self.thread.start() + sys.settrace(self.trace) + + def trace(self, frame, event, arg): + if threading.current_thread() is self.mainThread: # and 'threading' not in frame.f_code.co_filename: + self.thread.notify(frame) + # print("== Trace ==", event, arg) + # traceback.print_stack(frame) + return self.trace + + + +class ThreadColor(object): + """ + Wrapper on stdout/stderr that colors text by the current thread ID. + + *stream* must be 'stdout' or 'stderr'. + """ + colors = {} + lock = Mutex() + + def __init__(self, stream): + self.stream = getattr(sys, stream) + self.err = stream == 'stderr' + setattr(sys, stream, self) + + def write(self, msg): + with self.lock: + cprint.cprint(self.stream, self.color(), msg, -1, stderr=self.err) + + def flush(self): + with self.lock: + self.stream.flush() + + def color(self): + tid = threading.current_thread() + if tid not in self.colors: + c = (len(self.colors) % 15) + 1 + self.colors[tid] = c + return self.colors[tid] diff --git a/pyqtgraph/dockarea/Container.py b/pyqtgraph/dockarea/Container.py index 83610937..c3225edf 100644 --- a/pyqtgraph/dockarea/Container.py +++ b/pyqtgraph/dockarea/Container.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui import weakref class Container(object): @@ -22,6 +22,9 @@ class Container(object): return None def insert(self, new, pos=None, neighbor=None): + # remove from existing parent first + new.setParent(None) + if not isinstance(new, list): new = [new] if neighbor is None: @@ -241,6 +244,13 @@ class TContainer(Container, QtGui.QWidget): else: w.label.setDim(True) + def raiseDock(self, dock): + """Move *dock* to the top of the stack""" + self.stack.currentWidget().label.setDim(True) + self.stack.setCurrentWidget(dock) + dock.label.setDim(False) + + def type(self): return 'tab' diff --git a/pyqtgraph/dockarea/Dock.py b/pyqtgraph/dockarea/Dock.py index 414980ac..28d4244b 100644 --- a/pyqtgraph/dockarea/Dock.py +++ b/pyqtgraph/dockarea/Dock.py @@ -1,17 +1,20 @@ -from pyqtgraph.Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui from .DockDrop import * -from pyqtgraph.widgets.VerticalLabel import VerticalLabel +from ..widgets.VerticalLabel import VerticalLabel +from ..python2_3 import asUnicode class Dock(QtGui.QWidget, DockDrop): sigStretchChanged = QtCore.Signal() - def __init__(self, name, area=None, size=(10, 10), widget=None, hideTitle=False, autoOrientation=True): + def __init__(self, name, area=None, size=(10, 10), widget=None, hideTitle=False, autoOrientation=True, closable=False): QtGui.QWidget.__init__(self) DockDrop.__init__(self) self.area = area - self.label = DockLabel(name, self) + self.label = DockLabel(name, self, closable) + if closable: + self.label.sigCloseClicked.connect(self.close) self.labelHidden = False self.moveLabel = True ## If false, the dock is no longer allowed to move the label. self.autoOrient = autoOrientation @@ -34,30 +37,30 @@ class Dock(QtGui.QWidget, DockDrop): #self.titlePos = 'top' self.raiseOverlay() self.hStyle = """ - Dock > QWidget { - border: 1px solid #000; - border-radius: 5px; - border-top-left-radius: 0px; - border-top-right-radius: 0px; + Dock > QWidget { + border: 1px solid #000; + border-radius: 5px; + border-top-left-radius: 0px; + border-top-right-radius: 0px; border-top-width: 0px; }""" self.vStyle = """ - Dock > QWidget { - border: 1px solid #000; - border-radius: 5px; - border-top-left-radius: 0px; - border-bottom-left-radius: 0px; + Dock > QWidget { + border: 1px solid #000; + border-radius: 5px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; border-left-width: 0px; }""" self.nStyle = """ - Dock > QWidget { - border: 1px solid #000; - border-radius: 5px; + Dock > QWidget { + border: 1px solid #000; + border-radius: 5px; }""" self.dragStyle = """ - Dock > QWidget { - border: 4px solid #00F; - border-radius: 5px; + Dock > QWidget { + border: 4px solid #00F; + border-radius: 5px; }""" self.setAutoFillBackground(False) self.widgetArea.setStyleSheet(self.hStyle) @@ -78,7 +81,7 @@ class Dock(QtGui.QWidget, DockDrop): def setStretch(self, x=None, y=None): """ - Set the 'target' size for this Dock. + Set the 'target' size for this Dock. The actual size will be determined by comparing this Dock's stretch value to the rest of the docks it shares space with. """ @@ -129,7 +132,7 @@ class Dock(QtGui.QWidget, DockDrop): Sets the orientation of the title bar for this Dock. Must be one of 'auto', 'horizontal', or 'vertical'. By default ('auto'), the orientation is determined - based on the aspect ratio of the Dock. + based on the aspect ratio of the Dock. """ #print self.name(), "setOrientation", o, force if o == 'auto' and self.autoOrient: @@ -167,14 +170,14 @@ class Dock(QtGui.QWidget, DockDrop): self.resizeOverlay(self.size()) def name(self): - return str(self.label.text()) + return asUnicode(self.label.text()) def container(self): return self._container def addWidget(self, widget, row=None, col=0, rowspan=1, colspan=1): """ - Add a new widget to the interior of this Dock. + Add a new widget to the interior of this Dock. Each Dock uses a QGridLayout to arrange widgets within. """ if row is None: @@ -208,6 +211,11 @@ class Dock(QtGui.QWidget, DockDrop): self.moveLabel = False self.setOrientation(force=True) + + def raiseDock(self): + """If this Dock is stacked underneath others, raise it to the top.""" + self.container().raiseDock(self) + def close(self): """Remove this dock from the DockArea it lives inside.""" @@ -233,11 +241,13 @@ class Dock(QtGui.QWidget, DockDrop): def dropEvent(self, *args): DockDrop.dropEvent(self, *args) + class DockLabel(VerticalLabel): sigClicked = QtCore.Signal(object, object) + sigCloseClicked = QtCore.Signal() - def __init__(self, text, dock): + def __init__(self, text, dock, showCloseButton): self.dim = False self.fixedWidth = False VerticalLabel.__init__(self, text, orientation='horizontal', forceWidth=False) @@ -245,10 +255,13 @@ class DockLabel(VerticalLabel): self.dock = dock self.updateStyle() self.setAutoFillBackground(False) + self.startedDrag = False - #def minimumSizeHint(self): - ##sh = QtGui.QWidget.minimumSizeHint(self) - #return QtCore.QSize(20, 20) + self.closeButton = None + if showCloseButton: + self.closeButton = QtGui.QToolButton(self) + self.closeButton.clicked.connect(self.sigCloseClicked) + self.closeButton.setIcon(QtGui.QApplication.style().standardIcon(QtGui.QStyle.SP_TitleBarCloseButton)) def updateStyle(self): r = '3px' @@ -262,28 +275,28 @@ class DockLabel(VerticalLabel): border = '#55B' if self.orientation == 'vertical': - self.vStyle = """DockLabel { - background-color : %s; - color : %s; - border-top-right-radius: 0px; - border-top-left-radius: %s; - border-bottom-right-radius: 0px; - border-bottom-left-radius: %s; - border-width: 0px; + self.vStyle = """DockLabel { + background-color : %s; + color : %s; + border-top-right-radius: 0px; + border-top-left-radius: %s; + border-bottom-right-radius: 0px; + border-bottom-left-radius: %s; + border-width: 0px; border-right: 2px solid %s; padding-top: 3px; padding-bottom: 3px; }""" % (bg, fg, r, r, border) self.setStyleSheet(self.vStyle) else: - self.hStyle = """DockLabel { - background-color : %s; - color : %s; - border-top-right-radius: %s; - border-top-left-radius: %s; - border-bottom-right-radius: 0px; - border-bottom-left-radius: 0px; - border-width: 0px; + self.hStyle = """DockLabel { + background-color : %s; + color : %s; + border-top-right-radius: %s; + border-top-left-radius: %s; + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; + border-width: 0px; border-bottom: 2px solid %s; padding-left: 3px; padding-right: 3px; @@ -309,11 +322,9 @@ class DockLabel(VerticalLabel): if not self.startedDrag and (ev.pos() - self.pressPos).manhattanLength() > QtGui.QApplication.startDragDistance(): self.dock.startDrag() ev.accept() - #print ev.pos() def mouseReleaseEvent(self, ev): if not self.startedDrag: - #self.emit(QtCore.SIGNAL('clicked'), self, ev) self.sigClicked.emit(self, ev) ev.accept() @@ -321,13 +332,14 @@ class DockLabel(VerticalLabel): if ev.button() == QtCore.Qt.LeftButton: self.dock.float() - #def paintEvent(self, ev): - #p = QtGui.QPainter(self) - ##p.setBrush(QtGui.QBrush(QtGui.QColor(100, 100, 200))) - #p.setPen(QtGui.QPen(QtGui.QColor(50, 50, 100))) - #p.drawRect(self.rect().adjusted(0, 0, -1, -1)) - - #VerticalLabel.paintEvent(self, ev) - - - + def resizeEvent (self, ev): + if self.closeButton: + if self.orientation == 'vertical': + size = ev.size().width() + pos = QtCore.QPoint(0, 0) + else: + size = ev.size().height() + pos = QtCore.QPoint(ev.size().width() - size, 0) + self.closeButton.setFixedSize(QtCore.QSize(size, size)) + self.closeButton.move(pos) + super(DockLabel,self).resizeEvent(ev) diff --git a/pyqtgraph/dockarea/DockArea.py b/pyqtgraph/dockarea/DockArea.py index 882b29a3..a75d881d 100644 --- a/pyqtgraph/dockarea/DockArea.py +++ b/pyqtgraph/dockarea/DockArea.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui from .Container import * from .DockDrop import * from .Dock import Dock -import pyqtgraph.debug as debug +from .. import debug as debug import weakref ## TODO: @@ -36,16 +36,16 @@ class DockArea(Container, QtGui.QWidget, DockDrop): def addDock(self, dock=None, position='bottom', relativeTo=None, **kwds): """Adds a dock to this area. - =========== ================================================================= - Arguments: - dock The new Dock object to add. If None, then a new Dock will be - created. - position 'bottom', 'top', 'left', 'right', 'above', or 'below' - relativeTo If relativeTo is None, then the new Dock is added to fill an - entire edge of the window. If relativeTo is another Dock, then - the new Dock is placed adjacent to it (or in a tabbed - configuration for 'above' and 'below'). - =========== ================================================================= + ============== ================================================================= + **Arguments:** + dock The new Dock object to add. If None, then a new Dock will be + created. + position 'bottom', 'top', 'left', 'right', 'above', or 'below' + relativeTo If relativeTo is None, then the new Dock is added to fill an + entire edge of the window. If relativeTo is another Dock, then + the new Dock is placed adjacent to it (or in a tabbed + configuration for 'above' and 'below'). + ============== ================================================================= All extra keyword arguments are passed to Dock.__init__() if *dock* is None. diff --git a/pyqtgraph/dockarea/DockDrop.py b/pyqtgraph/dockarea/DockDrop.py index acab28cd..bd364f50 100644 --- a/pyqtgraph/dockarea/DockDrop.py +++ b/pyqtgraph/dockarea/DockDrop.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui class DockDrop(object): """Provides dock-dropping methods""" diff --git a/pyqtgraph/dockarea/tests/test_dock.py b/pyqtgraph/dockarea/tests/test_dock.py new file mode 100644 index 00000000..949f3f0e --- /dev/null +++ b/pyqtgraph/dockarea/tests/test_dock.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +#import sip +#sip.setapi('QString', 1) + +import pyqtgraph as pg +pg.mkQApp() + +import pyqtgraph.dockarea as da + +def test_dock(): + name = pg.asUnicode("évènts_zàhéér") + dock = da.Dock(name=name) + # make sure unicode names work correctly + assert dock.name() == name + # no surprises in return type. + assert type(dock.name()) == type(name) diff --git a/pyqtgraph/exceptionHandling.py b/pyqtgraph/exceptionHandling.py index daa821b7..3182b7eb 100644 --- a/pyqtgraph/exceptionHandling.py +++ b/pyqtgraph/exceptionHandling.py @@ -49,29 +49,45 @@ def setTracebackClearing(clear=True): class ExceptionHandler(object): def __call__(self, *args): - ## call original exception handler first (prints exception) - global original_excepthook, callbacks, clear_tracebacks - print("===== %s =====" % str(time.strftime("%Y.%m.%d %H:%m:%S", time.localtime(time.time())))) - ret = original_excepthook(*args) + ## Start by extending recursion depth just a bit. + ## If the error we are catching is due to recursion, we don't want to generate another one here. + recursionLimit = sys.getrecursionlimit() + try: + sys.setrecursionlimit(recursionLimit+100) - for cb in callbacks: + + ## call original exception handler first (prints exception) + global original_excepthook, callbacks, clear_tracebacks try: - cb(*args) - except: - print(" --------------------------------------------------------------") - print(" Error occurred during exception callback %s" % str(cb)) - print(" --------------------------------------------------------------") - traceback.print_exception(*sys.exc_info()) - - - ## Clear long-term storage of last traceback to prevent memory-hogging. - ## (If an exception occurs while a lot of data is present on the stack, - ## such as when loading large files, the data would ordinarily be kept - ## until the next exception occurs. We would rather release this memory - ## as soon as possible.) - if clear_tracebacks is True: - sys.last_traceback = None + print("===== %s =====" % str(time.strftime("%Y.%m.%d %H:%m:%S", time.localtime(time.time())))) + except Exception: + sys.stderr.write("Warning: stdout is broken! Falling back to stderr.\n") + sys.stdout = sys.stderr + ret = original_excepthook(*args) + + for cb in callbacks: + try: + cb(*args) + except Exception: + print(" --------------------------------------------------------------") + print(" Error occurred during exception callback %s" % str(cb)) + print(" --------------------------------------------------------------") + traceback.print_exception(*sys.exc_info()) + + + ## Clear long-term storage of last traceback to prevent memory-hogging. + ## (If an exception occurs while a lot of data is present on the stack, + ## such as when loading large files, the data would ordinarily be kept + ## until the next exception occurs. We would rather release this memory + ## as soon as possible.) + if clear_tracebacks is True: + sys.last_traceback = None + + finally: + sys.setrecursionlimit(recursionLimit) + + def implements(self, interface=None): ## this just makes it easy for us to detect whether an ExceptionHook is already installed. if interface is None: diff --git a/pyqtgraph/exporters/CSVExporter.py b/pyqtgraph/exporters/CSVExporter.py index 0439fc35..b87f0182 100644 --- a/pyqtgraph/exporters/CSVExporter.py +++ b/pyqtgraph/exporters/CSVExporter.py @@ -1,8 +1,7 @@ -import pyqtgraph as pg -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .Exporter import Exporter -from pyqtgraph.parametertree import Parameter - +from ..parametertree import Parameter +from .. import PlotItem __all__ = ['CSVExporter'] @@ -15,6 +14,7 @@ class CSVExporter(Exporter): self.params = Parameter(name='params', type='group', children=[ {'name': 'separator', 'type': 'list', 'value': 'comma', 'values': ['comma', 'tab']}, {'name': 'precision', 'type': 'int', 'value': 10, 'limits': [0, None]}, + {'name': 'columnMode', 'type': 'list', 'values': ['(x,y) per plot', '(x,y,y,y) for all plots']} ]) def parameters(self): @@ -22,7 +22,7 @@ class CSVExporter(Exporter): def export(self, fileName=None): - if not isinstance(self.item, pg.PlotItem): + if not isinstance(self.item, PlotItem): raise Exception("Must have a PlotItem selected for CSV export.") if fileName is None: @@ -32,9 +32,24 @@ class CSVExporter(Exporter): fd = open(fileName, 'w') data = [] header = [] - for c in self.item.curves: - data.append(c.getData()) - header.extend(['x', 'y']) + + appendAllX = self.params['columnMode'] == '(x,y) per plot' + + for i, c in enumerate(self.item.curves): + cd = c.getData() + if cd[0] is None: + continue + data.append(cd) + if hasattr(c, 'implements') and c.implements('plotData') and c.name() is not None: + name = c.name().replace('"', '""') + '_' + xName, yName = '"'+name+'x"', '"'+name+'y"' + else: + xName = 'x%04d' % i + yName = 'y%04d' % i + if appendAllX or i == 0: + header.extend([xName, yName]) + else: + header.extend([yName]) if self.params['separator'] == 'comma': sep = ',' @@ -44,16 +59,25 @@ class CSVExporter(Exporter): fd.write(sep.join(header) + '\n') i = 0 numFormat = '%%0.%dg' % self.params['precision'] - numRows = reduce(max, [len(d[0]) for d in data]) + numRows = max([len(d[0]) for d in data]) for i in range(numRows): - for d in data: - if i < len(d[0]): - fd.write(numFormat % d[0][i] + sep + numFormat % d[1][i] + sep) + for j, d in enumerate(data): + # write x value if this is the first column, or if we want x + # for all rows + if appendAllX or j == 0: + if d is not None and i < len(d[0]): + fd.write(numFormat % d[0][i] + sep) + else: + fd.write(' %s' % sep) + + # write y value + if d is not None and i < len(d[1]): + fd.write(numFormat % d[1][i] + sep) else: - fd.write(' %s %s' % (sep, sep)) + fd.write(' %s' % sep) fd.write('\n') fd.close() - +CSVExporter.register() diff --git a/pyqtgraph/exporters/Exporter.py b/pyqtgraph/exporters/Exporter.py index 6371a3b9..64a25294 100644 --- a/pyqtgraph/exporters/Exporter.py +++ b/pyqtgraph/exporters/Exporter.py @@ -1,7 +1,7 @@ -from pyqtgraph.widgets.FileDialog import FileDialog -import pyqtgraph as pg -from pyqtgraph.Qt import QtGui, QtCore, QtSvg -from pyqtgraph.python2_3 import asUnicode +from ..widgets.FileDialog import FileDialog +from ..Qt import QtGui, QtCore, QtSvg +from ..python2_3 import asUnicode +from ..GraphicsScene import GraphicsScene import os, re LastExportDirectory = None @@ -11,6 +11,14 @@ class Exporter(object): Abstract class used for exporting graphics to file / printer / whatever. """ allowCopy = False # subclasses set this to True if they can use the copy buffer + Exporters = [] + + @classmethod + def register(cls): + """ + Used to register Exporter classes to appear in the export dialog. + """ + Exporter.Exporters.append(cls) def __init__(self, item): """ @@ -20,9 +28,6 @@ class Exporter(object): object.__init__(self) self.item = item - #def item(self): - #return self.item - def parameters(self): """Return the parameters used to configure this exporter.""" raise Exception("Abstract method must be overridden in subclass.") @@ -72,20 +77,20 @@ class Exporter(object): self.export(fileName=fileName, **self.fileDialog.opts) def getScene(self): - if isinstance(self.item, pg.GraphicsScene): + if isinstance(self.item, GraphicsScene): return self.item else: return self.item.scene() def getSourceRect(self): - if isinstance(self.item, pg.GraphicsScene): + if isinstance(self.item, GraphicsScene): w = self.item.getViewWidget() return w.viewportTransform().inverted()[0].mapRect(w.rect()) else: return self.item.sceneBoundingRect() def getTargetRect(self): - if isinstance(self.item, pg.GraphicsScene): + if isinstance(self.item, GraphicsScene): return self.item.getViewWidget().rect() else: return self.item.mapRectToDevice(self.item.boundingRect()) @@ -131,45 +136,4 @@ class Exporter(object): return preItems + rootItem + postItems def render(self, painter, targetRect, sourceRect, item=None): - - #if item is None: - #item = self.item - #preItems = [] - #postItems = [] - #if isinstance(item, QtGui.QGraphicsScene): - #childs = [i for i in item.items() if i.parentItem() is None] - #rootItem = [] - #else: - #childs = item.childItems() - #rootItem = [item] - #childs.sort(lambda a,b: cmp(a.zValue(), b.zValue())) - #while len(childs) > 0: - #ch = childs.pop(0) - #if int(ch.flags() & ch.ItemStacksBehindParent) > 0 or (ch.zValue() < 0 and int(ch.flags() & ch.ItemNegativeZStacksBehindParent) > 0): - #preItems.extend(tree) - #else: - #postItems.extend(tree) - - #for ch in preItems: - #self.render(painter, sourceRect, targetRect, item=ch) - ### paint root here - #for ch in postItems: - #self.render(painter, sourceRect, targetRect, item=ch) - - self.getScene().render(painter, QtCore.QRectF(targetRect), QtCore.QRectF(sourceRect)) - - #def writePs(self, fileName=None, item=None): - #if fileName is None: - #self.fileSaveDialog(self.writeSvg, filter="PostScript (*.ps)") - #return - #if item is None: - #item = self - #printer = QtGui.QPrinter(QtGui.QPrinter.HighResolution) - #printer.setOutputFileName(fileName) - #painter = QtGui.QPainter(printer) - #self.render(painter) - #painter.end() - - #def writeToPrinter(self): - #pass diff --git a/pyqtgraph/exporters/HDF5Exporter.py b/pyqtgraph/exporters/HDF5Exporter.py new file mode 100644 index 00000000..cc8b5733 --- /dev/null +++ b/pyqtgraph/exporters/HDF5Exporter.py @@ -0,0 +1,58 @@ +from ..Qt import QtGui, QtCore +from .Exporter import Exporter +from ..parametertree import Parameter +from .. import PlotItem + +import numpy +try: + import h5py + HAVE_HDF5 = True +except ImportError: + HAVE_HDF5 = False + +__all__ = ['HDF5Exporter'] + + +class HDF5Exporter(Exporter): + Name = "HDF5 Export: plot (x,y)" + windows = [] + allowCopy = False + + def __init__(self, item): + Exporter.__init__(self, item) + self.params = Parameter(name='params', type='group', children=[ + {'name': 'Name', 'type': 'str', 'value': 'Export',}, + {'name': 'columnMode', 'type': 'list', 'values': ['(x,y) per plot', '(x,y,y,y) for all plots']}, + ]) + + def parameters(self): + return self.params + + def export(self, fileName=None): + if not HAVE_HDF5: + raise RuntimeError("This exporter requires the h5py package, " + "but it was not importable.") + + if not isinstance(self.item, PlotItem): + raise Exception("Must have a PlotItem selected for HDF5 export.") + + if fileName is None: + self.fileSaveDialog(filter=["*.h5", "*.hdf", "*.hd5"]) + return + dsname = self.params['Name'] + fd = h5py.File(fileName, 'a') # forces append to file... 'w' doesn't seem to "delete/overwrite" + data = [] + + appendAllX = self.params['columnMode'] == '(x,y) per plot' + for i,c in enumerate(self.item.curves): + d = c.getData() + if appendAllX or i == 0: + data.append(d[0]) + data.append(d[1]) + + fdata = numpy.array(data).astype('double') + dset = fd.create_dataset(dsname, data=fdata) + fd.close() + +if HAVE_HDF5: + HDF5Exporter.register() diff --git a/pyqtgraph/exporters/ImageExporter.py b/pyqtgraph/exporters/ImageExporter.py index 9fb77e2a..78d93106 100644 --- a/pyqtgraph/exporters/ImageExporter.py +++ b/pyqtgraph/exporters/ImageExporter.py @@ -1,7 +1,7 @@ from .Exporter import Exporter -from pyqtgraph.parametertree import Parameter -from pyqtgraph.Qt import QtGui, QtCore, QtSvg, USE_PYSIDE -import pyqtgraph as pg +from ..parametertree import Parameter +from ..Qt import QtGui, QtCore, QtSvg, USE_PYSIDE +from .. import functions as fn import numpy as np __all__ = ['ImageExporter'] @@ -73,7 +73,7 @@ class ImageExporter(Exporter): bg[:,:,1] = color.green() bg[:,:,2] = color.red() bg[:,:,3] = color.alpha() - self.png = pg.makeQImage(bg, alpha=True) + self.png = fn.makeQImage(bg, alpha=True) ## set resolution of image: origTargetRect = self.getTargetRect() @@ -98,4 +98,5 @@ class ImageExporter(Exporter): else: self.png.save(fileName) - \ No newline at end of file +ImageExporter.register() + diff --git a/pyqtgraph/exporters/Matplotlib.py b/pyqtgraph/exporters/Matplotlib.py index 76f878d2..8cec1cef 100644 --- a/pyqtgraph/exporters/Matplotlib.py +++ b/pyqtgraph/exporters/Matplotlib.py @@ -1,10 +1,32 @@ -import pyqtgraph as pg -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .Exporter import Exporter - +from .. import PlotItem +from .. import functions as fn __all__ = ['MatplotlibExporter'] - + +""" +It is helpful when using the matplotlib Exporter if your +.matplotlib/matplotlibrc file is configured appropriately. +The following are suggested for getting usable PDF output that +can be edited in Illustrator, etc. + +backend : Qt4Agg +text.usetex : True # Assumes you have a findable LaTeX installation +interactive : False +font.family : sans-serif +font.sans-serif : 'Arial' # (make first in list) +mathtext.default : sf +figure.facecolor : white # personal preference +# next setting allows pdf font to be readable in Adobe Illustrator +pdf.fonttype : 42 # set fonts to TrueType (otherwise it will be 3 + # and the text will be vectorized. +text.dvipnghack : True # primarily to clean up font appearance on Mac + +The advantage is that there is less to do to get an exported file cleaned and ready for +publication. Fonts are not vectorized (outlined), and window colors are white. + +""" class MatplotlibExporter(Exporter): Name = "Matplotlib Window" @@ -14,56 +36,86 @@ class MatplotlibExporter(Exporter): def parameters(self): return None + + def cleanAxes(self, axl): + if type(axl) is not list: + axl = [axl] + for ax in axl: + if ax is None: + continue + for loc, spine in ax.spines.iteritems(): + if loc in ['left', 'bottom']: + pass + elif loc in ['right', 'top']: + spine.set_color('none') + # do not draw the spine + else: + raise ValueError('Unknown spine location: %s' % loc) + # turn off ticks when there is no spine + ax.xaxis.set_ticks_position('bottom') def export(self, fileName=None): - if isinstance(self.item, pg.PlotItem): + if isinstance(self.item, PlotItem): mpw = MatplotlibWindow() MatplotlibExporter.windows.append(mpw) + + stdFont = 'Arial' + fig = mpw.getFigure() - ax = fig.add_subplot(111) + # get labels from the graphic item + xlabel = self.item.axes['bottom']['item'].label.toPlainText() + ylabel = self.item.axes['left']['item'].label.toPlainText() + title = self.item.titleLabel.text + + ax = fig.add_subplot(111, title=title) ax.clear() + self.cleanAxes(ax) #ax.grid(True) - for item in self.item.curves: x, y = item.getData() opts = item.opts - pen = pg.mkPen(opts['pen']) + pen = fn.mkPen(opts['pen']) if pen.style() == QtCore.Qt.NoPen: linestyle = '' else: linestyle = '-' - color = tuple([c/255. for c in pg.colorTuple(pen.color())]) + color = tuple([c/255. for c in fn.colorTuple(pen.color())]) symbol = opts['symbol'] if symbol == 't': symbol = '^' - symbolPen = pg.mkPen(opts['symbolPen']) - symbolBrush = pg.mkBrush(opts['symbolBrush']) - markeredgecolor = tuple([c/255. for c in pg.colorTuple(symbolPen.color())]) - markerfacecolor = tuple([c/255. for c in pg.colorTuple(symbolBrush.color())]) + symbolPen = fn.mkPen(opts['symbolPen']) + symbolBrush = fn.mkBrush(opts['symbolBrush']) + markeredgecolor = tuple([c/255. for c in fn.colorTuple(symbolPen.color())]) + markerfacecolor = tuple([c/255. for c in fn.colorTuple(symbolBrush.color())]) + markersize = opts['symbolSize'] if opts['fillLevel'] is not None and opts['fillBrush'] is not None: - fillBrush = pg.mkBrush(opts['fillBrush']) - fillcolor = tuple([c/255. for c in pg.colorTuple(fillBrush.color())]) + fillBrush = fn.mkBrush(opts['fillBrush']) + fillcolor = tuple([c/255. for c in fn.colorTuple(fillBrush.color())]) ax.fill_between(x=x, y1=y, y2=opts['fillLevel'], facecolor=fillcolor) - ax.plot(x, y, marker=symbol, color=color, linewidth=pen.width(), linestyle=linestyle, markeredgecolor=markeredgecolor, markerfacecolor=markerfacecolor) - + pl = ax.plot(x, y, marker=symbol, color=color, linewidth=pen.width(), + linestyle=linestyle, markeredgecolor=markeredgecolor, markerfacecolor=markerfacecolor, + markersize=markersize) xr, yr = self.item.viewRange() ax.set_xbound(*xr) ax.set_ybound(*yr) + ax.set_xlabel(xlabel) # place the labels. + ax.set_ylabel(ylabel) mpw.draw() else: raise Exception("Matplotlib export currently only works with plot items") +MatplotlibExporter.register() class MatplotlibWindow(QtGui.QMainWindow): def __init__(self): - import pyqtgraph.widgets.MatplotlibWidget + from ..widgets import MatplotlibWidget QtGui.QMainWindow.__init__(self) - self.mpl = pyqtgraph.widgets.MatplotlibWidget.MatplotlibWidget() + self.mpl = MatplotlibWidget.MatplotlibWidget() self.setCentralWidget(self.mpl) self.show() @@ -72,3 +124,5 @@ class MatplotlibWindow(QtGui.QMainWindow): def closeEvent(self, ev): MatplotlibExporter.windows.remove(self) + + diff --git a/pyqtgraph/exporters/PrintExporter.py b/pyqtgraph/exporters/PrintExporter.py index 5b31b45d..530a1800 100644 --- a/pyqtgraph/exporters/PrintExporter.py +++ b/pyqtgraph/exporters/PrintExporter.py @@ -1,6 +1,6 @@ from .Exporter import Exporter -from pyqtgraph.parametertree import Parameter -from pyqtgraph.Qt import QtGui, QtCore, QtSvg +from ..parametertree import Parameter +from ..Qt import QtGui, QtCore, QtSvg import re __all__ = ['PrintExporter'] @@ -36,7 +36,7 @@ class PrintExporter(Exporter): dialog = QtGui.QPrintDialog(printer) dialog.setWindowTitle("Print Document") if dialog.exec_() != QtGui.QDialog.Accepted: - return; + return #dpi = QtGui.QDesktopWidget().physicalDpiX() @@ -63,3 +63,6 @@ class PrintExporter(Exporter): finally: self.setExportMode(False) painter.end() + + +#PrintExporter.register() diff --git a/pyqtgraph/exporters/SVGExporter.py b/pyqtgraph/exporters/SVGExporter.py index 62b49d30..a91466c8 100644 --- a/pyqtgraph/exporters/SVGExporter.py +++ b/pyqtgraph/exporters/SVGExporter.py @@ -1,8 +1,9 @@ from .Exporter import Exporter -from pyqtgraph.python2_3 import asUnicode -from pyqtgraph.parametertree import Parameter -from pyqtgraph.Qt import QtGui, QtCore, QtSvg -import pyqtgraph as pg +from ..python2_3 import asUnicode +from ..parametertree import Parameter +from ..Qt import QtGui, QtCore, QtSvg, USE_PYSIDE +from .. import debug +from .. import functions as fn import re import xml.dom.minidom as xml import numpy as np @@ -101,14 +102,12 @@ xmlHeader = """\ pyqtgraph SVG export Generated with Qt and pyqtgraph - - """ def generateSvg(item): global xmlHeader try: - node = _generateItemSvg(item) + node, defs = _generateItemSvg(item) finally: ## reset export mode for all items in the tree if isinstance(item, QtGui.QGraphicsScene): @@ -123,7 +122,11 @@ def generateSvg(item): cleanXml(node) - return xmlHeader + node.toprettyxml(indent=' ') + "\n\n" + defsXml = "\n" + for d in defs: + defsXml += d.toprettyxml(indent=' ') + defsXml += "\n" + return xmlHeader + defsXml + node.toprettyxml(indent=' ') + "\n\n" def _generateItemSvg(item, nodes=None, root=None): @@ -156,7 +159,7 @@ def _generateItemSvg(item, nodes=None, root=None): ## ## Both 2 and 3 can be addressed by drawing all items in world coordinates. - prof = pg.debug.Profiler('generateItemSvg %s' % str(item), disabled=True) + profiler = debug.Profiler() if nodes is None: ## nodes maps all node IDs to their XML element. ## this allows us to ensure all elements receive unique names. @@ -196,17 +199,12 @@ def _generateItemSvg(item, nodes=None, root=None): tr2 = QtGui.QTransform() tr2.translate(-rootPos.x(), -rootPos.y()) tr = tr * tr2 - #print item, pg.SRTTransform(tr) - #tr.translate(item.pos().x(), item.pos().y()) - #tr = tr * item.transform() arr = QtCore.QByteArray() buf = QtCore.QBuffer(arr) svg = QtSvg.QSvgGenerator() svg.setOutputDevice(buf) dpi = QtGui.QDesktopWidget().physicalDpiX() - ### not really sure why this works, but it seems to be important: - #self.svg.setSize(QtCore.QSize(self.params['width']*dpi/90., self.params['height']*dpi/90.)) svg.setResolution(dpi) p = QtGui.QPainter() @@ -223,7 +221,10 @@ def _generateItemSvg(item, nodes=None, root=None): #if hasattr(item, 'setExportMode'): #item.setExportMode(False) - xmlStr = bytes(arr).decode('utf-8') + if USE_PYSIDE: + xmlStr = str(arr) + else: + xmlStr = bytes(arr).decode('utf-8') doc = xml.parseString(xmlStr) try: @@ -231,16 +232,20 @@ def _generateItemSvg(item, nodes=None, root=None): g1 = doc.getElementsByTagName('g')[0] ## get list of sub-groups g2 = [n for n in g1.childNodes if isinstance(n, xml.Element) and n.tagName == 'g'] + + defs = doc.getElementsByTagName('defs') + if len(defs) > 0: + defs = [n for n in defs[0].childNodes if isinstance(n, xml.Element)] except: print(doc.toxml()) raise - prof.mark('render') + profiler('render') ## Get rid of group transformation matrices by applying ## transformation to inner coordinates - correctCoordinates(g1, item) - prof.mark('correct') + correctCoordinates(g1, defs, item) + profiler('correct') ## make sure g1 has the transformation matrix #m = (tr.m11(), tr.m12(), tr.m21(), tr.m22(), tr.m31(), tr.m32()) #g1.setAttribute('transform', "matrix(%f,%f,%f,%f,%f,%f)" % m) @@ -276,7 +281,9 @@ def _generateItemSvg(item, nodes=None, root=None): path = QtGui.QGraphicsPathItem(item.mapToScene(item.shape())) item.scene().addItem(path) try: - pathNode = _generateItemSvg(path, root=root).getElementsByTagName('path')[0] + #pathNode = _generateItemSvg(path, root=root).getElementsByTagName('path')[0] + pathNode = _generateItemSvg(path, root=root)[0].getElementsByTagName('path')[0] + # assume for this path is empty.. possibly problematic. finally: item.scene().removeItem(path) @@ -290,20 +297,24 @@ def _generateItemSvg(item, nodes=None, root=None): childGroup = g1.ownerDocument.createElement('g') childGroup.setAttribute('clip-path', 'url(#%s)' % clip) g1.appendChild(childGroup) - prof.mark('clipping') + profiler('clipping') ## Add all child items as sub-elements. childs.sort(key=lambda c: c.zValue()) for ch in childs: - cg = _generateItemSvg(ch, nodes, root) - if cg is None: + csvg = _generateItemSvg(ch, nodes, root) + if csvg is None: continue + cg, cdefs = csvg childGroup.appendChild(cg) ### this isn't quite right--some items draw below their parent (good enough for now) - prof.mark('children') - prof.finish() - return g1 + defs.extend(cdefs) + + profiler('children') + return g1, defs -def correctCoordinates(node, item): +def correctCoordinates(node, defs, item): + # TODO: correct gradient coordinates inside defs + ## Remove transformation matrices from tags by applying matrix to coordinates inside. ## Each item is represented by a single top-level group with one or more groups inside. ## Each inner group contains one or more drawing primitives, possibly of different types. @@ -351,7 +362,7 @@ def correctCoordinates(node, item): if ch.tagName == 'polyline': removeTransform = True coords = np.array([[float(a) for a in c.split(',')] for c in ch.getAttribute('points').strip().split(' ')]) - coords = pg.transformCoordinates(tr, coords, transpose=True) + coords = fn.transformCoordinates(tr, coords, transpose=True) ch.setAttribute('points', ' '.join([','.join([str(a) for a in c]) for c in coords])) elif ch.tagName == 'path': removeTransform = True @@ -366,7 +377,7 @@ def correctCoordinates(node, item): x = x[1:] else: t = '' - nc = pg.transformCoordinates(tr, np.array([[float(x),float(y)]]), transpose=True) + nc = fn.transformCoordinates(tr, np.array([[float(x),float(y)]]), transpose=True) newCoords += t+str(nc[0,0])+','+str(nc[0,1])+' ' ch.setAttribute('d', newCoords) elif ch.tagName == 'text': @@ -376,7 +387,7 @@ def correctCoordinates(node, item): #[float(ch.getAttribute('x')), float(ch.getAttribute('y'))], #[float(ch.getAttribute('font-size')), 0], #[0,0]]) - #c = pg.transformCoordinates(tr, c, transpose=True) + #c = fn.transformCoordinates(tr, c, transpose=True) #ch.setAttribute('x', str(c[0,0])) #ch.setAttribute('y', str(c[0,1])) #fs = c[1]-c[2] @@ -398,13 +409,17 @@ def correctCoordinates(node, item): ## correct line widths if needed if removeTransform and ch.getAttribute('vector-effect') != 'non-scaling-stroke': w = float(grp.getAttribute('stroke-width')) - s = pg.transformCoordinates(tr, np.array([[w,0], [0,0]]), transpose=True) + s = fn.transformCoordinates(tr, np.array([[w,0], [0,0]]), transpose=True) w = ((s[0]-s[1])**2).sum()**0.5 ch.setAttribute('stroke-width', str(w)) if removeTransform: grp.removeAttribute('transform') + +SVGExporter.register() + + def itemTransform(item, root): ## Return the transformation mapping item to root ## (actually to parent coordinate system of root) @@ -440,35 +455,9 @@ def itemTransform(item, root): tr = item.sceneTransform() else: tr = itemTransform(nextRoot, root) * item.itemTransform(nextRoot)[0] - #pos = QtGui.QTransform() - #pos.translate(root.pos().x(), root.pos().y()) - #tr = pos * root.transform() * item.itemTransform(root)[0] - return tr - -#def correctStroke(node, item, root, width=1): - ##print "==============", item, node - #if node.hasAttribute('stroke-width'): - #width = float(node.getAttribute('stroke-width')) - #if node.getAttribute('vector-effect') == 'non-scaling-stroke': - #node.removeAttribute('vector-effect') - #if isinstance(root, QtGui.QGraphicsScene): - #w = item.mapFromScene(pg.Point(width,0)) - #o = item.mapFromScene(pg.Point(0,0)) - #else: - #w = item.mapFromItem(root, pg.Point(width,0)) - #o = item.mapFromItem(root, pg.Point(0,0)) - #w = w-o - ##print " ", w, o, w-o - #w = (w.x()**2 + w.y()**2) ** 0.5 - ##print " ", w - #node.setAttribute('stroke-width', str(w)) - - #for ch in node.childNodes: - #if isinstance(ch, xml.Element): - #correctStroke(ch, item, root, width) def cleanXml(node): ## remove extraneous text; let the xml library do the formatting. diff --git a/pyqtgraph/exporters/__init__.py b/pyqtgraph/exporters/__init__.py index 3f3c1f1d..62ab1331 100644 --- a/pyqtgraph/exporters/__init__.py +++ b/pyqtgraph/exporters/__init__.py @@ -1,27 +1,11 @@ -Exporters = [] -from pyqtgraph import importModules -#from .. import frozenSupport -import os -d = os.path.split(__file__)[0] -#files = [] -#for f in frozenSupport.listdir(d): - #if frozenSupport.isdir(os.path.join(d, f)) and f != '__pycache__': - #files.append(f) - #elif f[-3:] == '.py' and f not in ['__init__.py', 'Exporter.py']: - #files.append(f[:-3]) - -#for modName in files: - #mod = __import__(modName, globals(), locals(), fromlist=['*']) -for mod in importModules('', globals(), locals(), excludes=['Exporter']).values(): - if hasattr(mod, '__all__'): - names = mod.__all__ - else: - names = [n for n in dir(mod) if n[0] != '_'] - for k in names: - if hasattr(mod, k): - Exporters.append(getattr(mod, k)) - +from .Exporter import Exporter +from .ImageExporter import * +from .SVGExporter import * +from .Matplotlib import * +from .CSVExporter import * +from .PrintExporter import * +from .HDF5Exporter import * def listExporters(): - return Exporters[:] + return Exporter.Exporters[:] diff --git a/pyqtgraph/exporters/tests/test_csv.py b/pyqtgraph/exporters/tests/test_csv.py new file mode 100644 index 00000000..a98372ec --- /dev/null +++ b/pyqtgraph/exporters/tests/test_csv.py @@ -0,0 +1,49 @@ +""" +SVG export test +""" +import pyqtgraph as pg +import pyqtgraph.exporters +import csv + +app = pg.mkQApp() + +def approxeq(a, b): + return (a-b) <= ((a + b) * 1e-6) + +def test_CSVExporter(): + plt = pg.plot() + y1 = [1,3,2,3,1,6,9,8,4,2] + plt.plot(y=y1, name='myPlot') + + y2 = [3,4,6,1,2,4,2,3,5,3,5,1,3] + x2 = pg.np.linspace(0, 1.0, len(y2)) + plt.plot(x=x2, y=y2) + + y3 = [1,5,2,3,4,6,1,2,4,2,3,5,3] + x3 = pg.np.linspace(0, 1.0, len(y3)+1) + plt.plot(x=x3, y=y3, stepMode=True) + + ex = pg.exporters.CSVExporter(plt.plotItem) + ex.export(fileName='test.csv') + + r = csv.reader(open('test.csv', 'r')) + lines = [line for line in r] + header = lines.pop(0) + assert header == ['myPlot_x', 'myPlot_y', 'x0001', 'y0001', 'x0002', 'y0002'] + + i = 0 + for vals in lines: + vals = list(map(str.strip, vals)) + assert (i >= len(y1) and vals[0] == '') or approxeq(float(vals[0]), i) + assert (i >= len(y1) and vals[1] == '') or approxeq(float(vals[1]), y1[i]) + + assert (i >= len(x2) and vals[2] == '') or approxeq(float(vals[2]), x2[i]) + assert (i >= len(y2) and vals[3] == '') or approxeq(float(vals[3]), y2[i]) + + assert (i >= len(x3) and vals[4] == '') or approxeq(float(vals[4]), x3[i]) + assert (i >= len(y3) and vals[5] == '') or approxeq(float(vals[5]), y3[i]) + i += 1 + +if __name__ == '__main__': + test_CSVExporter() + \ No newline at end of file diff --git a/pyqtgraph/exporters/tests/test_svg.py b/pyqtgraph/exporters/tests/test_svg.py new file mode 100644 index 00000000..871f43c2 --- /dev/null +++ b/pyqtgraph/exporters/tests/test_svg.py @@ -0,0 +1,67 @@ +""" +SVG export test +""" +import pyqtgraph as pg +import pyqtgraph.exporters +app = pg.mkQApp() + +def test_plotscene(): + pg.setConfigOption('foreground', (0,0,0)) + w = pg.GraphicsWindow() + w.show() + p1 = w.addPlot() + p2 = w.addPlot() + p1.plot([1,3,2,3,1,6,9,8,4,2,3,5,3], pen={'color':'k'}) + p1.setXRange(0,5) + p2.plot([1,5,2,3,4,6,1,2,4,2,3,5,3], pen={'color':'k', 'cosmetic':False, 'width': 0.3}) + app.processEvents() + app.processEvents() + + ex = pg.exporters.SVGExporter(w.scene()) + ex.export(fileName='test.svg') + + +def test_simple(): + scene = pg.QtGui.QGraphicsScene() + #rect = pg.QtGui.QGraphicsRectItem(0, 0, 100, 100) + #scene.addItem(rect) + #rect.setPos(20,20) + #rect.translate(50, 50) + #rect.rotate(30) + #rect.scale(0.5, 0.5) + + #rect1 = pg.QtGui.QGraphicsRectItem(0, 0, 100, 100) + #rect1.setParentItem(rect) + #rect1.setFlag(rect1.ItemIgnoresTransformations) + #rect1.setPos(20, 20) + #rect1.scale(2,2) + + #el1 = pg.QtGui.QGraphicsEllipseItem(0, 0, 100, 100) + #el1.setParentItem(rect1) + ##grp = pg.ItemGroup() + #grp.setParentItem(rect) + #grp.translate(200,0) + ##grp.rotate(30) + + #rect2 = pg.QtGui.QGraphicsRectItem(0, 0, 100, 25) + #rect2.setFlag(rect2.ItemClipsChildrenToShape) + #rect2.setParentItem(grp) + #rect2.setPos(0,25) + #rect2.rotate(30) + #el = pg.QtGui.QGraphicsEllipseItem(0, 0, 100, 50) + #el.translate(10,-5) + #el.scale(0.5,2) + #el.setParentItem(rect2) + + grp2 = pg.ItemGroup() + scene.addItem(grp2) + grp2.scale(100,100) + + rect3 = pg.QtGui.QGraphicsRectItem(0,0,2,2) + rect3.setPen(pg.mkPen(width=1, cosmetic=False)) + grp2.addItem(rect3) + + ex = pg.exporters.SVGExporter(scene) + ex.export(fileName='test.svg') + + diff --git a/pyqtgraph/flowchart/Flowchart.py b/pyqtgraph/flowchart/Flowchart.py index 81f9e163..ab5f4a82 100644 --- a/pyqtgraph/flowchart/Flowchart.py +++ b/pyqtgraph/flowchart/Flowchart.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui, USE_PYSIDE +from ..Qt import QtCore, QtGui, USE_PYSIDE from .Node import * -from pyqtgraph.pgcollections import OrderedDict -from pyqtgraph.widgets.TreeWidget import * +from ..pgcollections import OrderedDict +from ..widgets.TreeWidget import * +from .. import FileDialog, DataTreeWidget ## pyside and pyqt use incompatible ui files. if USE_PYSIDE: @@ -14,60 +15,32 @@ else: from .Terminal import Terminal from numpy import ndarray -from . import library -from pyqtgraph.debug import printExc -import pyqtgraph.configfile as configfile -import pyqtgraph.dockarea as dockarea -import pyqtgraph as pg +from .library import LIBRARY +from ..debug import printExc +from .. import configfile as configfile +from .. import dockarea as dockarea from . import FlowchartGraphicsView +from .. import functions as fn def strDict(d): return dict([(str(k), v) for k, v in d.items()]) -def toposort(deps, nodes=None, seen=None, stack=None, depth=0): - """Topological sort. Arguments are: - deps dictionary describing dependencies where a:[b,c] means "a depends on b and c" - nodes optional, specifies list of starting nodes (these should be the nodes - which are not depended on by any other nodes) - """ - - if nodes is None: - ## run through deps to find nodes that are not depended upon - rem = set() - for dep in deps.values(): - rem |= set(dep) - nodes = set(deps.keys()) - rem - if seen is None: - seen = set() - stack = [] - sorted = [] - #print " "*depth, "Starting from", nodes - for n in nodes: - if n in stack: - raise Exception("Cyclic dependency detected", stack + [n]) - if n in seen: - continue - seen.add(n) - #print " "*depth, " descending into", n, deps[n] - sorted.extend( toposort(deps, deps[n], seen, stack+[n], depth=depth+1)) - #print " "*depth, " Added", n - sorted.append(n) - #print " "*depth, " ", sorted - return sorted class Flowchart(Node): - sigFileLoaded = QtCore.Signal(object) sigFileSaved = QtCore.Signal(object) #sigOutputChanged = QtCore.Signal() ## inherited from Node sigChartLoaded = QtCore.Signal() - sigStateChanged = QtCore.Signal() + sigStateChanged = QtCore.Signal() # called when output is expected to have changed + sigChartChanged = QtCore.Signal(object, object, object) # called when nodes are added, removed, or renamed. + # (self, action, node) - def __init__(self, terminals=None, name=None, filePath=None): + def __init__(self, terminals=None, name=None, filePath=None, library=None): + self.library = library or LIBRARY if name is None: name = "Flowchart" if terminals is None: @@ -105,6 +78,10 @@ class Flowchart(Node): for name, opts in terminals.items(): self.addTerminal(name, **opts) + def setLibrary(self, lib): + self.library = lib + self.widget().chartWidget.buildMenu() + def setInput(self, **args): """Set the input values of the flowchart. This will automatically propagate the new values throughout the flowchart, (possibly) causing the output to change. @@ -194,7 +171,7 @@ class Flowchart(Node): break n += 1 - node = library.getNodeType(nodeType)(name) + node = self.library.getNodeType(nodeType)(name) self.addNode(node, name, pos) return node @@ -213,6 +190,7 @@ class Flowchart(Node): node.sigClosed.connect(self.nodeClosed) node.sigRenamed.connect(self.nodeRenamed) node.sigOutputChanged.connect(self.nodeOutputChanged) + self.sigChartChanged.emit(self, 'add', node) def removeNode(self, node): node.close() @@ -220,23 +198,18 @@ class Flowchart(Node): def nodeClosed(self, node): del self._nodes[node.name()] self.widget().removeNode(node) - try: - node.sigClosed.disconnect(self.nodeClosed) - except TypeError: - pass - try: - node.sigRenamed.disconnect(self.nodeRenamed) - except TypeError: - pass - try: - node.sigOutputChanged.disconnect(self.nodeOutputChanged) - except TypeError: - pass + for signal in ['sigClosed', 'sigRenamed', 'sigOutputChanged']: + try: + getattr(node, signal).disconnect(self.nodeClosed) + except (TypeError, RuntimeError): + pass + self.sigChartChanged.emit(self, 'remove', node) def nodeRenamed(self, node, oldName): del self._nodes[oldName] self._nodes[node.name()] = node self.widget().nodeRenamed(node, oldName) + self.sigChartChanged.emit(self, 'rename', node) def arrangeNodes(self): pass @@ -276,9 +249,10 @@ class Flowchart(Node): ## Record inputs given to process() for n, t in self.inputNode.outputs().items(): - if n not in args: - raise Exception("Parameter %s required to process this chart." % n) - data[t] = args[n] + # if n not in args: + # raise Exception("Parameter %s required to process this chart." % n) + if n in args: + data[t] = args[n] ret = {} @@ -303,7 +277,7 @@ class Flowchart(Node): if len(inputs) == 0: continue if inp.isMultiValue(): ## multi-input terminals require a dict of all inputs - args[inp.name()] = dict([(i, data[i]) for i in inputs]) + args[inp.name()] = dict([(i, data[i]) for i in inputs if i in data]) else: ## single-inputs terminals only need the single input value available args[inp.name()] = data[inputs[0]] @@ -323,9 +297,8 @@ class Flowchart(Node): #print out.name() try: data[out] = result[out.name()] - except: - print(out, out.name()) - raise + except KeyError: + pass elif c == 'd': ## delete a terminal result (no longer needed; may be holding a lot of memory) #print "===> delete", arg if arg in data: @@ -350,7 +323,7 @@ class Flowchart(Node): #print "DEPS:", deps ## determine correct node-processing order #deps[self] = [] - order = toposort(deps) + order = fn.toposort(deps) #print "ORDER1:", order ## construct list of operations @@ -399,7 +372,7 @@ class Flowchart(Node): deps[node].extend(t.dependentNodes()) ## determine order of updates - order = toposort(deps, nodes=[startNode]) + order = fn.toposort(deps, nodes=[startNode]) order.reverse() ## keep track of terminals that have been updated @@ -532,7 +505,7 @@ class Flowchart(Node): startDir = self.filePath if startDir is None: startDir = '.' - self.fileDialog = pg.FileDialog(None, "Load Flowchart..", startDir, "Flowchart (*.fc)") + self.fileDialog = FileDialog(None, "Load Flowchart..", startDir, "Flowchart (*.fc)") #self.fileDialog.setFileMode(QtGui.QFileDialog.AnyFile) #self.fileDialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) self.fileDialog.show() @@ -540,7 +513,7 @@ class Flowchart(Node): return ## NOTE: was previously using a real widget for the file dialog's parent, but this caused weird mouse event bugs.. #fileName = QtGui.QFileDialog.getOpenFileName(None, "Load Flowchart..", startDir, "Flowchart (*.fc)") - fileName = str(fileName) + fileName = unicode(fileName) state = configfile.readConfigFile(fileName) self.restoreState(state, clear=True) self.viewBox.autoRange() @@ -553,7 +526,7 @@ class Flowchart(Node): startDir = self.filePath if startDir is None: startDir = '.' - self.fileDialog = pg.FileDialog(None, "Save Flowchart..", startDir, "Flowchart (*.fc)") + self.fileDialog = FileDialog(None, "Save Flowchart..", startDir, "Flowchart (*.fc)") #self.fileDialog.setFileMode(QtGui.QFileDialog.AnyFile) self.fileDialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) #self.fileDialog.setDirectory(startDir) @@ -561,7 +534,7 @@ class Flowchart(Node): self.fileDialog.fileSelected.connect(self.saveFile) return #fileName = QtGui.QFileDialog.getSaveFileName(None, "Save Flowchart..", startDir, "Flowchart (*.fc)") - fileName = str(fileName) + fileName = unicode(fileName) configfile.writeConfigFile(self.saveState(), fileName) self.sigFileSaved.emit(fileName) @@ -683,7 +656,7 @@ class FlowchartCtrlWidget(QtGui.QWidget): #self.setCurrentFile(newFile) def fileSaved(self, fileName): - self.setCurrentFile(str(fileName)) + self.setCurrentFile(unicode(fileName)) self.ui.saveBtn.success("Saved.") def saveClicked(self): @@ -712,7 +685,7 @@ class FlowchartCtrlWidget(QtGui.QWidget): #self.setCurrentFile(newFile) def setCurrentFile(self, fileName): - self.currentFileName = str(fileName) + self.currentFileName = unicode(fileName) if fileName is None: self.ui.fileNameLabel.setText("[ new ]") else: @@ -760,7 +733,7 @@ class FlowchartCtrlWidget(QtGui.QWidget): #self.disconnect(item.bypassBtn, QtCore.SIGNAL('clicked()'), self.bypassClicked) try: item.bypassBtn.clicked.disconnect(self.bypassClicked) - except TypeError: + except (TypeError, RuntimeError): pass self.ui.ctrlList.removeTopLevelItem(item) @@ -816,7 +789,7 @@ class FlowchartWidget(dockarea.DockArea): self.selDescLabel = QtGui.QLabel() self.selNameLabel = QtGui.QLabel() self.selDescLabel.setWordWrap(True) - self.selectedTree = pg.DataTreeWidget() + self.selectedTree = DataTreeWidget() #self.selectedTree.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) #self.selInfoLayout.addWidget(self.selNameLabel) self.selInfoLayout.addWidget(self.selDescLabel) @@ -846,20 +819,24 @@ class FlowchartWidget(dockarea.DockArea): self.nodeMenu.triggered.disconnect(self.nodeMenuTriggered) self.nodeMenu = None self.subMenus = [] - library.loadLibrary(reloadLibs=True) + self.chart.library.reload() self.buildMenu() def buildMenu(self, pos=None): + def buildSubMenu(node, rootMenu, subMenus, pos=None): + for section, node in node.items(): + menu = QtGui.QMenu(section) + rootMenu.addMenu(menu) + if isinstance(node, OrderedDict): + buildSubMenu(node, menu, subMenus, pos=pos) + subMenus.append(menu) + else: + act = rootMenu.addAction(section) + act.nodeType = section + act.pos = pos self.nodeMenu = QtGui.QMenu() - self.subMenus = [] - for section, nodes in library.getNodeTree().items(): - menu = QtGui.QMenu(section) - self.nodeMenu.addMenu(menu) - for name in nodes: - act = menu.addAction(name) - act.nodeType = name - act.pos = pos - self.subMenus.append(menu) + self.subMenus = [] + buildSubMenu(self.chart.library.getNodeTree(), self.nodeMenu, self.subMenus, pos=pos) self.nodeMenu.triggered.connect(self.nodeMenuTriggered) return self.nodeMenu diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate.ui b/pyqtgraph/flowchart/FlowchartCtrlTemplate.ui index 610846b6..0361ad3e 100644 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate.ui +++ b/pyqtgraph/flowchart/FlowchartCtrlTemplate.ui @@ -107,12 +107,12 @@ TreeWidget QTreeWidget -
pyqtgraph.widgets.TreeWidget
+
..widgets.TreeWidget
FeedbackButton QPushButton -
pyqtgraph.widgets.FeedbackButton
+
..widgets.FeedbackButton
diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt.py index 0410cdf3..8afd43f8 100644 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt.py +++ b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './flowchart/FlowchartCtrlTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/flowchart/FlowchartCtrlTemplate.ui' # -# Created: Sun Sep 9 14:41:30 2012 -# by: PyQt4 UI code generator 4.9.1 +# Created: Mon Dec 23 10:10:50 2013 +# by: PyQt4 UI code generator 4.10 # # WARNING! All changes made in this file will be lost! @@ -12,7 +12,16 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_Form(object): def setupUi(self, Form): @@ -60,12 +69,12 @@ class Ui_Form(object): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) - self.loadBtn.setText(QtGui.QApplication.translate("Form", "Load..", None, QtGui.QApplication.UnicodeUTF8)) - self.saveBtn.setText(QtGui.QApplication.translate("Form", "Save", None, QtGui.QApplication.UnicodeUTF8)) - self.saveAsBtn.setText(QtGui.QApplication.translate("Form", "As..", None, QtGui.QApplication.UnicodeUTF8)) - self.reloadBtn.setText(QtGui.QApplication.translate("Form", "Reload Libs", None, QtGui.QApplication.UnicodeUTF8)) - self.showChartBtn.setText(QtGui.QApplication.translate("Form", "Flowchart", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(_translate("Form", "Form", None)) + self.loadBtn.setText(_translate("Form", "Load..", None)) + self.saveBtn.setText(_translate("Form", "Save", None)) + self.saveAsBtn.setText(_translate("Form", "As..", None)) + self.reloadBtn.setText(_translate("Form", "Reload Libs", None)) + self.showChartBtn.setText(_translate("Form", "Flowchart", None)) -from pyqtgraph.widgets.FeedbackButton import FeedbackButton -from pyqtgraph.widgets.TreeWidget import TreeWidget +from ..widgets.TreeWidget import TreeWidget +from ..widgets.FeedbackButton import FeedbackButton diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside.py index f579c957..b722000e 100644 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside.py +++ b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './flowchart/FlowchartCtrlTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/flowchart/FlowchartCtrlTemplate.ui' # -# Created: Sun Sep 9 14:41:30 2012 -# by: pyside-uic 0.2.13 running on PySide 1.1.0 +# Created: Mon Dec 23 10:10:51 2013 +# by: pyside-uic 0.2.14 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! @@ -62,5 +62,5 @@ class Ui_Form(object): self.reloadBtn.setText(QtGui.QApplication.translate("Form", "Reload Libs", None, QtGui.QApplication.UnicodeUTF8)) self.showChartBtn.setText(QtGui.QApplication.translate("Form", "Flowchart", None, QtGui.QApplication.UnicodeUTF8)) -from pyqtgraph.widgets.FeedbackButton import FeedbackButton -from pyqtgraph.widgets.TreeWidget import TreeWidget +from ..widgets.TreeWidget import TreeWidget +from ..widgets.FeedbackButton import FeedbackButton diff --git a/pyqtgraph/flowchart/FlowchartGraphicsView.py b/pyqtgraph/flowchart/FlowchartGraphicsView.py index 0ec4d5c8..ab4b2914 100644 --- a/pyqtgraph/flowchart/FlowchartGraphicsView.py +++ b/pyqtgraph/flowchart/FlowchartGraphicsView.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.widgets.GraphicsView import GraphicsView -from pyqtgraph.GraphicsScene import GraphicsScene -from pyqtgraph.graphicsItems.ViewBox import ViewBox +from ..Qt import QtGui, QtCore +from ..widgets.GraphicsView import GraphicsView +from ..GraphicsScene import GraphicsScene +from ..graphicsItems.ViewBox import ViewBox #class FlowchartGraphicsView(QtGui.QGraphicsView): class FlowchartGraphicsView(GraphicsView): diff --git a/pyqtgraph/flowchart/FlowchartTemplate.ui b/pyqtgraph/flowchart/FlowchartTemplate.ui index 31b1359c..8b0c19da 100644 --- a/pyqtgraph/flowchart/FlowchartTemplate.ui +++ b/pyqtgraph/flowchart/FlowchartTemplate.ui @@ -85,12 +85,12 @@ DataTreeWidget QTreeWidget -
pyqtgraph.widgets.DataTreeWidget
+
..widgets.DataTreeWidget
FlowchartGraphicsView QGraphicsView -
pyqtgraph.flowchart.FlowchartGraphicsView
+
..flowchart.FlowchartGraphicsView
diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyqt.py b/pyqtgraph/flowchart/FlowchartTemplate_pyqt.py index c07dd734..06b10bfe 100644 --- a/pyqtgraph/flowchart/FlowchartTemplate_pyqt.py +++ b/pyqtgraph/flowchart/FlowchartTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './flowchart/FlowchartTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/flowchart/FlowchartTemplate.ui' # -# Created: Sun Feb 24 19:47:29 2013 -# by: PyQt4 UI code generator 4.9.3 +# Created: Mon Dec 23 10:10:51 2013 +# by: PyQt4 UI code generator 4.10 # # WARNING! All changes made in this file will be lost! @@ -12,7 +12,16 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_Form(object): def setupUi(self, Form): @@ -53,7 +62,7 @@ class Ui_Form(object): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(_translate("Form", "Form", None)) -from pyqtgraph.widgets.DataTreeWidget import DataTreeWidget -from pyqtgraph.flowchart.FlowchartGraphicsView import FlowchartGraphicsView +from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView +from ..widgets.DataTreeWidget import DataTreeWidget diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyside.py b/pyqtgraph/flowchart/FlowchartTemplate_pyside.py index c73f3c00..2c693c60 100644 --- a/pyqtgraph/flowchart/FlowchartTemplate_pyside.py +++ b/pyqtgraph/flowchart/FlowchartTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './flowchart/FlowchartTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/flowchart/FlowchartTemplate.ui' # -# Created: Sun Feb 24 19:47:30 2013 -# by: pyside-uic 0.2.13 running on PySide 1.1.1 +# Created: Mon Dec 23 10:10:51 2013 +# by: pyside-uic 0.2.14 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! @@ -50,5 +50,5 @@ class Ui_Form(object): def retranslateUi(self, Form): Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) -from pyqtgraph.widgets.DataTreeWidget import DataTreeWidget -from pyqtgraph.flowchart.FlowchartGraphicsView import FlowchartGraphicsView +from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView +from ..widgets.DataTreeWidget import DataTreeWidget diff --git a/pyqtgraph/flowchart/Node.py b/pyqtgraph/flowchart/Node.py index cd73b42b..fc7b04d3 100644 --- a/pyqtgraph/flowchart/Node.py +++ b/pyqtgraph/flowchart/Node.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui -from pyqtgraph.graphicsItems.GraphicsObject import GraphicsObject -import pyqtgraph.functions as fn +from ..Qt import QtCore, QtGui +from ..graphicsItems.GraphicsObject import GraphicsObject +from .. import functions as fn from .Terminal import * -from pyqtgraph.pgcollections import OrderedDict -from pyqtgraph.debug import * +from ..pgcollections import OrderedDict +from ..debug import * import numpy as np from .eq import * @@ -37,7 +37,7 @@ class Node(QtCore.QObject): def __init__(self, name, terminals=None, allowAddInput=False, allowAddOutput=False, allowRemove=True): """ ============== ============================================================ - Arguments + **Arguments:** name The name of this specific node instance. It can be any string, but must be unique within a flowchart. Usually, we simply let the flowchart decide on a name when calling @@ -501,8 +501,8 @@ class NodeGraphicsItem(GraphicsObject): bounds = self.boundingRect() self.nameItem.setPos(bounds.width()/2. - self.nameItem.boundingRect().width()/2., 0) - def setPen(self, pen): - self.pen = pen + def setPen(self, *args, **kwargs): + self.pen = fn.mkPen(*args, **kwargs) self.update() def setBrush(self, brush): @@ -617,9 +617,6 @@ class NodeGraphicsItem(GraphicsObject): def getMenu(self): return self.menu - - def getContextMenus(self, event): - return [self.menu] def raiseContextMenu(self, ev): menu = self.scene().addParentContextMenus(self, self.getMenu(), ev) diff --git a/pyqtgraph/flowchart/NodeLibrary.py b/pyqtgraph/flowchart/NodeLibrary.py new file mode 100644 index 00000000..8e04e97d --- /dev/null +++ b/pyqtgraph/flowchart/NodeLibrary.py @@ -0,0 +1,86 @@ +from ..pgcollections import OrderedDict +from .Node import Node + +def isNodeClass(cls): + try: + if not issubclass(cls, Node): + return False + except: + return False + return hasattr(cls, 'nodeName') + + + +class NodeLibrary: + """ + A library of flowchart Node types. Custom libraries may be built to provide + each flowchart with a specific set of allowed Node types. + """ + + def __init__(self): + self.nodeList = OrderedDict() + self.nodeTree = OrderedDict() + + def addNodeType(self, nodeClass, paths, override=False): + """ + Register a new node type. If the type's name is already in use, + an exception will be raised (unless override=True). + + ============== ========================================================= + **Arguments:** + + nodeClass a subclass of Node (must have typ.nodeName) + paths list of tuples specifying the location(s) this + type will appear in the library tree. + override if True, overwrite any class having the same name + ============== ========================================================= + """ + if not isNodeClass(nodeClass): + raise Exception("Object %s is not a Node subclass" % str(nodeClass)) + + name = nodeClass.nodeName + if not override and name in self.nodeList: + raise Exception("Node type name '%s' is already registered." % name) + + self.nodeList[name] = nodeClass + for path in paths: + root = self.nodeTree + for n in path: + if n not in root: + root[n] = OrderedDict() + root = root[n] + root[name] = nodeClass + + def getNodeType(self, name): + try: + return self.nodeList[name] + except KeyError: + raise Exception("No node type called '%s'" % name) + + def getNodeTree(self): + return self.nodeTree + + def copy(self): + """ + Return a copy of this library. + """ + lib = NodeLibrary() + lib.nodeList = self.nodeList.copy() + lib.nodeTree = self.treeCopy(self.nodeTree) + return lib + + @staticmethod + def treeCopy(tree): + copy = OrderedDict() + for k,v in tree.items(): + if isNodeClass(v): + copy[k] = v + else: + copy[k] = NodeLibrary.treeCopy(v) + return copy + + def reload(self): + """ + Reload Node classes in this library. + """ + raise NotImplementedError() diff --git a/pyqtgraph/flowchart/Terminal.py b/pyqtgraph/flowchart/Terminal.py index 45805cd8..6a6db62e 100644 --- a/pyqtgraph/flowchart/Terminal.py +++ b/pyqtgraph/flowchart/Terminal.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui import weakref -from pyqtgraph.graphicsItems.GraphicsObject import GraphicsObject -import pyqtgraph.functions as fn -from pyqtgraph.Point import Point +from ..graphicsItems.GraphicsObject import GraphicsObject +from .. import functions as fn +from ..Point import Point #from PySide import QtCore, QtGui from .eq import * @@ -436,10 +436,6 @@ class TerminalGraphicsItem(GraphicsObject): def toggleMulti(self): multi = self.menu.multiAct.isChecked() self.term.setMultiValue(multi) - - ## probably never need this - #def getContextMenus(self, ev): - #return [self.getMenu()] def removeSelf(self): self.term.node().removeTerminal(self.term) diff --git a/pyqtgraph/flowchart/eq.py b/pyqtgraph/flowchart/eq.py index 031ebce8..554989b2 100644 --- a/pyqtgraph/flowchart/eq.py +++ b/pyqtgraph/flowchart/eq.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- from numpy import ndarray, bool_ -from pyqtgraph.metaarray import MetaArray +from ..metaarray import MetaArray def eq(a, b): """The great missing equivalence function: Guaranteed evaluation to a single bool value.""" @@ -29,7 +29,7 @@ def eq(a, b): except: return False if (hasattr(e, 'implements') and e.implements('MetaArray')): - return e.asarray().all() + return e.asarray().all() else: return e.all() else: diff --git a/pyqtgraph/flowchart/library/Data.py b/pyqtgraph/flowchart/library/Data.py index cbef848a..5236de8d 100644 --- a/pyqtgraph/flowchart/library/Data.py +++ b/pyqtgraph/flowchart/library/Data.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- from ..Node import Node -from pyqtgraph.Qt import QtGui, QtCore +from ...Qt import QtGui, QtCore import numpy as np from .common import * -from pyqtgraph.SRTTransform import SRTTransform -from pyqtgraph.Point import Point -from pyqtgraph.widgets.TreeWidget import TreeWidget -from pyqtgraph.graphicsItems.LinearRegionItem import LinearRegionItem +from ...SRTTransform import SRTTransform +from ...Point import Point +from ...widgets.TreeWidget import TreeWidget +from ...graphicsItems.LinearRegionItem import LinearRegionItem from . import functions @@ -182,8 +182,8 @@ class EvalNode(Node): def __init__(self, name): Node.__init__(self, name, terminals = { - 'input': {'io': 'in', 'renamable': True}, - 'output': {'io': 'out', 'renamable': True}, + 'input': {'io': 'in', 'renamable': True, 'multiable': True}, + 'output': {'io': 'out', 'renamable': True, 'multiable': True}, }, allowAddInput=True, allowAddOutput=True) @@ -328,7 +328,7 @@ class ColumnJoinNode(Node): ## Node.restoreState should have created all of the terminals we need ## However: to maintain support for some older flowchart files, we need - ## to manually add any terminals that were not taken care of. + ## to manually add any terminals that were not taken care of. for name in [n for n in state['order'] if n not in inputs]: Node.addInput(self, name, renamable=True, removable=True, multiable=True) inputs = self.inputs() diff --git a/pyqtgraph/flowchart/library/Display.py b/pyqtgraph/flowchart/library/Display.py index 9068c0ec..642e6491 100644 --- a/pyqtgraph/flowchart/library/Display.py +++ b/pyqtgraph/flowchart/library/Display.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- from ..Node import Node import weakref -#from pyqtgraph import graphicsItems -from pyqtgraph.Qt import QtCore, QtGui -from pyqtgraph.graphicsItems.ScatterPlotItem import ScatterPlotItem -from pyqtgraph.graphicsItems.PlotCurveItem import PlotCurveItem -from pyqtgraph import PlotDataItem +from ...Qt import QtCore, QtGui +from ...graphicsItems.ScatterPlotItem import ScatterPlotItem +from ...graphicsItems.PlotCurveItem import PlotCurveItem +from ... import PlotDataItem, ComboBox from .common import * import numpy as np @@ -17,7 +16,9 @@ class PlotWidgetNode(Node): def __init__(self, name): Node.__init__(self, name, terminals={'In': {'io': 'in', 'multi': True}}) - self.plot = None + self.plot = None # currently selected plot + self.plots = {} # list of available plots user may select from + self.ui = None self.items = {} def disconnected(self, localTerm, remoteTerm): @@ -27,16 +28,27 @@ class PlotWidgetNode(Node): def setPlot(self, plot): #print "======set plot" + if plot == self.plot: + return + + # clear data from previous plot + if self.plot is not None: + for vid in list(self.items.keys()): + self.plot.removeItem(self.items[vid]) + del self.items[vid] + self.plot = plot + self.updateUi() + self.update() self.sigPlotChanged.emit(self) def getPlot(self): return self.plot def process(self, In, display=True): - if display: - #self.plot.clearPlots() + if display and self.plot is not None: items = set() + # Add all new input items to selected plot for name, vals in In.items(): if vals is None: continue @@ -46,14 +58,13 @@ class PlotWidgetNode(Node): for val in vals: vid = id(val) if vid in self.items and self.items[vid].scene() is self.plot.scene(): + # Item is already added to the correct scene + # possible bug: what if two plots occupy the same scene? (should + # rarely be a problem because items are removed from a plot before + # switching). items.add(vid) else: - #if isinstance(val, PlotCurveItem): - #self.plot.addItem(val) - #item = val - #if isinstance(val, ScatterPlotItem): - #self.plot.addItem(val) - #item = val + # Add the item to the plot, or generate a new item if needed. if isinstance(val, QtGui.QGraphicsItem): self.plot.addItem(val) item = val @@ -61,22 +72,48 @@ class PlotWidgetNode(Node): item = self.plot.plot(val) self.items[vid] = item items.add(vid) + + # Any left-over items that did not appear in the input must be removed for vid in list(self.items.keys()): if vid not in items: - #print "remove", self.items[vid] self.plot.removeItem(self.items[vid]) del self.items[vid] def processBypassed(self, args): + if self.plot is None: + return for item in list(self.items.values()): self.plot.removeItem(item) self.items = {} - #def setInput(self, **args): - #for k in args: - #self.plot.plot(args[k]) + def ctrlWidget(self): + if self.ui is None: + self.ui = ComboBox() + self.ui.currentIndexChanged.connect(self.plotSelected) + self.updateUi() + return self.ui - + def plotSelected(self, index): + self.setPlot(self.ui.value()) + + def setPlotList(self, plots): + """ + Specify the set of plots (PlotWidget or PlotItem) that the user may + select from. + + *plots* must be a dictionary of {name: plot} pairs. + """ + self.plots = plots + self.updateUi() + + def updateUi(self): + # sets list and automatically preserves previous selection + self.ui.setItems(self.plots) + try: + self.ui.setValue(self.plot) + except ValueError: + pass + class CanvasNode(Node): """Connection to a Canvas widget.""" @@ -272,4 +309,4 @@ class ScatterPlot(CtrlNode): #pos = file. - \ No newline at end of file + diff --git a/pyqtgraph/flowchart/library/Filters.py b/pyqtgraph/flowchart/library/Filters.py index 090c261c..88a2f6c5 100644 --- a/pyqtgraph/flowchart/library/Filters.py +++ b/pyqtgraph/flowchart/library/Filters.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui +from ...Qt import QtCore, QtGui from ..Node import Node -from scipy.signal import detrend -from scipy.ndimage import median_filter, gaussian_filter -#from pyqtgraph.SignalProxy import SignalProxy from . import functions +from ... import functions as pgfn from .common import * import numpy as np -import pyqtgraph.metaarray as metaarray +from ... import PolyLineROI +from ... import Point +from ... import metaarray as metaarray class Downsample(CtrlNode): @@ -119,7 +119,11 @@ class Median(CtrlNode): @metaArrayWrapper def processData(self, data): - return median_filter(data, self.ctrls['n'].value()) + try: + import scipy.ndimage + except ImportError: + raise Exception("MedianFilter node requires the package scipy.ndimage.") + return scipy.ndimage.median_filter(data, self.ctrls['n'].value()) class Mode(CtrlNode): """Filters data by taking the mode (histogram-based) of a sliding window""" @@ -156,7 +160,11 @@ class Gaussian(CtrlNode): @metaArrayWrapper def processData(self, data): - return gaussian_filter(data, self.ctrls['sigma'].value()) + try: + import scipy.ndimage + except ImportError: + raise Exception("GaussianFilter node requires the package scipy.ndimage.") + return pgfn.gaussianFilter(data, self.ctrls['sigma'].value()) class Derivative(CtrlNode): @@ -189,8 +197,78 @@ class Detrend(CtrlNode): @metaArrayWrapper def processData(self, data): + try: + from scipy.signal import detrend + except ImportError: + raise Exception("DetrendFilter node requires the package scipy.signal.") return detrend(data) +class RemoveBaseline(PlottingCtrlNode): + """Remove an arbitrary, graphically defined baseline from the data.""" + nodeName = 'RemoveBaseline' + + def __init__(self, name): + ## define inputs and outputs (one output needs to be a plot) + PlottingCtrlNode.__init__(self, name) + self.line = PolyLineROI([[0,0],[1,0]]) + self.line.sigRegionChanged.connect(self.changed) + + ## create a PolyLineROI, add it to a plot -- actually, I think we want to do this after the node is connected to a plot (look at EventDetection.ThresholdEvents node for ideas), and possible after there is data. We will need to update the end positions of the line each time the input data changes + #self.line = None ## will become a PolyLineROI + + def connectToPlot(self, node): + """Define what happens when the node is connected to a plot""" + + if node.plot is None: + return + node.getPlot().addItem(self.line) + + def disconnectFromPlot(self, plot): + """Define what happens when the node is disconnected from a plot""" + plot.removeItem(self.line) + + def processData(self, data): + ## get array of baseline (from PolyLineROI) + h0 = self.line.getHandles()[0] + h1 = self.line.getHandles()[-1] + + timeVals = data.xvals(0) + h0.setPos(timeVals[0], h0.pos()[1]) + h1.setPos(timeVals[-1], h1.pos()[1]) + + pts = self.line.listPoints() ## lists line handles in same coordinates as data + pts, indices = self.adjustXPositions(pts, timeVals) ## maxe sure x positions match x positions of data points + + ## construct an array that represents the baseline + arr = np.zeros(len(data), dtype=float) + n = 1 + arr[0] = pts[0].y() + for i in range(len(pts)-1): + x1 = pts[i].x() + x2 = pts[i+1].x() + y1 = pts[i].y() + y2 = pts[i+1].y() + m = (y2-y1)/(x2-x1) + b = y1 + + times = timeVals[(timeVals > x1)*(timeVals <= x2)] + arr[n:n+len(times)] = (m*(times-times[0]))+b + n += len(times) + + return data - arr ## subract baseline from data + + def adjustXPositions(self, pts, data): + """Return a list of Point() where the x position is set to the nearest x value in *data* for each point in *pts*.""" + points = [] + timeIndices = [] + for p in pts: + x = np.argwhere(abs(data - p.x()) == abs(data - p.x()).min()) + points.append(Point(data[x], p.y())) + timeIndices.append(x) + + return points, timeIndices + + class AdaptiveDetrend(CtrlNode): """Removes baseline from data, ignoring anomalous events""" @@ -265,4 +343,4 @@ class RemovePeriodic(CtrlNode): return ma - \ No newline at end of file + diff --git a/pyqtgraph/flowchart/library/__init__.py b/pyqtgraph/flowchart/library/__init__.py index 1e44edff..d8038aa4 100644 --- a/pyqtgraph/flowchart/library/__init__.py +++ b/pyqtgraph/flowchart/library/__init__.py @@ -1,103 +1,28 @@ # -*- coding: utf-8 -*- -from pyqtgraph.pgcollections import OrderedDict -from pyqtgraph import importModules +from ...pgcollections import OrderedDict import os, types -from pyqtgraph.debug import printExc -from ..Node import Node -import pyqtgraph.reload as reload +from ...debug import printExc +from ..NodeLibrary import NodeLibrary, isNodeClass +from ... import reload as reload -NODE_LIST = OrderedDict() ## maps name:class for all registered Node subclasses -NODE_TREE = OrderedDict() ## categorized tree of Node subclasses +# Build default library +LIBRARY = NodeLibrary() -def getNodeType(name): - try: - return NODE_LIST[name] - except KeyError: - raise Exception("No node type called '%s'" % name) +# For backward compatibility, expose the default library's properties here: +NODE_LIST = LIBRARY.nodeList +NODE_TREE = LIBRARY.nodeTree +registerNodeType = LIBRARY.addNodeType +getNodeTree = LIBRARY.getNodeTree +getNodeType = LIBRARY.getNodeType -def getNodeTree(): - return NODE_TREE - -def registerNodeType(cls, paths, override=False): - """ - Register a new node type. If the type's name is already in use, - an exception will be raised (unless override=True). +# Add all nodes to the default library +from . import Data, Display, Filters, Operators +for mod in [Data, Display, Filters, Operators]: + nodes = [getattr(mod, name) for name in dir(mod) if isNodeClass(getattr(mod, name))] + for node in nodes: + LIBRARY.addNodeType(node, [(mod.__name__.split('.')[-1],)]) - Arguments: - cls - a subclass of Node (must have typ.nodeName) - paths - list of tuples specifying the location(s) this - type will appear in the library tree. - override - if True, overwrite any class having the same name - """ - if not isNodeClass(cls): - raise Exception("Object %s is not a Node subclass" % str(cls)) - - name = cls.nodeName - if not override and name in NODE_LIST: - raise Exception("Node type name '%s' is already registered." % name) - - NODE_LIST[name] = cls - for path in paths: - root = NODE_TREE - for n in path: - if n not in root: - root[n] = OrderedDict() - root = root[n] - root[name] = cls -def isNodeClass(cls): - try: - if not issubclass(cls, Node): - return False - except: - return False - return hasattr(cls, 'nodeName') - -def loadLibrary(reloadLibs=False, libPath=None): - """Import all Node subclasses found within files in the library module.""" - - global NODE_LIST, NODE_TREE - #if libPath is None: - #libPath = os.path.dirname(os.path.abspath(__file__)) - - if reloadLibs: - reload.reloadAll(libPath) - - mods = importModules('', globals(), locals()) - #for f in frozenSupport.listdir(libPath): - #pathName, ext = os.path.splitext(f) - #if ext not in ('.py', '.pyc') or '__init__' in pathName or '__pycache__' in pathName: - #continue - #try: - ##print "importing from", f - #mod = __import__(pathName, globals(), locals()) - #except: - #printExc("Error loading flowchart library %s:" % pathName) - #continue - - for name, mod in mods.items(): - nodes = [] - for n in dir(mod): - o = getattr(mod, n) - if isNodeClass(o): - #print " ", str(o) - registerNodeType(o, [(name,)], override=reloadLibs) - #nodes.append((o.nodeName, o)) - #if len(nodes) > 0: - #NODE_TREE[name] = OrderedDict(nodes) - #NODE_LIST.extend(nodes) - #NODE_LIST = OrderedDict(NODE_LIST) - -def reloadLibrary(): - loadLibrary(reloadLibs=True) - -loadLibrary() -#NODE_LIST = [] -#for o in locals().values(): - #if type(o) is type(AddNode) and issubclass(o, Node) and o is not Node and hasattr(o, 'nodeName'): - #NODE_LIST.append((o.nodeName, o)) -#NODE_LIST.sort(lambda a,b: cmp(a[0], b[0])) -#NODE_LIST = OrderedDict(NODE_LIST) \ No newline at end of file diff --git a/pyqtgraph/flowchart/library/common.py b/pyqtgraph/flowchart/library/common.py index 65f8c1fd..425fe86c 100644 --- a/pyqtgraph/flowchart/library/common.py +++ b/pyqtgraph/flowchart/library/common.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui -from pyqtgraph.widgets.SpinBox import SpinBox -#from pyqtgraph.SignalProxy import SignalProxy -from pyqtgraph.WidgetGroup import WidgetGroup +from ...Qt import QtCore, QtGui +from ...widgets.SpinBox import SpinBox +#from ...SignalProxy import SignalProxy +from ...WidgetGroup import WidgetGroup #from ColorMapper import ColorMapper from ..Node import Node import numpy as np -from pyqtgraph.widgets.ColorButton import ColorButton +from ...widgets.ColorButton import ColorButton try: import metaarray HAVE_METAARRAY = True @@ -131,6 +131,42 @@ class CtrlNode(Node): l.show() +class PlottingCtrlNode(CtrlNode): + """Abstract class for CtrlNodes that can connect to plots.""" + + def __init__(self, name, ui=None, terminals=None): + #print "PlottingCtrlNode.__init__ called." + CtrlNode.__init__(self, name, ui=ui, terminals=terminals) + self.plotTerminal = self.addOutput('plot', optional=True) + + def connected(self, term, remote): + CtrlNode.connected(self, term, remote) + if term is not self.plotTerminal: + return + node = remote.node() + node.sigPlotChanged.connect(self.connectToPlot) + self.connectToPlot(node) + + def disconnected(self, term, remote): + CtrlNode.disconnected(self, term, remote) + if term is not self.plotTerminal: + return + remote.node().sigPlotChanged.disconnect(self.connectToPlot) + self.disconnectFromPlot(remote.node().getPlot()) + + def connectToPlot(self, node): + """Define what happens when the node is connected to a plot""" + raise Exception("Must be re-implemented in subclass") + + def disconnectFromPlot(self, plot): + """Define what happens when the node is disconnected from a plot""" + raise Exception("Must be re-implemented in subclass") + + def process(self, In, display=True): + out = CtrlNode.process(self, In, display) + out['plot'] = None + return out + def metaArrayWrapper(fn): def newFn(self, data, *args, **kargs): diff --git a/pyqtgraph/flowchart/library/functions.py b/pyqtgraph/flowchart/library/functions.py index 0476e02f..338d25c4 100644 --- a/pyqtgraph/flowchart/library/functions.py +++ b/pyqtgraph/flowchart/library/functions.py @@ -1,6 +1,5 @@ -import scipy import numpy as np -from pyqtgraph.metaarray import MetaArray +from ...metaarray import MetaArray def downsample(data, n, axis=0, xvals='subsample'): """Downsample by averaging points together across axis. @@ -47,6 +46,11 @@ def downsample(data, n, axis=0, xvals='subsample'): def applyFilter(data, b, a, padding=100, bidir=True): """Apply a linear filter with coefficients a, b. Optionally pad the data before filtering and/or run the filter in both directions.""" + try: + import scipy.signal + except ImportError: + raise Exception("applyFilter() requires the package scipy.signal.") + d1 = data.view(np.ndarray) if padding > 0: @@ -67,6 +71,11 @@ def applyFilter(data, b, a, padding=100, bidir=True): def besselFilter(data, cutoff, order=1, dt=None, btype='low', bidir=True): """return data passed through bessel filter""" + try: + import scipy.signal + except ImportError: + raise Exception("besselFilter() requires the package scipy.signal.") + if dt is None: try: tvals = data.xvals('Time') @@ -85,6 +94,11 @@ def besselFilter(data, cutoff, order=1, dt=None, btype='low', bidir=True): def butterworthFilter(data, wPass, wStop=None, gPass=2.0, gStop=20.0, order=1, dt=None, btype='low', bidir=True): """return data passed through bessel filter""" + try: + import scipy.signal + except ImportError: + raise Exception("butterworthFilter() requires the package scipy.signal.") + if dt is None: try: tvals = data.xvals('Time') @@ -175,6 +189,11 @@ def denoise(data, radius=2, threshold=4): def adaptiveDetrend(data, x=None, threshold=3.0): """Return the signal with baseline removed. Discards outliers from baseline measurement.""" + try: + import scipy.signal + except ImportError: + raise Exception("adaptiveDetrend() requires the package scipy.signal.") + if x is None: x = data.xvals(0) @@ -187,7 +206,7 @@ def adaptiveDetrend(data, x=None, threshold=3.0): #d3 = where(mask, 0, d2) #d4 = d2 - lowPass(d3, cutoffs[1], dt=dt) - lr = stats.linregress(x[mask], d[mask]) + lr = scipy.stats.linregress(x[mask], d[mask]) base = lr[1] + lr[0]*x d4 = d - base diff --git a/pyqtgraph/frozenSupport.py b/pyqtgraph/frozenSupport.py index 385bb435..c42a12e1 100644 --- a/pyqtgraph/frozenSupport.py +++ b/pyqtgraph/frozenSupport.py @@ -1,52 +1,52 @@ -## Definitions helpful in frozen environments (eg py2exe) -import os, sys, zipfile - -def listdir(path): - """Replacement for os.listdir that works in frozen environments.""" - if not hasattr(sys, 'frozen'): - return os.listdir(path) - - (zipPath, archivePath) = splitZip(path) - if archivePath is None: - return os.listdir(path) - - with zipfile.ZipFile(zipPath, "r") as zipobj: - contents = zipobj.namelist() - results = set() - for name in contents: - # components in zip archive paths are always separated by forward slash - if name.startswith(archivePath) and len(name) > len(archivePath): - name = name[len(archivePath):].split('/')[0] - results.add(name) - return list(results) - -def isdir(path): - """Replacement for os.path.isdir that works in frozen environments.""" - if not hasattr(sys, 'frozen'): - return os.path.isdir(path) - - (zipPath, archivePath) = splitZip(path) - if archivePath is None: - return os.path.isdir(path) - with zipfile.ZipFile(zipPath, "r") as zipobj: - contents = zipobj.namelist() - archivePath = archivePath.rstrip('/') + '/' ## make sure there's exactly one '/' at the end - for c in contents: - if c.startswith(archivePath): - return True - return False - - -def splitZip(path): - """Splits a path containing a zip file into (zipfile, subpath). - If there is no zip file, returns (path, None)""" - components = os.path.normpath(path).split(os.sep) - for index, component in enumerate(components): - if component.endswith('.zip'): - zipPath = os.sep.join(components[0:index+1]) - archivePath = ''.join([x+'/' for x in components[index+1:]]) - return (zipPath, archivePath) - else: - return (path, None) - +## Definitions helpful in frozen environments (eg py2exe) +import os, sys, zipfile + +def listdir(path): + """Replacement for os.listdir that works in frozen environments.""" + if not hasattr(sys, 'frozen'): + return os.listdir(path) + + (zipPath, archivePath) = splitZip(path) + if archivePath is None: + return os.listdir(path) + + with zipfile.ZipFile(zipPath, "r") as zipobj: + contents = zipobj.namelist() + results = set() + for name in contents: + # components in zip archive paths are always separated by forward slash + if name.startswith(archivePath) and len(name) > len(archivePath): + name = name[len(archivePath):].split('/')[0] + results.add(name) + return list(results) + +def isdir(path): + """Replacement for os.path.isdir that works in frozen environments.""" + if not hasattr(sys, 'frozen'): + return os.path.isdir(path) + + (zipPath, archivePath) = splitZip(path) + if archivePath is None: + return os.path.isdir(path) + with zipfile.ZipFile(zipPath, "r") as zipobj: + contents = zipobj.namelist() + archivePath = archivePath.rstrip('/') + '/' ## make sure there's exactly one '/' at the end + for c in contents: + if c.startswith(archivePath): + return True + return False + + +def splitZip(path): + """Splits a path containing a zip file into (zipfile, subpath). + If there is no zip file, returns (path, None)""" + components = os.path.normpath(path).split(os.sep) + for index, component in enumerate(components): + if component.endswith('.zip'): + zipPath = os.sep.join(components[0:index+1]) + archivePath = ''.join([x+'/' for x in components[index+1:]]) + return (zipPath, archivePath) + else: + return (path, None) + \ No newline at end of file diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 337dfb67..897a123d 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -7,15 +7,19 @@ Distributed under MIT/X11 license. See license.txt for more infomation. from __future__ import division from .python2_3 import asUnicode +from .Qt import QtGui, QtCore, USE_PYSIDE Colors = { - 'b': (0,0,255,255), - 'g': (0,255,0,255), - 'r': (255,0,0,255), - 'c': (0,255,255,255), - 'm': (255,0,255,255), - 'y': (255,255,0,255), - 'k': (0,0,0,255), - 'w': (255,255,255,255), + 'b': QtGui.QColor(0,0,255,255), + 'g': QtGui.QColor(0,255,0,255), + 'r': QtGui.QColor(255,0,0,255), + 'c': QtGui.QColor(0,255,255,255), + 'm': QtGui.QColor(255,0,255,255), + 'y': QtGui.QColor(255,255,0,255), + 'k': QtGui.QColor(0,0,0,255), + 'w': QtGui.QColor(255,255,255,255), + 'd': QtGui.QColor(150,150,150,255), + 'l': QtGui.QColor(200,200,200,255), + 's': QtGui.QColor(100,100,150,255), } SI_PREFIXES = asUnicode('yzafpnµm kMGTPEZY') @@ -24,23 +28,12 @@ SI_PREFIXES_ASCII = 'yzafpnum kMGTPEZY' from .Qt import QtGui, QtCore, USE_PYSIDE -import pyqtgraph as pg +from . import getConfigOption, setConfigOptions import numpy as np import decimal, re import ctypes import sys, struct -try: - import scipy.ndimage - HAVE_SCIPY = True - if pg.getConfigOption('useWeave'): - try: - import scipy.weave - except ImportError: - pg.setConfigOptions(useWeave=False) -except ImportError: - HAVE_SCIPY = False - from . import debug def siScale(x, minVal=1e-25, allowUnicode=True): @@ -168,17 +161,15 @@ def mkColor(*args): """ err = 'Not sure how to make a color from "%s"' % str(args) if len(args) == 1: - if isinstance(args[0], QtGui.QColor): - return QtGui.QColor(args[0]) - elif isinstance(args[0], float): - r = g = b = int(args[0] * 255) - a = 255 - elif isinstance(args[0], basestring): + if isinstance(args[0], basestring): c = args[0] if c[0] == '#': c = c[1:] if len(c) == 1: - (r, g, b, a) = Colors[c] + try: + return Colors[c] + except KeyError: + raise Exception('No color named "%s"' % c) if len(c) == 3: r = int(c[0]*2, 16) g = int(c[1]*2, 16) @@ -199,6 +190,11 @@ def mkColor(*args): g = int(c[2:4], 16) b = int(c[4:6], 16) a = int(c[6:8], 16) + elif isinstance(args[0], QtGui.QColor): + return QtGui.QColor(args[0]) + elif isinstance(args[0], float): + r = g = b = int(args[0] * 255) + a = 255 elif hasattr(args[0], '__len__'): if len(args[0]) == 3: (r, g, b) = args[0] @@ -282,7 +278,7 @@ def mkPen(*args, **kargs): color = args if color is None: - color = mkColor(200, 200, 200) + color = mkColor('l') if hsv is not None: color = hsvColor(*hsv) else: @@ -376,12 +372,12 @@ def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, """ Take a slice of any orientation through an array. This is useful for extracting sections of multi-dimensional arrays such as MRI images for viewing as 1D or 2D data. - The slicing axes are aribtrary; they do not need to be orthogonal to the original data or even to each other. It is possible to use this function to extract arbitrary linear, rectangular, or parallelepiped shapes from within larger datasets. The original data is interpolated onto a new array of coordinates using scipy.ndimage.map_coordinates (see the scipy documentation for more information about this). + The slicing axes are aribtrary; they do not need to be orthogonal to the original data or even to each other. It is possible to use this function to extract arbitrary linear, rectangular, or parallelepiped shapes from within larger datasets. The original data is interpolated onto a new array of coordinates using scipy.ndimage.map_coordinates if it is available (see the scipy documentation for more information about this). If scipy is not available, then a slower implementation of map_coordinates is used. For a graphical interface to this function, see :func:`ROI.getArrayRegion ` ============== ==================================================================================================== - Arguments: + **Arguments:** *data* (ndarray) the original dataset *shape* the shape of the slice to take (Note the return value may have more dimensions than len(shape)) *origin* the location in the original dataset that will become the origin of the sliced data. @@ -415,8 +411,12 @@ def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, affineSlice(data, shape=(20,20), origin=(40,0,0), vectors=((-1, 1, 0), (-1, 0, 1)), axes=(1,2,3)) """ - if not HAVE_SCIPY: - raise Exception("This function requires the scipy library, but it does not appear to be importable.") + try: + import scipy.ndimage + have_scipy = True + except ImportError: + have_scipy = False + have_scipy = False # sanity check if len(shape) != len(vectors): @@ -438,7 +438,6 @@ def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, #print "tr1:", tr1 ## dims are now [(slice axes), (other axes)] - ## make sure vectors are arrays if not isinstance(vectors, np.ndarray): vectors = np.array(vectors) @@ -454,12 +453,18 @@ def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, #print "X values:" #print x ## iterate manually over unused axes since map_coordinates won't do it for us - extraShape = data.shape[len(axes):] - output = np.empty(tuple(shape) + extraShape, dtype=data.dtype) - for inds in np.ndindex(*extraShape): - ind = (Ellipsis,) + inds - #print data[ind].shape, x.shape, output[ind].shape, output.shape - output[ind] = scipy.ndimage.map_coordinates(data[ind], x, order=order, **kargs) + if have_scipy: + extraShape = data.shape[len(axes):] + output = np.empty(tuple(shape) + extraShape, dtype=data.dtype) + for inds in np.ndindex(*extraShape): + ind = (Ellipsis,) + inds + output[ind] = scipy.ndimage.map_coordinates(data[ind], x, order=order, **kargs) + else: + # map_coordinates expects the indexes as the first axis, whereas + # interpolateArray expects indexes at the last axis. + tr = tuple(range(1,x.ndim)) + (0,) + output = interpolateArray(data, x.transpose(tr)) + tr = list(range(output.ndim)) trb = [] @@ -476,6 +481,160 @@ def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, else: return output +def interpolateArray(data, x, default=0.0): + """ + N-dimensional interpolation similar scipy.ndimage.map_coordinates. + + This function returns linearly-interpolated values sampled from a regular + grid of data. + + *data* is an array of any shape containing the values to be interpolated. + *x* is an array with (shape[-1] <= data.ndim) containing the locations + within *data* to interpolate. + + Returns array of shape (x.shape[:-1] + data.shape) + + For example, assume we have the following 2D image data:: + + >>> data = np.array([[1, 2, 4 ], + [10, 20, 40 ], + [100, 200, 400]]) + + To compute a single interpolated point from this data:: + + >>> x = np.array([(0.5, 0.5)]) + >>> interpolateArray(data, x) + array([ 8.25]) + + To compute a 1D list of interpolated locations:: + + >>> x = np.array([(0.5, 0.5), + (1.0, 1.0), + (1.0, 2.0), + (1.5, 0.0)]) + >>> interpolateArray(data, x) + array([ 8.25, 20. , 40. , 55. ]) + + To compute a 2D array of interpolated locations:: + + >>> x = np.array([[(0.5, 0.5), (1.0, 2.0)], + [(1.0, 1.0), (1.5, 0.0)]]) + >>> interpolateArray(data, x) + array([[ 8.25, 40. ], + [ 20. , 55. ]]) + + ..and so on. The *x* argument may have any shape as long as + ```x.shape[-1] <= data.ndim```. In the case that + ```x.shape[-1] < data.ndim```, then the remaining axes are simply + broadcasted as usual. For example, we can interpolate one location + from an entire row of the data:: + + >>> x = np.array([[0.5]]) + >>> interpolateArray(data, x) + array([[ 5.5, 11. , 22. ]]) + + This is useful for interpolating from arrays of colors, vertexes, etc. + """ + + prof = debug.Profiler() + + nd = data.ndim + md = x.shape[-1] + + # First we generate arrays of indexes that are needed to + # extract the data surrounding each point + fields = np.mgrid[(slice(0,2),) * md] + xmin = np.floor(x).astype(int) + xmax = xmin + 1 + indexes = np.concatenate([xmin[np.newaxis, ...], xmax[np.newaxis, ...]]) + fieldInds = [] + totalMask = np.ones(x.shape[:-1], dtype=bool) # keep track of out-of-bound indexes + for ax in range(md): + mask = (xmin[...,ax] >= 0) & (x[...,ax] <= data.shape[ax]-1) + # keep track of points that need to be set to default + totalMask &= mask + + # ..and keep track of indexes that are out of bounds + # (note that when x[...,ax] == data.shape[ax], then xmax[...,ax] will be out + # of bounds, but the interpolation will work anyway) + mask &= (xmax[...,ax] < data.shape[ax]) + axisIndex = indexes[...,ax][fields[ax]] + #axisMask = mask.astype(np.ubyte).reshape((1,)*(fields.ndim-1) + mask.shape) + axisIndex[axisIndex < 0] = 0 + axisIndex[axisIndex >= data.shape[ax]] = 0 + fieldInds.append(axisIndex) + prof() + + # Get data values surrounding each requested point + # fieldData[..., i] contains all 2**nd values needed to interpolate x[i] + fieldData = data[tuple(fieldInds)] + prof() + + ## Interpolate + s = np.empty((md,) + fieldData.shape, dtype=float) + dx = x - xmin + # reshape fields for arithmetic against dx + for ax in range(md): + f1 = fields[ax].reshape(fields[ax].shape + (1,)*(dx.ndim-1)) + sax = f1 * dx[...,ax] + (1-f1) * (1-dx[...,ax]) + sax = sax.reshape(sax.shape + (1,) * (s.ndim-1-sax.ndim)) + s[ax] = sax + s = np.product(s, axis=0) + result = fieldData * s + for i in range(md): + result = result.sum(axis=0) + + prof() + totalMask.shape = totalMask.shape + (1,) * (nd - md) + result[~totalMask] = default + prof() + return result + + +def subArray(data, offset, shape, stride): + """ + Unpack a sub-array from *data* using the specified offset, shape, and stride. + + Note that *stride* is specified in array elements, not bytes. + For example, we have a 2x3 array packed in a 1D array as follows:: + + data = [_, _, 00, 01, 02, _, 10, 11, 12, _] + + Then we can unpack the sub-array with this call:: + + subArray(data, offset=2, shape=(2, 3), stride=(4, 1)) + + ..which returns:: + + [[00, 01, 02], + [10, 11, 12]] + + This function operates only on the first axis of *data*. So changing + the input in the example above to have shape (10, 7) would cause the + output to have shape (2, 3, 7). + """ + #data = data.flatten() + data = data[offset:] + shape = tuple(shape) + stride = tuple(stride) + extraShape = data.shape[1:] + #print data.shape, offset, shape, stride + for i in range(len(shape)): + mask = (slice(None),) * i + (slice(None, shape[i] * stride[i]),) + newShape = shape[:i+1] + if i < len(shape)-1: + newShape += (stride[i],) + newShape += extraShape + #print i, mask, newShape + #print "start:\n", data.shape, data + data = data[mask] + #print "mask:\n", data.shape, data + data = data.reshape(newShape) + #print "reshape:\n", data.shape, data + + return data + + def transformToArray(tr): """ Given a QTransform, return a 3x3 numpy array. @@ -570,17 +729,25 @@ def transformCoordinates(tr, coords, transpose=False): def solve3DTransform(points1, points2): """ Find a 3D transformation matrix that maps points1 onto points2. - Points must be specified as a list of 4 Vectors. + Points must be specified as either lists of 4 Vectors or + (4, 3) arrays. """ - if not HAVE_SCIPY: - raise Exception("This function depends on the scipy library, but it does not appear to be importable.") - A = np.array([[points1[i].x(), points1[i].y(), points1[i].z(), 1] for i in range(4)]) - B = np.array([[points2[i].x(), points2[i].y(), points2[i].z(), 1] for i in range(4)]) + import numpy.linalg + pts = [] + for inp in (points1, points2): + if isinstance(inp, np.ndarray): + A = np.empty((4,4), dtype=float) + A[:,:3] = inp[:,:3] + A[:,3] = 1.0 + else: + A = np.array([[inp[i].x(), inp[i].y(), inp[i].z(), 1] for i in range(4)]) + pts.append(A) ## solve 3 sets of linear equations to determine transformation matrix elements matrix = np.zeros((4,4)) for i in range(3): - matrix[i] = scipy.linalg.solve(A, B[:,i]) ## solve Ax = B; x is one row of the desired transformation matrix + ## solve Ax = B; x is one row of the desired transformation matrix + matrix[i] = numpy.linalg.solve(pts[0], pts[1][:,i]) return matrix @@ -593,8 +760,7 @@ def solveBilinearTransform(points1, points2): mapped = np.dot(matrix, [x*y, x, y, 1]) """ - if not HAVE_SCIPY: - raise Exception("This function depends on the scipy library, but it does not appear to be importable.") + import numpy.linalg ## A is 4 rows (points) x 4 columns (xy, x, y, 1) ## B is 4 rows (points) x 2 columns (x, y) A = np.array([[points1[i].x()*points1[i].y(), points1[i].x(), points1[i].y(), 1] for i in range(4)]) @@ -603,7 +769,7 @@ def solveBilinearTransform(points1, points2): ## solve 2 sets of linear equations to determine transformation matrix elements matrix = np.zeros((2,4)) for i in range(2): - matrix[i] = scipy.linalg.solve(A, B[:,i]) ## solve Ax = B; x is one row of the desired transformation matrix + matrix[i] = numpy.linalg.solve(A, B[:,i]) ## solve Ax = B; x is one row of the desired transformation matrix return matrix @@ -620,8 +786,12 @@ def rescaleData(data, scale, offset, dtype=None): dtype = np.dtype(dtype) try: - if not pg.getConfigOption('useWeave'): + if not getConfigOption('useWeave'): raise Exception('Weave is disabled; falling back to slower version.') + try: + import scipy.weave + except ImportError: + raise Exception('scipy.weave is not importable; falling back to slower version.') ## require native dtype when using weave if not data.dtype.isnative: @@ -647,10 +817,10 @@ def rescaleData(data, scale, offset, dtype=None): newData = newData.astype(dtype) data = newData.reshape(data.shape) except: - if pg.getConfigOption('useWeave'): - if pg.getConfigOption('weaveDebug'): + if getConfigOption('useWeave'): + if getConfigOption('weaveDebug'): debug.printExc("Error; disabling weave.") - pg.setConfigOption('useWeave', False) + setConfigOptions(useWeave=False) #p = np.poly1d([scale, -offset*scale]) #data = p(data).astype(dtype) @@ -664,68 +834,13 @@ def applyLookupTable(data, lut): Uses values in *data* as indexes to select values from *lut*. The returned data has shape data.shape + lut.shape[1:] - Uses scipy.weave to improve performance if it is available. Note: color gradient lookup tables can be generated using GradientWidget. """ if data.dtype.kind not in ('i', 'u'): data = data.astype(int) - ## using np.take appears to be faster than even the scipy.weave method and takes care of clipping as well. return np.take(lut, data, axis=0, mode='clip') - ### old methods: - #data = np.clip(data, 0, lut.shape[0]-1) - - #try: - #if not USE_WEAVE: - #raise Exception('Weave is disabled; falling back to slower version.') - - ### number of values to copy for each LUT lookup - #if lut.ndim == 1: - #ncol = 1 - #else: - #ncol = sum(lut.shape[1:]) - - ### output array - #newData = np.empty((data.size, ncol), dtype=lut.dtype) - - ### flattened input arrays - #flatData = data.flatten() - #flatLut = lut.reshape((lut.shape[0], ncol)) - - #dataSize = data.size - - ### strides for accessing each item - #newStride = newData.strides[0] / newData.dtype.itemsize - #lutStride = flatLut.strides[0] / flatLut.dtype.itemsize - #dataStride = flatData.strides[0] / flatData.dtype.itemsize - - ### strides for accessing individual values within a single LUT lookup - #newColStride = newData.strides[1] / newData.dtype.itemsize - #lutColStride = flatLut.strides[1] / flatLut.dtype.itemsize - - #code = """ - - #for( int i=0; i0 and max->*scale*:: - - rescaled = (clip(data, min, max) - min) * (*scale* / (max - min)) - - It is also possible to use a 2D (N,2) array of values for levels. In this case, - it is assumed that each pair of min,max values in the levels array should be - applied to a different subset of the input data (for example, the input data may - already have RGB values and the levels are used to independently scale each - channel). The use of this feature requires that levels.shape[0] == data.shape[-1]. - scale The maximum value to which data will be rescaled before being passed through the - lookup table (or returned if there is no lookup table). By default this will - be set to the length of the lookup table, or 256 is no lookup table is provided. - For OpenGL color specifications (as in GLColor4f) use scale=1.0 - lut Optional lookup table (array with dtype=ubyte). - Values in data will be converted to color by indexing directly from lut. - The output data shape will be input.shape + lut.shape[1:]. - - Note: the output of makeARGB will have the same dtype as the lookup table, so - for conversion to QImage, the dtype must be ubyte. - - Lookup tables can be built using GradientWidget. - useRGBA If True, the data is returned in RGBA order (useful for building OpenGL textures). - The default is False, which returns in ARGB order for use with QImage - (Note that 'ARGB' is a term used by the Qt documentation; the _actual_ order - is BGRA). - ============ ================================================================================== + ============== ================================================================================== + **Arguments:** + data numpy array of int/float types. If + levels List [min, max]; optionally rescale data before converting through the + lookup table. The data is rescaled such that min->0 and max->*scale*:: + + rescaled = (clip(data, min, max) - min) * (*scale* / (max - min)) + + It is also possible to use a 2D (N,2) array of values for levels. In this case, + it is assumed that each pair of min,max values in the levels array should be + applied to a different subset of the input data (for example, the input data may + already have RGB values and the levels are used to independently scale each + channel). The use of this feature requires that levels.shape[0] == data.shape[-1]. + scale The maximum value to which data will be rescaled before being passed through the + lookup table (or returned if there is no lookup table). By default this will + be set to the length of the lookup table, or 256 is no lookup table is provided. + For OpenGL color specifications (as in GLColor4f) use scale=1.0 + lut Optional lookup table (array with dtype=ubyte). + Values in data will be converted to color by indexing directly from lut. + The output data shape will be input.shape + lut.shape[1:]. + + Note: the output of makeARGB will have the same dtype as the lookup table, so + for conversion to QImage, the dtype must be ubyte. + + Lookup tables can be built using GradientWidget. + useRGBA If True, the data is returned in RGBA order (useful for building OpenGL textures). + The default is False, which returns in ARGB order for use with QImage + (Note that 'ARGB' is a term used by the Qt documentation; the _actual_ order + is BGRA). + ============== ================================================================================== """ - prof = debug.Profiler('functions.makeARGB', disabled=True) + profile = debug.Profiler() if lut is not None and not isinstance(lut, np.ndarray): lut = np.array(lut) if levels is not None and not isinstance(levels, np.ndarray): levels = np.array(levels) - ## sanity checks - #if data.ndim == 3: - #if data.shape[2] not in (3,4): - #raise Exception("data.shape[2] must be 3 or 4") - ##if lut is not None: - ##raise Exception("can not use lookup table with 3D data") - #elif data.ndim != 2: - #raise Exception("data must be 2D or 3D") - - #if lut is not None: - ##if lut.ndim == 2: - ##if lut.shape[1] : - ##raise Exception("lut.shape[1] must be 3 or 4") - ##elif lut.ndim != 1: - ##raise Exception("lut must be 1D or 2D") - #if lut.dtype != np.ubyte: - #raise Exception('lookup table must have dtype=ubyte (got %s instead)' % str(lut.dtype)) - - if levels is not None: if levels.ndim == 1: if len(levels) != 2: @@ -813,18 +909,8 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): else: print(levels) raise Exception("levels argument must be 1D or 2D.") - #levels = np.array(levels) - #if levels.shape == (2,): - #pass - #elif levels.shape in [(3,2), (4,2)]: - #if data.ndim == 3: - #raise Exception("Can not use 2D levels with 3D data.") - #if lut is not None: - #raise Exception('Can not use 2D levels and lookup table together.') - #else: - #raise Exception("Levels must have shape (2,) or (3,2) or (4,2)") - - prof.mark('1') + + profile() if scale is None: if lut is not None: @@ -850,9 +936,12 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): minVal, maxVal = levels if minVal == maxVal: maxVal += 1e-16 - data = rescaleData(data, scale/(maxVal-minVal), minVal, dtype=int) - prof.mark('2') + if maxVal == minVal: + data = rescaleData(data, 1, minVal, dtype=int) + else: + data = rescaleData(data, scale/(maxVal-minVal), minVal, dtype=int) + profile() ## apply LUT if given if lut is not None: @@ -860,41 +949,43 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): else: if data.dtype is not np.ubyte: data = np.clip(data, 0, 255).astype(np.ubyte) - prof.mark('3') + profile() ## copy data into ARGB ordered array imgData = np.empty(data.shape[:2]+(4,), dtype=np.ubyte) - if data.ndim == 2: - data = data[..., np.newaxis] - prof.mark('4') + profile() if useRGBA: order = [0,1,2,3] ## array comes out RGBA else: order = [2,1,0,3] ## for some reason, the colors line up as BGR in the final image. - if data.shape[2] == 1: + if data.ndim == 2: + # This is tempting: + # imgData[..., :3] = data[..., np.newaxis] + # ..but it turns out this is faster: for i in range(3): - imgData[..., order[i]] = data[..., 0] + imgData[..., i] = data + elif data.shape[2] == 1: + for i in range(3): + imgData[..., i] = data[..., 0] else: for i in range(0, data.shape[2]): - imgData[..., order[i]] = data[..., i] + imgData[..., i] = data[..., order[i]] - prof.mark('5') + profile() - if data.shape[2] == 4: - alpha = True - else: + if data.ndim == 2 or data.shape[2] == 3: alpha = False imgData[..., 3] = 255 + else: + alpha = True - prof.mark('6') - - prof.finish() + profile() return imgData, alpha - + def makeQImage(imgData, alpha=None, copy=True, transpose=True): """ @@ -904,26 +995,26 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): pointing to the array which shares its data to prevent python freeing that memory while the image is in use. - =========== =================================================================== - Arguments: - imgData Array of data to convert. Must have shape (width, height, 3 or 4) - and dtype=ubyte. The order of values in the 3rd axis must be - (b, g, r, a). - alpha If True, the QImage returned will have format ARGB32. If False, - the format will be RGB32. By default, _alpha_ is True if - array.shape[2] == 4. - copy If True, the data is copied before converting to QImage. - If False, the new QImage points directly to the data in the array. - Note that the array must be contiguous for this to work - (see numpy.ascontiguousarray). - transpose If True (the default), the array x/y axes are transposed before - creating the image. Note that Qt expects the axes to be in - (height, width) order whereas pyqtgraph usually prefers the - opposite. - =========== =================================================================== + ============== =================================================================== + **Arguments:** + imgData Array of data to convert. Must have shape (width, height, 3 or 4) + and dtype=ubyte. The order of values in the 3rd axis must be + (b, g, r, a). + alpha If True, the QImage returned will have format ARGB32. If False, + the format will be RGB32. By default, _alpha_ is True if + array.shape[2] == 4. + copy If True, the data is copied before converting to QImage. + If False, the new QImage points directly to the data in the array. + Note that the array must be contiguous for this to work + (see numpy.ascontiguousarray). + transpose If True (the default), the array x/y axes are transposed before + creating the image. Note that Qt expects the axes to be in + (height, width) order whereas pyqtgraph usually prefers the + opposite. + ============== =================================================================== """ ## create QImage from buffer - prof = debug.Profiler('functions.makeQImage', disabled=True) + profile = debug.Profiler() ## If we didn't explicitly specify alpha, check the array shape. if alpha is None: @@ -947,7 +1038,9 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): if transpose: imgData = imgData.transpose((1, 0, 2)) ## QImage expects the row/column order to be opposite - + + profile() + if not imgData.flags['C_CONTIGUOUS']: if copy is False: extra = ' (try setting transpose=False)' if transpose else '' @@ -988,11 +1081,10 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): #except AttributeError: ## happens when image data is non-contiguous #buf = imgData.data - #prof.mark('1') + #profiler() #qimage = QtGui.QImage(buf, imgData.shape[1], imgData.shape[0], imgFormat) - #prof.mark('2') + #profiler() #qimage.data = imgData - #prof.finish() #return qimage def imageToArray(img, copy=False, transpose=True): @@ -1009,6 +1101,10 @@ def imageToArray(img, copy=False, transpose=True): else: ptr.setsize(img.byteCount()) arr = np.asarray(ptr) + if img.byteCount() != arr.size * arr.itemsize: + # Required for Python 2.6, PyQt 4.10 + # If this works on all platforms, then there is no need to use np.asarray.. + arr = np.frombuffer(ptr, np.ubyte, img.byteCount()) if fmt == img.Format_RGB32: arr = arr.reshape(img.height(), img.width(), 3) @@ -1067,7 +1163,88 @@ def colorToAlpha(data, color): #raise Exception() return np.clip(output, 0, 255).astype(np.ubyte) + +def gaussianFilter(data, sigma): + """ + Drop-in replacement for scipy.ndimage.gaussian_filter. + (note: results are only approximately equal to the output of + gaussian_filter) + """ + if np.isscalar(sigma): + sigma = (sigma,) * data.ndim + + baseline = data.mean() + filtered = data - baseline + for ax in range(data.ndim): + s = sigma[ax] + if s == 0: + continue + + # generate 1D gaussian kernel + ksize = int(s * 6) + x = np.arange(-ksize, ksize) + kernel = np.exp(-x**2 / (2*s**2)) + kshape = [1,] * data.ndim + kshape[ax] = len(kernel) + kernel = kernel.reshape(kshape) + + # convolve as product of FFTs + shape = data.shape[ax] + ksize + scale = 1.0 / (abs(s) * (2*np.pi)**0.5) + filtered = scale * np.fft.irfft(np.fft.rfft(filtered, shape, axis=ax) * + np.fft.rfft(kernel, shape, axis=ax), + axis=ax) + + # clip off extra data + sl = [slice(None)] * data.ndim + sl[ax] = slice(filtered.shape[ax]-data.shape[ax],None,None) + filtered = filtered[sl] + return filtered + baseline + + +def downsample(data, n, axis=0, xvals='subsample'): + """Downsample by averaging points together across axis. + If multiple axes are specified, runs once per axis. + If a metaArray is given, then the axis values can be either subsampled + or downsampled to match. + """ + ma = None + if (hasattr(data, 'implements') and data.implements('MetaArray')): + ma = data + data = data.view(np.ndarray) + + + if hasattr(axis, '__len__'): + if not hasattr(n, '__len__'): + n = [n]*len(axis) + for i in range(len(axis)): + data = downsample(data, n[i], axis[i]) + return data + + if n <= 1: + return data + nPts = int(data.shape[axis] / n) + s = list(data.shape) + s[axis] = nPts + s.insert(axis+1, n) + sl = [slice(None)] * data.ndim + sl[axis] = slice(0, nPts*n) + d1 = data[tuple(sl)] + #print d1.shape, s + d1.shape = tuple(s) + d2 = d1.mean(axis+1) + + if ma is None: + return d2 + else: + info = ma.infoCopy() + if 'values' in info[axis]: + if xvals == 'subsample': + info[axis]['values'] = info[axis]['values'][::n][:nPts] + elif xvals == 'downsample': + info[axis]['values'] = downsample(info[axis]['values'], n) + return MetaArray(d2, info=info) def arrayToQPath(x, y, connect='all'): @@ -1112,16 +1289,16 @@ def arrayToQPath(x, y, connect='all'): path = QtGui.QPainterPath() - #prof = debug.Profiler('PlotCurveItem.generatePath', disabled=True) + #profiler = debug.Profiler() n = x.shape[0] # create empty array, pad with extra space on either end arr = np.empty(n+2, dtype=[('x', '>f8'), ('y', '>f8'), ('c', '>i4')]) # write first two integers - #prof.mark('allocate empty') + #profiler('allocate empty') byteview = arr.view(dtype=np.ubyte) byteview[:12] = 0 byteview.data[12:20] = struct.pack('>ii', n, 0) - #prof.mark('pack header') + #profiler('pack header') # Fill array with vertex values arr[1:-1]['x'] = x arr[1:-1]['y'] = y @@ -1129,6 +1306,8 @@ def arrayToQPath(x, y, connect='all'): # decide which points are connected by lines if connect == 'pairs': connect = np.empty((n/2,2), dtype=np.int32) + if connect.size != n: + raise Exception("x,y array lengths must be multiple of 2 to use connect='pairs'") connect[:,0] = 1 connect[:,1] = 0 connect = connect.flatten() @@ -1142,11 +1321,11 @@ def arrayToQPath(x, y, connect='all'): else: raise Exception('connect argument must be "all", "pairs", or array') - #prof.mark('fill array') + #profiler('fill array') # write last 0 lastInd = 20*(n+1) byteview.data[lastInd:lastInd+4] = struct.pack('>i', 0) - #prof.mark('footer') + #profiler('footer') # create datastream object and stream into path ## Avoiding this method because QByteArray(str) leaks memory in PySide @@ -1157,13 +1336,11 @@ def arrayToQPath(x, y, connect='all'): buf = QtCore.QByteArray.fromRawData(path.strn) except TypeError: buf = QtCore.QByteArray(bytes(path.strn)) - #prof.mark('create buffer') + #profiler('create buffer') ds = QtCore.QDataStream(buf) ds >> path - #prof.mark('load') - - #prof.finish() + #profiler('load') return path @@ -1258,19 +1435,19 @@ def isocurve(data, level, connected=False, extendToEdge=False, path=False): """ Generate isocurve from 2D data using marching squares algorithm. - ============= ========================================================= - Arguments - data 2D numpy array of scalar values - level The level at which to generate an isosurface - connected If False, return a single long list of point pairs - If True, return multiple long lists of connected point - locations. (This is slower but better for drawing - continuous lines) - extendToEdge If True, extend the curves to reach the exact edges of - the data. - path if True, return a QPainterPath rather than a list of - vertex coordinates. This forces connected=True. - ============= ========================================================= + ============== ========================================================= + **Arguments:** + data 2D numpy array of scalar values + level The level at which to generate an isosurface + connected If False, return a single long list of point pairs + If True, return multiple long lists of connected point + locations. (This is slower but better for drawing + continuous lines) + extendToEdge If True, extend the curves to reach the exact edges of + the data. + path if True, return a QPainterPath rather than a list of + vertex coordinates. This forces connected=True. + ============== ========================================================= This function is SLOW; plenty of room for optimization here. """ @@ -1292,30 +1469,30 @@ def isocurve(data, level, connected=False, extendToEdge=False, path=False): data = d2 sideTable = [ - [], - [0,1], - [1,2], - [0,2], - [0,3], - [1,3], - [0,1,2,3], - [2,3], - [2,3], - [0,1,2,3], - [1,3], - [0,3], - [0,2], - [1,2], - [0,1], - [] - ] + [], + [0,1], + [1,2], + [0,2], + [0,3], + [1,3], + [0,1,2,3], + [2,3], + [2,3], + [0,1,2,3], + [1,3], + [0,3], + [0,2], + [1,2], + [0,1], + [] + ] edgeKey=[ - [(0,1), (0,0)], - [(0,0), (1,0)], - [(1,0), (1,1)], - [(1,1), (0,1)] - ] + [(0,1), (0,0)], + [(0,0), (1,0)], + [(1,0), (1,1)], + [(1,1), (0,1)] + ] lines = [] @@ -1445,7 +1622,11 @@ def traceImage(image, values, smooth=0.5): If image is RGB or RGBA, then the shape of values should be (nvals, 3/4) The parameter *smooth* is expressed in pixels. """ - import scipy.ndimage as ndi + try: + import scipy.ndimage as ndi + except ImportError: + raise Exception("traceImage() requires the package scipy.ndimage, but it is not importable.") + if values.ndim == 2: values = values.T values = values[np.newaxis, np.newaxis, ...].astype(float) @@ -1459,7 +1640,7 @@ def traceImage(image, values, smooth=0.5): paths = [] for i in range(diff.shape[-1]): d = (labels==i).astype(float) - d = ndi.gaussian_filter(d, (smooth, smooth)) + d = gaussianFilter(d, (smooth, smooth)) lines = isocurve(d, 0.5, connected=True, extendToEdge=True) path = QtGui.QPainterPath() for line in lines: @@ -1499,38 +1680,39 @@ def isosurface(data, level): ## edge index tells us which edges are cut by the isosurface. ## (Data stolen from Bourk; see above.) edgeTable = np.array([ - 0x0 , 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c, - 0x80c, 0x905, 0xa0f, 0xb06, 0xc0a, 0xd03, 0xe09, 0xf00, - 0x190, 0x99 , 0x393, 0x29a, 0x596, 0x49f, 0x795, 0x69c, - 0x99c, 0x895, 0xb9f, 0xa96, 0xd9a, 0xc93, 0xf99, 0xe90, - 0x230, 0x339, 0x33 , 0x13a, 0x636, 0x73f, 0x435, 0x53c, - 0xa3c, 0xb35, 0x83f, 0x936, 0xe3a, 0xf33, 0xc39, 0xd30, - 0x3a0, 0x2a9, 0x1a3, 0xaa , 0x7a6, 0x6af, 0x5a5, 0x4ac, - 0xbac, 0xaa5, 0x9af, 0x8a6, 0xfaa, 0xea3, 0xda9, 0xca0, - 0x460, 0x569, 0x663, 0x76a, 0x66 , 0x16f, 0x265, 0x36c, - 0xc6c, 0xd65, 0xe6f, 0xf66, 0x86a, 0x963, 0xa69, 0xb60, - 0x5f0, 0x4f9, 0x7f3, 0x6fa, 0x1f6, 0xff , 0x3f5, 0x2fc, - 0xdfc, 0xcf5, 0xfff, 0xef6, 0x9fa, 0x8f3, 0xbf9, 0xaf0, - 0x650, 0x759, 0x453, 0x55a, 0x256, 0x35f, 0x55 , 0x15c, - 0xe5c, 0xf55, 0xc5f, 0xd56, 0xa5a, 0xb53, 0x859, 0x950, - 0x7c0, 0x6c9, 0x5c3, 0x4ca, 0x3c6, 0x2cf, 0x1c5, 0xcc , - 0xfcc, 0xec5, 0xdcf, 0xcc6, 0xbca, 0xac3, 0x9c9, 0x8c0, - 0x8c0, 0x9c9, 0xac3, 0xbca, 0xcc6, 0xdcf, 0xec5, 0xfcc, - 0xcc , 0x1c5, 0x2cf, 0x3c6, 0x4ca, 0x5c3, 0x6c9, 0x7c0, - 0x950, 0x859, 0xb53, 0xa5a, 0xd56, 0xc5f, 0xf55, 0xe5c, - 0x15c, 0x55 , 0x35f, 0x256, 0x55a, 0x453, 0x759, 0x650, - 0xaf0, 0xbf9, 0x8f3, 0x9fa, 0xef6, 0xfff, 0xcf5, 0xdfc, - 0x2fc, 0x3f5, 0xff , 0x1f6, 0x6fa, 0x7f3, 0x4f9, 0x5f0, - 0xb60, 0xa69, 0x963, 0x86a, 0xf66, 0xe6f, 0xd65, 0xc6c, - 0x36c, 0x265, 0x16f, 0x66 , 0x76a, 0x663, 0x569, 0x460, - 0xca0, 0xda9, 0xea3, 0xfaa, 0x8a6, 0x9af, 0xaa5, 0xbac, - 0x4ac, 0x5a5, 0x6af, 0x7a6, 0xaa , 0x1a3, 0x2a9, 0x3a0, - 0xd30, 0xc39, 0xf33, 0xe3a, 0x936, 0x83f, 0xb35, 0xa3c, - 0x53c, 0x435, 0x73f, 0x636, 0x13a, 0x33 , 0x339, 0x230, - 0xe90, 0xf99, 0xc93, 0xd9a, 0xa96, 0xb9f, 0x895, 0x99c, - 0x69c, 0x795, 0x49f, 0x596, 0x29a, 0x393, 0x99 , 0x190, - 0xf00, 0xe09, 0xd03, 0xc0a, 0xb06, 0xa0f, 0x905, 0x80c, - 0x70c, 0x605, 0x50f, 0x406, 0x30a, 0x203, 0x109, 0x0 ], dtype=np.uint16) + 0x0 , 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c, + 0x80c, 0x905, 0xa0f, 0xb06, 0xc0a, 0xd03, 0xe09, 0xf00, + 0x190, 0x99 , 0x393, 0x29a, 0x596, 0x49f, 0x795, 0x69c, + 0x99c, 0x895, 0xb9f, 0xa96, 0xd9a, 0xc93, 0xf99, 0xe90, + 0x230, 0x339, 0x33 , 0x13a, 0x636, 0x73f, 0x435, 0x53c, + 0xa3c, 0xb35, 0x83f, 0x936, 0xe3a, 0xf33, 0xc39, 0xd30, + 0x3a0, 0x2a9, 0x1a3, 0xaa , 0x7a6, 0x6af, 0x5a5, 0x4ac, + 0xbac, 0xaa5, 0x9af, 0x8a6, 0xfaa, 0xea3, 0xda9, 0xca0, + 0x460, 0x569, 0x663, 0x76a, 0x66 , 0x16f, 0x265, 0x36c, + 0xc6c, 0xd65, 0xe6f, 0xf66, 0x86a, 0x963, 0xa69, 0xb60, + 0x5f0, 0x4f9, 0x7f3, 0x6fa, 0x1f6, 0xff , 0x3f5, 0x2fc, + 0xdfc, 0xcf5, 0xfff, 0xef6, 0x9fa, 0x8f3, 0xbf9, 0xaf0, + 0x650, 0x759, 0x453, 0x55a, 0x256, 0x35f, 0x55 , 0x15c, + 0xe5c, 0xf55, 0xc5f, 0xd56, 0xa5a, 0xb53, 0x859, 0x950, + 0x7c0, 0x6c9, 0x5c3, 0x4ca, 0x3c6, 0x2cf, 0x1c5, 0xcc , + 0xfcc, 0xec5, 0xdcf, 0xcc6, 0xbca, 0xac3, 0x9c9, 0x8c0, + 0x8c0, 0x9c9, 0xac3, 0xbca, 0xcc6, 0xdcf, 0xec5, 0xfcc, + 0xcc , 0x1c5, 0x2cf, 0x3c6, 0x4ca, 0x5c3, 0x6c9, 0x7c0, + 0x950, 0x859, 0xb53, 0xa5a, 0xd56, 0xc5f, 0xf55, 0xe5c, + 0x15c, 0x55 , 0x35f, 0x256, 0x55a, 0x453, 0x759, 0x650, + 0xaf0, 0xbf9, 0x8f3, 0x9fa, 0xef6, 0xfff, 0xcf5, 0xdfc, + 0x2fc, 0x3f5, 0xff , 0x1f6, 0x6fa, 0x7f3, 0x4f9, 0x5f0, + 0xb60, 0xa69, 0x963, 0x86a, 0xf66, 0xe6f, 0xd65, 0xc6c, + 0x36c, 0x265, 0x16f, 0x66 , 0x76a, 0x663, 0x569, 0x460, + 0xca0, 0xda9, 0xea3, 0xfaa, 0x8a6, 0x9af, 0xaa5, 0xbac, + 0x4ac, 0x5a5, 0x6af, 0x7a6, 0xaa , 0x1a3, 0x2a9, 0x3a0, + 0xd30, 0xc39, 0xf33, 0xe3a, 0x936, 0x83f, 0xb35, 0xa3c, + 0x53c, 0x435, 0x73f, 0x636, 0x13a, 0x33 , 0x339, 0x230, + 0xe90, 0xf99, 0xc93, 0xd9a, 0xa96, 0xb9f, 0x895, 0x99c, + 0x69c, 0x795, 0x49f, 0x596, 0x29a, 0x393, 0x99 , 0x190, + 0xf00, 0xe09, 0xd03, 0xc0a, 0xb06, 0xa0f, 0x905, 0x80c, + 0x70c, 0x605, 0x50f, 0x406, 0x30a, 0x203, 0x109, 0x0 + ], dtype=np.uint16) ## Table of triangles to use for filling each grid cell. ## Each set of three integers tells us which three edges to @@ -1808,7 +1990,7 @@ def isosurface(data, level): [1, 1, 0, 2], [0, 1, 0, 2], #[9, 9, 9, 9] ## fake - ], dtype=np.ubyte) + ], dtype=np.uint16) # don't use ubyte here! This value gets added to cell index later; will need the extra precision. nTableFaces = np.array([len(f)/3 for f in triTable], dtype=np.ubyte) faceShiftTables = [None] for i in range(1,6): @@ -1890,7 +2072,7 @@ def isosurface(data, level): faces = np.empty((totFaces, 3), dtype=np.uint32) ptr = 0 #import debug - #p = debug.Profiler('isosurface', disabled=False) + #p = debug.Profiler() ## this helps speed up an indexing operation later on cs = np.array(cutEdges.strides)//cutEdges.itemsize @@ -1902,32 +2084,29 @@ def isosurface(data, level): for i in range(1,6): ### expensive: - #p.mark('1') + #profiler() cells = np.argwhere(nFaces == i) ## all cells which require i faces (argwhere is expensive) - #p.mark('2') + #profiler() if cells.shape[0] == 0: continue - #cellInds = index[(cells*ins[np.newaxis,:]).sum(axis=1)] cellInds = index[cells[:,0], cells[:,1], cells[:,2]] ## index values of cells to process for this round - #p.mark('3') + #profiler() ### expensive: verts = faceShiftTables[i][cellInds] - #p.mark('4') + #profiler() verts[...,:3] += cells[:,np.newaxis,np.newaxis,:] ## we now have indexes into cutEdges verts = verts.reshape((verts.shape[0]*i,)+verts.shape[2:]) - #p.mark('5') + #profiler() ### expensive: - #print verts.shape verts = (verts * cs[np.newaxis, np.newaxis, :]).sum(axis=2) - #vertInds = cutEdges[verts[...,0], verts[...,1], verts[...,2], verts[...,3]] ## and these are the vertex indexes we want. vertInds = cutEdges[verts] - #p.mark('6') + #profiler() nv = vertInds.shape[0] - #p.mark('7') + #profiler() faces[ptr:ptr+nv] = vertInds #.reshape((nv, 3)) - #p.mark('8') + #profiler() ptr += nv return vertexes, faces @@ -1942,14 +2121,16 @@ def invertQTransform(tr): bugs in that method. (specifically, Qt has floating-point precision issues when determining whether a matrix is invertible) """ - if not HAVE_SCIPY: + try: + import numpy.linalg + arr = np.array([[tr.m11(), tr.m12(), tr.m13()], [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]]) + inv = numpy.linalg.inv(arr) + return QtGui.QTransform(inv[0,0], inv[0,1], inv[0,2], inv[1,0], inv[1,1], inv[1,2], inv[2,0], inv[2,1]) + except ImportError: inv = tr.inverted() if inv[1] is False: raise Exception("Transform is not invertible.") return inv[0] - arr = np.array([[tr.m11(), tr.m12(), tr.m13()], [tr.m21(), tr.m22(), tr.m23()], [tr.m31(), tr.m32(), tr.m33()]]) - inv = scipy.linalg.inv(arr) - return QtGui.QTransform(inv[0,0], inv[0,1], inv[0,2], inv[1,0], inv[1,1], inv[1,2], inv[2,0], inv[2,1]) def pseudoScatter(data, spacing=None, shuffle=True, bidir=False): @@ -2021,3 +2202,51 @@ def pseudoScatter(data, spacing=None, shuffle=True, bidir=False): yvals[i] = y return yvals[np.argsort(inds)] ## un-shuffle values before returning + + + +def toposort(deps, nodes=None, seen=None, stack=None, depth=0): + """Topological sort. Arguments are: + deps dictionary describing dependencies where a:[b,c] means "a depends on b and c" + nodes optional, specifies list of starting nodes (these should be the nodes + which are not depended on by any other nodes). Other candidate starting + nodes will be ignored. + + Example:: + + # Sort the following graph: + # + # B ──┬─────> C <── D + # │ │ + # E <─┴─> A <─┘ + # + deps = {'a': ['b', 'c'], 'c': ['b', 'd'], 'e': ['b']} + toposort(deps) + => ['b', 'd', 'c', 'a', 'e'] + """ + # fill in empty dep lists + deps = deps.copy() + for k,v in list(deps.items()): + for k in v: + if k not in deps: + deps[k] = [] + + if nodes is None: + ## run through deps to find nodes that are not depended upon + rem = set() + for dep in deps.values(): + rem |= set(dep) + nodes = set(deps.keys()) - rem + if seen is None: + seen = set() + stack = [] + sorted = [] + for n in nodes: + if n in stack: + raise Exception("Cyclic dependency detected", stack + [n]) + if n in seen: + continue + seen.add(n) + sorted.extend( toposort(deps, deps[n], seen, stack+[n], depth=depth+1)) + sorted.append(n) + return sorted diff --git a/pyqtgraph/graphicsItems/ArrowItem.py b/pyqtgraph/graphicsItems/ArrowItem.py index dcede02a..77e6195f 100644 --- a/pyqtgraph/graphicsItems/ArrowItem.py +++ b/pyqtgraph/graphicsItems/ArrowItem.py @@ -1,5 +1,5 @@ -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph.functions as fn +from ..Qt import QtGui, QtCore +from .. import functions as fn import numpy as np __all__ = ['ArrowItem'] @@ -16,12 +16,14 @@ class ArrowItem(QtGui.QGraphicsPathItem): Arrows can be initialized with any keyword arguments accepted by the setStyle() method. """ + self.opts = {} QtGui.QGraphicsPathItem.__init__(self, opts.get('parent', None)) + if 'size' in opts: opts['headLen'] = opts['size'] if 'width' in opts: opts['headWidth'] = opts['width'] - defOpts = { + defaultOpts = { 'pxMode': True, 'angle': -150, ## If the angle is 0, the arrow points left 'pos': (0,0), @@ -33,12 +35,9 @@ class ArrowItem(QtGui.QGraphicsPathItem): 'pen': (200,200,200), 'brush': (50,50,200), } - defOpts.update(opts) + defaultOpts.update(opts) - self.setStyle(**defOpts) - - self.setPen(fn.mkPen(defOpts['pen'])) - self.setBrush(fn.mkBrush(defOpts['brush'])) + self.setStyle(**defaultOpts) self.rotate(self.opts['angle']) self.moveBy(*self.opts['pos']) @@ -48,35 +47,38 @@ class ArrowItem(QtGui.QGraphicsPathItem): Changes the appearance of the arrow. All arguments are optional: - ================= ================================================= - Keyword Arguments - angle Orientation of the arrow in degrees. Default is - 0; arrow pointing to the left. - headLen Length of the arrow head, from tip to base. - default=20 - headWidth Width of the arrow head at its base. - tipAngle Angle of the tip of the arrow in degrees. Smaller - values make a 'sharper' arrow. If tipAngle is - specified, ot overrides headWidth. default=25 - baseAngle Angle of the base of the arrow head. Default is - 0, which means that the base of the arrow head - is perpendicular to the arrow shaft. - tailLen Length of the arrow tail, measured from the base - of the arrow head to the tip of the tail. If - this value is None, no tail will be drawn. - default=None - tailWidth Width of the tail. default=3 - pen The pen used to draw the outline of the arrow. - brush The brush used to fill the arrow. - ================= ================================================= + ====================== ================================================= + **Keyword Arguments:** + angle Orientation of the arrow in degrees. Default is + 0; arrow pointing to the left. + headLen Length of the arrow head, from tip to base. + default=20 + headWidth Width of the arrow head at its base. + tipAngle Angle of the tip of the arrow in degrees. Smaller + values make a 'sharper' arrow. If tipAngle is + specified, ot overrides headWidth. default=25 + baseAngle Angle of the base of the arrow head. Default is + 0, which means that the base of the arrow head + is perpendicular to the arrow tail. + tailLen Length of the arrow tail, measured from the base + of the arrow head to the end of the tail. If + this value is None, no tail will be drawn. + default=None + tailWidth Width of the tail. default=3 + pen The pen used to draw the outline of the arrow. + brush The brush used to fill the arrow. + ====================== ================================================= """ - self.opts = opts + self.opts.update(opts) opt = dict([(k,self.opts[k]) for k in ['headLen', 'tipAngle', 'baseAngle', 'tailLen', 'tailWidth']]) self.path = fn.makeArrowPath(**opt) self.setPath(self.path) - if opts['pxMode']: + self.setPen(fn.mkPen(self.opts['pen'])) + self.setBrush(fn.mkBrush(self.opts['brush'])) + + if self.opts['pxMode']: self.setFlags(self.flags() | self.ItemIgnoresTransformations) else: self.setFlags(self.flags() & ~self.ItemIgnoresTransformations) @@ -121,4 +123,4 @@ class ArrowItem(QtGui.QGraphicsPathItem): return pad - \ No newline at end of file + diff --git a/pyqtgraph/graphicsItems/AxisItem.py b/pyqtgraph/graphicsItems/AxisItem.py index 429ff49c..b125cb7e 100644 --- a/pyqtgraph/graphicsItems/AxisItem.py +++ b/pyqtgraph/graphicsItems/AxisItem.py @@ -1,11 +1,11 @@ -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.python2_3 import asUnicode +from ..Qt import QtGui, QtCore +from ..python2_3 import asUnicode import numpy as np -from pyqtgraph.Point import Point -import pyqtgraph.debug as debug +from ..Point import Point +from .. import debug as debug import weakref -import pyqtgraph.functions as fn -import pyqtgraph as pg +from .. import functions as fn +from .. import getConfigOption from .GraphicsWidget import GraphicsWidget __all__ = ['AxisItem'] @@ -33,7 +33,6 @@ class AxisItem(GraphicsWidget): GraphicsWidget.__init__(self, parent) self.label = QtGui.QGraphicsTextItem(self) - self.showValues = showValues self.picture = None self.orientation = orientation if orientation not in ['left', 'right', 'top', 'bottom']: @@ -42,7 +41,7 @@ class AxisItem(GraphicsWidget): self.label.rotate(-90) self.style = { - 'tickTextOffset': (5, 2), ## (horizontal, vertical) spacing between text and axis + 'tickTextOffset': [5, 2], ## (horizontal, vertical) spacing between text and axis 'tickTextWidth': 30, ## space reserved for tick text 'tickTextHeight': 18, 'autoExpandTextSpace': True, ## automatically expand text space if needed @@ -53,12 +52,21 @@ class AxisItem(GraphicsWidget): (2, 0.6), ## If we already have 2 ticks with text, fill no more than 60% of the axis (4, 0.4), ## If we already have 4 ticks with text, fill no more than 40% of the axis (6, 0.2), ## If we already have 6 ticks with text, fill no more than 20% of the axis - ] + ], + 'showValues': showValues, + 'tickLength': maxTickLength, + 'maxTickLevel': 2, + 'maxTextLevel': 2, } self.textWidth = 30 ## Keeps track of maximum width / height of tick text self.textHeight = 18 + # If the user specifies a width / height, remember that setting + # indefinitely. + self.fixedWidth = None + self.fixedHeight = None + self.labelText = '' self.labelUnits = '' self.labelUnitPrefix='' @@ -66,15 +74,18 @@ class AxisItem(GraphicsWidget): self.logMode = False self.tickFont = None - self.tickLength = maxTickLength self._tickLevels = None ## used to override the automatic ticking system with explicit ticks + self._tickSpacing = None # used to override default tickSpacing method self.scale = 1.0 self.autoSIPrefix = True self.autoSIPrefixScale = 1.0 self.setRange(0, 1) - self.setPen(pen) + if pen is None: + self.setPen() + else: + self.setPen(pen) self._linkedView = None if linkView is not None: @@ -84,6 +95,73 @@ class AxisItem(GraphicsWidget): self.grid = False #self.setCacheMode(self.DeviceCoordinateCache) + + def setStyle(self, **kwds): + """ + Set various style options. + + =================== ======================================================= + Keyword Arguments: + tickLength (int) The maximum length of ticks in pixels. + Positive values point toward the text; negative + values point away. + tickTextOffset (int) reserved spacing between text and axis in px + tickTextWidth (int) Horizontal space reserved for tick text in px + tickTextHeight (int) Vertical space reserved for tick text in px + autoExpandTextSpace (bool) Automatically expand text space if the tick + strings become too long. + tickFont (QFont or None) Determines the font used for tick + values. Use None for the default font. + stopAxisAtTick (tuple: (bool min, bool max)) If True, the axis + line is drawn only as far as the last tick. + Otherwise, the line is drawn to the edge of the + AxisItem boundary. + textFillLimits (list of (tick #, % fill) tuples). This structure + determines how the AxisItem decides how many ticks + should have text appear next to them. Each tuple in + the list specifies what fraction of the axis length + may be occupied by text, given the number of ticks + that already have text displayed. For example:: + + [(0, 0.8), # Never fill more than 80% of the axis + (2, 0.6), # If we already have 2 ticks with text, + # fill no more than 60% of the axis + (4, 0.4), # If we already have 4 ticks with text, + # fill no more than 40% of the axis + (6, 0.2)] # If we already have 6 ticks with text, + # fill no more than 20% of the axis + + showValues (bool) indicates whether text is displayed adjacent + to ticks. + =================== ======================================================= + + Added in version 0.9.9 + """ + for kwd,value in kwds.items(): + if kwd not in self.style: + raise NameError("%s is not a valid style argument." % kwd) + + if kwd in ('tickLength', 'tickTextOffset', 'tickTextWidth', 'tickTextHeight'): + if not isinstance(value, int): + raise ValueError("Argument '%s' must be int" % kwd) + + if kwd == 'tickTextOffset': + if self.orientation in ('left', 'right'): + self.style['tickTextOffset'][0] = value + else: + self.style['tickTextOffset'][1] = value + elif kwd == 'stopAxisAtTick': + try: + assert len(value) == 2 and isinstance(value[0], bool) and isinstance(value[1], bool) + except: + raise ValueError("Argument 'stopAxisAtTick' must have type (bool, bool)") + self.style[kwd] = value + else: + self.style[kwd] = value + + self.picture = None + self._adjustSize() + self.update() def close(self): self.scene().removeItem(self.label) @@ -91,7 +169,11 @@ class AxisItem(GraphicsWidget): self.scene().removeItem(self) def setGrid(self, grid): - """Set the alpha value for the grid, or False to disable.""" + """Set the alpha value (0-255) for the grid, or False to disable. + + When grid lines are enabled, the axis tick lines are extended to cover + the extent of the linked ViewBox, if any. + """ self.grid = grid self.picture = None self.prepareGeometryChange() @@ -125,20 +207,15 @@ class AxisItem(GraphicsWidget): if self.orientation == 'left': p.setY(int(self.size().height()/2 + br.width()/2)) p.setX(-nudge) - #s.setWidth(10) elif self.orientation == 'right': - #s.setWidth(10) p.setY(int(self.size().height()/2 + br.width()/2)) p.setX(int(self.size().width()-br.height()+nudge)) elif self.orientation == 'top': - #s.setHeight(10) p.setY(-nudge) p.setX(int(self.size().width()/2. - br.width()/2.)) elif self.orientation == 'bottom': p.setX(int(self.size().width()/2. - br.width()/2.)) - #s.setHeight(10) p.setY(int(self.size().height()-br.height()+nudge)) - #self.label.resize(s) self.label.setPos(p) self.picture = None @@ -147,26 +224,26 @@ class AxisItem(GraphicsWidget): #self.drawLabel = show self.label.setVisible(show) if self.orientation in ['left', 'right']: - self.setWidth() + self._updateWidth() else: - self.setHeight() + self._updateHeight() if self.autoSIPrefix: self.updateAutoSIPrefix() def setLabel(self, text=None, units=None, unitPrefix=None, **args): """Set the text displayed adjacent to the axis. - ============= ============================================================= - Arguments - text The text (excluding units) to display on the label for this - axis. - units The units for this axis. Units should generally be given - without any scaling prefix (eg, 'V' instead of 'mV'). The - scaling prefix will be automatically prepended based on the - range of data displayed. - **args All extra keyword arguments become CSS style options for - the tag which will surround the axis label and units. - ============= ============================================================= + ============== ============================================================= + **Arguments:** + text The text (excluding units) to display on the label for this + axis. + units The units for this axis. Units should generally be given + without any scaling prefix (eg, 'V' instead of 'mV'). The + scaling prefix will be automatically prepended based on the + range of data displayed. + **args All extra keyword arguments become CSS style options for + the tag which will surround the axis label and units. + ============== ============================================================= The final text generated for the label will look like:: @@ -219,69 +296,101 @@ class AxisItem(GraphicsWidget): if mx > self.textWidth or mx < self.textWidth-10: self.textWidth = mx if self.style['autoExpandTextSpace'] is True: - self.setWidth() + self._updateWidth() #return True ## size has changed else: mx = max(self.textHeight, x) if mx > self.textHeight or mx < self.textHeight-10: self.textHeight = mx if self.style['autoExpandTextSpace'] is True: - self.setHeight() + self._updateHeight() #return True ## size has changed def _adjustSize(self): if self.orientation in ['left', 'right']: - self.setWidth() + self._updateWidth() else: - self.setHeight() + self._updateHeight() def setHeight(self, h=None): """Set the height of this axis reserved for ticks and tick labels. - The height of the axis label is automatically added.""" - if h is None: - if self.style['autoExpandTextSpace'] is True: - h = self.textHeight + The height of the axis label is automatically added. + + If *height* is None, then the value will be determined automatically + based on the size of the tick text.""" + self.fixedHeight = h + self._updateHeight() + + def _updateHeight(self): + if not self.isVisible(): + h = 0 + else: + if self.fixedHeight is None: + if not self.style['showValues']: + h = 0 + elif self.style['autoExpandTextSpace'] is True: + h = self.textHeight + else: + h = self.style['tickTextHeight'] + h += self.style['tickTextOffset'][1] if self.style['showValues'] else 0 + h += max(0, self.style['tickLength']) + if self.label.isVisible(): + h += self.label.boundingRect().height() * 0.8 else: - h = self.style['tickTextHeight'] - h += max(0, self.tickLength) + self.style['tickTextOffset'][1] - if self.label.isVisible(): - h += self.label.boundingRect().height() * 0.8 + h = self.fixedHeight + self.setMaximumHeight(h) self.setMinimumHeight(h) self.picture = None - def setWidth(self, w=None): """Set the width of this axis reserved for ticks and tick labels. - The width of the axis label is automatically added.""" - if w is None: - if self.style['autoExpandTextSpace'] is True: - w = self.textWidth + The width of the axis label is automatically added. + + If *width* is None, then the value will be determined automatically + based on the size of the tick text.""" + self.fixedWidth = w + self._updateWidth() + + def _updateWidth(self): + if not self.isVisible(): + w = 0 + else: + if self.fixedWidth is None: + if not self.style['showValues']: + w = 0 + elif self.style['autoExpandTextSpace'] is True: + w = self.textWidth + else: + w = self.style['tickTextWidth'] + w += self.style['tickTextOffset'][0] if self.style['showValues'] else 0 + w += max(0, self.style['tickLength']) + if self.label.isVisible(): + w += self.label.boundingRect().height() * 0.8 ## bounding rect is usually an overestimate else: - w = self.style['tickTextWidth'] - w += max(0, self.tickLength) + self.style['tickTextOffset'][0] - if self.label.isVisible(): - w += self.label.boundingRect().height() * 0.8 ## bounding rect is usually an overestimate + w = self.fixedWidth + self.setMaximumWidth(w) self.setMinimumWidth(w) self.picture = None def pen(self): if self._pen is None: - return fn.mkPen(pg.getConfigOption('foreground')) - return pg.mkPen(self._pen) + return fn.mkPen(getConfigOption('foreground')) + return fn.mkPen(self._pen) - def setPen(self, pen): + def setPen(self, *args, **kwargs): """ Set the pen used for drawing text, axes, ticks, and grid lines. - if pen == None, the default will be used (see :func:`setConfigOption - `) + If no arguments are given, the default foreground color will be used + (see :func:`setConfigOption `). """ - self._pen = pen self.picture = None - if pen is None: - pen = pg.getConfigOption('foreground') - self.labelStyle['color'] = '#' + pg.colorStr(pg.mkPen(pen).color())[:6] + if args or kwargs: + self._pen = fn.mkPen(*args, **kwargs) + else: + self._pen = fn.mkPen(getConfigOption('foreground')) + self.labelStyle['color'] = '#' + fn.colorStr(self._pen.color())[:6] self.setLabel() self.update() @@ -383,7 +492,10 @@ class AxisItem(GraphicsWidget): else: if newRange is None: newRange = view.viewRange()[0] - self.setRange(*newRange) + if view.xInverted(): + self.setRange(*newRange[::-1]) + else: + self.setRange(*newRange) def boundingRect(self): linkedView = self.linkedView() @@ -391,38 +503,36 @@ class AxisItem(GraphicsWidget): rect = self.mapRectFromParent(self.geometry()) ## extend rect if ticks go in negative direction ## also extend to account for text that flows past the edges + tl = self.style['tickLength'] if self.orientation == 'left': - rect = rect.adjusted(0, -15, -min(0,self.tickLength), 15) + rect = rect.adjusted(0, -15, -min(0,tl), 15) elif self.orientation == 'right': - rect = rect.adjusted(min(0,self.tickLength), -15, 0, 15) + rect = rect.adjusted(min(0,tl), -15, 0, 15) elif self.orientation == 'top': - rect = rect.adjusted(-15, 0, 15, -min(0,self.tickLength)) + rect = rect.adjusted(-15, 0, 15, -min(0,tl)) elif self.orientation == 'bottom': - rect = rect.adjusted(-15, min(0,self.tickLength), 15, 0) + rect = rect.adjusted(-15, min(0,tl), 15, 0) return rect else: return self.mapRectFromParent(self.geometry()) | linkedView.mapRectToItem(self, linkedView.boundingRect()) def paint(self, p, opt, widget): - prof = debug.Profiler('AxisItem.paint', disabled=True) + profiler = debug.Profiler() if self.picture is None: try: picture = QtGui.QPicture() painter = QtGui.QPainter(picture) specs = self.generateDrawSpecs(painter) - prof.mark('generate specs') + profiler('generate specs') if specs is not None: self.drawPicture(painter, *specs) - prof.mark('draw picture') + profiler('draw picture') finally: painter.end() self.picture = picture #p.setRenderHint(p.Antialiasing, False) ## Sometimes we get a segfault here ??? #p.setRenderHint(p.TextAntialiasing, True) self.picture.play(p) - prof.finish() - - def setTicks(self, ticks): """Explicitly determine which ticks to display. @@ -441,6 +551,37 @@ class AxisItem(GraphicsWidget): self.picture = None self.update() + def setTickSpacing(self, major=None, minor=None, levels=None): + """ + Explicitly determine the spacing of major and minor ticks. This + overrides the default behavior of the tickSpacing method, and disables + the effect of setTicks(). Arguments may be either *major* and *minor*, + or *levels* which is a list of (spacing, offset) tuples for each + tick level desired. + + If no arguments are given, then the default behavior of tickSpacing + is enabled. + + Examples:: + + # two levels, all offsets = 0 + axis.setTickSpacing(5, 1) + # three levels, all offsets = 0 + axis.setTickSpacing([(3, 0), (1, 0), (0.25, 0)]) + # reset to default + axis.setTickSpacing() + """ + + if levels is None: + if major is None: + levels = None + else: + levels = [(major, 0), (minor, 0)] + self._tickSpacing = levels + self.picture = None + self.update() + + def tickSpacing(self, minVal, maxVal, size): """Return values describing the desired spacing and offset of ticks. @@ -456,13 +597,16 @@ class AxisItem(GraphicsWidget): ... ] """ + # First check for override tick spacing + if self._tickSpacing is not None: + return self._tickSpacing + dif = abs(maxVal - minVal) if dif == 0: return [] ## decide optimal minor tick spacing in pixels (this is just aesthetics) - pixelSpacing = size / np.log(size) - optimalTickCount = max(2., size / pixelSpacing) + optimalTickCount = max(2., np.log(size)) ## optimal minor tick spacing optimalSpacing = dif / optimalTickCount @@ -482,12 +626,13 @@ class AxisItem(GraphicsWidget): #(intervals[minorIndex], 0) ## Pretty, but eats up CPU ] - ## decide whether to include the last level of ticks - minSpacing = min(size / 20., 30.) - maxTickCount = size / minSpacing - if dif / intervals[minorIndex] <= maxTickCount: - levels.append((intervals[minorIndex], 0)) - return levels + if self.style['maxTickLevel'] >= 2: + ## decide whether to include the last level of ticks + minSpacing = min(size / 20., 30.) + maxTickCount = size / minSpacing + if dif / intervals[minorIndex] <= maxTickCount: + levels.append((intervals[minorIndex], 0)) + return levels @@ -513,8 +658,6 @@ class AxisItem(GraphicsWidget): #(intervals[intIndexes[0]], 0) #] - - def tickValues(self, minVal, maxVal, size): """ Return the values and spacing of ticks to draw:: @@ -622,12 +765,12 @@ class AxisItem(GraphicsWidget): def generateDrawSpecs(self, p): """ - Calls tickValues() and tickStrings to determine where and how ticks should + Calls tickValues() and tickStrings() to determine where and how ticks should be drawn, then generates from this a set of drawing commands to be interpreted by drawPicture(). """ - prof = debug.Profiler("AxisItem.generateDrawSpecs", disabled=True) - + profiler = debug.Profiler() + #bounds = self.boundingRect() bounds = self.mapRectFromParent(self.geometry()) @@ -671,6 +814,7 @@ class AxisItem(GraphicsWidget): if lengthInPixels == 0: return + # Determine major / minor / subminor axis ticks if self._tickLevels is None: tickLevels = self.tickValues(self.range[0], self.range[1], lengthInPixels) tickStrings = None @@ -687,12 +831,10 @@ class AxisItem(GraphicsWidget): values.append(val) strings.append(strn) - textLevel = 1 ## draw text at this scale level - ## determine mapping between tick values and local coordinates dif = self.range[1] - self.range[0] if dif == 0: - xscale = 1 + xScale = 1 offset = 0 else: if axis == 0: @@ -706,12 +848,11 @@ class AxisItem(GraphicsWidget): xMin = min(xRange) xMax = max(xRange) - prof.mark('init') + profiler('init') tickPositions = [] # remembers positions of previously drawn ticks - ## draw ticks - ## (to improve performance, we do not interleave line and text drawing, since this causes unnecessary pipeline switching) + ## compute coordinates to draw ticks ## draw three different intervals, long ticks first tickSpecs = [] for i in range(len(tickLevels)): @@ -719,7 +860,7 @@ class AxisItem(GraphicsWidget): ticks = tickLevels[i][1] ## length of tick - tickLength = self.tickLength / ((i*0.5)+1.0) + tickLength = self.style['tickLength'] / ((i*0.5)+1.0) lineAlpha = 255 / (i+1) if self.grid is not False: @@ -744,9 +885,8 @@ class AxisItem(GraphicsWidget): color.setAlpha(lineAlpha) tickPen.setColor(color) tickSpecs.append((tickPen, Point(p1), Point(p2))) - prof.mark('compute ticks') + profiler('compute ticks') - ## This is where the long axis line should be drawn if self.style['stopAxisAtTick'][0] is True: stop = max(span[0].y(), min(map(min, tickPositions))) @@ -763,7 +903,6 @@ class AxisItem(GraphicsWidget): axisSpec = (self.pen(), span[0], span[1]) - textOffset = self.style['tickTextOffset'][axis] ## spacing between axis and text #if self.style['autoExpandTextSpace'] is True: #textWidth = self.textWidth @@ -775,8 +914,12 @@ class AxisItem(GraphicsWidget): textSize2 = 0 textRects = [] textSpecs = [] ## list of draw - textSize2 = 0 - for i in range(len(tickLevels)): + + # If values are hidden, return early + if not self.style['showValues']: + return (axisSpec, tickSpecs, textSpecs) + + for i in range(min(len(tickLevels), self.style['maxTextLevel']+1)): ## Get the list of strings to display for this level if tickStrings is None: spacing, values = tickLevels[i] @@ -798,7 +941,7 @@ class AxisItem(GraphicsWidget): if s is None: rects.append(None) else: - br = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, str(s)) + br = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, asUnicode(s)) ## boundingRect is usually just a bit too large ## (but this probably depends on per-font metrics?) br.setHeight(br.height() * 0.8) @@ -806,7 +949,7 @@ class AxisItem(GraphicsWidget): rects.append(br) textRects.append(rects[-1]) - if i > 0: ## always draw top level + if len(textRects) > 0: ## measure all text, make sure there's enough room if axis == 0: textSize = np.sum([r.height() for r in textRects]) @@ -814,7 +957,11 @@ class AxisItem(GraphicsWidget): else: textSize = np.sum([r.width() for r in textRects]) textSize2 = np.max([r.height() for r in textRects]) + else: + textSize = 0 + textSize2 = 0 + if i > 0: ## always draw top level ## If the strings are too crowded, stop drawing text now. ## We use three different crowding limits based on the number ## of texts drawn so far. @@ -829,18 +976,19 @@ class AxisItem(GraphicsWidget): #spacing, values = tickLevels[best] #strings = self.tickStrings(values, self.scale, spacing) + # Determine exactly where tick text should be drawn for j in range(len(strings)): vstr = strings[j] if vstr is None: ## this tick was ignored because it is out of bounds continue - vstr = str(vstr) + vstr = asUnicode(vstr) x = tickPositions[i][j] #textRect = p.boundingRect(QtCore.QRectF(0, 0, 100, 100), QtCore.Qt.AlignCenter, vstr) textRect = rects[j] height = textRect.height() width = textRect.width() #self.textHeight = height - offset = max(0,self.tickLength) + textOffset + offset = max(0,self.style['tickLength']) + textOffset if self.orientation == 'left': textFlags = QtCore.Qt.TextDontClip|QtCore.Qt.AlignRight|QtCore.Qt.AlignVCenter rect = QtCore.QRectF(tickStop-offset-width, x-(height/2), width, height) @@ -857,16 +1005,16 @@ class AxisItem(GraphicsWidget): #p.setPen(self.pen()) #p.drawText(rect, textFlags, vstr) textSpecs.append((rect, textFlags, vstr)) - prof.mark('compute text') - + profiler('compute text') + ## update max text size if needed. self._updateMaxTextSize(textSize2) return (axisSpec, tickSpecs, textSpecs) def drawPicture(self, p, axisSpec, tickSpecs, textSpecs): - prof = debug.Profiler("AxisItem.drawPicture", disabled=True) - + profiler = debug.Profiler() + p.setRenderHint(p.Antialiasing, False) p.setRenderHint(p.TextAntialiasing, True) @@ -880,8 +1028,8 @@ class AxisItem(GraphicsWidget): for pen, p1, p2 in tickSpecs: p.setPen(pen) p.drawLine(p1, p2) - prof.mark('draw ticks') - + profiler('draw ticks') + ## Draw all text if self.tickFont is not None: p.setFont(self.tickFont) @@ -889,24 +1037,21 @@ class AxisItem(GraphicsWidget): for rect, flags, text in textSpecs: p.drawText(rect, flags, text) #p.drawRect(rect) - - prof.mark('draw text') - prof.finish() - + profiler('draw text') + def show(self): - - if self.orientation in ['left', 'right']: - self.setWidth() - else: - self.setHeight() GraphicsWidget.show(self) + if self.orientation in ['left', 'right']: + self._updateWidth() + else: + self._updateHeight() def hide(self): - if self.orientation in ['left', 'right']: - self.setWidth(0) - else: - self.setHeight(0) GraphicsWidget.hide(self) + if self.orientation in ['left', 'right']: + self._updateWidth() + else: + self._updateHeight() def wheelEvent(self, ev): if self.linkedView() is None: diff --git a/pyqtgraph/graphicsItems/BarGraphItem.py b/pyqtgraph/graphicsItems/BarGraphItem.py index 0527e9f1..a1d5d029 100644 --- a/pyqtgraph/graphicsItems/BarGraphItem.py +++ b/pyqtgraph/graphicsItems/BarGraphItem.py @@ -1,8 +1,10 @@ -import pyqtgraph as pg -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .GraphicsObject import GraphicsObject +from .. import getConfigOption +from .. import functions as fn import numpy as np + __all__ = ['BarGraphItem'] class BarGraphItem(GraphicsObject): @@ -45,23 +47,27 @@ class BarGraphItem(GraphicsObject): pens=None, brushes=None, ) + self._shape = None + self.picture = None self.setOpts(**opts) def setOpts(self, **opts): self.opts.update(opts) self.picture = None + self._shape = None self.update() self.informViewBoundsChanged() def drawPicture(self): self.picture = QtGui.QPicture() + self._shape = QtGui.QPainterPath() p = QtGui.QPainter(self.picture) pen = self.opts['pen'] pens = self.opts['pens'] if pen is None and pens is None: - pen = pg.getConfigOption('foreground') + pen = getConfigOption('foreground') brush = self.opts['brush'] brushes = self.opts['brushes'] @@ -112,14 +118,18 @@ class BarGraphItem(GraphicsObject): raise Exception('must specify either y1 or height') height = y1 - y0 - p.setPen(pg.mkPen(pen)) - p.setBrush(pg.mkBrush(brush)) + p.setPen(fn.mkPen(pen)) + p.setBrush(fn.mkBrush(brush)) for i in range(len(x0)): if pens is not None: - p.setPen(pg.mkPen(pens[i])) + p.setPen(fn.mkPen(pens[i])) if brushes is not None: - p.setBrush(pg.mkBrush(brushes[i])) + p.setBrush(fn.mkBrush(brushes[i])) + if np.isscalar(x0): + x = x0 + else: + x = x0[i] if np.isscalar(y0): y = y0 else: @@ -128,9 +138,15 @@ class BarGraphItem(GraphicsObject): w = width else: w = width[i] - - p.drawRect(QtCore.QRectF(x0[i], y, w, height[i])) - + if np.isscalar(height): + h = height + else: + h = height[i] + + + rect = QtCore.QRectF(x, y, w, h) + p.drawRect(rect) + self._shape.addRect(rect) p.end() self.prepareGeometryChange() @@ -146,4 +162,7 @@ class BarGraphItem(GraphicsObject): self.drawPicture() return QtCore.QRectF(self.picture.boundingRect()) - \ No newline at end of file + def shape(self): + if self.picture is None: + self.drawPicture() + return self._shape diff --git a/pyqtgraph/graphicsItems/ButtonItem.py b/pyqtgraph/graphicsItems/ButtonItem.py index 741f2666..1c796823 100644 --- a/pyqtgraph/graphicsItems/ButtonItem.py +++ b/pyqtgraph/graphicsItems/ButtonItem.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .GraphicsObject import GraphicsObject __all__ = ['ButtonItem'] diff --git a/pyqtgraph/graphicsItems/CurvePoint.py b/pyqtgraph/graphicsItems/CurvePoint.py index 668830f7..bb6beebc 100644 --- a/pyqtgraph/graphicsItems/CurvePoint.py +++ b/pyqtgraph/graphicsItems/CurvePoint.py @@ -1,7 +1,7 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from . import ArrowItem import numpy as np -from pyqtgraph.Point import Point +from ..Point import Point import weakref from .GraphicsObject import GraphicsObject @@ -112,6 +112,6 @@ class CurveArrow(CurvePoint): self.arrow = ArrowItem.ArrowItem(**opts) self.arrow.setParentItem(self) - def setStyle(**opts): + def setStyle(self, **opts): return self.arrow.setStyle(**opts) diff --git a/pyqtgraph/graphicsItems/ErrorBarItem.py b/pyqtgraph/graphicsItems/ErrorBarItem.py index 656b9e2e..986c5140 100644 --- a/pyqtgraph/graphicsItems/ErrorBarItem.py +++ b/pyqtgraph/graphicsItems/ErrorBarItem.py @@ -1,21 +1,14 @@ -import pyqtgraph as pg -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .GraphicsObject import GraphicsObject +from .. import getConfigOption +from .. import functions as fn __all__ = ['ErrorBarItem'] class ErrorBarItem(GraphicsObject): def __init__(self, **opts): """ - Valid keyword options are: - x, y, height, width, top, bottom, left, right, beam, pen - - x and y must be numpy arrays specifying the coordinates of data points. - height, width, top, bottom, left, right, and beam may be numpy arrays, - single values, or None to disable. All values should be positive. - - If height is specified, it overrides top and bottom. - If width is specified, it overrides left and right. + All keyword arguments are passed to setData(). """ GraphicsObject.__init__(self) self.opts = dict( @@ -30,14 +23,37 @@ class ErrorBarItem(GraphicsObject): beam=None, pen=None ) - self.setOpts(**opts) + self.setData(**opts) + + def setData(self, **opts): + """ + Update the data in the item. All arguments are optional. - def setOpts(self, **opts): + Valid keyword options are: + x, y, height, width, top, bottom, left, right, beam, pen + + * x and y must be numpy arrays specifying the coordinates of data points. + * height, width, top, bottom, left, right, and beam may be numpy arrays, + single values, or None to disable. All values should be positive. + * top, bottom, left, and right specify the lengths of bars extending + in each direction. + * If height is specified, it overrides top and bottom. + * If width is specified, it overrides left and right. + * beam specifies the width of the beam at the end of each bar. + * pen may be any single argument accepted by pg.mkPen(). + + This method was added in version 0.9.9. For prior versions, use setOpts. + """ self.opts.update(opts) self.path = None self.update() + self.prepareGeometryChange() self.informViewBoundsChanged() + def setOpts(self, **opts): + # for backward compatibility + self.setData(**opts) + def drawPath(self): p = QtGui.QPainterPath() @@ -121,8 +137,8 @@ class ErrorBarItem(GraphicsObject): self.drawPath() pen = self.opts['pen'] if pen is None: - pen = pg.getConfigOption('foreground') - p.setPen(pg.mkPen(pen)) + pen = getConfigOption('foreground') + p.setPen(fn.mkPen(pen)) p.drawPath(self.path) def boundingRect(self): diff --git a/pyqtgraph/graphicsItems/FillBetweenItem.py b/pyqtgraph/graphicsItems/FillBetweenItem.py index e0011177..15a14f86 100644 --- a/pyqtgraph/graphicsItems/FillBetweenItem.py +++ b/pyqtgraph/graphicsItems/FillBetweenItem.py @@ -1,23 +1,73 @@ -import pyqtgraph as pg +from ..Qt import QtGui +from .. import functions as fn +from .PlotDataItem import PlotDataItem +from .PlotCurveItem import PlotCurveItem -class FillBetweenItem(pg.QtGui.QGraphicsPathItem): +class FillBetweenItem(QtGui.QGraphicsPathItem): """ GraphicsItem filling the space between two PlotDataItems. """ - def __init__(self, p1, p2, brush=None): - pg.QtGui.QGraphicsPathItem.__init__(self) - self.p1 = p1 - self.p2 = p2 - p1.sigPlotChanged.connect(self.updatePath) - p2.sigPlotChanged.connect(self.updatePath) + def __init__(self, curve1=None, curve2=None, brush=None): + QtGui.QGraphicsPathItem.__init__(self) + self.curves = None + if curve1 is not None and curve2 is not None: + self.setCurves(curve1, curve2) + elif curve1 is not None or curve2 is not None: + raise Exception("Must specify two curves to fill between.") + if brush is not None: - self.setBrush(pg.mkBrush(brush)) - self.setZValue(min(p1.zValue(), p2.zValue())-1) + self.setBrush(fn.mkBrush(brush)) + self.updatePath() + + def setCurves(self, curve1, curve2): + """Set the curves to fill between. + + Arguments must be instances of PlotDataItem or PlotCurveItem. + + Added in version 0.9.9 + """ + + if self.curves is not None: + for c in self.curves: + try: + c.sigPlotChanged.disconnect(self.curveChanged) + except (TypeError, RuntimeError): + pass + + curves = [curve1, curve2] + for c in curves: + if not isinstance(c, PlotDataItem) and not isinstance(c, PlotCurveItem): + raise TypeError("Curves must be PlotDataItem or PlotCurveItem.") + self.curves = curves + curve1.sigPlotChanged.connect(self.curveChanged) + curve2.sigPlotChanged.connect(self.curveChanged) + self.setZValue(min(curve1.zValue(), curve2.zValue())-1) + self.curveChanged() + + def setBrush(self, *args, **kwds): + """Change the fill brush. Acceps the same arguments as pg.mkBrush()""" + QtGui.QGraphicsPathItem.setBrush(self, fn.mkBrush(*args, **kwds)) + + def curveChanged(self): self.updatePath() def updatePath(self): - p1 = self.p1.curve.path - p2 = self.p2.curve.path - path = pg.QtGui.QPainterPath() - path.addPolygon(p1.toSubpathPolygons()[0] + p2.toReversed().toSubpathPolygons()[0]) + if self.curves is None: + self.setPath(QtGui.QPainterPath()) + return + paths = [] + for c in self.curves: + if isinstance(c, PlotDataItem): + paths.append(c.curve.getPath()) + elif isinstance(c, PlotCurveItem): + paths.append(c.getPath()) + + path = QtGui.QPainterPath() + p1 = paths[0].toSubpathPolygons() + p2 = paths[1].toReversed().toSubpathPolygons() + if len(p1) == 0 or len(p2) == 0: + self.setPath(QtGui.QPainterPath()) + return + + path.addPolygon(p1[0] + p2[0]) self.setPath(path) diff --git a/pyqtgraph/graphicsItems/GradientEditorItem.py b/pyqtgraph/graphicsItems/GradientEditorItem.py index 955106d8..a151798a 100644 --- a/pyqtgraph/graphicsItems/GradientEditorItem.py +++ b/pyqtgraph/graphicsItems/GradientEditorItem.py @@ -1,11 +1,12 @@ -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.python2_3 import sortList -import pyqtgraph.functions as fn +from ..Qt import QtGui, QtCore +from ..python2_3 import sortList +from .. import functions as fn from .GraphicsObject import GraphicsObject from .GraphicsWidget import GraphicsWidget +from ..widgets.SpinBox import SpinBox import weakref -from pyqtgraph.pgcollections import OrderedDict -from pyqtgraph.colormap import ColorMap +from ..pgcollections import OrderedDict +from ..colormap import ColorMap import numpy as np @@ -35,14 +36,14 @@ class TickSliderItem(GraphicsWidget): def __init__(self, orientation='bottom', allowAdd=True, **kargs): """ - ============= ================================================================================= - **Arguments** - orientation Set the orientation of the gradient. Options are: 'left', 'right' - 'top', and 'bottom'. - allowAdd Specifies whether ticks can be added to the item by the user. - tickPen Default is white. Specifies the color of the outline of the ticks. - Can be any of the valid arguments for :func:`mkPen ` - ============= ================================================================================= + ============== ================================================================================= + **Arguments:** + orientation Set the orientation of the gradient. Options are: 'left', 'right' + 'top', and 'bottom'. + allowAdd Specifies whether ticks can be added to the item by the user. + tickPen Default is white. Specifies the color of the outline of the ticks. + Can be any of the valid arguments for :func:`mkPen ` + ============== ================================================================================= """ ## public GraphicsWidget.__init__(self) @@ -103,13 +104,13 @@ class TickSliderItem(GraphicsWidget): ## public """Set the orientation of the TickSliderItem. - ============= =================================================================== - **Arguments** - orientation Options are: 'left', 'right', 'top', 'bottom' - The orientation option specifies which side of the slider the - ticks are on, as well as whether the slider is vertical ('right' - and 'left') or horizontal ('top' and 'bottom'). - ============= =================================================================== + ============== =================================================================== + **Arguments:** + orientation Options are: 'left', 'right', 'top', 'bottom' + The orientation option specifies which side of the slider the + ticks are on, as well as whether the slider is vertical ('right' + and 'left') or horizontal ('top' and 'bottom'). + ============== =================================================================== """ self.orientation = orientation self.setMaxDim() @@ -136,13 +137,13 @@ class TickSliderItem(GraphicsWidget): """ Add a tick to the item. - ============= ================================================================== - **Arguments** - x Position where tick should be added. - color Color of added tick. If color is not specified, the color will be - white. - movable Specifies whether the tick is movable with the mouse. - ============= ================================================================== + ============== ================================================================== + **Arguments:** + x Position where tick should be added. + color Color of added tick. If color is not specified, the color will be + white. + movable Specifies whether the tick is movable with the mouse. + ============== ================================================================== """ if color is None: @@ -265,14 +266,14 @@ class TickSliderItem(GraphicsWidget): def setTickColor(self, tick, color): """Set the color of the specified tick. - ============= ================================================================== - **Arguments** - tick Can be either an integer corresponding to the index of the tick - or a Tick object. Ex: if you had a slider with 3 ticks and you - wanted to change the middle tick, the index would be 1. - color The color to make the tick. Can be any argument that is valid for - :func:`mkBrush ` - ============= ================================================================== + ============== ================================================================== + **Arguments:** + tick Can be either an integer corresponding to the index of the tick + or a Tick object. Ex: if you had a slider with 3 ticks and you + wanted to change the middle tick, the index would be 1. + color The color to make the tick. Can be any argument that is valid for + :func:`mkBrush ` + ============== ================================================================== """ tick = self.getTick(tick) tick.color = color @@ -284,14 +285,14 @@ class TickSliderItem(GraphicsWidget): """ Set the position (along the slider) of the tick. - ============= ================================================================== - **Arguments** - tick Can be either an integer corresponding to the index of the tick - or a Tick object. Ex: if you had a slider with 3 ticks and you - wanted to change the middle tick, the index would be 1. - val The desired position of the tick. If val is < 0, position will be - set to 0. If val is > 1, position will be set to 1. - ============= ================================================================== + ============== ================================================================== + **Arguments:** + tick Can be either an integer corresponding to the index of the tick + or a Tick object. Ex: if you had a slider with 3 ticks and you + wanted to change the middle tick, the index would be 1. + val The desired position of the tick. If val is < 0, position will be + set to 0. If val is > 1, position will be set to 1. + ============== ================================================================== """ tick = self.getTick(tick) val = min(max(0.0, val), 1.0) @@ -300,17 +301,18 @@ class TickSliderItem(GraphicsWidget): pos.setX(x) tick.setPos(pos) self.ticks[tick] = val + self.updateGradient() def tickValue(self, tick): ## public """Return the value (from 0.0 to 1.0) of the specified tick. - ============= ================================================================== - **Arguments** - tick Can be either an integer corresponding to the index of the tick - or a Tick object. Ex: if you had a slider with 3 ticks and you - wanted the value of the middle tick, the index would be 1. - ============= ================================================================== + ============== ================================================================== + **Arguments:** + tick Can be either an integer corresponding to the index of the tick + or a Tick object. Ex: if you had a slider with 3 ticks and you + wanted the value of the middle tick, the index would be 1. + ============== ================================================================== """ tick = self.getTick(tick) return self.ticks[tick] @@ -319,11 +321,11 @@ class TickSliderItem(GraphicsWidget): ## public """Return the Tick object at the specified index. - ============= ================================================================== - **Arguments** - tick An integer corresponding to the index of the desired tick. If the - argument is not an integer it will be returned unchanged. - ============= ================================================================== + ============== ================================================================== + **Arguments:** + tick An integer corresponding to the index of the desired tick. If the + argument is not an integer it will be returned unchanged. + ============== ================================================================== """ if type(tick) is int: tick = self.listTicks()[tick][0] @@ -349,7 +351,7 @@ class GradientEditorItem(TickSliderItem): with a GradientEditorItem that can be added to a GUI. ================================ =========================================================== - **Signals** + **Signals:** sigGradientChanged(self) Signal is emitted anytime the gradient changes. The signal is emitted in real time while ticks are being dragged or colors are being changed. @@ -366,14 +368,14 @@ class GradientEditorItem(TickSliderItem): Create a new GradientEditorItem. All arguments are passed to :func:`TickSliderItem.__init__ ` - ============= ================================================================================= - **Arguments** - orientation Set the orientation of the gradient. Options are: 'left', 'right' - 'top', and 'bottom'. - allowAdd Default is True. Specifies whether ticks can be added to the item. - tickPen Default is white. Specifies the color of the outline of the ticks. - Can be any of the valid arguments for :func:`mkPen ` - ============= ================================================================================= + =============== ================================================================================= + **Arguments:** + orientation Set the orientation of the gradient. Options are: 'left', 'right' + 'top', and 'bottom'. + allowAdd Default is True. Specifies whether ticks can be added to the item. + tickPen Default is white. Specifies the color of the outline of the ticks. + Can be any of the valid arguments for :func:`mkPen ` + =============== ================================================================================= """ self.currentTick = None self.currentTickColor = None @@ -445,13 +447,13 @@ class GradientEditorItem(TickSliderItem): """ Set the orientation of the GradientEditorItem. - ============= =================================================================== - **Arguments** - orientation Options are: 'left', 'right', 'top', 'bottom' - The orientation option specifies which side of the gradient the - ticks are on, as well as whether the gradient is vertical ('right' - and 'left') or horizontal ('top' and 'bottom'). - ============= =================================================================== + ============== =================================================================== + **Arguments:** + orientation Options are: 'left', 'right', 'top', 'bottom' + The orientation option specifies which side of the gradient the + ticks are on, as well as whether the gradient is vertical ('right' + and 'left') or horizontal ('top' and 'bottom'). + ============== =================================================================== """ TickSliderItem.setOrientation(self, orientation) self.translate(0, self.rectSize) @@ -537,23 +539,22 @@ class GradientEditorItem(TickSliderItem): def tickClicked(self, tick, ev): #private if ev.button() == QtCore.Qt.LeftButton: - if not tick.colorChangeAllowed: - return - self.currentTick = tick - self.currentTickColor = tick.color - self.colorDialog.setCurrentColor(tick.color) - self.colorDialog.open() - #color = QtGui.QColorDialog.getColor(tick.color, self, "Select Color", QtGui.QColorDialog.ShowAlphaChannel) - #if color.isValid(): - #self.setTickColor(tick, color) - #self.updateGradient() + self.raiseColorDialog(tick) elif ev.button() == QtCore.Qt.RightButton: - if not tick.removeAllowed: - return - if len(self.ticks) > 2: - self.removeTick(tick) - self.updateGradient() - + self.raiseTickContextMenu(tick, ev) + + def raiseColorDialog(self, tick): + if not tick.colorChangeAllowed: + return + self.currentTick = tick + self.currentTickColor = tick.color + self.colorDialog.setCurrentColor(tick.color) + self.colorDialog.open() + + def raiseTickContextMenu(self, tick, ev): + self.tickMenu = TickMenu(tick, self) + self.tickMenu.popup(ev.screenPos().toQPoint()) + def tickMoved(self, tick, pos): #private TickSliderItem.tickMoved(self, tick, pos) @@ -588,11 +589,11 @@ class GradientEditorItem(TickSliderItem): """ Return a color for a given value. - ============= ================================================================== - **Arguments** - x Value (position on gradient) of requested color. - toQColor If true, returns a QColor object, else returns a (r,g,b,a) tuple. - ============= ================================================================== + ============== ================================================================== + **Arguments:** + x Value (position on gradient) of requested color. + toQColor If true, returns a QColor object, else returns a (r,g,b,a) tuple. + ============== ================================================================== """ ticks = self.listTicks() if x <= ticks[0][1]: @@ -648,12 +649,12 @@ class GradientEditorItem(TickSliderItem): """ Return an RGB(A) lookup table (ndarray). - ============= ============================================================================ - **Arguments** - nPts The number of points in the returned lookup table. - alpha True, False, or None - Specifies whether or not alpha values are included - in the table.If alpha is None, alpha will be automatically determined. - ============= ============================================================================ + ============== ============================================================================ + **Arguments:** + nPts The number of points in the returned lookup table. + alpha True, False, or None - Specifies whether or not alpha values are included + in the table.If alpha is None, alpha will be automatically determined. + ============== ============================================================================ """ if alpha is None: alpha = self.usesAlpha() @@ -702,13 +703,13 @@ class GradientEditorItem(TickSliderItem): """ Add a tick to the gradient. Return the tick. - ============= ================================================================== - **Arguments** - x Position where tick should be added. - color Color of added tick. If color is not specified, the color will be - the color of the gradient at the specified position. - movable Specifies whether the tick is movable with the mouse. - ============= ================================================================== + ============== ================================================================== + **Arguments:** + x Position where tick should be added. + color Color of added tick. If color is not specified, the color will be + the color of the gradient at the specified position. + movable Specifies whether the tick is movable with the mouse. + ============== ================================================================== """ @@ -726,6 +727,7 @@ class GradientEditorItem(TickSliderItem): def removeTick(self, tick, finish=True): TickSliderItem.removeTick(self, tick) if finish: + self.updateGradient() self.sigGradientChangeFinished.emit(self) @@ -748,16 +750,16 @@ class GradientEditorItem(TickSliderItem): """ Restore the gradient specified in state. - ============= ==================================================================== - **Arguments** - state A dictionary with same structure as those returned by - :func:`saveState ` + ============== ==================================================================== + **Arguments:** + state A dictionary with same structure as those returned by + :func:`saveState ` - Keys must include: + Keys must include: - - 'mode': hsv or rgb - - 'ticks': a list of tuples (pos, (r,g,b,a)) - ============= ==================================================================== + - 'mode': hsv or rgb + - 'ticks': a list of tuples (pos, (r,g,b,a)) + ============== ==================================================================== """ ## public self.setColorMode(state['mode']) @@ -867,44 +869,59 @@ class Tick(QtGui.QGraphicsObject): ## NOTE: Making this a subclass of GraphicsO self.currentPen = self.pen self.update() - #def mouseMoveEvent(self, ev): - ##print self, "move", ev.scenePos() - #if not self.movable: - #return - #if not ev.buttons() & QtCore.Qt.LeftButton: - #return - - - #newPos = ev.scenePos() + self.mouseOffset - #newPos.setY(self.pos().y()) - ##newPos.setX(min(max(newPos.x(), 0), 100)) - #self.setPos(newPos) - #self.view().tickMoved(self, newPos) - #self.movedSincePress = True - ##self.emit(QtCore.SIGNAL('tickChanged'), self) - #ev.accept() - #def mousePressEvent(self, ev): - #self.movedSincePress = False - #if ev.button() == QtCore.Qt.LeftButton: - #ev.accept() - #self.mouseOffset = self.pos() - ev.scenePos() - #self.pressPos = ev.scenePos() - #elif ev.button() == QtCore.Qt.RightButton: - #ev.accept() - ##if self.endTick: - ##return - ##self.view.tickChanged(self, delete=True) - - #def mouseReleaseEvent(self, ev): - ##print self, "release", ev.scenePos() - #if not self.movedSincePress: - #self.view().tickClicked(self, ev) +class TickMenu(QtGui.QMenu): + + def __init__(self, tick, sliderItem): + QtGui.QMenu.__init__(self) - ##if ev.button() == QtCore.Qt.LeftButton and ev.scenePos() == self.pressPos: - ##color = QtGui.QColorDialog.getColor(self.color, None, "Select Color", QtGui.QColorDialog.ShowAlphaChannel) - ##if color.isValid(): - ##self.color = color - ##self.setBrush(QtGui.QBrush(QtGui.QColor(self.color))) - ###self.emit(QtCore.SIGNAL('tickChanged'), self) - ##self.view.tickChanged(self) + self.tick = weakref.ref(tick) + self.sliderItem = weakref.ref(sliderItem) + + self.removeAct = self.addAction("Remove Tick", lambda: self.sliderItem().removeTick(tick)) + if (not self.tick().removeAllowed) or len(self.sliderItem().ticks) < 3: + self.removeAct.setEnabled(False) + + positionMenu = self.addMenu("Set Position") + w = QtGui.QWidget() + l = QtGui.QGridLayout() + w.setLayout(l) + + value = sliderItem.tickValue(tick) + self.fracPosSpin = SpinBox() + self.fracPosSpin.setOpts(value=value, bounds=(0.0, 1.0), step=0.01, decimals=2) + #self.dataPosSpin = SpinBox(value=dataVal) + #self.dataPosSpin.setOpts(decimals=3, siPrefix=True) + + l.addWidget(QtGui.QLabel("Position:"), 0,0) + l.addWidget(self.fracPosSpin, 0, 1) + #l.addWidget(QtGui.QLabel("Position (data units):"), 1, 0) + #l.addWidget(self.dataPosSpin, 1,1) + + #if self.sliderItem().dataParent is None: + # self.dataPosSpin.setEnabled(False) + + a = QtGui.QWidgetAction(self) + a.setDefaultWidget(w) + positionMenu.addAction(a) + + self.fracPosSpin.sigValueChanging.connect(self.fractionalValueChanged) + #self.dataPosSpin.valueChanged.connect(self.dataValueChanged) + + colorAct = self.addAction("Set Color", lambda: self.sliderItem().raiseColorDialog(self.tick())) + if not self.tick().colorChangeAllowed: + colorAct.setEnabled(False) + + def fractionalValueChanged(self, x): + self.sliderItem().setTickValue(self.tick(), self.fracPosSpin.value()) + #if self.sliderItem().dataParent is not None: + # self.dataPosSpin.blockSignals(True) + # self.dataPosSpin.setValue(self.sliderItem().tickDataValue(self.tick())) + # self.dataPosSpin.blockSignals(False) + + #def dataValueChanged(self, val): + # self.sliderItem().setTickValue(self.tick(), val, dataUnits=True) + # self.fracPosSpin.blockSignals(True) + # self.fracPosSpin.setValue(self.sliderItem().tickValue(self.tick())) + # self.fracPosSpin.blockSignals(False) + diff --git a/pyqtgraph/graphicsItems/GradientLegend.py b/pyqtgraph/graphicsItems/GradientLegend.py index 4528b7ed..28c2cd63 100644 --- a/pyqtgraph/graphicsItems/GradientLegend.py +++ b/pyqtgraph/graphicsItems/GradientLegend.py @@ -1,6 +1,6 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .UIGraphicsItem import * -import pyqtgraph.functions as fn +from .. import functions as fn __all__ = ['GradientLegend'] diff --git a/pyqtgraph/graphicsItems/GraphItem.py b/pyqtgraph/graphicsItems/GraphItem.py index b1f34baa..c80138fb 100644 --- a/pyqtgraph/graphicsItems/GraphItem.py +++ b/pyqtgraph/graphicsItems/GraphItem.py @@ -1,8 +1,9 @@ from .. import functions as fn from .GraphicsObject import GraphicsObject from .ScatterPlotItem import ScatterPlotItem -import pyqtgraph as pg +from ..Qt import QtGui, QtCore import numpy as np +from .. import getConfigOption __all__ = ['GraphItem'] @@ -27,55 +28,79 @@ class GraphItem(GraphicsObject): """ Change the data displayed by the graph. - ============ ========================================================= - Arguments - pos (N,2) array of the positions of each node in the graph. - adj (M,2) array of connection data. Each row contains indexes - of two nodes that are connected. - pen The pen to use when drawing lines between connected - nodes. May be one of: + ============== ======================================================================= + **Arguments:** + pos (N,2) array of the positions of each node in the graph. + adj (M,2) array of connection data. Each row contains indexes + of two nodes that are connected. + pen The pen to use when drawing lines between connected + nodes. May be one of: - * QPen - * a single argument to pass to pg.mkPen - * a record array of length M - with fields (red, green, blue, alpha, width). Note - that using this option may have a significant performance - cost. - * None (to disable connection drawing) - * 'default' to use the default foreground color. + * QPen + * a single argument to pass to pg.mkPen + * a record array of length M + with fields (red, green, blue, alpha, width). Note + that using this option may have a significant performance + cost. + * None (to disable connection drawing) + * 'default' to use the default foreground color. - symbolPen The pen used for drawing nodes. - ``**opts`` All other keyword arguments are given to - :func:`ScatterPlotItem.setData() ` - to affect the appearance of nodes (symbol, size, brush, - etc.) - ============ ========================================================= + symbolPen The pen(s) used for drawing nodes. + symbolBrush The brush(es) used for drawing nodes. + ``**opts`` All other keyword arguments are given to + :func:`ScatterPlotItem.setData() ` + to affect the appearance of nodes (symbol, size, brush, + etc.) + ============== ======================================================================= """ if 'adj' in kwds: self.adjacency = kwds.pop('adj') - assert self.adjacency.dtype.kind in 'iu' - self.picture = None + if self.adjacency.dtype.kind not in 'iu': + raise Exception("adjacency array must have int or unsigned type.") + self._update() if 'pos' in kwds: self.pos = kwds['pos'] - self.picture = None + self._update() if 'pen' in kwds: self.setPen(kwds.pop('pen')) - self.picture = None + self._update() + if 'symbolPen' in kwds: kwds['pen'] = kwds.pop('symbolPen') + if 'symbolBrush' in kwds: + kwds['brush'] = kwds.pop('symbolBrush') self.scatter.setData(**kwds) self.informViewBoundsChanged() - def setPen(self, pen): - self.pen = pen + def _update(self): self.picture = None + self.prepareGeometryChange() + self.update() + + def setPen(self, *args, **kwargs): + """ + Set the pen used to draw graph lines. + May be: + + * None to disable line drawing + * Record array with fields (red, green, blue, alpha, width) + * Any set of arguments and keyword arguments accepted by + :func:`mkPen `. + * 'default' to use the default foreground color. + """ + if len(args) == 1 and len(kwargs) == 0: + self.pen = args[0] + else: + self.pen = fn.mkPen(*args, **kwargs) + self.picture = None + self.update() def generatePicture(self): - self.picture = pg.QtGui.QPicture() + self.picture = QtGui.QPicture() if self.pen is None or self.pos is None or self.adjacency is None: return - p = pg.QtGui.QPainter(self.picture) + p = QtGui.QPainter(self.picture) try: pts = self.pos[self.adjacency] pen = self.pen @@ -86,14 +111,14 @@ class GraphItem(GraphicsObject): if np.any(pen != lastPen): lastPen = pen if pen.dtype.fields is None: - p.setPen(pg.mkPen(color=(pen[0], pen[1], pen[2], pen[3]), width=1)) + p.setPen(fn.mkPen(color=(pen[0], pen[1], pen[2], pen[3]), width=1)) else: - p.setPen(pg.mkPen(color=(pen['red'], pen['green'], pen['blue'], pen['alpha']), width=pen['width'])) - p.drawLine(pg.QtCore.QPointF(*pts[i][0]), pg.QtCore.QPointF(*pts[i][1])) + p.setPen(fn.mkPen(color=(pen['red'], pen['green'], pen['blue'], pen['alpha']), width=pen['width'])) + p.drawLine(QtCore.QPointF(*pts[i][0]), QtCore.QPointF(*pts[i][1])) else: if pen == 'default': - pen = pg.getConfigOption('foreground') - p.setPen(pg.mkPen(pen)) + pen = getConfigOption('foreground') + p.setPen(fn.mkPen(pen)) pts = pts.reshape((pts.shape[0]*pts.shape[1], pts.shape[2])) path = fn.arrayToQPath(x=pts[:,0], y=pts[:,1], connect='pairs') p.drawPath(path) @@ -103,7 +128,7 @@ class GraphItem(GraphicsObject): def paint(self, p, *args): if self.picture == None: self.generatePicture() - if pg.getConfigOption('antialias') is True: + if getConfigOption('antialias') is True: p.setRenderHint(p.Antialiasing) self.picture.play(p) diff --git a/pyqtgraph/graphicsItems/GraphicsItem.py b/pyqtgraph/graphicsItems/GraphicsItem.py index a129436e..2ca35193 100644 --- a/pyqtgraph/graphicsItems/GraphicsItem.py +++ b/pyqtgraph/graphicsItems/GraphicsItem.py @@ -1,31 +1,11 @@ -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.GraphicsScene import GraphicsScene -from pyqtgraph.Point import Point -import pyqtgraph.functions as fn +from ..Qt import QtGui, QtCore, isQObjectAlive +from ..GraphicsScene import GraphicsScene +from ..Point import Point +from .. import functions as fn import weakref -from pyqtgraph.pgcollections import OrderedDict -import operator, sys +import operator +from ..util.lru_cache import LRUCache -class FiniteCache(OrderedDict): - """Caches a finite number of objects, removing - least-frequently used items.""" - def __init__(self, length): - self._length = length - OrderedDict.__init__(self) - - def __setitem__(self, item, val): - self.pop(item, None) # make sure item is added to end - OrderedDict.__setitem__(self, item, val) - while len(self) > self._length: - del self[list(self.keys())[0]] - - def __getitem__(self, item): - val = OrderedDict.__getitem__(self, item) - del self[item] - self[item] = val ## promote this key - return val - - class GraphicsItem(object): """ @@ -38,7 +18,7 @@ class GraphicsItem(object): The GraphicsView system places a lot of emphasis on the notion that the graphics within the scene should be device independent--you should be able to take the same graphics and display them on screens of different resolutions, printers, export to SVG, etc. This is nice in principle, but causes me a lot of headache in practice. It means that I have to circumvent all the device-independent expectations any time I want to operate in pixel coordinates rather than arbitrary scene coordinates. A lot of the code in GraphicsItem is devoted to this task--keeping track of view widgets and device transforms, computing the size and shape of a pixel in local item coordinates, etc. Note that in item coordinates, a pixel does not have to be square or even rectangular, so just asking how to increase a bounding rect by 2px can be a rather complex task. """ - _pixelVectorGlobalCache = FiniteCache(100) + _pixelVectorGlobalCache = LRUCache(100, 70) def __init__(self, register=True): if not hasattr(self, '_qtBaseClass'): @@ -62,8 +42,11 @@ class GraphicsItem(object): def getViewWidget(self): """ - Return the view widget for this item. If the scene has multiple views, only the first view is returned. - The return value is cached; clear the cached value with forgetViewWidget() + Return the view widget for this item. + + If the scene has multiple views, only the first view is returned. + The return value is cached; clear the cached value with forgetViewWidget(). + If the view has been deleted by Qt, return None. """ if self._viewWidget is None: scene = self.scene() @@ -73,7 +56,12 @@ class GraphicsItem(object): if len(views) < 1: return None self._viewWidget = weakref.ref(self.scene().views()[0]) - return self._viewWidget() + + v = self._viewWidget() + if v is not None and not isQObjectAlive(v): + return None + + return v def forgetViewWidget(self): self._viewWidget = None @@ -114,7 +102,7 @@ class GraphicsItem(object): Extends deviceTransform to automatically determine the viewportTransform. """ if self._exportOpts is not False and 'painter' in self._exportOpts: ## currently exporting; device transform may be different. - return self._exportOpts['painter'].deviceTransform() + return self._exportOpts['painter'].deviceTransform() * self.sceneTransform() if viewportTransform is None: view = self.getViewWidget() @@ -330,6 +318,8 @@ class GraphicsItem(object): vt = self.deviceTransform() if vt is None: return None + if isinstance(obj, QtCore.QPoint): + obj = QtCore.QPointF(obj) vt = fn.invertQTransform(vt) return vt.map(obj) @@ -479,24 +469,29 @@ class GraphicsItem(object): ## disconnect from previous view if oldView is not None: - #print "disconnect:", self, oldView - try: - oldView.sigRangeChanged.disconnect(self.viewRangeChanged) - except TypeError: - pass - - try: - oldView.sigTransformChanged.disconnect(self.viewTransformChanged) - except TypeError: - pass + for signal, slot in [('sigRangeChanged', self.viewRangeChanged), + ('sigDeviceRangeChanged', self.viewRangeChanged), + ('sigTransformChanged', self.viewTransformChanged), + ('sigDeviceTransformChanged', self.viewTransformChanged)]: + try: + getattr(oldView, signal).disconnect(slot) + except (TypeError, AttributeError, RuntimeError): + # TypeError and RuntimeError are from pyqt and pyside, respectively + pass self._connectedView = None ## connect to new view if view is not None: #print "connect:", self, view - view.sigRangeChanged.connect(self.viewRangeChanged) - view.sigTransformChanged.connect(self.viewTransformChanged) + if hasattr(view, 'sigDeviceRangeChanged'): + # connect signals from GraphicsView + view.sigDeviceRangeChanged.connect(self.viewRangeChanged) + view.sigDeviceTransformChanged.connect(self.viewTransformChanged) + else: + # connect signals from ViewBox + view.sigRangeChanged.connect(self.viewRangeChanged) + view.sigTransformChanged.connect(self.viewTransformChanged) self._connectedView = weakref.ref(view) self.viewRangeChanged() self.viewTransformChanged() @@ -585,3 +580,6 @@ class GraphicsItem(object): #def update(self): #self._qtBaseClass.update(self) #print "Update:", self + + def getContextMenus(self, event): + return [self.getMenu()] if hasattr(self, "getMenu") else [] diff --git a/pyqtgraph/graphicsItems/GraphicsLayout.py b/pyqtgraph/graphicsItems/GraphicsLayout.py index 9d48e627..6ec38fb5 100644 --- a/pyqtgraph/graphicsItems/GraphicsLayout.py +++ b/pyqtgraph/graphicsItems/GraphicsLayout.py @@ -1,5 +1,5 @@ -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph.functions as fn +from ..Qt import QtGui, QtCore +from .. import functions as fn from .GraphicsWidget import GraphicsWidget ## Must be imported at the end to avoid cyclic-dependency hell: from .ViewBox import ViewBox @@ -31,6 +31,15 @@ class GraphicsLayout(GraphicsWidget): #ret = GraphicsWidget.resizeEvent(self, ev) #print self.pos(), self.mapToDevice(self.rect().topLeft()) #return ret + + def setBorder(self, *args, **kwds): + """ + Set the pen used to draw border between cells. + + See :func:`mkPen ` for arguments. + """ + self.border = fn.mkPen(*args, **kwds) + self.update() def nextRow(self): """Advance to next row for automatic item placement""" @@ -151,4 +160,12 @@ class GraphicsLayout(GraphicsWidget): for i in list(self.items.keys()): self.removeItem(i) + def setContentsMargins(self, *args): + # Wrap calls to layout. This should happen automatically, but there + # seems to be a Qt bug: + # http://stackoverflow.com/questions/27092164/margins-in-pyqtgraphs-graphicslayout + self.layout.setContentsMargins(*args) + def setSpacing(self, *args): + self.layout.setSpacing(*args) + \ No newline at end of file diff --git a/pyqtgraph/graphicsItems/GraphicsObject.py b/pyqtgraph/graphicsItems/GraphicsObject.py index d8f55d27..015a78c6 100644 --- a/pyqtgraph/graphicsItems/GraphicsObject.py +++ b/pyqtgraph/graphicsItems/GraphicsObject.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE +from ..Qt import QtGui, QtCore, USE_PYSIDE if not USE_PYSIDE: import sip from .GraphicsItem import GraphicsItem @@ -21,8 +21,15 @@ class GraphicsObject(GraphicsItem, QtGui.QGraphicsObject): ret = QtGui.QGraphicsObject.itemChange(self, change, value) if change in [self.ItemParentHasChanged, self.ItemSceneHasChanged]: self.parentChanged() - if self.__inform_view_on_changes and change in [self.ItemPositionHasChanged, self.ItemTransformHasChanged]: - self.informViewBoundsChanged() + try: + inform_view_on_change = self.__inform_view_on_changes + except AttributeError: + # It's possible that the attribute was already collected when the itemChange happened + # (if it was triggered during the gc of the object). + pass + else: + if inform_view_on_change and change in [self.ItemPositionHasChanged, self.ItemTransformHasChanged]: + self.informViewBoundsChanged() ## workaround for pyqt bug: ## http://www.riverbankcomputing.com/pipermail/pyqt/2012-August/031818.html diff --git a/pyqtgraph/graphicsItems/GraphicsWidget.py b/pyqtgraph/graphicsItems/GraphicsWidget.py index 7650b125..c379ce8e 100644 --- a/pyqtgraph/graphicsItems/GraphicsWidget.py +++ b/pyqtgraph/graphicsItems/GraphicsWidget.py @@ -1,5 +1,5 @@ -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.GraphicsScene import GraphicsScene +from ..Qt import QtGui, QtCore +from ..GraphicsScene import GraphicsScene from .GraphicsItem import GraphicsItem __all__ = ['GraphicsWidget'] diff --git a/pyqtgraph/graphicsItems/GridItem.py b/pyqtgraph/graphicsItems/GridItem.py index 29b0aa2c..87f90a62 100644 --- a/pyqtgraph/graphicsItems/GridItem.py +++ b/pyqtgraph/graphicsItems/GridItem.py @@ -1,8 +1,8 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .UIGraphicsItem import * import numpy as np -from pyqtgraph.Point import Point -import pyqtgraph.functions as fn +from ..Point import Point +from .. import functions as fn __all__ = ['GridItem'] class GridItem(UIGraphicsItem): diff --git a/pyqtgraph/graphicsItems/HistogramLUTItem.py b/pyqtgraph/graphicsItems/HistogramLUTItem.py index 5a3b63d6..89ebef3e 100644 --- a/pyqtgraph/graphicsItems/HistogramLUTItem.py +++ b/pyqtgraph/graphicsItems/HistogramLUTItem.py @@ -3,8 +3,8 @@ GraphicsWidget displaying an image histogram along with gradient editor. Can be """ -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph.functions as fn +from ..Qt import QtGui, QtCore +from .. import functions as fn from .GraphicsWidget import GraphicsWidget from .ViewBox import * from .GradientEditorItem import * @@ -12,11 +12,12 @@ from .LinearRegionItem import * from .PlotDataItem import * from .AxisItem import * from .GridItem import * -from pyqtgraph.Point import Point -import pyqtgraph.functions as fn +from ..Point import Point +from .. import functions as fn import numpy as np -import pyqtgraph.debug as debug +from .. import debug as debug +import weakref __all__ = ['HistogramLUTItem'] @@ -42,13 +43,13 @@ class HistogramLUTItem(GraphicsWidget): """ GraphicsWidget.__init__(self) self.lut = None - self.imageItem = None + self.imageItem = lambda: None # fake a dead weakref self.layout = QtGui.QGraphicsGridLayout() self.setLayout(self.layout) self.layout.setContentsMargins(1,1,1,1) self.layout.setSpacing(0) - self.vb = ViewBox() + self.vb = ViewBox(parent=self) self.vb.setMaximumWidth(152) self.vb.setMinimumWidth(45) self.vb.setMouseEnabled(x=False, y=True) @@ -58,7 +59,7 @@ class HistogramLUTItem(GraphicsWidget): self.region = LinearRegionItem([0, 1], LinearRegionItem.Horizontal) self.region.setZValue(1000) self.vb.addItem(self.region) - self.axis = AxisItem('left', linkView=self.vb, maxTickLength=-10, showValues=False) + self.axis = AxisItem('left', linkView=self.vb, maxTickLength=-10, parent=self) self.layout.addItem(self.axis, 0, 0) self.layout.addItem(self.vb, 0, 1) self.layout.addItem(self.gradient, 0, 2) @@ -138,7 +139,7 @@ class HistogramLUTItem(GraphicsWidget): #self.region.setBounds([vr.top(), vr.bottom()]) def setImageItem(self, img): - self.imageItem = img + self.imageItem = weakref.ref(img) img.sigImageChanged.connect(self.imageChanged) img.setLookupTable(self.getLookupTable) ## send function pointer, not the result #self.gradientChanged() @@ -150,11 +151,11 @@ class HistogramLUTItem(GraphicsWidget): self.update() def gradientChanged(self): - if self.imageItem is not None: + if self.imageItem() is not None: if self.gradient.isLookupTrivial(): - self.imageItem.setLookupTable(None) #lambda x: x.astype(np.uint8)) + self.imageItem().setLookupTable(None) #lambda x: x.astype(np.uint8)) else: - self.imageItem.setLookupTable(self.getLookupTable) ## send function pointer, not the result + self.imageItem().setLookupTable(self.getLookupTable) ## send function pointer, not the result self.lut = None #if self.imageItem is not None: @@ -178,25 +179,24 @@ class HistogramLUTItem(GraphicsWidget): #self.update() def regionChanging(self): - if self.imageItem is not None: - self.imageItem.setLevels(self.region.getRegion()) + if self.imageItem() is not None: + self.imageItem().setLevels(self.region.getRegion()) self.sigLevelsChanged.emit(self) self.update() def imageChanged(self, autoLevel=False, autoRange=False): - prof = debug.Profiler('HistogramLUTItem.imageChanged', disabled=True) - h = self.imageItem.getHistogram() - prof.mark('get histogram') + profiler = debug.Profiler() + h = self.imageItem().getHistogram() + profiler('get histogram') if h[0] is None: return self.plot.setData(*h) - prof.mark('set plot') + profiler('set plot') if autoLevel: mn = h[0][0] mx = h[0][-1] self.region.setRegion([mn, mx]) - prof.mark('set region') - prof.finish() + profiler('set region') def getLevels(self): return self.region.getRegion() diff --git a/pyqtgraph/graphicsItems/ImageItem.py b/pyqtgraph/graphicsItems/ImageItem.py index 530db7fb..5b041433 100644 --- a/pyqtgraph/graphicsItems/ImageItem.py +++ b/pyqtgraph/graphicsItems/ImageItem.py @@ -1,11 +1,16 @@ -from pyqtgraph.Qt import QtGui, QtCore +from __future__ import division + +from ..Qt import QtGui, QtCore import numpy as np import collections -import pyqtgraph.functions as fn -import pyqtgraph.debug as debug +from .. import functions as fn +from .. import debug as debug from .GraphicsObject import GraphicsObject +from ..Point import Point __all__ = ['ImageItem'] + + class ImageItem(GraphicsObject): """ **Bases:** :class:`GraphicsObject ` @@ -32,20 +37,16 @@ class ImageItem(GraphicsObject): See :func:`setImage ` for all allowed initialization arguments. """ GraphicsObject.__init__(self) - #self.pixmapItem = QtGui.QGraphicsPixmapItem(self) - #self.qimage = QtGui.QImage() - #self._pixmap = None self.menu = None self.image = None ## original image data self.qimage = None ## rendered image for display - #self.clipMask = None self.paintMode = None self.levels = None ## [min, max] or [[redMin, redMax], ...] self.lut = None + self.autoDownsample = False - #self.clipLevel = None self.drawKernel = None self.border = None self.removable = False @@ -140,7 +141,18 @@ class ImageItem(GraphicsObject): if update: self.updateImage() + def setAutoDownsample(self, ads): + """ + Set the automatic downsampling mode for this ImageItem. + + Added in version 0.9.9 + """ + self.autoDownsample = ads + self.qimage = None + self.update() + def setOpts(self, update=True, **kargs): + if 'lut' in kargs: self.setLookupTable(kargs['lut'], update=update) if 'levels' in kargs: @@ -156,6 +168,10 @@ class ImageItem(GraphicsObject): if 'removable' in kargs: self.removable = kargs['removable'] self.menu = None + if 'autoDownsample' in kargs: + self.setAutoDownsample(kargs['autoDownsample']) + if update: + self.update() def setRect(self, rect): """Scale and translate the image to fit within rect (must be a QRect or QRectF).""" @@ -163,6 +179,12 @@ class ImageItem(GraphicsObject): self.translate(rect.left(), rect.top()) self.scale(rect.width() / self.width(), rect.height() / self.height()) + def clear(self): + self.image = None + self.prepareGeometryChange() + self.informViewBoundsChanged() + self.update() + def setImage(self, image=None, autoLevels=None, **kargs): """ Update the image displayed by this item. For more information on how the image @@ -186,10 +208,13 @@ class ImageItem(GraphicsObject): opacity (float 0.0-1.0) compositionMode see :func:`setCompositionMode ` border Sets the pen used when drawing the image border. Default is None. + autoDownsample (bool) If True, the image is automatically downsampled to match the + screen resolution. This improves performance for large images and + reduces aliasing. ================= ========================================================================= """ - prof = debug.Profiler('ImageItem.setImage', disabled=True) - + profile = debug.Profiler() + gotNewData = False if image is None: if self.image is None: @@ -198,12 +223,15 @@ class ImageItem(GraphicsObject): gotNewData = True shapeChanged = (self.image is None or image.shape != self.image.shape) self.image = image.view(np.ndarray) + if self.image.shape[0] > 2**15-1 or self.image.shape[1] > 2**15-1: + if 'autoDownsample' not in kargs: + kargs['autoDownsample'] = True if shapeChanged: self.prepareGeometryChange() self.informViewBoundsChanged() - - prof.mark('1') - + + profile() + if autoLevels is None: if 'levels' in kargs: autoLevels = False @@ -218,23 +246,22 @@ class ImageItem(GraphicsObject): mn = 0 mx = 255 kargs['levels'] = [mn,mx] - prof.mark('2') - + + profile() + self.setOpts(update=False, **kargs) - prof.mark('3') - + + profile() + self.qimage = None self.update() - prof.mark('4') + + profile() if gotNewData: self.sigImageChanged.emit() - prof.finish() - - - def updateImage(self, *args, **kargs): ## used for re-rendering qimage from self.image. @@ -245,45 +272,53 @@ class ImageItem(GraphicsObject): } defaults.update(kargs) return self.setImage(*args, **defaults) - - - def render(self): - prof = debug.Profiler('ImageItem.render', disabled=True) + # Convert data to QImage for display. + + profile = debug.Profiler() if self.image is None or self.image.size == 0: return if isinstance(self.lut, collections.Callable): lut = self.lut(self.image) else: lut = self.lut - #print lut.shape - #print self.lut - - argb, alpha = fn.makeARGB(self.image, lut=lut, levels=self.levels) - self.qimage = fn.makeQImage(argb, alpha) - prof.finish() - + + if self.autoDownsample: + # reduce dimensions of image based on screen resolution + o = self.mapToDevice(QtCore.QPointF(0,0)) + x = self.mapToDevice(QtCore.QPointF(1,0)) + y = self.mapToDevice(QtCore.QPointF(0,1)) + w = Point(x-o).length() + h = Point(y-o).length() + xds = max(1, int(1/w)) + yds = max(1, int(1/h)) + image = fn.downsample(self.image, xds, axis=0) + image = fn.downsample(image, yds, axis=1) + else: + image = self.image + + argb, alpha = fn.makeARGB(image.transpose((1, 0, 2)[:image.ndim]), lut=lut, levels=self.levels) + self.qimage = fn.makeQImage(argb, alpha, transpose=False) def paint(self, p, *args): - prof = debug.Profiler('ImageItem.paint', disabled=True) + profile = debug.Profiler() if self.image is None: return if self.qimage is None: self.render() if self.qimage is None: return - prof.mark('render QImage') + profile('render QImage') if self.paintMode is not None: p.setCompositionMode(self.paintMode) - prof.mark('set comp mode') - - p.drawImage(QtCore.QPointF(0,0), self.qimage) - prof.mark('p.drawImage') + profile('set comp mode') + + p.drawImage(QtCore.QRectF(0,0,self.image.shape[0],self.image.shape[1]), self.qimage) + profile('p.drawImage') if self.border is not None: p.setPen(self.border) p.drawRect(self.boundingRect()) - prof.finish() def save(self, fileName, *args): """Save this image to file. Note that this saves the visible image (after scale/color changes), not the original data.""" @@ -291,15 +326,47 @@ class ImageItem(GraphicsObject): self.render() self.qimage.save(fileName, *args) - def getHistogram(self, bins=500, step=3): + def getHistogram(self, bins='auto', step='auto', targetImageSize=200, targetHistogramSize=500, **kwds): """Returns x and y arrays containing the histogram values for the current image. - The step argument causes pixels to be skipped when computing the histogram to save time. + For an explanation of the return format, see numpy.histogram(). + + The *step* argument causes pixels to be skipped when computing the histogram to save time. + If *step* is 'auto', then a step is chosen such that the analyzed data has + dimensions roughly *targetImageSize* for each axis. + + The *bins* argument and any extra keyword arguments are passed to + np.histogram(). If *bins* is 'auto', then a bin number is automatically + chosen based on the image characteristics: + + * Integer images will have approximately *targetHistogramSize* bins, + with each bin having an integer width. + * All other types will have *targetHistogramSize* bins. + This method is also used when automatically computing levels. """ if self.image is None: return None,None - stepData = self.image[::step, ::step] - hist = np.histogram(stepData, bins=bins) + if step == 'auto': + step = (np.ceil(self.image.shape[0] / targetImageSize), + np.ceil(self.image.shape[1] / targetImageSize)) + if np.isscalar(step): + step = (step, step) + stepData = self.image[::step[0], ::step[1]] + + if bins == 'auto': + if stepData.dtype.kind in "ui": + mn = stepData.min() + mx = stepData.max() + step = np.ceil((mx-mn) / 500.) + bins = np.arange(mn, mx+1.01*step, step, dtype=np.int) + if len(bins) == 0: + bins = [mn, mx] + else: + bins = 500 + + kwds['bins'] = bins + hist = np.histogram(stepData, **kwds) + return hist[1][:-1], hist[0] def setPxMode(self, b): @@ -327,6 +394,11 @@ class ImageItem(GraphicsObject): if self.image is None: return 1,1 return br.width()/self.width(), br.height()/self.height() + + def viewTransformChanged(self): + if self.autoDownsample: + self.qimage = None + self.update() #def mousePressEvent(self, ev): #if self.drawKernel is not None and ev.button() == QtCore.Qt.LeftButton: @@ -448,6 +520,9 @@ class ImageItem(GraphicsObject): def removeClicked(self): ## Send remove event only after we have exited the menu event handler self.removeTimer = QtCore.QTimer() - self.removeTimer.timeout.connect(lambda: self.sigRemoveRequested.emit(self)) + self.removeTimer.timeout.connect(self.emitRemoveRequested) self.removeTimer.start(0) + def emitRemoveRequested(self): + self.removeTimer.timeout.disconnect(self.emitRemoveRequested) + self.sigRemoveRequested.emit(self) diff --git a/pyqtgraph/graphicsItems/InfiniteLine.py b/pyqtgraph/graphicsItems/InfiniteLine.py index 4f0df863..8108c3cf 100644 --- a/pyqtgraph/graphicsItems/InfiniteLine.py +++ b/pyqtgraph/graphicsItems/InfiniteLine.py @@ -1,7 +1,7 @@ -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.Point import Point +from ..Qt import QtGui, QtCore +from ..Point import Point from .GraphicsObject import GraphicsObject -import pyqtgraph.functions as fn +from .. import functions as fn import numpy as np import weakref @@ -15,7 +15,7 @@ class InfiniteLine(GraphicsObject): This line may be dragged to indicate a position in data coordinates. =============================== =================================================== - **Signals** + **Signals:** sigDragged(self) sigPositionChangeFinished(self) sigPositionChanged(self) @@ -28,18 +28,18 @@ class InfiniteLine(GraphicsObject): def __init__(self, pos=None, angle=90, pen=None, movable=False, bounds=None): """ - ============= ================================================================== - **Arguments** - pos Position of the line. This can be a QPointF or a single value for - vertical/horizontal lines. - angle Angle of line in degrees. 0 is horizontal, 90 is vertical. - pen Pen to use when drawing line. Can be any arguments that are valid - for :func:`mkPen `. Default pen is transparent - yellow. - movable If True, the line can be dragged to a new position by the user. - bounds Optional [min, max] bounding values. Bounds are only valid if the - line is vertical or horizontal. - ============= ================================================================== + =============== ================================================================== + **Arguments:** + pos Position of the line. This can be a QPointF or a single value for + vertical/horizontal lines. + angle Angle of line in degrees. 0 is horizontal, 90 is vertical. + pen Pen to use when drawing line. Can be any arguments that are valid + for :func:`mkPen `. Default pen is transparent + yellow. + movable If True, the line can be dragged to a new position by the user. + bounds Optional [min, max] bounding values. Bounds are only valid if the + line is vertical or horizontal. + =============== ================================================================== """ GraphicsObject.__init__(self) @@ -59,7 +59,9 @@ class InfiniteLine(GraphicsObject): if pen is None: pen = (200, 200, 100) + self.setPen(pen) + self.setHoverPen(color=(255,0,0), width=self.pen.width()) self.currentPen = self.pen #self.setFlag(self.ItemSendsScenePositionChanges) @@ -73,12 +75,26 @@ class InfiniteLine(GraphicsObject): self.maxRange = bounds self.setValue(self.value()) - def setPen(self, pen): + def setPen(self, *args, **kwargs): """Set the pen for drawing the line. Allowable arguments are any that are valid for :func:`mkPen `.""" - self.pen = fn.mkPen(pen) - self.currentPen = self.pen - self.update() + self.pen = fn.mkPen(*args, **kwargs) + if not self.mouseHovering: + self.currentPen = self.pen + self.update() + + def setHoverPen(self, *args, **kwargs): + """Set the pen for drawing the line while the mouse hovers over it. + Allowable arguments are any that are valid + for :func:`mkPen `. + + If the line is not movable, then hovering is also disabled. + + Added in version 0.9.9.""" + self.hoverPen = fn.mkPen(*args, **kwargs) + if self.mouseHovering: + self.currentPen = self.hoverPen + self.update() def setAngle(self, angle): """ @@ -168,8 +184,9 @@ class InfiniteLine(GraphicsObject): px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line if px is None: px = 0 - br.setBottom(-px*4) - br.setTop(px*4) + w = (max(4, self.pen.width()/2, self.hoverPen.width()/2)+1) * px + br.setBottom(-w) + br.setTop(w) return br.normalized() def paint(self, p, *args): @@ -183,25 +200,6 @@ class InfiniteLine(GraphicsObject): return None ## x axis should never be auto-scaled else: return (0,0) - - #def mousePressEvent(self, ev): - #if self.movable and ev.button() == QtCore.Qt.LeftButton: - #ev.accept() - #self.pressDelta = self.mapToParent(ev.pos()) - QtCore.QPointF(*self.p) - #else: - #ev.ignore() - - #def mouseMoveEvent(self, ev): - #self.setPos(self.mapToParent(ev.pos()) - self.pressDelta) - ##self.emit(QtCore.SIGNAL('dragged'), self) - #self.sigDragged.emit(self) - #self.hasMoved = True - - #def mouseReleaseEvent(self, ev): - #if self.hasMoved and ev.button() == QtCore.Qt.LeftButton: - #self.hasMoved = False - ##self.emit(QtCore.SIGNAL('positionChangeFinished'), self) - #self.sigPositionChangeFinished.emit(self) def mouseDragEvent(self, ev): if self.movable and ev.button() == QtCore.Qt.LeftButton: @@ -239,12 +237,12 @@ class InfiniteLine(GraphicsObject): self.setMouseHover(False) def setMouseHover(self, hover): - ## Inform the item that the mouse is(not) hovering over it + ## Inform the item that the mouse is (not) hovering over it if self.mouseHovering == hover: return self.mouseHovering = hover if hover: - self.currentPen = fn.mkPen(255, 0,0) + self.currentPen = self.hoverPen else: self.currentPen = self.pen self.update() diff --git a/pyqtgraph/graphicsItems/IsocurveItem.py b/pyqtgraph/graphicsItems/IsocurveItem.py index 01ef57b6..4474e29a 100644 --- a/pyqtgraph/graphicsItems/IsocurveItem.py +++ b/pyqtgraph/graphicsItems/IsocurveItem.py @@ -1,8 +1,8 @@ from .GraphicsObject import * -import pyqtgraph.functions as fn -from pyqtgraph.Qt import QtGui, QtCore +from .. import functions as fn +from ..Qt import QtGui, QtCore class IsocurveItem(GraphicsObject): @@ -18,14 +18,14 @@ class IsocurveItem(GraphicsObject): """ Create a new isocurve item. - ============= =============================================================== - **Arguments** - data A 2-dimensional ndarray. Can be initialized as None, and set - later using :func:`setData ` - level The cutoff value at which to draw the isocurve. - pen The color of the curve item. Can be anything valid for - :func:`mkPen ` - ============= =============================================================== + ============== =============================================================== + **Arguments:** + data A 2-dimensional ndarray. Can be initialized as None, and set + later using :func:`setData ` + level The cutoff value at which to draw the isocurve. + pen The color of the curve item. Can be anything valid for + :func:`mkPen ` + ============== =============================================================== """ GraphicsObject.__init__(self) @@ -35,22 +35,17 @@ class IsocurveItem(GraphicsObject): self.setPen(pen) self.setData(data, level) - - - #if data is not None and level is not None: - #self.updateLines(data, level) - def setData(self, data, level=None): """ Set the data/image to draw isocurves for. - ============= ======================================================================== - **Arguments** - data A 2-dimensional ndarray. - level The cutoff value at which to draw the curve. If level is not specified, - the previously set level is used. - ============= ======================================================================== + ============== ======================================================================== + **Arguments:** + data A 2-dimensional ndarray. + level The cutoff value at which to draw the curve. If level is not specified, + the previously set level is used. + ============== ======================================================================== """ if level is None: level = self.level @@ -65,6 +60,7 @@ class IsocurveItem(GraphicsObject): """Set the level at which the isocurve is drawn.""" self.level = level self.path = None + self.prepareGeometryChange() self.update() diff --git a/pyqtgraph/graphicsItems/ItemGroup.py b/pyqtgraph/graphicsItems/ItemGroup.py index 930fdf80..4eb0ee0d 100644 --- a/pyqtgraph/graphicsItems/ItemGroup.py +++ b/pyqtgraph/graphicsItems/ItemGroup.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .GraphicsObject import GraphicsObject __all__ = ['ItemGroup'] diff --git a/pyqtgraph/graphicsItems/LabelItem.py b/pyqtgraph/graphicsItems/LabelItem.py index 6101c4bc..37980ee3 100644 --- a/pyqtgraph/graphicsItems/LabelItem.py +++ b/pyqtgraph/graphicsItems/LabelItem.py @@ -1,8 +1,8 @@ -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph.functions as fn -import pyqtgraph as pg +from ..Qt import QtGui, QtCore +from .. import functions as fn from .GraphicsWidget import GraphicsWidget from .GraphicsWidgetAnchor import GraphicsWidgetAnchor +from .. import getConfigOption __all__ = ['LabelItem'] @@ -54,7 +54,7 @@ class LabelItem(GraphicsWidget, GraphicsWidgetAnchor): color = self.opts['color'] if color is None: - color = pg.getConfigOption('foreground') + color = getConfigOption('foreground') color = fn.mkColor(color) optlist.append('color: #' + fn.colorStr(color)[:6]) if 'size' in opts: diff --git a/pyqtgraph/graphicsItems/LegendItem.py b/pyqtgraph/graphicsItems/LegendItem.py index 69ddffea..20d6416e 100644 --- a/pyqtgraph/graphicsItems/LegendItem.py +++ b/pyqtgraph/graphicsItems/LegendItem.py @@ -3,8 +3,9 @@ from .LabelItem import LabelItem from ..Qt import QtGui, QtCore from .. import functions as fn from ..Point import Point +from .ScatterPlotItem import ScatterPlotItem, drawSymbol +from .PlotDataItem import PlotDataItem from .GraphicsWidgetAnchor import GraphicsWidgetAnchor -import pyqtgraph as pg __all__ = ['LegendItem'] class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): @@ -20,17 +21,17 @@ class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): """ def __init__(self, size=None, offset=None): """ - ========== =============================================================== - Arguments - size Specifies the fixed size (width, height) of the legend. If - this argument is omitted, the legend will autimatically resize - to fit its contents. - offset Specifies the offset position relative to the legend's parent. - Positive values offset from the left or top; negative values - offset from the right or bottom. If offset is None, the - legend must be anchored manually by calling anchor() or - positioned by calling setPos(). - ========== =============================================================== + ============== =============================================================== + **Arguments:** + size Specifies the fixed size (width, height) of the legend. If + this argument is omitted, the legend will autimatically resize + to fit its contents. + offset Specifies the offset position relative to the legend's parent. + Positive values offset from the left or top; negative values + offset from the right or bottom. If offset is None, the + legend must be anchored manually by calling anchor() or + positioned by calling setPos(). + ============== =============================================================== """ @@ -60,21 +61,21 @@ class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): """ Add a new entry to the legend. - =========== ======================================================== - Arguments - item A PlotDataItem from which the line and point style - of the item will be determined or an instance of - ItemSample (or a subclass), allowing the item display - to be customized. - title The title to display for this item. Simple HTML allowed. - =========== ======================================================== + ============== ======================================================== + **Arguments:** + item A PlotDataItem from which the line and point style + of the item will be determined or an instance of + ItemSample (or a subclass), allowing the item display + to be customized. + title The title to display for this item. Simple HTML allowed. + ============== ======================================================== """ label = LabelItem(name) if isinstance(item, ItemSample): sample = item else: sample = ItemSample(item) - row = len(self.items) + row = self.layout.rowCount() self.items.append((sample, label)) self.layout.addItem(sample, row, 0) self.layout.addItem(label, row, 1) @@ -84,10 +85,10 @@ class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): """ Removes one item from the legend. - =========== ======================================================== - Arguments - title The title displayed for this item. - =========== ======================================================== + ============== ======================================================== + **Arguments:** + title The title displayed for this item. + ============== ======================================================== """ # Thanks, Ulrich! # cycle for a match @@ -152,21 +153,21 @@ class ItemSample(GraphicsWidget): p.setPen(fn.mkPen(None)) p.drawPolygon(QtGui.QPolygonF([QtCore.QPointF(2,18), QtCore.QPointF(18,2), QtCore.QPointF(18,18)])) - if not isinstance(self.item, pg.ScatterPlotItem): + if not isinstance(self.item, ScatterPlotItem): p.setPen(fn.mkPen(opts['pen'])) p.drawLine(2, 18, 18, 2) symbol = opts.get('symbol', None) if symbol is not None: - if isinstance(self.item, pg.PlotDataItem): + if isinstance(self.item, PlotDataItem): opts = self.item.scatter.opts - pen = pg.mkPen(opts['pen']) - brush = pg.mkBrush(opts['brush']) + pen = fn.mkPen(opts['pen']) + brush = fn.mkBrush(opts['brush']) size = opts['size'] p.translate(10,10) - path = pg.graphicsItems.ScatterPlotItem.drawSymbol(p, symbol, size, pen, brush) + path = drawSymbol(p, symbol, size, pen, brush) diff --git a/pyqtgraph/graphicsItems/LinearRegionItem.py b/pyqtgraph/graphicsItems/LinearRegionItem.py index a35e8efc..e139190b 100644 --- a/pyqtgraph/graphicsItems/LinearRegionItem.py +++ b/pyqtgraph/graphicsItems/LinearRegionItem.py @@ -1,8 +1,8 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .UIGraphicsItem import UIGraphicsItem from .InfiniteLine import InfiniteLine -import pyqtgraph.functions as fn -import pyqtgraph.debug as debug +from .. import functions as fn +from .. import debug as debug __all__ = ['LinearRegionItem'] @@ -30,19 +30,19 @@ class LinearRegionItem(UIGraphicsItem): def __init__(self, values=[0,1], orientation=None, brush=None, movable=True, bounds=None): """Create a new LinearRegionItem. - ============= ===================================================================== - **Arguments** - values A list of the positions of the lines in the region. These are not - limits; limits can be set by specifying bounds. - orientation Options are LinearRegionItem.Vertical or LinearRegionItem.Horizontal. - If not specified it will be vertical. - brush Defines the brush that fills the region. Can be any arguments that - are valid for :func:`mkBrush `. Default is - transparent blue. - movable If True, the region and individual lines are movable by the user; if - False, they are static. - bounds Optional [min, max] bounding values for the region - ============= ===================================================================== + ============== ===================================================================== + **Arguments:** + values A list of the positions of the lines in the region. These are not + limits; limits can be set by specifying bounds. + orientation Options are LinearRegionItem.Vertical or LinearRegionItem.Horizontal. + If not specified it will be vertical. + brush Defines the brush that fills the region. Can be any arguments that + are valid for :func:`mkBrush `. Default is + transparent blue. + movable If True, the region and individual lines are movable by the user; if + False, they are static. + bounds Optional [min, max] bounding values for the region + ============== ===================================================================== """ UIGraphicsItem.__init__(self) @@ -89,10 +89,10 @@ class LinearRegionItem(UIGraphicsItem): def setRegion(self, rgn): """Set the values for the edges of the region. - ============= ============================================== - **Arguments** - rgn A list or tuple of the lower and upper values. - ============= ============================================== + ============== ============================================== + **Arguments:** + rgn A list or tuple of the lower and upper values. + ============== ============================================== """ if self.lines[0].value() == rgn[0] and self.lines[1].value() == rgn[1]: return @@ -140,12 +140,11 @@ class LinearRegionItem(UIGraphicsItem): return br.normalized() def paint(self, p, *args): - #prof = debug.Profiler('LinearRegionItem.paint') + profiler = debug.Profiler() UIGraphicsItem.paint(self, p, *args) p.setBrush(self.currentBrush) p.setPen(fn.mkPen(None)) p.drawRect(self.boundingRect()) - #prof.finish() def dataBounds(self, axis, frac=1.0, orthoRange=None): if axis == self.orientation: diff --git a/pyqtgraph/graphicsItems/MultiPlotItem.py b/pyqtgraph/graphicsItems/MultiPlotItem.py index d20467a9..be775d4a 100644 --- a/pyqtgraph/graphicsItems/MultiPlotItem.py +++ b/pyqtgraph/graphicsItems/MultiPlotItem.py @@ -7,26 +7,23 @@ Distributed under MIT/X11 license. See license.txt for more infomation. from numpy import ndarray from . import GraphicsLayout +from ..metaarray import * -try: - from metaarray import * - HAVE_METAARRAY = True -except: - #raise - HAVE_METAARRAY = False - __all__ = ['MultiPlotItem'] class MultiPlotItem(GraphicsLayout.GraphicsLayout): """ - Automaticaly generates a grid of plots from a multi-dimensional array + Automatically generates a grid of plots from a multi-dimensional array """ - + def __init__(self, *args, **kwds): + GraphicsLayout.GraphicsLayout.__init__(self, *args, **kwds) + self.plots = [] + + def plot(self, data): #self.layout.clear() - self.plots = [] - - if HAVE_METAARRAY and (hasattr(data, 'implements') and data.implements('MetaArray')): + + if hasattr(data, 'implements') and data.implements('MetaArray'): if data.ndim != 2: raise Exception("MultiPlot currently only accepts 2D MetaArray.") ic = data.infoCopy() @@ -44,21 +41,17 @@ class MultiPlotItem(GraphicsLayout.GraphicsLayout): pi.plot(data[tuple(sl)]) #self.layout.addItem(pi, i, 0) self.plots.append((pi, i, 0)) - title = None - units = None info = ic[ax]['cols'][i] - if 'title' in info: - title = info['title'] - elif 'name' in info: - title = info['name'] - if 'units' in info: - units = info['units'] - + title = info.get('title', info.get('name', None)) + units = info.get('units', None) pi.setLabel('left', text=title, units=units) - + info = ic[1-ax] + title = info.get('title', info.get('name', None)) + units = info.get('units', None) + pi.setLabel('bottom', text=title, units=units) else: raise Exception("Data type %s not (yet?) supported for MultiPlot." % type(data)) - + def close(self): for p in self.plots: p[0].close() diff --git a/pyqtgraph/graphicsItems/PlotCurveItem.py b/pyqtgraph/graphicsItems/PlotCurveItem.py index 28214552..3d3e969d 100644 --- a/pyqtgraph/graphicsItems/PlotCurveItem.py +++ b/pyqtgraph/graphicsItems/PlotCurveItem.py @@ -1,17 +1,17 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore try: - from pyqtgraph.Qt import QtOpenGL + from ..Qt import QtOpenGL HAVE_OPENGL = True except: HAVE_OPENGL = False import numpy as np from .GraphicsObject import GraphicsObject -import pyqtgraph.functions as fn -from pyqtgraph import debug -from pyqtgraph.Point import Point -import pyqtgraph as pg +from .. import functions as fn +from ..Point import Point import struct, sys +from .. import getConfigOption +from .. import debug __all__ = ['PlotCurveItem'] class PlotCurveItem(GraphicsObject): @@ -53,9 +53,6 @@ class PlotCurveItem(GraphicsObject): """ GraphicsObject.__init__(self, kargs.get('parent', None)) self.clear() - self.path = None - self.fillPath = None - self._boundsCache = [None, None] ## this is disastrous for performance. #self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) @@ -68,8 +65,9 @@ class PlotCurveItem(GraphicsObject): 'brush': None, 'stepMode': False, 'name': None, - 'antialias': pg.getConfigOption('antialias'),\ + 'antialias': getConfigOption('antialias'), 'connect': 'all', + 'mouseWidth': 8, # width of shape responding to mouse click } self.setClickable(kargs.get('clickable', False)) self.setData(*args, **kargs) @@ -80,9 +78,20 @@ class PlotCurveItem(GraphicsObject): return ints return interface in ints - def setClickable(self, s): - """Sets whether the item responds to mouse clicks.""" + def name(self): + return self.opts.get('name', None) + + def setClickable(self, s, width=None): + """Sets whether the item responds to mouse clicks. + + The *width* argument specifies the width in pixels orthogonal to the + curve that will respond to a mouse click. + """ self.clickable = s + if width is not None: + self.opts['mouseWidth'] = width + self._mouseShape = None + self._boundingRect = None def getData(self): @@ -148,6 +157,8 @@ class PlotCurveItem(GraphicsObject): w += pen.widthF()*0.7072 if spen is not None and spen.isCosmetic() and spen.style() != QtCore.Qt.NoPen: w = max(w, spen.widthF()*0.7072) + if self.clickable: + w = max(w, self.opts['mouseWidth']//2 + 1) return w def boundingRect(self): @@ -162,8 +173,14 @@ class PlotCurveItem(GraphicsObject): if pxPad > 0: # determine length of pixel in local x, y directions px, py = self.pixelVectors() - px = 0 if px is None else px.length() - py = 0 if py is None else py.length() + try: + px = 0 if px is None else px.length() + except OverflowError: + px = 0 + try: + py = 0 if py is None else py.length() + except OverflowError: + py = 0 # return bounds expanded by pixel size px *= pxPad @@ -171,6 +188,7 @@ class PlotCurveItem(GraphicsObject): #px += self._maxSpotWidth * 0.5 #py += self._maxSpotWidth * 0.5 self._boundingRect = QtCore.QRectF(xmn-px, ymn-py, (2*px)+xmx-xmn, (2*py)+ymx-ymn) + return self._boundingRect def viewTransformChanged(self): @@ -281,7 +299,7 @@ class PlotCurveItem(GraphicsObject): self.updateData(*args, **kargs) def updateData(self, *args, **kargs): - prof = debug.Profiler('PlotCurveItem.updateData', disabled=True) + profiler = debug.Profiler() if len(args) == 1: kargs['y'] = args[0] @@ -304,7 +322,7 @@ class PlotCurveItem(GraphicsObject): if 'complex' in str(data.dtype): raise Exception("Can not plot complex data types.") - prof.mark("data checks") + profiler("data checks") #self.setCacheMode(QtGui.QGraphicsItem.NoCache) ## Disabling and re-enabling the cache works around a bug in Qt 4.6 causing the cached results to display incorrectly ## Test this bug with test_PlotWidget and zoom in on the animated plot @@ -314,7 +332,7 @@ class PlotCurveItem(GraphicsObject): self.yData = kargs['y'].view(np.ndarray) self.xData = kargs['x'].view(np.ndarray) - prof.mark('copy') + profiler('copy') if 'stepMode' in kargs: self.opts['stepMode'] = kargs['stepMode'] @@ -328,6 +346,7 @@ class PlotCurveItem(GraphicsObject): self.path = None self.fillPath = None + self._mouseShape = None #self.xDisp = self.yDisp = None if 'name' in kargs: @@ -346,12 +365,11 @@ class PlotCurveItem(GraphicsObject): self.opts['antialias'] = kargs['antialias'] - prof.mark('set') + profiler('set') self.update() - prof.mark('update') + profiler('update') self.sigPlotChanged.emit(self) - prof.mark('emit') - prof.finish() + profiler('emit') def generatePath(self, x, y): if self.opts['stepMode']: @@ -377,35 +395,33 @@ class PlotCurveItem(GraphicsObject): return path - def shape(self): + def getPath(self): if self.path is None: - try: + x,y = self.getData() + if x is None or len(x) == 0 or y is None or len(y) == 0: + self.path = QtGui.QPainterPath() + else: self.path = self.generatePath(*self.getData()) - except: - return QtGui.QPainterPath() + self.fillPath = None + self._mouseShape = None + return self.path - @pg.debug.warnOnException ## raising an exception here causes crash + @debug.warnOnException ## raising an exception here causes crash def paint(self, p, opt, widget): - prof = debug.Profiler('PlotCurveItem.paint '+str(id(self)), disabled=True) - if self.xData is None: + profiler = debug.Profiler() + if self.xData is None or len(self.xData) == 0: return - if HAVE_OPENGL and pg.getConfigOption('enableExperimental') and isinstance(widget, QtOpenGL.QGLWidget): + if HAVE_OPENGL and getConfigOption('enableExperimental') and isinstance(widget, QtOpenGL.QGLWidget): self.paintGL(p, opt, widget) return x = None y = None - if self.path is None: - x,y = self.getData() - if x is None or len(x) == 0 or y is None or len(y) == 0: - return - self.path = self.generatePath(x,y) - self.fillPath = None - - path = self.path - prof.mark('generate path') + path = self.getPath() + + profiler('generate path') if self._exportOpts is not False: aa = self._exportOpts.get('antialias', True) @@ -426,9 +442,9 @@ class PlotCurveItem(GraphicsObject): p2.closeSubpath() self.fillPath = p2 - prof.mark('generate fill path') + profiler('generate fill path') p.fillPath(self.fillPath, self.opts['brush']) - prof.mark('draw fill path') + profiler('draw fill path') sp = fn.mkPen(self.opts['shadowPen']) cp = fn.mkPen(self.opts['pen']) @@ -451,10 +467,9 @@ class PlotCurveItem(GraphicsObject): p.drawPath(path) p.setPen(cp) p.drawPath(path) - prof.mark('drawPath') + profiler('drawPath') #print "Render hints:", int(p.renderHints()) - prof.finish() #p.setPen(QtGui.QPen(QtGui.QColor(255,0,0))) #p.drawRect(self.boundingRect()) @@ -477,7 +492,7 @@ class PlotCurveItem(GraphicsObject): gl.glStencilOp(gl.GL_REPLACE, gl.GL_KEEP, gl.GL_KEEP) ## draw stencil pattern - gl.glStencilMask(0xFF); + gl.glStencilMask(0xFF) gl.glClear(gl.GL_STENCIL_BUFFER_BIT) gl.glBegin(gl.GL_TRIANGLES) gl.glVertex2f(rect.x(), rect.y()) @@ -511,7 +526,7 @@ class PlotCurveItem(GraphicsObject): gl.glEnable(gl.GL_LINE_SMOOTH) gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST); + gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) gl.glDrawArrays(gl.GL_LINE_STRIP, 0, pos.size / pos.shape[-1]) finally: gl.glDisableClientState(gl.GL_VERTEX_ARRAY) @@ -524,13 +539,36 @@ class PlotCurveItem(GraphicsObject): self.xDisp = None ## display values (after log / fft) self.yDisp = None self.path = None + self.fillPath = None + self._mouseShape = None + self._mouseBounds = None + self._boundsCache = [None, None] #del self.xData, self.yData, self.xDisp, self.yDisp, self.path + + def mouseShape(self): + """ + Return a QPainterPath representing the clickable shape of the curve + + """ + if self._mouseShape is None: + view = self.getViewBox() + if view is None: + return QtGui.QPainterPath() + stroker = QtGui.QPainterPathStroker() + path = self.getPath() + path = self.mapToItem(view, path) + stroker.setWidth(self.opts['mouseWidth']) + mousePath = stroker.createStroke(path) + self._mouseShape = self.mapFromItem(view, mousePath) + return self._mouseShape def mouseClickEvent(self, ev): if not self.clickable or ev.button() != QtCore.Qt.LeftButton: return - ev.accept() - self.sigClicked.emit(self) + if self.mouseShape().contains(ev.pos()): + ev.accept() + self.sigClicked.emit(self) + class ROIPlotItem(PlotCurveItem): diff --git a/pyqtgraph/graphicsItems/PlotDataItem.py b/pyqtgraph/graphicsItems/PlotDataItem.py index 87b47227..6148989d 100644 --- a/pyqtgraph/graphicsItems/PlotDataItem.py +++ b/pyqtgraph/graphicsItems/PlotDataItem.py @@ -1,12 +1,12 @@ -import pyqtgraph.metaarray as metaarray -from pyqtgraph.Qt import QtCore +from .. import metaarray as metaarray +from ..Qt import QtCore from .GraphicsObject import GraphicsObject from .PlotCurveItem import PlotCurveItem from .ScatterPlotItem import ScatterPlotItem import numpy as np -import pyqtgraph.functions as fn -import pyqtgraph.debug as debug -import pyqtgraph as pg +from .. import functions as fn +from .. import debug as debug +from .. import getConfigOption class PlotDataItem(GraphicsObject): """ @@ -15,7 +15,7 @@ class PlotDataItem(GraphicsObject): GraphicsItem for displaying plot curves, scatter plots, or both. While it is possible to use :class:`PlotCurveItem ` or :class:`ScatterPlotItem ` individually, this class - provides a unified interface to both. Inspances of :class:`PlotDataItem` are + provides a unified interface to both. Instances of :class:`PlotDataItem` are usually created by plot() methods such as :func:`pyqtgraph.plot` and :func:`PlotItem.plot() `. @@ -56,10 +56,11 @@ class PlotDataItem(GraphicsObject): =========================== ========================================= **Line style keyword arguments:** - ========== ================================================ - connect Specifies how / whether vertexes should be connected. - See :func:`arrayToQPath() ` - pen Pen to use for drawing line between points. + + ========== ============================================================================== + connect Specifies how / whether vertexes should be connected. See + :func:`arrayToQPath() ` + pen Pen to use for drawing line between points. Default is solid grey, 1px width. Use None to disable line drawing. May be any single argument accepted by :func:`mkPen() ` shadowPen Pen for secondary line to draw behind the primary line. disabled by default. @@ -67,21 +68,29 @@ class PlotDataItem(GraphicsObject): fillLevel Fill the area between the curve and fillLevel fillBrush Fill to use when fillLevel is specified. May be any single argument accepted by :func:`mkBrush() ` - ========== ================================================ + stepMode If True, two orthogonal lines are drawn for each sample + as steps. This is commonly used when drawing histograms. + Note that in this case, `len(x) == len(y) + 1` + (added in version 0.9.9) + ========== ============================================================================== **Point style keyword arguments:** (see :func:`ScatterPlotItem.setData() ` for more information) - ============ ================================================ - symbol Symbol to use for drawing points OR list of symbols, one per point. Default is no symbol. + ============ ===================================================== + symbol Symbol to use for drawing points OR list of symbols, + one per point. Default is no symbol. Options are o, s, t, d, +, or any QPainterPath - symbolPen Outline pen for drawing points OR list of pens, one per point. - May be any single argument accepted by :func:`mkPen() ` - symbolBrush Brush for filling points OR list of brushes, one per point. - May be any single argument accepted by :func:`mkBrush() ` + symbolPen Outline pen for drawing points OR list of pens, one + per point. May be any single argument accepted by + :func:`mkPen() ` + symbolBrush Brush for filling points OR list of brushes, one per + point. May be any single argument accepted by + :func:`mkBrush() ` symbolSize Diameter of symbols OR list of diameters. - pxMode (bool) If True, then symbolSize is specified in pixels. If False, then symbolSize is + pxMode (bool) If True, then symbolSize is specified in + pixels. If False, then symbolSize is specified in data coordinates. - ============ ================================================ + ============ ===================================================== **Optimization keyword arguments:** @@ -92,11 +101,11 @@ class PlotDataItem(GraphicsObject): decimate deprecated. downsample (int) Reduce the number of samples displayed by this value downsampleMethod 'subsample': Downsample by taking the first of N samples. - This method is fastest and least accurate. + This method is fastest and least accurate. 'mean': Downsample by taking the mean of N samples. 'peak': Downsample by drawing a saw wave that follows the min - and max of the original data. This method produces the best - visual representation of the data but is slower. + and max of the original data. This method produces the best + visual representation of the data but is slower. autoDownsample (bool) If True, resample the data before plotting to avoid plotting multiple line segments per pixel. This can improve performance when viewing very high-density data, but increases the initial overhead @@ -145,6 +154,7 @@ class PlotDataItem(GraphicsObject): 'shadowPen': None, 'fillLevel': None, 'fillBrush': None, + 'stepMode': None, 'symbol': None, 'symbolSize': 10, @@ -152,12 +162,13 @@ class PlotDataItem(GraphicsObject): 'symbolBrush': (50, 50, 150), 'pxMode': True, - 'antialias': pg.getConfigOption('antialias'), + 'antialias': getConfigOption('antialias'), 'pointMode': None, 'downsample': 1, 'autoDownsample': False, 'downsampleMethod': 'peak', + 'autoDownsampleFactor': 5., # draw ~5 samples per pixel 'clipToView': False, 'data': None, @@ -170,6 +181,9 @@ class PlotDataItem(GraphicsObject): return ints return interface in ints + def name(self): + return self.opts.get('name', None) + def boundingRect(self): return QtCore.QRectF() ## let child items handle this @@ -287,18 +301,18 @@ class PlotDataItem(GraphicsObject): Set the downsampling mode of this item. Downsampling reduces the number of samples drawn to increase performance. - =========== ================================================================= - Arguments - ds (int) Reduce visible plot samples by this factor. To disable, - set ds=1. - auto (bool) If True, automatically pick *ds* based on visible range - mode 'subsample': Downsample by taking the first of N samples. - This method is fastest and least accurate. - 'mean': Downsample by taking the mean of N samples. - 'peak': Downsample by drawing a saw wave that follows the min - and max of the original data. This method produces the best - visual representation of the data but is slower. - =========== ================================================================= + ============== ================================================================= + **Arguments:** + ds (int) Reduce visible plot samples by this factor. To disable, + set ds=1. + auto (bool) If True, automatically pick *ds* based on visible range + mode 'subsample': Downsample by taking the first of N samples. + This method is fastest and least accurate. + 'mean': Downsample by taking the mean of N samples. + 'peak': Downsample by drawing a saw wave that follows the min + and max of the original data. This method produces the best + visual representation of the data but is slower. + ============== ================================================================= """ changed = False if ds is not None: @@ -333,7 +347,7 @@ class PlotDataItem(GraphicsObject): See :func:`__init__() ` for details; it accepts the same arguments. """ #self.clear() - prof = debug.Profiler('PlotDataItem.setData (0x%x)' % id(self), disabled=True) + profiler = debug.Profiler() y = None x = None if len(args) == 1: @@ -367,14 +381,23 @@ class PlotDataItem(GraphicsObject): elif len(args) == 2: seq = ('listOfValues', 'MetaArray', 'empty') - if dataType(args[0]) not in seq or dataType(args[1]) not in seq: + dtyp = dataType(args[0]), dataType(args[1]) + if dtyp[0] not in seq or dtyp[1] not in seq: raise Exception('When passing two unnamed arguments, both must be a list or array of values. (got %s, %s)' % (str(type(args[0])), str(type(args[1])))) if not isinstance(args[0], np.ndarray): - x = np.array(args[0]) + #x = np.array(args[0]) + if dtyp[0] == 'MetaArray': + x = args[0].asarray() + else: + x = np.array(args[0]) else: x = args[0].view(np.ndarray) if not isinstance(args[1], np.ndarray): - y = np.array(args[1]) + #y = np.array(args[1]) + if dtyp[1] == 'MetaArray': + y = args[1].asarray() + else: + y = np.array(args[1]) else: y = args[1].view(np.ndarray) @@ -383,7 +406,7 @@ class PlotDataItem(GraphicsObject): if 'y' in kargs: y = kargs['y'] - prof.mark('interpret data') + profiler('interpret data') ## pull in all style arguments. ## Use self.opts to fill in anything not present in kargs. @@ -432,10 +455,10 @@ class PlotDataItem(GraphicsObject): self.xClean = self.yClean = None self.xDisp = None self.yDisp = None - prof.mark('set data') + profiler('set data') self.updateItems() - prof.mark('update items') + profiler('update items') self.informViewBoundsChanged() #view = self.getViewBox() @@ -443,14 +466,12 @@ class PlotDataItem(GraphicsObject): #view.itemBoundsChanged(self) ## inform view so it can update its range if it wants self.sigPlotChanged.emit(self) - prof.mark('emit') - prof.finish() - + profiler('emit') def updateItems(self): curveArgs = {} - for k,v in [('pen','pen'), ('shadowPen','shadowPen'), ('fillLevel','fillLevel'), ('fillBrush', 'brush'), ('antialias', 'antialias'), ('connect', 'connect')]: + for k,v in [('pen','pen'), ('shadowPen','shadowPen'), ('fillLevel','fillLevel'), ('fillBrush', 'brush'), ('antialias', 'antialias'), ('connect', 'connect'), ('stepMode', 'stepMode')]: curveArgs[v] = self.opts[k] scatterArgs = {} @@ -526,19 +547,22 @@ class PlotDataItem(GraphicsObject): x0 = (range.left()-x[0]) / dx x1 = (range.right()-x[0]) / dx width = self.getViewBox().width() - ds = int(max(1, int(0.2 * (x1-x0) / width))) + if width != 0.0: + ds = int(max(1, int((x1-x0) / (width*self.opts['autoDownsampleFactor'])))) ## downsampling is expensive; delay until after clipping. if self.opts['clipToView']: - # this option presumes that x-values have uniform spacing - range = self.viewRect() - if range is not None: - dx = float(x[-1]-x[0]) / (len(x)-1) - # clip to visible region extended by downsampling value - x0 = np.clip(int((range.left()-x[0])/dx)-1*ds , 0, len(x)-1) - x1 = np.clip(int((range.right()-x[0])/dx)+2*ds , 0, len(x)-1) - x = x[x0:x1] - y = y[x0:x1] + view = self.getViewBox() + if view is None or not view.autoRangeEnabled()[0]: + # this option presumes that x-values have uniform spacing + range = self.viewRect() + if range is not None and len(x) > 1: + dx = float(x[-1]-x[0]) / (len(x)-1) + # clip to visible region extended by downsampling value + x0 = np.clip(int((range.left()-x[0])/dx)-1*ds , 0, len(x)-1) + x1 = np.clip(int((range.right()-x[0])/dx)+2*ds , 0, len(x)-1) + x = x[x0:x1] + y = y[x0:x1] if ds > 1: if self.opts['downsampleMethod'] == 'subsample': @@ -643,13 +667,12 @@ class PlotDataItem(GraphicsObject): def _fourierTransform(self, x, y): ## Perform fourier transform. If x values are not sampled uniformly, - ## then use interpolate.griddata to resample before taking fft. + ## then use np.interp to resample before taking fft. dx = np.diff(x) uniform = not np.any(np.abs(dx-dx[0]) > (abs(dx[0]) / 1000.)) if not uniform: - import scipy.interpolate as interp x2 = np.linspace(x[0], x[-1], len(x)) - y = interp.griddata(x, y, x2, method='linear') + y = np.interp(x2, x, y) x = x2 f = np.fft.fft(y) / len(y) y = abs(f[1:len(f)/2]) diff --git a/pyqtgraph/graphicsItems/PlotItem/PlotItem.py b/pyqtgraph/graphicsItems/PlotItem/PlotItem.py index ec0960ba..4f10b0e3 100644 --- a/pyqtgraph/graphicsItems/PlotItem/PlotItem.py +++ b/pyqtgraph/graphicsItems/PlotItem/PlotItem.py @@ -16,16 +16,17 @@ This class is very heavily featured: - Control panel with a huge feature set including averaging, decimation, display, power spectrum, svg/png export, plot linking, and more. """ -from pyqtgraph.Qt import QtGui, QtCore, QtSvg, USE_PYSIDE -import pyqtgraph.pixmaps +from ...Qt import QtGui, QtCore, QtSvg, USE_PYSIDE +from ... import pixmaps +import sys if USE_PYSIDE: from .plotConfigTemplate_pyside import * else: from .plotConfigTemplate_pyqt import * -import pyqtgraph.functions as fn -from pyqtgraph.widgets.FileDialog import FileDialog +from ... import functions as fn +from ...widgets.FileDialog import FileDialog import weakref import numpy as np import os @@ -37,7 +38,7 @@ from .. LegendItem import LegendItem from .. GraphicsWidget import GraphicsWidget from .. ButtonItem import ButtonItem from .. InfiniteLine import InfiniteLine -from pyqtgraph.WidgetGroup import WidgetGroup +from ...WidgetGroup import WidgetGroup __all__ = ['PlotItem'] @@ -69,6 +70,7 @@ class PlotItem(GraphicsWidget): :func:`setYLink `, :func:`setAutoPan `, :func:`setAutoVisible `, + :func:`setLimits `, :func:`viewRect `, :func:`viewRange `, :func:`setMouseEnabled `, @@ -76,13 +78,14 @@ class PlotItem(GraphicsWidget): :func:`disableAutoRange `, :func:`setAspectLocked `, :func:`invertY `, + :func:`invertX `, :func:`register `, :func:`unregister ` The ViewBox itself can be accessed by calling :func:`getViewBox() ` ==================== ======================================================================= - **Signals** + **Signals:** sigYRangeChanged wrapped from :class:`ViewBox ` sigXRangeChanged wrapped from :class:`ViewBox ` sigRangeChanged wrapped from :class:`ViewBox ` @@ -95,7 +98,6 @@ class PlotItem(GraphicsWidget): lastFileDir = None - managers = {} def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None, axisItems=None, enableMenu=True, **kargs): """ @@ -103,7 +105,7 @@ class PlotItem(GraphicsWidget): Any extra keyword arguments are passed to PlotItem.plot(). ============== ========================================================================================== - **Arguments** + **Arguments:** *title* Title to display at the top of the item. Html is allowed. *labels* A dictionary specifying the axis labels to display:: @@ -129,7 +131,7 @@ class PlotItem(GraphicsWidget): path = os.path.dirname(__file__) #self.autoImageFile = os.path.join(path, 'auto.png') #self.lockImageFile = os.path.join(path, 'lock.png') - self.autoBtn = ButtonItem(pyqtgraph.pixmaps.getPixmap('auto'), 14, self) + self.autoBtn = ButtonItem(pixmaps.getPixmap('auto'), 14, self) self.autoBtn.mode = 'auto' self.autoBtn.clicked.connect(self.autoBtnClicked) #self.autoBtn.hide() @@ -143,7 +145,7 @@ class PlotItem(GraphicsWidget): self.layout.setVerticalSpacing(0) if viewBox is None: - viewBox = ViewBox() + viewBox = ViewBox(parent=self) self.vb = viewBox self.vb.sigStateChanged.connect(self.viewStateChanged) self.setMenuEnabled(enableMenu, enableMenu) ## en/disable plotitem and viewbox menus @@ -166,14 +168,14 @@ class PlotItem(GraphicsWidget): axisItems = {} self.axes = {} for k, pos in (('top', (1,1)), ('bottom', (3,1)), ('left', (2,0)), ('right', (2,2))): - axis = axisItems.get(k, AxisItem(orientation=k)) + axis = axisItems.get(k, AxisItem(orientation=k, parent=self)) axis.linkToView(self.vb) self.axes[k] = {'item': axis, 'pos': pos} self.layout.addItem(axis, *pos) axis.setZValue(-1000) axis.setFlag(axis.ItemNegativeZStacksBehindParent) - self.titleLabel = LabelItem('', size='11pt') + self.titleLabel = LabelItem('', size='11pt', parent=self) self.layout.addItem(self.titleLabel, 0, 1) self.setTitle(None) ## hide @@ -193,14 +195,6 @@ class PlotItem(GraphicsWidget): self.layout.setColumnStretchFactor(1, 100) - ## Wrap a few methods from viewBox - for m in [ - 'setXRange', 'setYRange', 'setXLink', 'setYLink', 'setAutoPan', 'setAutoVisible', - 'setRange', 'autoRange', 'viewRect', 'viewRange', 'setMouseEnabled', - 'enableAutoRange', 'disableAutoRange', 'setAspectLocked', 'invertY', - 'register', 'unregister']: ## NOTE: If you update this list, please update the class docstring as well. - setattr(self, m, getattr(self.vb, m)) - self.items = [] self.curves = [] self.itemMeta = weakref.WeakKeyDictionary() @@ -297,7 +291,26 @@ class PlotItem(GraphicsWidget): def getViewBox(self): """Return the :class:`ViewBox ` contained within.""" return self.vb + + ## Wrap a few methods from viewBox. + #Important: don't use a settattr(m, getattr(self.vb, m)) as we'd be leaving the viebox alive + #because we had a reference to an instance method (creating wrapper methods at runtime instead). + + for m in ['setXRange', 'setYRange', 'setXLink', 'setYLink', 'setAutoPan', # NOTE: + 'setAutoVisible', 'setRange', 'autoRange', 'viewRect', 'viewRange', # If you update this list, please + 'setMouseEnabled', 'setLimits', 'enableAutoRange', 'disableAutoRange', # update the class docstring + 'setAspectLocked', 'invertY', 'invertX', 'register', 'unregister']: # as well. + + def _create_method(name): + def method(self, *args, **kwargs): + return getattr(self.vb, name)(*args, **kwargs) + method.__name__ = name + return method + + locals()[m] = _create_method(m) + + del _create_method def setLogMode(self, x=None, y=None): @@ -339,9 +352,8 @@ class PlotItem(GraphicsWidget): self.ctrl.gridAlphaSlider.setValue(v) #def paint(self, *args): - #prof = debug.Profiler('PlotItem.paint', disabled=True) + #prof = debug.Profiler() #QtGui.QGraphicsWidget.paint(self, *args) - #prof.finish() ## bad idea. #def __getattr__(self, attr): ## wrap ms @@ -357,10 +369,8 @@ class PlotItem(GraphicsWidget): self.ctrlMenu.setParent(None) self.ctrlMenu = None - #self.ctrlBtn.setParent(None) - #self.ctrlBtn = None - #self.autoBtn.setParent(None) - #self.autoBtn = None + self.autoBtn.setParent(None) + self.autoBtn = None for k in self.axes: i = self.axes[k]['item'] @@ -370,28 +380,6 @@ class PlotItem(GraphicsWidget): self.scene().removeItem(self.vb) self.vb = None - ## causes invalid index errors: - #for i in range(self.layout.count()): - #self.layout.removeAt(i) - - #for p in self.proxies: - #try: - #p.setWidget(None) - #except RuntimeError: - #break - #self.scene().removeItem(p) - #self.proxies = [] - - #self.menuAction.releaseWidget(self.menuAction.defaultWidget()) - #self.menuAction.setParent(None) - #self.menuAction = None - - #if self.manager is not None: - #self.manager.sigWidgetListChanged.disconnect(self.updatePlotList) - #self.manager.removeWidget(self.name) - #else: - #print "no manager" - def registerPlot(self, name): ## for backward compatibility self.vb.register(name) @@ -481,7 +469,8 @@ class PlotItem(GraphicsWidget): ### Average data together (x, y) = curve.getData() - if plot.yData is not None: + if plot.yData is not None and y.shape == plot.yData.shape: + # note that if shapes do not match, then the average resets. newData = plot.yData * (n-1) / float(n) + y * 1.0 / float(n) plot.setData(plot.xData, newData) else: @@ -515,7 +504,9 @@ class PlotItem(GraphicsWidget): if 'ignoreBounds' in kargs: vbargs['ignoreBounds'] = kargs['ignoreBounds'] self.vb.addItem(item, *args, **vbargs) + name = None if hasattr(item, 'implements') and item.implements('plotData'): + name = item.name() self.dataItems.append(item) #self.plotChanged() @@ -548,7 +539,7 @@ class PlotItem(GraphicsWidget): #c.connect(c, QtCore.SIGNAL('plotChanged'), self.plotChanged) #item.sigPlotChanged.connect(self.plotChanged) #self.plotChanged() - name = kargs.get('name', getattr(item, 'opts', {}).get('name', None)) + #name = kargs.get('name', getattr(item, 'opts', {}).get('name', None)) if name is not None and hasattr(self, 'legend') and self.legend is not None: self.legend.addItem(item, name=name) @@ -952,18 +943,18 @@ class PlotItem(GraphicsWidget): def setDownsampling(self, ds=None, auto=None, mode=None): """Change the default downsampling mode for all PlotDataItems managed by this plot. - =========== ================================================================= - Arguments - ds (int) Reduce visible plot samples by this factor, or - (bool) To enable/disable downsampling without changing the value. - auto (bool) If True, automatically pick *ds* based on visible range - mode 'subsample': Downsample by taking the first of N samples. - This method is fastest and least accurate. - 'mean': Downsample by taking the mean of N samples. - 'peak': Downsample by drawing a saw wave that follows the min - and max of the original data. This method produces the best - visual representation of the data but is slower. - =========== ================================================================= + =============== ================================================================= + **Arguments:** + ds (int) Reduce visible plot samples by this factor, or + (bool) To enable/disable downsampling without changing the value. + auto (bool) If True, automatically pick *ds* based on visible range + mode 'subsample': Downsample by taking the first of N samples. + This method is fastest and least accurate. + 'mean': Downsample by taking the mean of N samples. + 'peak': Downsample by drawing a saw wave that follows the min + and max of the original data. This method produces the best + visual representation of the data but is slower. + =============== ================================================================= """ if ds is not None: if ds is False: @@ -1134,15 +1125,15 @@ class PlotItem(GraphicsWidget): """ Set the label for an axis. Basic HTML formatting is allowed. - ============= ================================================================= - **Arguments** - axis must be one of 'left', 'bottom', 'right', or 'top' - text text to display along the axis. HTML allowed. - units units to display after the title. If units are given, - then an SI prefix will be automatically appended - and the axis values will be scaled accordingly. - (ie, use 'V' instead of 'mV'; 'm' will be added automatically) - ============= ================================================================= + ============== ================================================================= + **Arguments:** + axis must be one of 'left', 'bottom', 'right', or 'top' + text text to display along the axis. HTML allowed. + units units to display after the title. If units are given, + then an SI prefix will be automatically appended + and the axis values will be scaled accordingly. + (ie, use 'V' instead of 'mV'; 'm' will be added automatically) + ============== ================================================================= """ self.getAxis(axis).setLabel(text=text, units=units, **args) self.showAxis(axis) @@ -1217,10 +1208,13 @@ class PlotItem(GraphicsWidget): self.updateButtons() def updateButtons(self): - if self._exportOpts is False and self.mouseHovering and not self.buttonsHidden and not all(self.vb.autoRangeEnabled()): - self.autoBtn.show() - else: - self.autoBtn.hide() + try: + if self._exportOpts is False and self.mouseHovering and not self.buttonsHidden and not all(self.vb.autoRangeEnabled()): + self.autoBtn.show() + else: + self.autoBtn.hide() + except RuntimeError: + pass # this can happen if the plot has been deleted. def _plotArray(self, arr, x=None, **kargs): if arr.ndim != 1: diff --git a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py index 5335ee76..e09c9978 100644 --- a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py +++ b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py @@ -2,8 +2,8 @@ # Form implementation generated from reading ui file './pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate.ui' # -# Created: Mon Jul 1 23:21:08 2013 -# by: PyQt4 UI code generator 4.9.3 +# Created: Mon Dec 23 10:10:51 2013 +# by: PyQt4 UI code generator 4.10 # # WARNING! All changes made in this file will be lost! @@ -12,7 +12,16 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_Form(object): def setupUi(self, Form): @@ -139,35 +148,35 @@ class Ui_Form(object): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) - self.averageGroup.setToolTip(QtGui.QApplication.translate("Form", "Display averages of the curves displayed in this plot. The parameter list allows you to choose parameters to average over (if any are available).", None, QtGui.QApplication.UnicodeUTF8)) - self.averageGroup.setTitle(QtGui.QApplication.translate("Form", "Average", None, QtGui.QApplication.UnicodeUTF8)) - self.clipToViewCheck.setToolTip(QtGui.QApplication.translate("Form", "Plot only the portion of each curve that is visible. This assumes X values are uniformly spaced.", None, QtGui.QApplication.UnicodeUTF8)) - self.clipToViewCheck.setText(QtGui.QApplication.translate("Form", "Clip to View", None, QtGui.QApplication.UnicodeUTF8)) - self.maxTracesCheck.setToolTip(QtGui.QApplication.translate("Form", "If multiple curves are displayed in this plot, check this box to limit the number of traces that are displayed.", None, QtGui.QApplication.UnicodeUTF8)) - self.maxTracesCheck.setText(QtGui.QApplication.translate("Form", "Max Traces:", None, QtGui.QApplication.UnicodeUTF8)) - self.downsampleCheck.setText(QtGui.QApplication.translate("Form", "Downsample", None, QtGui.QApplication.UnicodeUTF8)) - self.peakRadio.setToolTip(QtGui.QApplication.translate("Form", "Downsample by drawing a saw wave that follows the min and max of the original data. This method produces the best visual representation of the data but is slower.", None, QtGui.QApplication.UnicodeUTF8)) - self.peakRadio.setText(QtGui.QApplication.translate("Form", "Peak", None, QtGui.QApplication.UnicodeUTF8)) - self.maxTracesSpin.setToolTip(QtGui.QApplication.translate("Form", "If multiple curves are displayed in this plot, check \"Max Traces\" and set this value to limit the number of traces that are displayed.", None, QtGui.QApplication.UnicodeUTF8)) - self.forgetTracesCheck.setToolTip(QtGui.QApplication.translate("Form", "If MaxTraces is checked, remove curves from memory after they are hidden (saves memory, but traces can not be un-hidden).", None, QtGui.QApplication.UnicodeUTF8)) - self.forgetTracesCheck.setText(QtGui.QApplication.translate("Form", "Forget hidden traces", None, QtGui.QApplication.UnicodeUTF8)) - self.meanRadio.setToolTip(QtGui.QApplication.translate("Form", "Downsample by taking the mean of N samples.", None, QtGui.QApplication.UnicodeUTF8)) - self.meanRadio.setText(QtGui.QApplication.translate("Form", "Mean", None, QtGui.QApplication.UnicodeUTF8)) - self.subsampleRadio.setToolTip(QtGui.QApplication.translate("Form", "Downsample by taking the first of N samples. This method is fastest and least accurate.", None, QtGui.QApplication.UnicodeUTF8)) - self.subsampleRadio.setText(QtGui.QApplication.translate("Form", "Subsample", None, QtGui.QApplication.UnicodeUTF8)) - self.autoDownsampleCheck.setToolTip(QtGui.QApplication.translate("Form", "Automatically downsample data based on the visible range. This assumes X values are uniformly spaced.", None, QtGui.QApplication.UnicodeUTF8)) - self.autoDownsampleCheck.setText(QtGui.QApplication.translate("Form", "Auto", None, QtGui.QApplication.UnicodeUTF8)) - self.downsampleSpin.setToolTip(QtGui.QApplication.translate("Form", "Downsample data before plotting. (plot every Nth sample)", None, QtGui.QApplication.UnicodeUTF8)) - self.downsampleSpin.setSuffix(QtGui.QApplication.translate("Form", "x", None, QtGui.QApplication.UnicodeUTF8)) - self.fftCheck.setText(QtGui.QApplication.translate("Form", "Power Spectrum (FFT)", None, QtGui.QApplication.UnicodeUTF8)) - self.logXCheck.setText(QtGui.QApplication.translate("Form", "Log X", None, QtGui.QApplication.UnicodeUTF8)) - self.logYCheck.setText(QtGui.QApplication.translate("Form", "Log Y", None, QtGui.QApplication.UnicodeUTF8)) - self.pointsGroup.setTitle(QtGui.QApplication.translate("Form", "Points", None, QtGui.QApplication.UnicodeUTF8)) - self.autoPointsCheck.setText(QtGui.QApplication.translate("Form", "Auto", None, QtGui.QApplication.UnicodeUTF8)) - self.xGridCheck.setText(QtGui.QApplication.translate("Form", "Show X Grid", None, QtGui.QApplication.UnicodeUTF8)) - self.yGridCheck.setText(QtGui.QApplication.translate("Form", "Show Y Grid", None, QtGui.QApplication.UnicodeUTF8)) - self.label.setText(QtGui.QApplication.translate("Form", "Opacity", None, QtGui.QApplication.UnicodeUTF8)) - self.alphaGroup.setTitle(QtGui.QApplication.translate("Form", "Alpha", None, QtGui.QApplication.UnicodeUTF8)) - self.autoAlphaCheck.setText(QtGui.QApplication.translate("Form", "Auto", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(_translate("Form", "Form", None)) + self.averageGroup.setToolTip(_translate("Form", "Display averages of the curves displayed in this plot. The parameter list allows you to choose parameters to average over (if any are available).", None)) + self.averageGroup.setTitle(_translate("Form", "Average", None)) + self.clipToViewCheck.setToolTip(_translate("Form", "Plot only the portion of each curve that is visible. This assumes X values are uniformly spaced.", None)) + self.clipToViewCheck.setText(_translate("Form", "Clip to View", None)) + self.maxTracesCheck.setToolTip(_translate("Form", "If multiple curves are displayed in this plot, check this box to limit the number of traces that are displayed.", None)) + self.maxTracesCheck.setText(_translate("Form", "Max Traces:", None)) + self.downsampleCheck.setText(_translate("Form", "Downsample", None)) + self.peakRadio.setToolTip(_translate("Form", "Downsample by drawing a saw wave that follows the min and max of the original data. This method produces the best visual representation of the data but is slower.", None)) + self.peakRadio.setText(_translate("Form", "Peak", None)) + self.maxTracesSpin.setToolTip(_translate("Form", "If multiple curves are displayed in this plot, check \"Max Traces\" and set this value to limit the number of traces that are displayed.", None)) + self.forgetTracesCheck.setToolTip(_translate("Form", "If MaxTraces is checked, remove curves from memory after they are hidden (saves memory, but traces can not be un-hidden).", None)) + self.forgetTracesCheck.setText(_translate("Form", "Forget hidden traces", None)) + self.meanRadio.setToolTip(_translate("Form", "Downsample by taking the mean of N samples.", None)) + self.meanRadio.setText(_translate("Form", "Mean", None)) + self.subsampleRadio.setToolTip(_translate("Form", "Downsample by taking the first of N samples. This method is fastest and least accurate.", None)) + self.subsampleRadio.setText(_translate("Form", "Subsample", None)) + self.autoDownsampleCheck.setToolTip(_translate("Form", "Automatically downsample data based on the visible range. This assumes X values are uniformly spaced.", None)) + self.autoDownsampleCheck.setText(_translate("Form", "Auto", None)) + self.downsampleSpin.setToolTip(_translate("Form", "Downsample data before plotting. (plot every Nth sample)", None)) + self.downsampleSpin.setSuffix(_translate("Form", "x", None)) + self.fftCheck.setText(_translate("Form", "Power Spectrum (FFT)", None)) + self.logXCheck.setText(_translate("Form", "Log X", None)) + self.logYCheck.setText(_translate("Form", "Log Y", None)) + self.pointsGroup.setTitle(_translate("Form", "Points", None)) + self.autoPointsCheck.setText(_translate("Form", "Auto", None)) + self.xGridCheck.setText(_translate("Form", "Show X Grid", None)) + self.yGridCheck.setText(_translate("Form", "Show Y Grid", None)) + self.label.setText(_translate("Form", "Opacity", None)) + self.alphaGroup.setTitle(_translate("Form", "Alpha", None)) + self.autoAlphaCheck.setText(_translate("Form", "Auto", None)) diff --git a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside.py b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside.py index b8e0b19e..aff31211 100644 --- a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside.py +++ b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside.py @@ -2,8 +2,8 @@ # Form implementation generated from reading ui file './pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate.ui' # -# Created: Mon Jul 1 23:21:08 2013 -# by: pyside-uic 0.2.13 running on PySide 1.1.2 +# Created: Mon Dec 23 10:10:52 2013 +# by: pyside-uic 0.2.14 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! diff --git a/pyqtgraph/graphicsItems/ROI.py b/pyqtgraph/graphicsItems/ROI.py index f6ce4680..7707466a 100644 --- a/pyqtgraph/graphicsItems/ROI.py +++ b/pyqtgraph/graphicsItems/ROI.py @@ -12,23 +12,20 @@ The ROI class is meant to serve as the base for more specific types; see several of how to build an ROI at the bottom of the file. """ -from pyqtgraph.Qt import QtCore, QtGui -#if not hasattr(QtCore, 'Signal'): - #QtCore.Signal = QtCore.pyqtSignal +from ..Qt import QtCore, QtGui import numpy as np -from numpy.linalg import norm -import scipy.ndimage as ndimage -from pyqtgraph.Point import * -from pyqtgraph.SRTTransform import SRTTransform +#from numpy.linalg import norm +from ..Point import * +from ..SRTTransform import SRTTransform from math import cos, sin -import pyqtgraph.functions as fn +from .. import functions as fn from .GraphicsObject import GraphicsObject from .UIGraphicsItem import UIGraphicsItem __all__ = [ 'ROI', 'TestROI', 'RectROI', 'EllipseROI', 'CircleROI', 'PolygonROI', - 'LineROI', 'MultiLineROI', 'MultiRectROI', 'LineSegmentROI', 'PolyLineROI', 'SpiralROI', + 'LineROI', 'MultiLineROI', 'MultiRectROI', 'LineSegmentROI', 'PolyLineROI', 'SpiralROI', 'CrosshairROI', ] @@ -36,11 +33,56 @@ def rectStr(r): return "[%f, %f] + [%f, %f]" % (r.x(), r.y(), r.width(), r.height()) class ROI(GraphicsObject): - """Generic region-of-interest widget. - Can be used for implementing many types of selection box with rotate/translate/scale handles. + """ + Generic region-of-interest widget. - Signals - ----------------------- ---------------------------------------------------- + Can be used for implementing many types of selection box with + rotate/translate/scale handles. + ROIs can be customized to have a variety of shapes (by subclassing or using + any of the built-in subclasses) and any combination of draggable handles + that allow the user to manipulate the ROI. + + + + ================ =========================================================== + **Arguments** + pos (length-2 sequence) Indicates the position of the ROI's + origin. For most ROIs, this is the lower-left corner of + its bounding rectangle. + size (length-2 sequence) Indicates the width and height of the + ROI. + angle (float) The rotation of the ROI in degrees. Default is 0. + invertible (bool) If True, the user may resize the ROI to have + negative width or height (assuming the ROI has scale + handles). Default is False. + maxBounds (QRect, QRectF, or None) Specifies boundaries that the ROI + cannot be dragged outside of by the user. Default is None. + snapSize (float) The spacing of snap positions used when *scaleSnap* + or *translateSnap* are enabled. Default is 1.0. + scaleSnap (bool) If True, the width and height of the ROI are forced + to be integer multiples of *snapSize* when being resized + by the user. Default is False. + translateSnap (bool) If True, the x and y positions of the ROI are forced + to be integer multiples of *snapSize* when being resized + by the user. Default is False. + rotateSnap (bool) If True, the ROI angle is forced to a multiple of + 15 degrees when rotated by the user. Default is False. + parent (QGraphicsItem) The graphics item parent of this ROI. It + is generally not necessary to specify the parent. + pen (QPen or argument to pg.mkPen) The pen to use when drawing + the shape of the ROI. + movable (bool) If True, the ROI can be moved by dragging anywhere + inside the ROI. Default is True. + removable (bool) If True, the ROI will be given a context menu with + an option to remove the ROI. The ROI emits + sigRemoveRequested when this menu action is selected. + Default is False. + ================ =========================================================== + + + + ======================= ==================================================== + **Signals** sigRegionChangeFinished Emitted when the user stops dragging the ROI (or one of its handles) or if the ROI is changed programatically. @@ -58,7 +100,7 @@ class ROI(GraphicsObject): details. sigRemoveRequested Emitted when the user selects 'remove' from the ROI's context menu (if available). - ----------------------- ---------------------------------------------------- + ======================= ==================================================== """ sigRegionChangeFinished = QtCore.Signal(object) @@ -117,7 +159,11 @@ class ROI(GraphicsObject): return sc def saveState(self): - """Return the state of the widget in a format suitable for storing to disk. (Points are converted to tuple)""" + """Return the state of the widget in a format suitable for storing to + disk. (Points are converted to tuple) + + Combined with setState(), this allows ROIs to be easily saved and + restored.""" state = {} state['pos'] = tuple(self.state['pos']) state['size'] = tuple(self.state['size']) @@ -125,6 +171,10 @@ class ROI(GraphicsObject): return state def setState(self, state, update=True): + """ + Set the state of the ROI from a structure generated by saveState() or + getState(). + """ self.setPos(state['pos'], update=False) self.setSize(state['size'], update=False) self.setAngle(state['angle'], update=update) @@ -135,20 +185,32 @@ class ROI(GraphicsObject): h['item'].setZValue(z+1) def parentBounds(self): + """ + Return the bounding rectangle of this ROI in the coordinate system + of its parent. + """ return self.mapToParent(self.boundingRect()).boundingRect() - def setPen(self, pen): - self.pen = fn.mkPen(pen) + def setPen(self, *args, **kwargs): + """ + Set the pen to use when drawing the ROI shape. + For arguments, see :func:`mkPen `. + """ + self.pen = fn.mkPen(*args, **kwargs) self.currentPen = self.pen self.update() def size(self): + """Return the size (w,h) of the ROI.""" return self.getState()['size'] def pos(self): + """Return the position (x,y) of the ROI's origin. + For most ROIs, this will be the lower-left corner.""" return self.getState()['pos'] def angle(self): + """Return the angle of the ROI in degrees.""" return self.getState()['angle'] def setPos(self, pos, update=True, finish=True): @@ -214,11 +276,14 @@ class ROI(GraphicsObject): If the ROI is bounded and the move would exceed boundaries, then the ROI is moved to the nearest acceptable position instead. - snap can be: - None (default): use self.translateSnap and self.snapSize to determine whether/how to snap - False: do not snap - Point(w,h) snap to rectangular grid with spacing (w,h) - True: snap using self.snapSize (and ignoring self.translateSnap) + *snap* can be: + + =============== ========================================================================== + None (default) use self.translateSnap and self.snapSize to determine whether/how to snap + False do not snap + Point(w,h) snap to rectangular grid with spacing (w,h) + True snap using self.snapSize (and ignoring self.translateSnap) + =============== ========================================================================== Also accepts *update* and *finish* arguments (see setPos() for a description of these). """ @@ -264,21 +329,86 @@ class ROI(GraphicsObject): #self.stateChanged() def rotate(self, angle, update=True, finish=True): + """ + Rotate the ROI by *angle* degrees. + + Also accepts *update* and *finish* arguments (see setPos() for a + description of these). + """ self.setAngle(self.angle()+angle, update=update, finish=finish) def handleMoveStarted(self): self.preMoveState = self.getState() def addTranslateHandle(self, pos, axes=None, item=None, name=None, index=None): + """ + Add a new translation handle to the ROI. Dragging the handle will move + the entire ROI without changing its angle or shape. + + Note that, by default, ROIs may be moved by dragging anywhere inside the + ROI. However, for larger ROIs it may be desirable to disable this and + instead provide one or more translation handles. + + =================== ==================================================== + **Arguments** + pos (length-2 sequence) The position of the handle + relative to the shape of the ROI. A value of (0,0) + indicates the origin, whereas (1, 1) indicates the + upper-right corner, regardless of the ROI's size. + item The Handle instance to add. If None, a new handle + will be created. + name The name of this handle (optional). Handles are + identified by name when calling + getLocalHandlePositions and getSceneHandlePositions. + =================== ==================================================== + """ pos = Point(pos) return self.addHandle({'name': name, 'type': 't', 'pos': pos, 'item': item}, index=index) def addFreeHandle(self, pos=None, axes=None, item=None, name=None, index=None): + """ + Add a new free handle to the ROI. Dragging free handles has no effect + on the position or shape of the ROI. + + =================== ==================================================== + **Arguments** + pos (length-2 sequence) The position of the handle + relative to the shape of the ROI. A value of (0,0) + indicates the origin, whereas (1, 1) indicates the + upper-right corner, regardless of the ROI's size. + item The Handle instance to add. If None, a new handle + will be created. + name The name of this handle (optional). Handles are + identified by name when calling + getLocalHandlePositions and getSceneHandlePositions. + =================== ==================================================== + """ if pos is not None: pos = Point(pos) return self.addHandle({'name': name, 'type': 'f', 'pos': pos, 'item': item}, index=index) def addScaleHandle(self, pos, center, axes=None, item=None, name=None, lockAspect=False, index=None): + """ + Add a new scale handle to the ROI. Dragging a scale handle allows the + user to change the height and/or width of the ROI. + + =================== ==================================================== + **Arguments** + pos (length-2 sequence) The position of the handle + relative to the shape of the ROI. A value of (0,0) + indicates the origin, whereas (1, 1) indicates the + upper-right corner, regardless of the ROI's size. + center (length-2 sequence) The center point around which + scaling takes place. If the center point has the + same x or y value as the handle position, then + scaling will be disabled for that axis. + item The Handle instance to add. If None, a new handle + will be created. + name The name of this handle (optional). Handles are + identified by name when calling + getLocalHandlePositions and getSceneHandlePositions. + =================== ==================================================== + """ pos = Point(pos) center = Point(center) info = {'name': name, 'type': 's', 'center': center, 'pos': pos, 'item': item, 'lockAspect': lockAspect} @@ -289,11 +419,51 @@ class ROI(GraphicsObject): return self.addHandle(info, index=index) def addRotateHandle(self, pos, center, item=None, name=None, index=None): + """ + Add a new rotation handle to the ROI. Dragging a rotation handle allows + the user to change the angle of the ROI. + + =================== ==================================================== + **Arguments** + pos (length-2 sequence) The position of the handle + relative to the shape of the ROI. A value of (0,0) + indicates the origin, whereas (1, 1) indicates the + upper-right corner, regardless of the ROI's size. + center (length-2 sequence) The center point around which + rotation takes place. + item The Handle instance to add. If None, a new handle + will be created. + name The name of this handle (optional). Handles are + identified by name when calling + getLocalHandlePositions and getSceneHandlePositions. + =================== ==================================================== + """ pos = Point(pos) center = Point(center) return self.addHandle({'name': name, 'type': 'r', 'center': center, 'pos': pos, 'item': item}, index=index) def addScaleRotateHandle(self, pos, center, item=None, name=None, index=None): + """ + Add a new scale+rotation handle to the ROI. When dragging a handle of + this type, the user can simultaneously rotate the ROI around an + arbitrary center point as well as scale the ROI by dragging the handle + toward or away from the center point. + + =================== ==================================================== + **Arguments** + pos (length-2 sequence) The position of the handle + relative to the shape of the ROI. A value of (0,0) + indicates the origin, whereas (1, 1) indicates the + upper-right corner, regardless of the ROI's size. + center (length-2 sequence) The center point around which + scaling and rotation take place. + item The Handle instance to add. If None, a new handle + will be created. + name The name of this handle (optional). Handles are + identified by name when calling + getLocalHandlePositions and getSceneHandlePositions. + =================== ==================================================== + """ pos = Point(pos) center = Point(center) if pos[0] != center[0] and pos[1] != center[1]: @@ -301,6 +471,27 @@ class ROI(GraphicsObject): return self.addHandle({'name': name, 'type': 'sr', 'center': center, 'pos': pos, 'item': item}, index=index) def addRotateFreeHandle(self, pos, center, axes=None, item=None, name=None, index=None): + """ + Add a new rotation+free handle to the ROI. When dragging a handle of + this type, the user can rotate the ROI around an + arbitrary center point, while moving toward or away from the center + point has no effect on the shape of the ROI. + + =================== ==================================================== + **Arguments** + pos (length-2 sequence) The position of the handle + relative to the shape of the ROI. A value of (0,0) + indicates the origin, whereas (1, 1) indicates the + upper-right corner, regardless of the ROI's size. + center (length-2 sequence) The center point around which + rotation takes place. + item The Handle instance to add. If None, a new handle + will be created. + name The name of this handle (optional). Handles are + identified by name when calling + getLocalHandlePositions and getSceneHandlePositions. + =================== ==================================================== + """ pos = Point(pos) center = Point(center) return self.addHandle({'name': name, 'type': 'rf', 'center': center, 'pos': pos, 'item': item}, index=index) @@ -329,6 +520,9 @@ class ROI(GraphicsObject): return h def indexOfHandle(self, handle): + """ + Return the index of *handle* in the list of this ROI's handles. + """ if isinstance(handle, Handle): index = [i for i, info in enumerate(self.handles) if info['item'] is handle] if len(index) == 0: @@ -338,7 +532,8 @@ class ROI(GraphicsObject): return handle def removeHandle(self, handle): - """Remove a handle from this ROI. Argument may be either a Handle instance or the integer index of the handle.""" + """Remove a handle from this ROI. Argument may be either a Handle + instance or the integer index of the handle.""" index = self.indexOfHandle(handle) handle = self.handles[index]['item'] @@ -349,20 +544,17 @@ class ROI(GraphicsObject): self.stateChanged() def replaceHandle(self, oldHandle, newHandle): - """Replace one handle in the ROI for another. This is useful when connecting multiple ROIs together. - *oldHandle* may be a Handle instance or the index of a handle.""" - #print "=========================" - #print "replace", oldHandle, newHandle - #print self - #print self.handles - #print "-----------------" + """Replace one handle in the ROI for another. This is useful when + connecting multiple ROIs together. + + *oldHandle* may be a Handle instance or the index of a handle to be + replaced.""" index = self.indexOfHandle(oldHandle) info = self.handles[index] self.removeHandle(index) info['item'] = newHandle info['pos'] = newHandle.pos() self.addHandle(info, index=index) - #print self.handles def checkRemoveHandle(self, handle): ## This is used when displaying a Handle's context menu to determine @@ -373,7 +565,10 @@ class ROI(GraphicsObject): def getLocalHandlePositions(self, index=None): - """Returns the position of a handle in ROI coordinates""" + """Returns the position of handles in the ROI's coordinate system. + + The format returned is a list of (name, pos) tuples. + """ if index == None: positions = [] for h in self.handles: @@ -383,6 +578,10 @@ class ROI(GraphicsObject): return (self.handles[index]['name'], self.handles[index]['pos']) def getSceneHandlePositions(self, index=None): + """Returns the position of handles in the scene coordinate system. + + The format returned is a list of (name, pos) tuples. + """ if index == None: positions = [] for h in self.handles: @@ -392,6 +591,9 @@ class ROI(GraphicsObject): return (self.handles[index]['name'], self.handles[index]['item'].scenePos()) def getHandles(self): + """ + Return a list of this ROI's Handles. + """ return [h['item'] for h in self.handles] def mapSceneToParent(self, pt): @@ -463,12 +665,8 @@ class ROI(GraphicsObject): def removeClicked(self): ## Send remove event only after we have exited the menu event handler - self.removeTimer = QtCore.QTimer() - self.removeTimer.timeout.connect(lambda: self.sigRemoveRequested.emit(self)) - self.removeTimer.start(0) + QtCore.QTimer.singleShot(0, lambda: self.sigRemoveRequested.emit(self)) - - def mouseDragEvent(self, ev): if ev.isStart(): #p = ev.pos() @@ -510,56 +708,16 @@ class ROI(GraphicsObject): self.sigClicked.emit(self, ev) else: ev.ignore() - - - def cancelMove(self): self.isMoving = False self.setState(self.preMoveState) - - #def pointDragEvent(self, pt, ev): - ### just for handling drag start/stop. - ### drag moves are handled through movePoint() - - #if ev.isStart(): - #self.isMoving = True - #self.preMoveState = self.getState() - - #self.sigRegionChangeStarted.emit(self) - #elif ev.isFinish(): - #self.isMoving = False - #self.sigRegionChangeFinished.emit(self) - #return - - - #def pointPressEvent(self, pt, ev): - ##print "press" - #self.isMoving = True - #self.preMoveState = self.getState() - - ##self.emit(QtCore.SIGNAL('regionChangeStarted'), self) - #self.sigRegionChangeStarted.emit(self) - ##self.pressPos = self.mapFromScene(ev.scenePos()) - ##self.pressHandlePos = self.handles[pt]['item'].pos() - - #def pointReleaseEvent(self, pt, ev): - ##print "release" - #self.isMoving = False - ##self.emit(QtCore.SIGNAL('regionChangeFinished'), self) - #self.sigRegionChangeFinished.emit(self) - - #def pointMoveEvent(self, pt, ev): - #self.movePoint(pt, ev.scenePos(), ev.modifiers()) - - def checkPointMove(self, handle, pos, modifiers): """When handles move, they must ask the ROI if the move is acceptable. By default, this always returns True. Subclasses may wish override. """ return True - def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish=True, coords='parent'): ## called by Handles when they are moved. @@ -664,7 +822,10 @@ class ROI(GraphicsObject): if not self.rotateAllowed: return ## If the handle is directly over its center point, we can't compute an angle. - if lp1.length() == 0 or lp0.length() == 0: + try: + if lp1.length() == 0 or lp0.length() == 0: + return + except OverflowError: return ## determine new rotation angle, constrained if necessary @@ -701,10 +862,15 @@ class ROI(GraphicsObject): elif h['type'] == 'sr': if h['center'][0] == h['pos'][0]: scaleAxis = 1 + nonScaleAxis=0 else: scaleAxis = 0 + nonScaleAxis=1 - if lp1.length() == 0 or lp0.length() == 0: + try: + if lp1.length() == 0 or lp0.length() == 0: + return + except OverflowError: return ang = newState['angle'] - lp0.angle(lp1) @@ -721,6 +887,8 @@ class ROI(GraphicsObject): newState['size'][scaleAxis] = round(newState['size'][scaleAxis] / self.snapSize) * self.snapSize if newState['size'][scaleAxis] == 0: newState['size'][scaleAxis] = 1 + if self.aspectLocked: + newState['size'][nonScaleAxis] = newState['size'][scaleAxis] c1 = c * newState['size'] tr = QtGui.QTransform() @@ -804,19 +972,20 @@ class ROI(GraphicsObject): round(pos[1] / snap[1]) * snap[1] ) - def boundingRect(self): return QtCore.QRectF(0, 0, self.state['size'][0], self.state['size'][1]).normalized() def paint(self, p, opt, widget): - p.save() - r = self.boundingRect() + # p.save() + # Note: don't use self.boundingRect here, because subclasses may need to redefine it. + r = QtCore.QRectF(0, 0, self.state['size'][0], self.state['size'][1]).normalized() + p.setRenderHint(QtGui.QPainter.Antialiasing) p.setPen(self.currentPen) p.translate(r.left(), r.top()) p.scale(r.width(), r.height()) p.drawRect(0, 0, 1, 1) - p.restore() + # p.restore() def getArraySlice(self, data, img, axes=(0,1), returnSlice=True): """Return a tuple of slice objects that can be used to slice the region from data covered by this ROI. @@ -871,7 +1040,25 @@ class ROI(GraphicsObject): return bounds, tr def getArrayRegion(self, data, img, axes=(0,1), returnMappedCoords=False, **kwds): - """Use the position and orientation of this ROI relative to an imageItem to pull a slice from an array. + """Use the position and orientation of this ROI relative to an imageItem + to pull a slice from an array. + + =================== ==================================================== + **Arguments** + data The array to slice from. Note that this array does + *not* have to be the same data that is represented + in *img*. + img (ImageItem or other suitable QGraphicsItem) + Used to determine the relationship between the + ROI and the boundaries of *data*. + axes (length-2 tuple) Specifies the axes in *data* that + correspond to the x and y axes of *img*. + returnMappedCoords (bool) If True, the array slice is returned along + with a corresponding array of coordinates that were + used to extract data from the original array. + \**kwds All keyword arguments are passed to + :func:`affineSlice `. + =================== ==================================================== This method uses :func:`affineSlice ` to generate the slice from *data* and uses :func:`getAffineSliceParams ` to determine the parameters to @@ -905,105 +1092,6 @@ class ROI(GraphicsObject): #mapped += translate.reshape((2,1,1)) mapped = fn.transformCoordinates(img.transform(), coords) return result, mapped - - - ### transpose data so x and y are the first 2 axes - #trAx = range(0, data.ndim) - #trAx.remove(axes[0]) - #trAx.remove(axes[1]) - #tr1 = tuple(axes) + tuple(trAx) - #arr = data.transpose(tr1) - - ### Determine the minimal area of the data we will need - #(dataBounds, roiDataTransform) = self.getArraySlice(data, img, returnSlice=False, axes=axes) - - ### Pad data boundaries by 1px if possible - #dataBounds = ( - #(max(dataBounds[0][0]-1, 0), min(dataBounds[0][1]+1, arr.shape[0])), - #(max(dataBounds[1][0]-1, 0), min(dataBounds[1][1]+1, arr.shape[1])) - #) - - ### Extract minimal data from array - #arr1 = arr[dataBounds[0][0]:dataBounds[0][1], dataBounds[1][0]:dataBounds[1][1]] - - ### Update roiDataTransform to reflect this extraction - #roiDataTransform *= QtGui.QTransform().translate(-dataBounds[0][0], -dataBounds[1][0]) - #### (roiDataTransform now maps from ROI coords to extracted data coords) - - - ### Rotate array - #if abs(self.state['angle']) > 1e-5: - #arr2 = ndimage.rotate(arr1, self.state['angle'] * 180 / np.pi, order=1) - - ### update data transforms to reflect this rotation - #rot = QtGui.QTransform().rotate(self.state['angle'] * 180 / np.pi) - #roiDataTransform *= rot - - ### The rotation also causes a shift which must be accounted for: - #dataBound = QtCore.QRectF(0, 0, arr1.shape[0], arr1.shape[1]) - #rotBound = rot.mapRect(dataBound) - #roiDataTransform *= QtGui.QTransform().translate(-rotBound.left(), -rotBound.top()) - - #else: - #arr2 = arr1 - - - - #### Shift off partial pixels - ## 1. map ROI into current data space - #roiBounds = roiDataTransform.mapRect(self.boundingRect()) - - ## 2. Determine amount to shift data - #shift = (int(roiBounds.left()) - roiBounds.left(), int(roiBounds.bottom()) - roiBounds.bottom()) - #if abs(shift[0]) > 1e-6 or abs(shift[1]) > 1e-6: - ## 3. pad array with 0s before shifting - #arr2a = np.zeros((arr2.shape[0]+2, arr2.shape[1]+2) + arr2.shape[2:], dtype=arr2.dtype) - #arr2a[1:-1, 1:-1] = arr2 - - ## 4. shift array and udpate transforms - #arr3 = ndimage.shift(arr2a, shift + (0,)*(arr2.ndim-2), order=1) - #roiDataTransform *= QtGui.QTransform().translate(1+shift[0], 1+shift[1]) - #else: - #arr3 = arr2 - - - #### Extract needed region from rotated/shifted array - ## 1. map ROI into current data space (round these values off--they should be exact integer values at this point) - #roiBounds = roiDataTransform.mapRect(self.boundingRect()) - ##print self, roiBounds.height() - ##import traceback - ##traceback.print_stack() - - #roiBounds = QtCore.QRect(round(roiBounds.left()), round(roiBounds.top()), round(roiBounds.width()), round(roiBounds.height())) - - ##2. intersect ROI with data bounds - #dataBounds = roiBounds.intersect(QtCore.QRect(0, 0, arr3.shape[0], arr3.shape[1])) - - ##3. Extract data from array - #db = dataBounds - #bounds = ( - #(db.left(), db.right()+1), - #(db.top(), db.bottom()+1) - #) - #arr4 = arr3[bounds[0][0]:bounds[0][1], bounds[1][0]:bounds[1][1]] - - #### Create zero array in size of ROI - #arr5 = np.zeros((roiBounds.width(), roiBounds.height()) + arr4.shape[2:], dtype=arr4.dtype) - - ### Fill array with ROI data - #orig = Point(dataBounds.topLeft() - roiBounds.topLeft()) - #subArr = arr5[orig[0]:orig[0]+arr4.shape[0], orig[1]:orig[1]+arr4.shape[1]] - #subArr[:] = arr4[:subArr.shape[0], :subArr.shape[1]] - - - ### figure out the reverse transpose order - #tr2 = np.array(tr1) - #for i in range(0, len(tr2)): - #tr2[tr1[i]] = i - #tr2 = tuple(tr2) - - ### Untranspose array before returning - #return arr5.transpose(tr2) def getAffineSliceParams(self, data, img, axes=(0,1)): """ @@ -1088,7 +1176,18 @@ class ROI(GraphicsObject): class Handle(UIGraphicsItem): + """ + Handle represents a single user-interactable point attached to an ROI. They + are usually created by a call to one of the ROI.add___Handle() methods. + Handles are represented as a square, diamond, or circle, and are drawn with + fixed pixel size regardless of the scaling of the view they are displayed in. + + Handles may be dragged to change the position, size, orientation, or other + properties of the ROI they are attached to. + + + """ types = { ## defines number of sides, start angle for each handle type 't': (4, np.pi/4), 'f': (4, np.pi/4), @@ -1202,11 +1301,7 @@ class Handle(UIGraphicsItem): def getMenu(self): return self.menu - - - def getContextMenus(self, event): - return [self.menu] - + def raiseContextMenu(self, ev): menu = self.scene().addParentContextMenus(self, self.getMenu(), ev) @@ -1364,6 +1459,22 @@ class TestROI(ROI): class RectROI(ROI): + """ + Rectangular ROI subclass with a single scale handle at the top-right corner. + + ============== ============================================================= + **Arguments** + pos (length-2 sequence) The position of the ROI origin. + See ROI(). + size (length-2 sequence) The size of the ROI. See ROI(). + centered (bool) If True, scale handles affect the ROI relative to its + center, rather than its origin. + sideScalers (bool) If True, extra scale handles are added at the top and + right edges. + \**args All extra keyword arguments are passed to ROI() + ============== ============================================================= + + """ def __init__(self, pos, size, centered=False, sideScalers=False, **args): #QtGui.QGraphicsRectItem.__init__(self, 0, 0, size[0], size[1]) ROI.__init__(self, pos, size, **args) @@ -1379,6 +1490,22 @@ class RectROI(ROI): self.addScaleHandle([0.5, 1], [0.5, center[1]]) class LineROI(ROI): + """ + Rectangular ROI subclass with scale-rotate handles on either side. This + allows the ROI to be positioned as if moving the ends of a line segment. + A third handle controls the width of the ROI orthogonal to its "line" axis. + + ============== ============================================================= + **Arguments** + pos1 (length-2 sequence) The position of the center of the ROI's + left edge. + pos2 (length-2 sequence) The position of the center of the ROI's + right edge. + width (float) The width of the ROI. + \**args All extra keyword arguments are passed to ROI() + ============== ============================================================= + + """ def __init__(self, pos1, pos2, width, **args): pos1 = Point(pos1) pos2 = Point(pos2) @@ -1403,6 +1530,13 @@ class MultiRectROI(QtGui.QGraphicsObject): This is generally used to mark a curved path through an image similarly to PolyLineROI. It differs in that each segment of the chain is rectangular instead of linear and thus has width. + + ============== ============================================================= + **Arguments** + points (list of length-2 sequences) The list of points in the path. + width (float) The width of the ROIs orthogonal to the path. + \**args All extra keyword arguments are passed to ROI() + ============== ============================================================= """ sigRegionChangeFinished = QtCore.Signal(object) sigRegionChangeStarted = QtCore.Signal(object) @@ -1527,6 +1661,18 @@ class MultiLineROI(MultiRectROI): print("Warning: MultiLineROI has been renamed to MultiRectROI. (and MultiLineROI may be redefined in the future)") class EllipseROI(ROI): + """ + Elliptical ROI subclass with one scale handle and one rotation handle. + + + ============== ============================================================= + **Arguments** + pos (length-2 sequence) The position of the ROI's origin. + size (length-2 sequence) The size of the ROI's bounding rectangle. + \**args All extra keyword arguments are passed to ROI() + ============== ============================================================= + + """ def __init__(self, pos, size, **args): #QtGui.QGraphicsRectItem.__init__(self, 0, 0, size[0], size[1]) ROI.__init__(self, pos, size, **args) @@ -1544,6 +1690,10 @@ class EllipseROI(ROI): p.drawEllipse(r) def getArrayRegion(self, arr, img=None): + """ + Return the result of ROI.getArrayRegion() masked by the elliptical shape + of the ROI. Regions outside the ellipse are set to 0. + """ arr = ROI.getArrayRegion(self, arr, img) if arr is None or arr.shape[0] == 0 or arr.shape[1] == 0: return None @@ -1561,12 +1711,25 @@ class EllipseROI(ROI): class CircleROI(EllipseROI): + """ + Circular ROI subclass. Behaves exactly as EllipseROI, but may only be scaled + proportionally to maintain its aspect ratio. + + ============== ============================================================= + **Arguments** + pos (length-2 sequence) The position of the ROI's origin. + size (length-2 sequence) The size of the ROI's bounding rectangle. + \**args All extra keyword arguments are passed to ROI() + ============== ============================================================= + + """ def __init__(self, pos, size, **args): ROI.__init__(self, pos, size, **args) self.aspectLocked = True #self.addTranslateHandle([0.5, 0.5]) self.addScaleHandle([0.5*2.**-0.5 + 0.5, 0.5*2.**-0.5 + 0.5], [0.5, 0.5]) - + + class PolygonROI(ROI): ## deprecated. Use PloyLineROI instead. @@ -1620,24 +1783,83 @@ class PolygonROI(ROI): return sc class PolyLineROI(ROI): - """Container class for multiple connected LineSegmentROIs. Responsible for adding new - line segments, and for translation/(rotation?) of multiple lines together.""" + """ + Container class for multiple connected LineSegmentROIs. + + This class allows the user to draw paths of multiple line segments. + + ============== ============================================================= + **Arguments** + positions (list of length-2 sequences) The list of points in the path. + Note that, unlike the handle positions specified in other + ROIs, these positions must be expressed in the normal + coordinate system of the ROI, rather than (0 to 1) relative + to the size of the ROI. + closed (bool) if True, an extra LineSegmentROI is added connecting + the beginning and end points. + \**args All extra keyword arguments are passed to ROI() + ============== ============================================================= + + """ def __init__(self, positions, closed=False, pos=None, **args): if pos is None: pos = [0,0] - ROI.__init__(self, pos, size=[1,1], **args) self.closed = closed self.segments = [] + ROI.__init__(self, pos, size=[1,1], **args) - for p in positions: - self.addFreeHandle(p) + self.setPoints(positions) + #for p in positions: + #self.addFreeHandle(p) + #start = -1 if self.closed else 0 + #for i in range(start, len(self.handles)-1): + #self.addSegment(self.handles[i]['item'], self.handles[i+1]['item']) + + def setPoints(self, points, closed=None): + """ + Set the complete sequence of points displayed by this ROI. + + ============= ========================================================= + **Arguments** + points List of (x,y) tuples specifying handle locations to set. + closed If bool, then this will set whether the ROI is closed + (the last point is connected to the first point). If + None, then the closed mode is left unchanged. + ============= ========================================================= + + """ + if closed is not None: + self.closed = closed + + for p in points: + self.addFreeHandle(p) + start = -1 if self.closed else 0 for i in range(start, len(self.handles)-1): self.addSegment(self.handles[i]['item'], self.handles[i+1]['item']) + + + def clearPoints(self): + """ + Remove all handles and segments. + """ + while len(self.handles) > 0: + self.removeHandle(self.handles[0]['item']) + def saveState(self): + state = ROI.saveState(self) + state['closed'] = self.closed + state['points'] = [tuple(h.pos()) for h in self.getHandles()] + return state + + def setState(self, state): + ROI.setState(self, state) + self.clearPoints() + self.setPoints(state['points'], closed=state['closed']) + def addSegment(self, h1, h2, index=None): seg = LineSegmentROI(handles=(h1, h2), pen=self.pen, parent=self, movable=False) if index is None: @@ -1734,6 +1956,10 @@ class PolyLineROI(ROI): return p def getArrayRegion(self, data, img, axes=(0,1), returnMappedCoords=False, **kwds): + """ + Return the result of ROI.getArrayRegion(), masked by the shape of the + ROI. Values outside the ROI shape are set to 0. + """ sl = self.getArraySlice(data, img, axes=(0,1)) if sl is None: return None @@ -1754,10 +1980,26 @@ class PolyLineROI(ROI): shape[axes[1]] = sliced.shape[axes[1]] return sliced * mask.reshape(shape) + def setPen(self, *args, **kwds): + ROI.setPen(self, *args, **kwds) + for seg in self.segments: + seg.setPen(*args, **kwds) + + class LineSegmentROI(ROI): """ ROI subclass with two freely-moving handles defining a line. + + ============== ============================================================= + **Arguments** + positions (list of two length-2 sequences) The endpoints of the line + segment. Note that, unlike the handle positions specified in + other ROIs, these positions must be expressed in the normal + coordinate system of the ROI, rather than (0 to 1) relative + to the size of the ROI. + \**args All extra keyword arguments are passed to ROI() + ============== ============================================================= """ def __init__(self, positions=(None, None), pos=None, handles=(None,None), **args): @@ -1810,8 +2052,13 @@ class LineSegmentROI(ROI): def getArrayRegion(self, data, img, axes=(0,1)): """ - Use the position of this ROI relative to an imageItem to pull a slice from an array. - Since this pulls 1D data from a 2D coordinate system, the return value will have ndim = data.ndim-1 + Use the position of this ROI relative to an imageItem to pull a slice + from an array. + + Since this pulls 1D data from a 2D coordinate system, the return value + will have ndim = data.ndim-1 + + See ROI.getArrayRegion() for a description of the arguments. """ imgPts = [self.mapToItem(img, h['item'].pos()) for h in self.handles] @@ -1898,6 +2145,102 @@ class SpiralROI(ROI): p.drawRect(self.boundingRect()) +class CrosshairROI(ROI): + """A crosshair ROI whose position is at the center of the crosshairs. By default, it is scalable, rotatable and translatable.""" + + def __init__(self, pos=None, size=None, **kargs): + if size == None: + #size = [100e-6,100e-6] + size=[1,1] + if pos == None: + pos = [0,0] + self._shape = None + ROI.__init__(self, pos, size, **kargs) + + self.sigRegionChanged.connect(self.invalidate) + self.addScaleRotateHandle(Point(1, 0), Point(0, 0)) + self.aspectLocked = True + def invalidate(self): + self._shape = None + self.prepareGeometryChange() + + def boundingRect(self): + #size = self.size() + #return QtCore.QRectF(-size[0]/2., -size[1]/2., size[0], size[1]).normalized() + return self.shape().boundingRect() + + #def getRect(self): + ### same as boundingRect -- for internal use so that boundingRect can be re-implemented in subclasses + #size = self.size() + #return QtCore.QRectF(-size[0]/2., -size[1]/2., size[0], size[1]).normalized() + + + def shape(self): + if self._shape is None: + radius = self.getState()['size'][1] + p = QtGui.QPainterPath() + p.moveTo(Point(0, -radius)) + p.lineTo(Point(0, radius)) + p.moveTo(Point(-radius, 0)) + p.lineTo(Point(radius, 0)) + p = self.mapToDevice(p) + stroker = QtGui.QPainterPathStroker() + stroker.setWidth(10) + outline = stroker.createStroke(p) + self._shape = self.mapFromDevice(outline) - + + ##h1 = self.handles[0]['item'].pos() + ##h2 = self.handles[1]['item'].pos() + #w1 = Point(-0.5, 0)*self.size() + #w2 = Point(0.5, 0)*self.size() + #h1 = Point(0, -0.5)*self.size() + #h2 = Point(0, 0.5)*self.size() + + #dh = h2-h1 + #dw = w2-w1 + #if dh.length() == 0 or dw.length() == 0: + #return p + #pxv = self.pixelVectors(dh)[1] + #if pxv is None: + #return p + + #pxv *= 4 + + #p.moveTo(h1+pxv) + #p.lineTo(h2+pxv) + #p.lineTo(h2-pxv) + #p.lineTo(h1-pxv) + #p.lineTo(h1+pxv) + + #pxv = self.pixelVectors(dw)[1] + #if pxv is None: + #return p + + #pxv *= 4 + + #p.moveTo(w1+pxv) + #p.lineTo(w2+pxv) + #p.lineTo(w2-pxv) + #p.lineTo(w1-pxv) + #p.lineTo(w1+pxv) + + return self._shape + + def paint(self, p, *args): + #p.save() + #r = self.getRect() + radius = self.getState()['size'][1] + p.setRenderHint(QtGui.QPainter.Antialiasing) + p.setPen(self.currentPen) + #p.translate(r.left(), r.top()) + #p.scale(r.width()/10., r.height()/10.) ## need to scale up a little because drawLine has trouble dealing with 0.5 + #p.drawLine(0,5, 10,5) + #p.drawLine(5,0, 5,10) + #p.restore() + + p.drawLine(Point(0, -radius), Point(0, radius)) + p.drawLine(Point(-radius, 0), Point(radius, 0)) + + diff --git a/pyqtgraph/graphicsItems/ScaleBar.py b/pyqtgraph/graphicsItems/ScaleBar.py index 768f6978..8ba546f7 100644 --- a/pyqtgraph/graphicsItems/ScaleBar.py +++ b/pyqtgraph/graphicsItems/ScaleBar.py @@ -1,10 +1,11 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .GraphicsObject import * from .GraphicsWidgetAnchor import * from .TextItem import TextItem import numpy as np -import pyqtgraph.functions as fn -import pyqtgraph as pg +from .. import functions as fn +from .. import getConfigOption +from ..Point import Point __all__ = ['ScaleBar'] @@ -12,18 +13,21 @@ class ScaleBar(GraphicsObject, GraphicsWidgetAnchor): """ Displays a rectangular bar to indicate the relative scale of objects on the view. """ - def __init__(self, size, width=5, brush=None, pen=None, suffix='m'): + def __init__(self, size, width=5, brush=None, pen=None, suffix='m', offset=None): GraphicsObject.__init__(self) GraphicsWidgetAnchor.__init__(self) self.setFlag(self.ItemHasNoContents) self.setAcceptedMouseButtons(QtCore.Qt.NoButton) if brush is None: - brush = pg.getConfigOption('foreground') + brush = getConfigOption('foreground') self.brush = fn.mkBrush(brush) self.pen = fn.mkPen(pen) self._width = width self.size = size + if offset == None: + offset = (0,0) + self.offset = offset self.bar = QtGui.QGraphicsRectItem() self.bar.setPen(self.pen) @@ -54,51 +58,14 @@ class ScaleBar(GraphicsObject, GraphicsWidgetAnchor): def boundingRect(self): return QtCore.QRectF() + def setParentItem(self, p): + ret = GraphicsObject.setParentItem(self, p) + if self.offset is not None: + offset = Point(self.offset) + anchorx = 1 if offset[0] <= 0 else 0 + anchory = 1 if offset[1] <= 0 else 0 + anchor = (anchorx, anchory) + self.anchor(itemPos=anchor, parentPos=anchor, offset=offset) + return ret - - -#class ScaleBar(UIGraphicsItem): - #""" - #Displays a rectangular bar with 10 divisions to indicate the relative scale of objects on the view. - #""" - #def __init__(self, size, width=5, color=(100, 100, 255)): - #UIGraphicsItem.__init__(self) - #self.setAcceptedMouseButtons(QtCore.Qt.NoButton) - - #self.brush = fn.mkBrush(color) - #self.pen = fn.mkPen((0,0,0)) - #self._width = width - #self.size = size - - #def paint(self, p, opt, widget): - #UIGraphicsItem.paint(self, p, opt, widget) - - #rect = self.boundingRect() - #unit = self.pixelSize() - #y = rect.top() + (rect.bottom()-rect.top()) * 0.02 - #y1 = y + unit[1]*self._width - #x = rect.right() + (rect.left()-rect.right()) * 0.02 - #x1 = x - self.size - - #p.setPen(self.pen) - #p.setBrush(self.brush) - #rect = QtCore.QRectF( - #QtCore.QPointF(x1, y1), - #QtCore.QPointF(x, y) - #) - #p.translate(x1, y1) - #p.scale(rect.width(), rect.height()) - #p.drawRect(0, 0, 1, 1) - - #alpha = np.clip(((self.size/unit[0]) - 40.) * 255. / 80., 0, 255) - #p.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0, alpha))) - #for i in range(1, 10): - ##x2 = x + (x1-x) * 0.1 * i - #x2 = 0.1 * i - #p.drawLine(QtCore.QPointF(x2, 0), QtCore.QPointF(x2, 1)) - - - #def setSize(self, s): - #self.size = s - diff --git a/pyqtgraph/graphicsItems/ScatterPlotItem.py b/pyqtgraph/graphicsItems/ScatterPlotItem.py index f1a5201d..faae8632 100644 --- a/pyqtgraph/graphicsItems/ScatterPlotItem.py +++ b/pyqtgraph/graphicsItems/ScatterPlotItem.py @@ -1,14 +1,19 @@ -from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE -from pyqtgraph.Point import Point -import pyqtgraph.functions as fn +from ..Qt import QtGui, QtCore, USE_PYSIDE +from ..Point import Point +from .. import functions as fn from .GraphicsItem import GraphicsItem from .GraphicsObject import GraphicsObject +from itertools import starmap, repeat +try: + from itertools import imap +except ImportError: + imap = map import numpy as np import weakref -import pyqtgraph.debug as debug -from pyqtgraph.pgcollections import OrderedDict -import pyqtgraph as pg -#import pyqtgraph as pg +from .. import getConfigOption +from .. import debug as debug +from ..pgcollections import OrderedDict +from .. import debug __all__ = ['ScatterPlotItem', 'SpotItem'] @@ -63,10 +68,12 @@ def renderSymbol(symbol, size, pen, brush, device=None): device = QtGui.QImage(int(size+penPxWidth), int(size+penPxWidth), QtGui.QImage.Format_ARGB32) device.fill(0) p = QtGui.QPainter(device) - p.setRenderHint(p.Antialiasing) - p.translate(device.width()*0.5, device.height()*0.5) - drawSymbol(p, symbol, size, pen, brush) - p.end() + try: + p.setRenderHint(p.Antialiasing) + p.translate(device.width()*0.5, device.height()*0.5) + drawSymbol(p, symbol, size, pen, brush) + finally: + p.end() return device def makeSymbolPixmap(size, pen, brush, symbol): @@ -86,11 +93,8 @@ class SymbolAtlas(object): pm = atlas.getAtlas() """ - class SymbolCoords(list): ## needed because lists are not allowed in weak references. - pass - def __init__(self): - # symbol key : [x, y, w, h] atlas coordinates + # symbol key : QRect(...) coordinates where symbol can be found in atlas. # note that the coordinate list will always be the same list object as # long as the symbol is in the atlas, but the coordinates may # change if the atlas is rebuilt. @@ -101,28 +105,32 @@ class SymbolAtlas(object): self.atlasData = None # numpy array of atlas image self.atlas = None # atlas as QPixmap self.atlasValid = False + self.max_width=0 def getSymbolCoords(self, opts): """ Given a list of spot records, return an object representing the coordinates of that symbol within the atlas """ - coords = np.empty(len(opts), dtype=object) + sourceRect = np.empty(len(opts), dtype=object) + keyi = None + sourceRecti = None for i, rec in enumerate(opts): - symbol, size, pen, brush = rec['symbol'], rec['size'], rec['pen'], rec['brush'] - pen = fn.mkPen(pen) if not isinstance(pen, QtGui.QPen) else pen - brush = fn.mkBrush(brush) if not isinstance(pen, QtGui.QBrush) else brush - key = (symbol, size, fn.colorTuple(pen.color()), pen.widthF(), pen.style(), fn.colorTuple(brush.color())) - if key not in self.symbolMap: - newCoords = SymbolAtlas.SymbolCoords() - self.symbolMap[key] = newCoords - self.atlasValid = False - #try: - #self.addToAtlas(key) ## squeeze this into the atlas if there is room - #except: - #self.buildAtlas() ## otherwise, we need to rebuild - - coords[i] = self.symbolMap[key] - return coords + key = (rec[3], rec[2], id(rec[4]), id(rec[5])) # TODO: use string indexes? + if key == keyi: + sourceRect[i] = sourceRecti + else: + try: + sourceRect[i] = self.symbolMap[key] + except KeyError: + newRectSrc = QtCore.QRectF() + newRectSrc.pen = rec['pen'] + newRectSrc.brush = rec['brush'] + self.symbolMap[key] = newRectSrc + self.atlasValid = False + sourceRect[i] = newRectSrc + keyi = key + sourceRecti = newRectSrc + return sourceRect def buildAtlas(self): # get rendered array for all symbols, keep track of avg/max width @@ -130,15 +138,13 @@ class SymbolAtlas(object): avgWidth = 0.0 maxWidth = 0 images = [] - for key, coords in self.symbolMap.items(): - if len(coords) == 0: - pen = fn.mkPen(color=key[2], width=key[3], style=key[4]) - brush = fn.mkBrush(color=key[5]) - img = renderSymbol(key[0], key[1], pen, brush) + for key, sourceRect in self.symbolMap.items(): + if sourceRect.width() == 0: + img = renderSymbol(key[0], key[1], sourceRect.pen, sourceRect.brush) images.append(img) ## we only need this to prevent the images being garbage collected immediately arr = fn.imageToArray(img, copy=False, transpose=False) else: - (x,y,w,h) = self.symbolMap[key] + (y,x,h,w) = sourceRect.getRect() arr = self.atlasData[x:x+w, y:y+w] rendered[key] = arr w = arr.shape[0] @@ -169,17 +175,18 @@ class SymbolAtlas(object): x = 0 rowheight = h self.atlasRows.append([y, rowheight, 0]) - self.symbolMap[key][:] = x, y, w, h + self.symbolMap[key].setRect(y, x, h, w) x += w self.atlasRows[-1][2] = x height = y + rowheight self.atlasData = np.zeros((width, height, 4), dtype=np.ubyte) for key in symbols: - x, y, w, h = self.symbolMap[key] + y, x, h, w = self.symbolMap[key].getRect() self.atlasData[x:x+w, y:y+h] = rendered[key] self.atlas = None self.atlasValid = True + self.max_width = maxWidth def getAtlas(self): if not self.atlasValid: @@ -219,32 +226,31 @@ class ScatterPlotItem(GraphicsObject): """ Accepts the same arguments as setData() """ - prof = debug.Profiler('ScatterPlotItem.__init__', disabled=True) + profiler = debug.Profiler() GraphicsObject.__init__(self) self.picture = None # QPicture used for rendering when pxmode==False - self.fragments = None # fragment specification for pxmode; updated every time the view changes. self.fragmentAtlas = SymbolAtlas() - self.data = np.empty(0, dtype=[('x', float), ('y', float), ('size', float), ('symbol', object), ('pen', object), ('brush', object), ('data', object), ('fragCoords', object), ('item', object)]) + self.data = np.empty(0, dtype=[('x', float), ('y', float), ('size', float), ('symbol', object), ('pen', object), ('brush', object), ('data', object), ('item', object), ('sourceRect', object), ('targetRect', object), ('width', float)]) self.bounds = [None, None] ## caches data bounds self._maxSpotWidth = 0 ## maximum size of the scale-variant portion of all spots self._maxSpotPxWidth = 0 ## maximum size of the scale-invariant portion of all spots self.opts = { 'pxMode': True, 'useCache': True, ## If useCache is False, symbols are re-drawn on every paint. - 'antialias': pg.getConfigOption('antialias'), - } - - self.setPen(200,200,200, update=False) - self.setBrush(100,100,150, update=False) + 'antialias': getConfigOption('antialias'), + 'name': None, + } + + self.setPen(fn.mkPen(getConfigOption('foreground')), update=False) + self.setBrush(fn.mkBrush(100,100,150), update=False) self.setSymbol('o', update=False) self.setSize(7, update=False) - prof.mark('1') + profiler() self.setData(*args, **kargs) - prof.mark('setData') - prof.finish() - + profiler('setData') + #self.setCacheMode(self.DeviceCoordinateCache) def setData(self, *args, **kargs): @@ -282,6 +288,8 @@ class ScatterPlotItem(GraphicsObject): *antialias* Whether to draw symbols with antialiasing. Note that if pxMode is True, symbols are always rendered with antialiasing (since the rendered symbols can be cached, this incurs very little performance cost) + *name* The name of this item. Names are used for automatically + generating LegendItem entries and by some exporters. ====================== =============================================================================================== """ oldData = self.data ## this causes cached pixmaps to be preserved while new data is registered. @@ -343,16 +351,12 @@ class ScatterPlotItem(GraphicsObject): newData = self.data[len(oldData):] newData['size'] = -1 ## indicates to use default size - + if 'spots' in kargs: spots = kargs['spots'] for i in range(len(spots)): spot = spots[i] for k in spot: - #if k == 'pen': - #newData[k] = fn.mkPen(spot[k]) - #elif k == 'brush': - #newData[k] = fn.mkBrush(spot[k]) if k == 'pos': pos = spot[k] if isinstance(pos, QtCore.QPointF): @@ -361,10 +365,12 @@ class ScatterPlotItem(GraphicsObject): x,y = pos[0], pos[1] newData[i]['x'] = x newData[i]['y'] = y - elif k in ['x', 'y', 'size', 'symbol', 'pen', 'brush', 'data']: + elif k == 'pen': + newData[i][k] = fn.mkPen(spot[k]) + elif k == 'brush': + newData[i][k] = fn.mkBrush(spot[k]) + elif k in ['x', 'y', 'size', 'symbol', 'brush', 'data']: newData[i][k] = spot[k] - #elif k == 'data': - #self.pointData[i] = spot[k] else: raise Exception("Unknown spot parameter: %s" % k) elif 'y' in kargs: @@ -381,11 +387,12 @@ class ScatterPlotItem(GraphicsObject): if k in kargs: setMethod = getattr(self, 'set' + k[0].upper() + k[1:]) setMethod(kargs[k], update=False, dataSet=newData, mask=kargs.get('mask', None)) - + if 'data' in kargs: self.setPointData(kargs['data'], dataSet=newData) - + self.prepareGeometryChange() + self.informViewBoundsChanged() self.bounds = [None, None] self.invalidate() self.updateSpots(newData) @@ -394,12 +401,10 @@ class ScatterPlotItem(GraphicsObject): def invalidate(self): ## clear any cached drawing state self.picture = None - self.fragments = None self.update() def getData(self): - return self.data['x'], self.data['y'] - + return self.data['x'], self.data['y'] def setPoints(self, *args, **kargs): ##Deprecated; use setData @@ -411,6 +416,9 @@ class ScatterPlotItem(GraphicsObject): return ints return interface in ints + def name(self): + return self.opts.get('name', None) + def setPen(self, *args, **kargs): """Set the pen(s) used to draw the outline around each spot. If a list or array is provided, then the pen for each spot will be set separately. @@ -418,10 +426,10 @@ class ScatterPlotItem(GraphicsObject): all spots which do not have a pen explicitly set.""" update = kargs.pop('update', True) dataSet = kargs.pop('dataSet', self.data) - + if len(args) == 1 and (isinstance(args[0], np.ndarray) or isinstance(args[0], list)): pens = args[0] - if kargs['mask'] is not None: + if 'mask' in kargs and kargs['mask'] is not None: pens = pens[kargs['mask']] if len(pens) != len(dataSet): raise Exception("Number of pens does not match number of points (%d != %d)" % (len(pens), len(dataSet))) @@ -429,7 +437,7 @@ class ScatterPlotItem(GraphicsObject): else: self.opts['pen'] = fn.mkPen(*args, **kargs) - dataSet['fragCoords'] = None + dataSet['sourceRect'] = None if update: self.updateSpots(dataSet) @@ -443,7 +451,7 @@ class ScatterPlotItem(GraphicsObject): if len(args) == 1 and (isinstance(args[0], np.ndarray) or isinstance(args[0], list)): brushes = args[0] - if kargs['mask'] is not None: + if 'mask' in kargs and kargs['mask'] is not None: brushes = brushes[kargs['mask']] if len(brushes) != len(dataSet): raise Exception("Number of brushes does not match number of points (%d != %d)" % (len(brushes), len(dataSet))) @@ -454,7 +462,7 @@ class ScatterPlotItem(GraphicsObject): self.opts['brush'] = fn.mkBrush(*args, **kargs) #self._spotPixmap = None - dataSet['fragCoords'] = None + dataSet['sourceRect'] = None if update: self.updateSpots(dataSet) @@ -477,7 +485,7 @@ class ScatterPlotItem(GraphicsObject): self.opts['symbol'] = symbol self._spotPixmap = None - dataSet['fragCoords'] = None + dataSet['sourceRect'] = None if update: self.updateSpots(dataSet) @@ -500,7 +508,7 @@ class ScatterPlotItem(GraphicsObject): self.opts['size'] = size self._spotPixmap = None - dataSet['fragCoords'] = None + dataSet['sourceRect'] = None if update: self.updateSpots(dataSet) @@ -532,22 +540,26 @@ class ScatterPlotItem(GraphicsObject): def updateSpots(self, dataSet=None): if dataSet is None: dataSet = self.data - self._maxSpotWidth = 0 - self._maxSpotPxWidth = 0 + invalidate = False - self.measureSpotSizes(dataSet) if self.opts['pxMode']: - mask = np.equal(dataSet['fragCoords'], None) + mask = np.equal(dataSet['sourceRect'], None) if np.any(mask): invalidate = True opts = self.getSpotOpts(dataSet[mask]) - coords = self.fragmentAtlas.getSymbolCoords(opts) - dataSet['fragCoords'][mask] = coords + sourceRect = self.fragmentAtlas.getSymbolCoords(opts) + dataSet['sourceRect'][mask] = sourceRect - #for rec in dataSet: - #if rec['fragCoords'] is None: - #invalidate = True - #rec['fragCoords'] = self.fragmentAtlas.getSymbolCoords(*self.getSpotOpts(rec)) + self.fragmentAtlas.getAtlas() # generate atlas so source widths are available. + + dataSet['width'] = np.array(list(imap(QtCore.QRectF.width, dataSet['sourceRect'])))/2 + dataSet['targetRect'] = None + self._maxSpotPxWidth = self.fragmentAtlas.max_width + else: + self._maxSpotWidth = 0 + self._maxSpotPxWidth = 0 + self.measureSpotSizes(dataSet) + if invalidate: self.invalidate() @@ -652,8 +664,14 @@ class ScatterPlotItem(GraphicsObject): if pxPad > 0: # determine length of pixel in local x, y directions px, py = self.pixelVectors() - px = 0 if px is None else px.length() - py = 0 if py is None else py.length() + try: + px = 0 if px is None else px.length() + except OverflowError: + px = 0 + try: + py = 0 if py is None else py.length() + except OverflowError: + py = 0 # return bounds expanded by pixel size px *= pxPad @@ -664,31 +682,44 @@ class ScatterPlotItem(GraphicsObject): self.prepareGeometryChange() GraphicsObject.viewTransformChanged(self) self.bounds = [None, None] - self.fragments = None - - def generateFragments(self): - tr = self.deviceTransform() - if tr is None: - return - pts = np.empty((2,len(self.data['x']))) - pts[0] = self.data['x'] - pts[1] = self.data['y'] - pts = fn.transformCoordinates(tr, pts) - self.fragments = [] - pts = np.clip(pts, -2**30, 2**30) ## prevent Qt segmentation fault. - ## Still won't be able to render correctly, though. - for i in xrange(len(self.data)): - rec = self.data[i] - pos = QtCore.QPointF(pts[0,i], pts[1,i]) - x,y,w,h = rec['fragCoords'] - rect = QtCore.QRectF(y, x, h, w) - self.fragments.append(QtGui.QPainter.PixmapFragment.create(pos, rect)) - + self.data['targetRect'] = None + def setExportMode(self, *args, **kwds): GraphicsObject.setExportMode(self, *args, **kwds) self.invalidate() + + + def mapPointsToDevice(self, pts): + # Map point locations to device + tr = self.deviceTransform() + if tr is None: + return None + + #pts = np.empty((2,len(self.data['x']))) + #pts[0] = self.data['x'] + #pts[1] = self.data['y'] + pts = fn.transformCoordinates(tr, pts) + pts -= self.data['width'] + pts = np.clip(pts, -2**30, 2**30) ## prevent Qt segmentation fault. - @pg.debug.warnOnException ## raising an exception here causes crash + return pts + + def getViewMask(self, pts): + # Return bool mask indicating all points that are within viewbox + # pts is expressed in *device coordiantes* + vb = self.getViewBox() + if vb is None: + return None + viewBounds = vb.mapRectToDevice(vb.boundingRect()) + w = self.data['width'] + mask = ((pts[0] + w > viewBounds.left()) & + (pts[0] - w < viewBounds.right()) & + (pts[1] + w > viewBounds.top()) & + (pts[1] - w < viewBounds.bottom())) ## remove out of view points + return mask + + + @debug.warnOnException ## raising an exception here causes crash def paint(self, p, *args): #p.setPen(fn.mkPen('r')) @@ -702,29 +733,44 @@ class ScatterPlotItem(GraphicsObject): scale = 1.0 if self.opts['pxMode'] is True: - atlas = self.fragmentAtlas.getAtlas() - #arr = fn.imageToArray(atlas.toImage(), copy=True) - #if hasattr(self, 'lastAtlas'): - #if np.any(self.lastAtlas != arr): - #print "Atlas changed:", arr - #self.lastAtlas = arr - - if self.fragments is None: - self.updateSpots() - self.generateFragments() - p.resetTransform() - if not USE_PYSIDE and self.opts['useCache'] and self._exportOpts is False: - p.drawPixmapFragments(self.fragments, atlas) - else: - p.setRenderHint(p.Antialiasing, aa) + # Map point coordinates to device + pts = np.vstack([self.data['x'], self.data['y']]) + pts = self.mapPointsToDevice(pts) + if pts is None: + return + + # Cull points that are outside view + viewMask = self.getViewMask(pts) + #pts = pts[:,mask] + #data = self.data[mask] + + if self.opts['useCache'] and self._exportOpts is False: + # Draw symbols from pre-rendered atlas + atlas = self.fragmentAtlas.getAtlas() - for i in range(len(self.data)): - rec = self.data[i] - frag = self.fragments[i] + # Update targetRects if necessary + updateMask = viewMask & np.equal(self.data['targetRect'], None) + if np.any(updateMask): + updatePts = pts[:,updateMask] + width = self.data[updateMask]['width']*2 + self.data['targetRect'][updateMask] = list(imap(QtCore.QRectF, updatePts[0,:], updatePts[1,:], width, width)) + + data = self.data[viewMask] + if USE_PYSIDE: + list(imap(p.drawPixmap, data['targetRect'], repeat(atlas), data['sourceRect'])) + else: + p.drawPixmapFragments(data['targetRect'].tolist(), data['sourceRect'].tolist(), atlas) + else: + # render each symbol individually + p.setRenderHint(p.Antialiasing, aa) + + data = self.data[viewMask] + pts = pts[:,viewMask] + for i, rec in enumerate(data): p.resetTransform() - p.translate(frag.x, frag.y) + p.translate(pts[0,i] + rec['width'], pts[1,i] + rec['width']) drawSymbol(p, *self.getSpotOpts(rec, scale)) else: if self.picture is None: @@ -886,7 +932,7 @@ class SpotItem(object): self._data['data'] = data def updateItem(self): - self._data['fragCoords'] = None + self._data['sourceRect'] = None self._plot.updateSpots(self._data.reshape(1)) self._plot.invalidate() diff --git a/pyqtgraph/graphicsItems/TextItem.py b/pyqtgraph/graphicsItems/TextItem.py index 911057f4..d3c98006 100644 --- a/pyqtgraph/graphicsItems/TextItem.py +++ b/pyqtgraph/graphicsItems/TextItem.py @@ -1,7 +1,7 @@ -from pyqtgraph.Qt import QtCore, QtGui -import pyqtgraph as pg +from ..Qt import QtCore, QtGui +from ..Point import Point from .UIGraphicsItem import * -import pyqtgraph.functions as fn +from .. import functions as fn class TextItem(UIGraphicsItem): """ @@ -9,25 +9,25 @@ class TextItem(UIGraphicsItem): """ def __init__(self, text='', color=(200,200,200), html=None, anchor=(0,0), border=None, fill=None, angle=0): """ - =========== ================================================================================= - Arguments: - *text* The text to display - *color* The color of the text (any format accepted by pg.mkColor) - *html* If specified, this overrides both *text* and *color* - *anchor* A QPointF or (x,y) sequence indicating what region of the text box will - be anchored to the item's position. A value of (0,0) sets the upper-left corner - of the text box to be at the position specified by setPos(), while a value of (1,1) - sets the lower-right corner. - *border* A pen to use when drawing the border - *fill* A brush to use when filling within the border - =========== ================================================================================= + ============== ================================================================================= + **Arguments:** + *text* The text to display + *color* The color of the text (any format accepted by pg.mkColor) + *html* If specified, this overrides both *text* and *color* + *anchor* A QPointF or (x,y) sequence indicating what region of the text box will + be anchored to the item's position. A value of (0,0) sets the upper-left corner + of the text box to be at the position specified by setPos(), while a value of (1,1) + sets the lower-right corner. + *border* A pen to use when drawing the border + *fill* A brush to use when filling within the border + ============== ================================================================================= """ ## not working yet #*angle* Angle in degrees to rotate text (note that the rotation assigned in this item's #transformation will be ignored) - self.anchor = pg.Point(anchor) + self.anchor = Point(anchor) #self.angle = 0 UIGraphicsItem.__init__(self) self.textItem = QtGui.QGraphicsTextItem() @@ -38,13 +38,18 @@ class TextItem(UIGraphicsItem): self.setText(text, color) else: self.setHtml(html) - self.fill = pg.mkBrush(fill) - self.border = pg.mkPen(border) + self.fill = fn.mkBrush(fill) + self.border = fn.mkPen(border) self.rotate(angle) self.setFlag(self.ItemIgnoresTransformations) ## This is required to keep the text unscaled inside the viewport def setText(self, text, color=(200,200,200)): - color = pg.mkColor(color) + """ + Set the text and color of this item. + + This method sets the plain text of the item; see also setHtml(). + """ + color = fn.mkColor(color) self.textItem.setDefaultTextColor(color) self.textItem.setPlainText(text) self.updateText() @@ -57,18 +62,41 @@ class TextItem(UIGraphicsItem): #self.translate(0, 20) def setPlainText(self, *args): + """ + Set the plain text to be rendered by this item. + + See QtGui.QGraphicsTextItem.setPlainText(). + """ self.textItem.setPlainText(*args) self.updateText() def setHtml(self, *args): + """ + Set the HTML code to be rendered by this item. + + See QtGui.QGraphicsTextItem.setHtml(). + """ self.textItem.setHtml(*args) self.updateText() def setTextWidth(self, *args): + """ + Set the width of the text. + + If the text requires more space than the width limit, then it will be + wrapped into multiple lines. + + See QtGui.QGraphicsTextItem.setTextWidth(). + """ self.textItem.setTextWidth(*args) self.updateText() def setFont(self, *args): + """ + Set the font for this text. + + See QtGui.QGraphicsTextItem.setFont(). + """ self.textItem.setFont(*args) self.updateText() @@ -89,7 +117,7 @@ class TextItem(UIGraphicsItem): #br = self.textItem.mapRectToParent(self.textItem.boundingRect()) self.textItem.setPos(0,0) br = self.textItem.boundingRect() - apos = self.textItem.mapToParent(pg.Point(br.width()*self.anchor.x(), br.height()*self.anchor.y())) + apos = self.textItem.mapToParent(Point(br.width()*self.anchor.x(), br.height()*self.anchor.y())) #print br, apos self.textItem.setPos(-apos.x(), -apos.y()) diff --git a/pyqtgraph/graphicsItems/UIGraphicsItem.py b/pyqtgraph/graphicsItems/UIGraphicsItem.py index 19fda424..6f756334 100644 --- a/pyqtgraph/graphicsItems/UIGraphicsItem.py +++ b/pyqtgraph/graphicsItems/UIGraphicsItem.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE +from ..Qt import QtGui, QtCore, USE_PYSIDE import weakref from .GraphicsObject import GraphicsObject if not USE_PYSIDE: diff --git a/pyqtgraph/graphicsItems/VTickGroup.py b/pyqtgraph/graphicsItems/VTickGroup.py index c6880f91..1db4a4a2 100644 --- a/pyqtgraph/graphicsItems/VTickGroup.py +++ b/pyqtgraph/graphicsItems/VTickGroup.py @@ -3,8 +3,8 @@ if __name__ == '__main__': path = os.path.abspath(os.path.dirname(__file__)) sys.path.insert(0, os.path.join(path, '..', '..')) -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph.functions as fn +from ..Qt import QtGui, QtCore +from .. import functions as fn import weakref from .UIGraphicsItem import UIGraphicsItem @@ -19,15 +19,15 @@ class VTickGroup(UIGraphicsItem): """ def __init__(self, xvals=None, yrange=None, pen=None): """ - ============= =================================================================== - **Arguments** - xvals A list of x values (in data coordinates) at which to draw ticks. - yrange A list of [low, high] limits for the tick. 0 is the bottom of - the view, 1 is the top. [0.8, 1] would draw ticks in the top - fifth of the view. - pen The pen to use for drawing ticks. Default is grey. Can be specified - as any argument valid for :func:`mkPen` - ============= =================================================================== + ============== =================================================================== + **Arguments:** + xvals A list of x values (in data coordinates) at which to draw ticks. + yrange A list of [low, high] limits for the tick. 0 is the bottom of + the view, 1 is the top. [0.8, 1] would draw ticks in the top + fifth of the view. + pen The pen to use for drawing ticks. Default is grey. Can be specified + as any argument valid for :func:`mkPen` + ============== =================================================================== """ if yrange is None: yrange = [0, 1] @@ -56,10 +56,10 @@ class VTickGroup(UIGraphicsItem): def setXVals(self, vals): """Set the x values for the ticks. - ============= ===================================================================== - **Arguments** - vals A list of x values (in data/plot coordinates) at which to draw ticks. - ============= ===================================================================== + ============== ===================================================================== + **Arguments:** + vals A list of x values (in data/plot coordinates) at which to draw ticks. + ============== ===================================================================== """ self.xvals = vals self.rebuildTicks() @@ -96,18 +96,4 @@ class VTickGroup(UIGraphicsItem): p.setPen(self.pen) p.drawPath(self.path) - -if __name__ == '__main__': - app = QtGui.QApplication([]) - import pyqtgraph as pg - vt = VTickGroup([1,3,4,7,9], [0.8, 1.0]) - p = pg.plot() - p.addItem(vt) - - if sys.flags.interactive == 0: - app.exec_() - - - - \ No newline at end of file diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py index 3cbb1ea2..900c2038 100644 --- a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py +++ b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py @@ -1,32 +1,67 @@ -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.python2_3 import sortList +from ...Qt import QtGui, QtCore +from ...python2_3 import sortList import numpy as np -from pyqtgraph.Point import Point -import pyqtgraph.functions as fn +from ...Point import Point +from ... import functions as fn from .. ItemGroup import ItemGroup from .. GraphicsWidget import GraphicsWidget -from pyqtgraph.GraphicsScene import GraphicsScene -import pyqtgraph import weakref from copy import deepcopy -import pyqtgraph.debug as debug +from ... import debug as debug +from ... import getConfigOption +import sys +from ...Qt import isQObjectAlive __all__ = ['ViewBox'] +class WeakList(object): + + def __init__(self): + self._items = [] + + def append(self, obj): + #Add backwards to iterate backwards (to make iterating more efficient on removal). + self._items.insert(0, weakref.ref(obj)) + + def __iter__(self): + i = len(self._items)-1 + while i >= 0: + ref = self._items[i] + d = ref() + if d is None: + del self._items[i] + else: + yield d + i -= 1 class ChildGroup(ItemGroup): - sigItemsChanged = QtCore.Signal() def __init__(self, parent): ItemGroup.__init__(self, parent) + + # Used as callback to inform ViewBox when items are added/removed from + # the group. + # Note 1: We would prefer to override itemChange directly on the + # ViewBox, but this causes crashes on PySide. + # Note 2: We might also like to use a signal rather than this callback + # mechanism, but this causes a different PySide crash. + self.itemsChangedListeners = WeakList() + # excempt from telling view when transform changes self._GraphicsObject__inform_view_on_change = False def itemChange(self, change, value): ret = ItemGroup.itemChange(self, change, value) if change == self.ItemChildAddedChange or change == self.ItemChildRemovedChange: - self.sigItemsChanged.emit() - + try: + itemsChangedListeners = self.itemsChangedListeners + except AttributeError: + # It's possible that the attribute was already collected when the itemChange happened + # (if it was triggered during the gc of the object). + pass + else: + for listener in itemsChangedListeners: + listener.itemsChanged() return ret @@ -39,12 +74,11 @@ class ViewBox(GraphicsWidget): Features: - - Scaling contents by mouse or auto-scale when contents change - - View linking--multiple views display the same data ranges - - Configurable by context menu - - Item coordinate mapping methods + * Scaling contents by mouse or auto-scale when contents change + * View linking--multiple views display the same data ranges + * Configurable by context menu + * Item coordinate mapping methods - Not really compatible with GraphicsView having the same functionality. """ sigYRangeChanged = QtCore.Signal(object, object) @@ -69,22 +103,27 @@ class ViewBox(GraphicsWidget): NamedViews = weakref.WeakValueDictionary() # name: ViewBox AllViews = weakref.WeakKeyDictionary() # ViewBox: None - def __init__(self, parent=None, border=None, lockAspect=False, enableMouse=True, invertY=False, enableMenu=True, name=None): + def __init__(self, parent=None, border=None, lockAspect=False, enableMouse=True, invertY=False, enableMenu=True, name=None, invertX=False): """ - ============= ============================================================= - **Arguments** - *parent* (QGraphicsWidget) Optional parent widget - *border* (QPen) Do draw a border around the view, give any - single argument accepted by :func:`mkPen ` - *lockAspect* (False or float) The aspect ratio to lock the view - coorinates to. (or False to allow the ratio to change) - *enableMouse* (bool) Whether mouse can be used to scale/pan the view - *invertY* (bool) See :func:`invertY ` - ============= ============================================================= + ============== ============================================================= + **Arguments:** + *parent* (QGraphicsWidget) Optional parent widget + *border* (QPen) Do draw a border around the view, give any + single argument accepted by :func:`mkPen ` + *lockAspect* (False or float) The aspect ratio to lock the view + coorinates to. (or False to allow the ratio to change) + *enableMouse* (bool) Whether mouse can be used to scale/pan the view + *invertY* (bool) See :func:`invertY ` + *invertX* (bool) See :func:`invertX ` + *enableMenu* (bool) Whether to display a context menu when + right-clicking on the ViewBox background. + *name* (str) Used to register this ViewBox so that it appears + in the "Link axis" dropdown inside other ViewBox + context menus. This allows the user to manually link + the axes of any other view to this one. + ============== ============================================================= """ - - GraphicsWidget.__init__(self, parent) self.name = None self.linksBlocked = False @@ -104,6 +143,7 @@ class ViewBox(GraphicsWidget): 'viewRange': [[0,1], [0,1]], ## actual range viewed 'yInverted': invertY, + 'xInverted': invertX, 'aspectLocked': False, ## False if aspect is unlocked, otherwise float specifies the locked ratio. 'autoRange': [True, True], ## False if auto range is disabled, ## otherwise float gives the fraction of data that is visible @@ -113,11 +153,20 @@ class ViewBox(GraphicsWidget): ## a name string indicates that the view *should* link to another, but no view with that name exists yet. 'mouseEnabled': [enableMouse, enableMouse], - 'mouseMode': ViewBox.PanMode if pyqtgraph.getConfigOption('leftButtonPan') else ViewBox.RectMode, + 'mouseMode': ViewBox.PanMode if getConfigOption('leftButtonPan') else ViewBox.RectMode, 'enableMenu': enableMenu, 'wheelScaleFactor': -1.0 / 8.0, 'background': None, + + # Limits + 'limits': { + 'xLimits': [None, None], # Maximum and minimum visible X values + 'yLimits': [None, None], # Maximum and minimum visible Y values + 'xRange': [None, None], # Maximum and minimum X range + 'yRange': [None, None], # Maximum and minimum Y range + } + } self._updatingRange = False ## Used to break recursive loops. See updateAutoRange. self._itemBoundsCache = weakref.WeakKeyDictionary() @@ -131,7 +180,7 @@ class ViewBox(GraphicsWidget): ## this is a workaround for a Qt + OpenGL bug that causes improper clipping ## https://bugreports.qt.nokia.com/browse/QTBUG-23723 self.childGroup = ChildGroup(self) - self.childGroup.sigItemsChanged.connect(self.itemsChanged) + self.childGroup.itemsChangedListeners.append(self) self.background = QtGui.QGraphicsRectItem(self.rect()) self.background.setParentItem(self) @@ -174,7 +223,11 @@ class ViewBox(GraphicsWidget): def register(self, name): """ Add this ViewBox to the registered list of views. - *name* will appear in the drop-down lists for axis linking in all other views. + + This allows users to manually link the axes of any other ViewBox to + this one. The specified *name* will appear in the drop-down lists for + axis linking in the context menus of all other views. + The same can be accomplished by initializing the ViewBox with the *name* attribute. """ ViewBox.AllViews[self] = None @@ -197,6 +250,7 @@ class ViewBox(GraphicsWidget): del ViewBox.NamedViews[self.name] def close(self): + self.clear() self.unregister() def implements(self, interface): @@ -276,6 +330,17 @@ class ViewBox(GraphicsWidget): self.updateViewRange() self.sigStateChanged.emit(self) + def setBackgroundColor(self, color): + """ + Set the background color of the ViewBox. + + If color is None, then no background will be drawn. + + Added in version 0.9.9 + """ + self.background.setVisible(color is not None) + self.state['background'] = color + self.updateBackground() def setMouseMode(self, mode): """ @@ -362,11 +427,11 @@ class ViewBox(GraphicsWidget): self.linkedYChanged() self.updateAutoRange() self.updateViewRange() + self._matrixNeedsUpdate = True self.sigStateChanged.emit(self) self.background.setRect(self.rect()) self.sigResized.emit(self) - def viewRange(self): """Return a the view's visible range as a list: [[xmin, xmax], [ymin, ymax]]""" return [x[:] for x in self.state['viewRange']] ## return copy @@ -398,13 +463,20 @@ class ViewBox(GraphicsWidget): print("make qrectf failed:", self.state['targetRange']) raise + def _resetTarget(self): + # Reset target range to exactly match current view range. + # This is used during mouse interaction to prevent unpredictable + # behavior (because the user is unaware of targetRange). + if self.state['aspectLocked'] is False: # (interferes with aspect locking) + self.state['targetRange'] = [self.state['viewRange'][0][:], self.state['viewRange'][1][:]] + def setRange(self, rect=None, xRange=None, yRange=None, padding=None, update=True, disableAutoRange=True): """ Set the visible range of the ViewBox. Must specify at least one of *rect*, *xRange*, or *yRange*. ================== ===================================================================== - **Arguments** + **Arguments:** *rect* (QRectF) The full range that should be visible in the view box. *xRange* (min,max) The range that should be visible along the x-axis. *yRange* (min,max) The range that should be visible along the y-axis. @@ -546,14 +618,14 @@ class ViewBox(GraphicsWidget): Note that this is not the same as enableAutoRange, which causes the view to automatically auto-range whenever its contents are changed. - =========== ============================================================ - Arguments - padding The fraction of the total data range to add on to the final - visible range. By default, this value is set between 0.02 - and 0.1 depending on the size of the ViewBox. - items If specified, this is a list of items to consider when - determining the visible range. - =========== ============================================================ + ============== ============================================================ + **Arguments:** + padding The fraction of the total data range to add on to the final + visible range. By default, this value is set between 0.02 + and 0.1 depending on the size of the ViewBox. + items If specified, this is a list of items to consider when + determining the visible range. + ============== ============================================================ """ if item is None: bounds = self.childrenBoundingRect(items=items) @@ -571,6 +643,60 @@ class ViewBox(GraphicsWidget): else: padding = 0.02 return padding + + def setLimits(self, **kwds): + """ + Set limits that constrain the possible view ranges. + + **Panning limits**. The following arguments define the region within the + viewbox coordinate system that may be accessed by panning the view. + + =========== ============================================================ + xMin Minimum allowed x-axis value + xMax Maximum allowed x-axis value + yMin Minimum allowed y-axis value + yMax Maximum allowed y-axis value + =========== ============================================================ + + **Scaling limits**. These arguments prevent the view being zoomed in or + out too far. + + =========== ============================================================ + minXRange Minimum allowed left-to-right span across the view. + maxXRange Maximum allowed left-to-right span across the view. + minYRange Minimum allowed top-to-bottom span across the view. + maxYRange Maximum allowed top-to-bottom span across the view. + =========== ============================================================ + + Added in version 0.9.9 + """ + update = False + allowed = ['xMin', 'xMax', 'yMin', 'yMax', 'minXRange', 'maxXRange', 'minYRange', 'maxYRange'] + for kwd in kwds: + if kwd not in allowed: + raise ValueError("Invalid keyword argument '%s'." % kwd) + #for kwd in ['xLimits', 'yLimits', 'minRange', 'maxRange']: + #if kwd in kwds and self.state['limits'][kwd] != kwds[kwd]: + #self.state['limits'][kwd] = kwds[kwd] + #update = True + for axis in [0,1]: + for mnmx in [0,1]: + kwd = [['xMin', 'xMax'], ['yMin', 'yMax']][axis][mnmx] + lname = ['xLimits', 'yLimits'][axis] + if kwd in kwds and self.state['limits'][lname][mnmx] != kwds[kwd]: + self.state['limits'][lname][mnmx] = kwds[kwd] + update = True + kwd = [['minXRange', 'maxXRange'], ['minYRange', 'maxYRange']][axis][mnmx] + lname = ['xRange', 'yRange'][axis] + if kwd in kwds and self.state['limits'][lname][mnmx] != kwds[kwd]: + self.state['limits'][lname][mnmx] = kwds[kwd] + update = True + + if update: + self.updateViewRange() + + + def scaleBy(self, s=None, center=None, x=None, y=None): """ @@ -634,7 +760,8 @@ class ViewBox(GraphicsWidget): x = vr.left()+x, vr.right()+x if y is not None: y = vr.top()+y, vr.bottom()+y - self.setRange(xRange=x, yRange=y, padding=0) + if x is not None or y is not None: + self.setRange(xRange=x, yRange=y, padding=0) @@ -776,6 +903,14 @@ class ViewBox(GraphicsWidget): return args['padding'] = 0 args['disableAutoRange'] = False + + # check for and ignore bad ranges + for k in ['xRange', 'yRange']: + if k in args: + if not np.all(np.isfinite(args[k])): + r = args.pop(k) + #print("Warning: %s is invalid: %s" % (k, str(r)) + self.setRange(**args) finally: self._autoRangeNeedsUpdate = False @@ -818,7 +953,7 @@ class ViewBox(GraphicsWidget): try: getattr(oldLink, signal).disconnect(slot) oldLink.sigResized.disconnect(slot) - except TypeError: + except (TypeError, RuntimeError): ## This can occur if the view has been deleted already pass @@ -882,7 +1017,10 @@ class ViewBox(GraphicsWidget): x2 = vr.right() else: ## views overlap; line them up upp = float(vr.width()) / vg.width() - x1 = vr.left() + (sg.x()-vg.x()) * upp + if self.xInverted(): + x1 = vr.left() + (sg.right()-vg.right()) * upp + else: + x1 = vr.left() + (sg.x()-vg.x()) * upp x2 = x1 + sg.width() * upp self.enableAutoRange(ViewBox.XAxis, False) self.setXRange(x1, x2, padding=0) @@ -937,13 +1075,30 @@ class ViewBox(GraphicsWidget): return self.state['yInverted'] = b - #self.updateMatrix(changed=(False, True)) + self._matrixNeedsUpdate = True # updateViewRange won't detect this for us self.updateViewRange() self.sigStateChanged.emit(self) + self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) def yInverted(self): return self.state['yInverted'] + def invertX(self, b=True): + """ + By default, the positive x-axis points rightward on the screen. Use invertX(True) to reverse the x-axis. + """ + if self.state['xInverted'] == b: + return + + self.state['xInverted'] = b + #self.updateMatrix(changed=(False, True)) + self.updateViewRange() + self.sigStateChanged.emit(self) + self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) + + def xInverted(self): + return self.state['xInverted'] + def setAspectLocked(self, lock=True, ratio=1): """ If the aspect ratio is locked, view scaling must always preserve the aspect ratio. @@ -980,6 +1135,8 @@ class ViewBox(GraphicsWidget): Return the transform that maps from child(item in the childGroup) coordinates to local coordinates. (This maps from inside the viewbox to outside) """ + if self._matrixNeedsUpdate: + self.updateMatrix() m = self.childGroup.transform() #m1 = QtGui.QTransform() #m1.translate(self.childGroup.pos().x(), self.childGroup.pos().y()) @@ -1056,6 +1213,7 @@ class ViewBox(GraphicsWidget): center = Point(fn.invertQTransform(self.childGroup.transform()).map(ev.pos())) #center = ev.pos() + self._resetTarget() self.scaleBy(s, center) self.sigRangeChangedManually.emit(self.state['mouseEnabled']) ev.accept() @@ -1065,33 +1223,17 @@ class ViewBox(GraphicsWidget): if ev.button() == QtCore.Qt.RightButton and self.menuEnabled(): ev.accept() self.raiseContextMenu(ev) - + def raiseContextMenu(self, ev): - #print "viewbox.raiseContextMenu called." - - #menu = self.getMenu(ev) menu = self.getMenu(ev) self.scene().addParentContextMenus(self, menu, ev) - #print "2:", [str(a.text()) for a in self.menu.actions()] - pos = ev.screenPos() - #pos2 = ev.scenePos() - #print "3:", [str(a.text()) for a in self.menu.actions()] - #self.sigActionPositionChanged.emit(pos2) + menu.popup(ev.screenPos().toPoint()) - menu.popup(QtCore.QPoint(pos.x(), pos.y())) - #print "4:", [str(a.text()) for a in self.menu.actions()] - def getMenu(self, ev): - self._menuCopy = self.menu.copy() ## temporary storage to prevent menu disappearing - return self._menuCopy - + return self.menu + def getContextMenus(self, event): - if self.menuEnabled(): - return self.menu.subMenus() - else: - return None - #return [self.getMenu(event)] - + return self.menu.actions() if self.menuEnabled() else [] def mouseDragEvent(self, ev, axis=None): ## if axis is specified, event will only affect that axis. @@ -1129,7 +1271,9 @@ class ViewBox(GraphicsWidget): x = tr.x() if mask[0] == 1 else None y = tr.y() if mask[1] == 1 else None - self.translateBy(x=x, y=y) + self._resetTarget() + if x is not None or y is not None: + self.translateBy(x=x, y=y) self.sigRangeChangedManually.emit(self.state['mouseEnabled']) elif ev.button() & QtCore.Qt.RightButton: #print "vb.rightDrag" @@ -1148,6 +1292,7 @@ class ViewBox(GraphicsWidget): y = s[1] if mouseEnabled[1] == 1 else None center = Point(tr.map(ev.buttonDownPos(QtCore.Qt.RightButton))) + self._resetTarget() self.scaleBy(x=x, y=y, center=center) self.sigRangeChangedManually.emit(self.state['mouseEnabled']) @@ -1178,6 +1323,8 @@ class ViewBox(GraphicsWidget): ev.ignore() def scaleHistory(self, d): + if len(self.axHistory) == 0: + return ptr = max(0, min(len(self.axHistory)-1, self.axHistoryPointer+d)) if ptr != self.axHistoryPointer: self.axHistoryPointer = ptr @@ -1221,7 +1368,7 @@ class ViewBox(GraphicsWidget): [[xmin, xmax], [ymin, ymax]] Values may be None if there are no specific bounds for an axis. """ - prof = debug.Profiler('updateAutoRange', disabled=True) + profiler = debug.Profiler() if items is None: items = self.addedItems @@ -1298,7 +1445,7 @@ class ViewBox(GraphicsWidget): range[0] = [min(bounds.left(), range[0][0]), max(bounds.right(), range[0][1])] else: range[0] = [bounds.left(), bounds.right()] - prof.mark('2') + profiler() #print "range", range @@ -1322,10 +1469,7 @@ class ViewBox(GraphicsWidget): continue range[1][0] = min(range[1][0], bounds.top() - px*pxSize) range[1][1] = max(range[1][1], bounds.bottom() + px*pxSize) - - #print "final range", range - - prof.finish() + return range def childrenBoundingRect(self, *args, **kwds): @@ -1346,18 +1490,19 @@ class ViewBox(GraphicsWidget): viewRange = [self.state['targetRange'][0][:], self.state['targetRange'][1][:]] changed = [False, False] - # Make correction for aspect ratio constraint + #-------- Make correction for aspect ratio constraint ---------- - ## aspect is (widget w/h) / (view range w/h) + # aspect is (widget w/h) / (view range w/h) aspect = self.state['aspectLocked'] # size ratio / view ratio tr = self.targetRect() bounds = self.rect() - if aspect is not False and aspect != 0 and tr.height() != 0 and bounds.height() != 0: + if aspect is not False and 0 not in [aspect, tr.height(), bounds.height(), bounds.width()]: ## This is the view range aspect ratio we have requested - targetRatio = tr.width() / tr.height() + targetRatio = tr.width() / tr.height() if tr.height() != 0 else 1 ## This is the view range aspect ratio we need to obey aspect constraint - viewRatio = (bounds.width() / bounds.height()) / aspect + viewRatio = (bounds.width() / bounds.height() if bounds.height() != 0 else 1) / aspect + viewRatio = 1 if viewRatio == 0 else viewRatio # Decide which range to keep unchanged #print self.name, "aspect:", aspect, "changed:", changed, "auto:", self.state['autoRange'] @@ -1370,7 +1515,6 @@ class ViewBox(GraphicsWidget): # then make the entire target range visible ax = 0 if targetRatio > viewRatio else 1 - #### these should affect viewRange, not targetRange! if ax == 0: ## view range needs to be taller than target dy = 0.5 * (tr.width() / viewRatio - tr.height()) @@ -1383,8 +1527,60 @@ class ViewBox(GraphicsWidget): if dx != 0: changed[0] = True viewRange[0] = [self.state['targetRange'][0][0] - dx, self.state['targetRange'][0][1] + dx] + - changed = [(viewRange[i][0] != self.state['viewRange'][i][0]) and (viewRange[i][1] != self.state['viewRange'][i][1]) for i in (0,1)] + # ----------- Make corrections for view limits ----------- + + limits = (self.state['limits']['xLimits'], self.state['limits']['yLimits']) + minRng = [self.state['limits']['xRange'][0], self.state['limits']['yRange'][0]] + maxRng = [self.state['limits']['xRange'][1], self.state['limits']['yRange'][1]] + + for axis in [0, 1]: + if limits[axis][0] is None and limits[axis][1] is None and minRng[axis] is None and maxRng[axis] is None: + continue + + # max range cannot be larger than bounds, if they are given + if limits[axis][0] is not None and limits[axis][1] is not None: + if maxRng[axis] is not None: + maxRng[axis] = min(maxRng[axis], limits[axis][1]-limits[axis][0]) + else: + maxRng[axis] = limits[axis][1]-limits[axis][0] + + #print "\nLimits for axis %d: range=%s min=%s max=%s" % (axis, limits[axis], minRng[axis], maxRng[axis]) + #print "Starting range:", viewRange[axis] + + # Apply xRange, yRange + diff = viewRange[axis][1] - viewRange[axis][0] + if maxRng[axis] is not None and diff > maxRng[axis]: + delta = maxRng[axis] - diff + changed[axis] = True + elif minRng[axis] is not None and diff < minRng[axis]: + delta = minRng[axis] - diff + changed[axis] = True + else: + delta = 0 + + viewRange[axis][0] -= delta/2. + viewRange[axis][1] += delta/2. + + #print "after applying min/max:", viewRange[axis] + + # Apply xLimits, yLimits + mn, mx = limits[axis] + if mn is not None and viewRange[axis][0] < mn: + delta = mn - viewRange[axis][0] + viewRange[axis][0] += delta + viewRange[axis][1] += delta + changed[axis] = True + elif mx is not None and viewRange[axis][1] > mx: + delta = mx - viewRange[axis][1] + viewRange[axis][0] += delta + viewRange[axis][1] += delta + changed[axis] = True + + #print "after applying edge limits:", viewRange[axis] + + changed = [(viewRange[i][0] != self.state['viewRange'][i][0]) or (viewRange[i][1] != self.state['viewRange'][i][1]) for i in (0,1)] self.state['viewRange'] = viewRange # emit range change signals @@ -1396,17 +1592,16 @@ class ViewBox(GraphicsWidget): if any(changed): self.sigRangeChanged.emit(self, self.state['viewRange']) self.update() + self._matrixNeedsUpdate = True - # Inform linked views that the range has changed - for ax in [0, 1]: - if not changed[ax]: - continue - link = self.linkedView(ax) - if link is not None: - link.linkedViewChanged(self, ax) + # Inform linked views that the range has changed + for ax in [0, 1]: + if not changed[ax]: + continue + link = self.linkedView(ax) + if link is not None: + link.linkedViewChanged(self, ax) - self._matrixNeedsUpdate = True - def updateMatrix(self, changed=None): ## Make the childGroup's transform match the requested viewRange. bounds = self.rect() @@ -1417,6 +1612,8 @@ class ViewBox(GraphicsWidget): scale = Point(bounds.width()/vr.width(), bounds.height()/vr.height()) if not self.state['yInverted']: scale = scale * Point(1, -1) + if self.state['xInverted']: + scale = scale * Point(-1, 1) m = QtGui.QTransform() ## First center the viewport at 0 @@ -1499,6 +1696,8 @@ class ViewBox(GraphicsWidget): def forgetView(vid, name): if ViewBox is None: ## can happen as python is shutting down return + if QtGui.QApplication.instance() is None: + return ## Called with ID and name of view (the view itself is no longer available) for v in list(ViewBox.AllViews.keys()): if id(v) == vid: @@ -1512,12 +1711,17 @@ class ViewBox(GraphicsWidget): ## called when the application is about to exit. ## this disables all callbacks, which might otherwise generate errors if invoked during exit. for k in ViewBox.AllViews: + if isQObjectAlive(k) and getConfigOption('crashWarning'): + sys.stderr.write('Warning: ViewBox should be closed before application exit.\n') + try: k.destroyed.disconnect() except RuntimeError: ## signal is already disconnected. pass except TypeError: ## view has already been deleted (?) pass + except AttributeError: # PySide has deleted signal + pass def locate(self, item, timeout=3.0, children=False): """ diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py b/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py index 5242ecdd..0e7d7912 100644 --- a/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py +++ b/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py @@ -1,6 +1,6 @@ -from pyqtgraph.Qt import QtCore, QtGui, USE_PYSIDE -from pyqtgraph.python2_3 import asUnicode -from pyqtgraph.WidgetGroup import WidgetGroup +from ...Qt import QtCore, QtGui, USE_PYSIDE +from ...python2_3 import asUnicode +from ...WidgetGroup import WidgetGroup if USE_PYSIDE: from .axisCtrlTemplate_pyside import Ui_Form as AxisCtrlTemplate @@ -56,7 +56,7 @@ class ViewBoxMenu(QtGui.QMenu): for sig, fn in connects: sig.connect(getattr(self, axis.lower()+fn)) - self.ctrl[0].invertCheck.hide() ## no invert for x-axis + self.ctrl[0].invertCheck.toggled.connect(self.xInvertToggled) self.ctrl[1].invertCheck.toggled.connect(self.yInvertToggled) ## exporting is handled by GraphicsScene now #self.export = QtGui.QMenu("Export") @@ -88,22 +88,6 @@ class ViewBoxMenu(QtGui.QMenu): self.updateState() - def copy(self): - m = QtGui.QMenu() - for sm in self.subMenus(): - if isinstance(sm, QtGui.QMenu): - m.addMenu(sm) - else: - m.addAction(sm) - m.setTitle(self.title()) - return m - - def subMenus(self): - if not self.valid: - self.updateState() - return [self.viewAll] + self.axes + [self.leftMenu] - - def setExportMethods(self, methods): self.exportMethods = methods self.export.clear() @@ -155,10 +139,15 @@ class ViewBoxMenu(QtGui.QMenu): self.ctrl[i].autoPanCheck.setChecked(state['autoPan'][i]) self.ctrl[i].visibleOnlyCheck.setChecked(state['autoVisibleOnly'][i]) - - self.ctrl[1].invertCheck.setChecked(state['yInverted']) + xy = ['x', 'y'][i] + self.ctrl[i].invertCheck.setChecked(state.get(xy+'Inverted', False)) + self.valid = True + def popup(self, *args): + if not self.valid: + self.updateState() + QtGui.QMenu.popup(self, *args) def autoRange(self): self.view().autoRange() ## don't let signal call this directly--it'll add an unwanted argument @@ -229,19 +218,19 @@ class ViewBoxMenu(QtGui.QMenu): def yInvertToggled(self, b): self.view().invertY(b) + def xInvertToggled(self, b): + self.view().invertX(b) def exportMethod(self): act = self.sender() self.exportMethods[str(act.text())]() - def set3ButtonMode(self): self.view().setLeftButtonAction('pan') def set1ButtonMode(self): self.view().setLeftButtonAction('rect') - def setViewList(self, views): names = [''] self.viewMap.clear() @@ -275,4 +264,4 @@ class ViewBoxMenu(QtGui.QMenu): from .ViewBox import ViewBox - \ No newline at end of file + diff --git a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt.py b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt.py index db14033e..d8ef1925 100644 --- a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt.py +++ b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './graphicsItems/ViewBox/axisCtrlTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate.ui' # -# Created: Sun Sep 9 14:41:31 2012 -# by: PyQt4 UI code generator 4.9.1 +# Created: Mon Dec 23 10:10:51 2013 +# by: PyQt4 UI code generator 4.10 # # WARNING! All changes made in this file will be lost! @@ -12,7 +12,16 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_Form(object): def setupUi(self, Form): @@ -69,25 +78,25 @@ class Ui_Form(object): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) - self.label.setText(QtGui.QApplication.translate("Form", "Link Axis:", None, QtGui.QApplication.UnicodeUTF8)) - self.linkCombo.setToolTip(QtGui.QApplication.translate("Form", "

Links this axis with another view. When linked, both views will display the same data range.

", None, QtGui.QApplication.UnicodeUTF8)) - self.autoPercentSpin.setToolTip(QtGui.QApplication.translate("Form", "

Percent of data to be visible when auto-scaling. It may be useful to decrease this value for data with spiky noise.

", None, QtGui.QApplication.UnicodeUTF8)) - self.autoPercentSpin.setSuffix(QtGui.QApplication.translate("Form", "%", None, QtGui.QApplication.UnicodeUTF8)) - self.autoRadio.setToolTip(QtGui.QApplication.translate("Form", "

Automatically resize this axis whenever the displayed data is changed.

", None, QtGui.QApplication.UnicodeUTF8)) - self.autoRadio.setText(QtGui.QApplication.translate("Form", "Auto", None, QtGui.QApplication.UnicodeUTF8)) - self.manualRadio.setToolTip(QtGui.QApplication.translate("Form", "

Set the range for this axis manually. This disables automatic scaling.

", None, QtGui.QApplication.UnicodeUTF8)) - self.manualRadio.setText(QtGui.QApplication.translate("Form", "Manual", None, QtGui.QApplication.UnicodeUTF8)) - self.minText.setToolTip(QtGui.QApplication.translate("Form", "

Minimum value to display for this axis.

", None, QtGui.QApplication.UnicodeUTF8)) - self.minText.setText(QtGui.QApplication.translate("Form", "0", None, QtGui.QApplication.UnicodeUTF8)) - self.maxText.setToolTip(QtGui.QApplication.translate("Form", "

Maximum value to display for this axis.

", None, QtGui.QApplication.UnicodeUTF8)) - self.maxText.setText(QtGui.QApplication.translate("Form", "0", None, QtGui.QApplication.UnicodeUTF8)) - self.invertCheck.setToolTip(QtGui.QApplication.translate("Form", "

Inverts the display of this axis. (+y points downward instead of upward)

", None, QtGui.QApplication.UnicodeUTF8)) - self.invertCheck.setText(QtGui.QApplication.translate("Form", "Invert Axis", None, QtGui.QApplication.UnicodeUTF8)) - self.mouseCheck.setToolTip(QtGui.QApplication.translate("Form", "

Enables mouse interaction (panning, scaling) for this axis.

", None, QtGui.QApplication.UnicodeUTF8)) - self.mouseCheck.setText(QtGui.QApplication.translate("Form", "Mouse Enabled", None, QtGui.QApplication.UnicodeUTF8)) - self.visibleOnlyCheck.setToolTip(QtGui.QApplication.translate("Form", "

When checked, the axis will only auto-scale to data that is visible along the orthogonal axis.

", None, QtGui.QApplication.UnicodeUTF8)) - self.visibleOnlyCheck.setText(QtGui.QApplication.translate("Form", "Visible Data Only", None, QtGui.QApplication.UnicodeUTF8)) - self.autoPanCheck.setToolTip(QtGui.QApplication.translate("Form", "

When checked, the axis will automatically pan to center on the current data, but the scale along this axis will not change.

", None, QtGui.QApplication.UnicodeUTF8)) - self.autoPanCheck.setText(QtGui.QApplication.translate("Form", "Auto Pan Only", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(_translate("Form", "Form", None)) + self.label.setText(_translate("Form", "Link Axis:", None)) + self.linkCombo.setToolTip(_translate("Form", "

Links this axis with another view. When linked, both views will display the same data range.

", None)) + self.autoPercentSpin.setToolTip(_translate("Form", "

Percent of data to be visible when auto-scaling. It may be useful to decrease this value for data with spiky noise.

", None)) + self.autoPercentSpin.setSuffix(_translate("Form", "%", None)) + self.autoRadio.setToolTip(_translate("Form", "

Automatically resize this axis whenever the displayed data is changed.

", None)) + self.autoRadio.setText(_translate("Form", "Auto", None)) + self.manualRadio.setToolTip(_translate("Form", "

Set the range for this axis manually. This disables automatic scaling.

", None)) + self.manualRadio.setText(_translate("Form", "Manual", None)) + self.minText.setToolTip(_translate("Form", "

Minimum value to display for this axis.

", None)) + self.minText.setText(_translate("Form", "0", None)) + self.maxText.setToolTip(_translate("Form", "

Maximum value to display for this axis.

", None)) + self.maxText.setText(_translate("Form", "0", None)) + self.invertCheck.setToolTip(_translate("Form", "

Inverts the display of this axis. (+y points downward instead of upward)

", None)) + self.invertCheck.setText(_translate("Form", "Invert Axis", None)) + self.mouseCheck.setToolTip(_translate("Form", "

Enables mouse interaction (panning, scaling) for this axis.

", None)) + self.mouseCheck.setText(_translate("Form", "Mouse Enabled", None)) + self.visibleOnlyCheck.setToolTip(_translate("Form", "

When checked, the axis will only auto-scale to data that is visible along the orthogonal axis.

", None)) + self.visibleOnlyCheck.setText(_translate("Form", "Visible Data Only", None)) + self.autoPanCheck.setToolTip(_translate("Form", "

When checked, the axis will automatically pan to center on the current data, but the scale along this axis will not change.

", None)) + self.autoPanCheck.setText(_translate("Form", "Auto Pan Only", None)) diff --git a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside.py b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside.py index 18510bc2..9ddeb5d1 100644 --- a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside.py +++ b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './graphicsItems/ViewBox/axisCtrlTemplate.ui' +# Form implementation generated from reading ui file './pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate.ui' # -# Created: Sun Sep 9 14:41:32 2012 -# by: pyside-uic 0.2.13 running on PySide 1.1.0 +# Created: Mon Dec 23 10:10:51 2013 +# by: pyside-uic 0.2.14 running on PySide 1.1.2 # # WARNING! All changes made in this file will be lost! diff --git a/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBox.py b/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBox.py new file mode 100644 index 00000000..f1063e7f --- /dev/null +++ b/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBox.py @@ -0,0 +1,85 @@ +#import PySide +import pyqtgraph as pg + +app = pg.mkQApp() +qtest = pg.Qt.QtTest.QTest + +def assertMapping(vb, r1, r2): + assert vb.mapFromView(r1.topLeft()) == r2.topLeft() + assert vb.mapFromView(r1.bottomLeft()) == r2.bottomLeft() + assert vb.mapFromView(r1.topRight()) == r2.topRight() + assert vb.mapFromView(r1.bottomRight()) == r2.bottomRight() + +def test_ViewBox(): + global app, win, vb + QRectF = pg.QtCore.QRectF + + win = pg.GraphicsWindow() + win.ci.layout.setContentsMargins(0,0,0,0) + win.resize(200, 200) + win.show() + vb = win.addViewBox() + + # set range before viewbox is shown + vb.setRange(xRange=[0, 10], yRange=[0, 10], padding=0) + + # required to make mapFromView work properly. + qtest.qWaitForWindowShown(win) + + g = pg.GridItem() + vb.addItem(g) + + app.processEvents() + + w = vb.geometry().width() + h = vb.geometry().height() + view1 = QRectF(0, 0, 10, 10) + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + # test resize + win.resize(400, 400) + app.processEvents() + w = vb.geometry().width() + h = vb.geometry().height() + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + # now lock aspect + vb.setAspectLocked() + + # test wide resize + win.resize(800, 400) + app.processEvents() + w = vb.geometry().width() + h = vb.geometry().height() + view1 = QRectF(-5, 0, 20, 10) + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + # test tall resize + win.resize(400, 800) + app.processEvents() + w = vb.geometry().width() + h = vb.geometry().height() + view1 = QRectF(0, -5, 10, 20) + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + # test limits + resize (aspect ratio constraint has priority over limits + win.resize(400, 400) + app.processEvents() + vb.setLimits(xMin=0, xMax=10, yMin=0, yMax=10) + win.resize(800, 400) + app.processEvents() + w = vb.geometry().width() + h = vb.geometry().height() + view1 = QRectF(-5, 0, 20, 10) + size1 = QRectF(0, h, w, -h) + assertMapping(vb, view1, size1) + + +if __name__ == '__main__': + import user,sys + test_ViewBox() + \ No newline at end of file diff --git a/pyqtgraph/graphicsItems/tests/ViewBox.py b/pyqtgraph/graphicsItems/tests/ViewBox.py deleted file mode 100644 index 91d9b617..00000000 --- a/pyqtgraph/graphicsItems/tests/ViewBox.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -ViewBox test cases: - -* call setRange then resize; requested range must be fully visible -* lockAspect works correctly for arbitrary aspect ratio -* autoRange works correctly with aspect locked -* call setRange with aspect locked, then resize -* AutoRange with all the bells and whistles - * item moves / changes transformation / changes bounds - * pan only - * fractional range - - -""" - -import pyqtgraph as pg -app = pg.mkQApp() - -imgData = pg.np.zeros((10, 10)) -imgData[0] = 3 -imgData[-1] = 3 -imgData[:,0] = 3 -imgData[:,-1] = 3 - -def testLinkWithAspectLock(): - global win, vb - win = pg.GraphicsWindow() - vb = win.addViewBox(name="image view") - vb.setAspectLocked() - vb.enableAutoRange(x=False, y=False) - p1 = win.addPlot(name="plot 1") - p2 = win.addPlot(name="plot 2", row=1, col=0) - win.ci.layout.setRowFixedHeight(1, 150) - win.ci.layout.setColumnFixedWidth(1, 150) - - def viewsMatch(): - r0 = pg.np.array(vb.viewRange()) - r1 = pg.np.array(p1.vb.viewRange()[1]) - r2 = pg.np.array(p2.vb.viewRange()[1]) - match = (abs(r0[1]-r1) <= (abs(r1) * 0.001)).all() and (abs(r0[0]-r2) <= (abs(r2) * 0.001)).all() - return match - - p1.setYLink(vb) - p2.setXLink(vb) - print "link views match:", viewsMatch() - win.show() - print "show views match:", viewsMatch() - img = pg.ImageItem(imgData) - vb.addItem(img) - vb.autoRange() - p1.plot(x=imgData.sum(axis=0), y=range(10)) - p2.plot(x=range(10), y=imgData.sum(axis=1)) - print "add items views match:", viewsMatch() - #p1.setAspectLocked() - #grid = pg.GridItem() - #vb.addItem(grid) - pg.QtGui.QApplication.processEvents() - pg.QtGui.QApplication.processEvents() - #win.resize(801, 600) - -def testAspectLock(): - global win, vb - win = pg.GraphicsWindow() - vb = win.addViewBox(name="image view") - vb.setAspectLocked() - img = pg.ImageItem(imgData) - vb.addItem(img) - - -#app.processEvents() -#print "init views match:", viewsMatch() -#p2.setYRange(-300, 300) -#print "setRange views match:", viewsMatch() -#app.processEvents() -#print "setRange views match (after update):", viewsMatch() - -#print "--lock aspect--" -#p1.setAspectLocked(True) -#print "lockAspect views match:", viewsMatch() -#p2.setYRange(-200, 200) -#print "setRange views match:", viewsMatch() -#app.processEvents() -#print "setRange views match (after update):", viewsMatch() - -#win.resize(100, 600) -#app.processEvents() -#vb.setRange(xRange=[-10, 10], padding=0) -#app.processEvents() -#win.resize(600, 100) -#app.processEvents() -#print vb.viewRange() - - -if __name__ == '__main__': - testLinkWithAspectLock() diff --git a/pyqtgraph/graphicsItems/tests/test_GraphicsItem.py b/pyqtgraph/graphicsItems/tests/test_GraphicsItem.py new file mode 100644 index 00000000..112dd4d5 --- /dev/null +++ b/pyqtgraph/graphicsItems/tests/test_GraphicsItem.py @@ -0,0 +1,47 @@ +import gc +import weakref +try: + import faulthandler + faulthandler.enable() +except ImportError: + pass + +import pyqtgraph as pg +pg.mkQApp() + +def test_getViewWidget(): + view = pg.PlotWidget() + vref = weakref.ref(view) + item = pg.InfiniteLine() + view.addItem(item) + assert item.getViewWidget() is view + del view + gc.collect() + assert vref() is None + assert item.getViewWidget() is None + +def test_getViewWidget_deleted(): + view = pg.PlotWidget() + item = pg.InfiniteLine() + view.addItem(item) + assert item.getViewWidget() is view + + # Arrange to have Qt automatically delete the view widget + obj = pg.QtGui.QWidget() + view.setParent(obj) + del obj + gc.collect() + + assert not pg.Qt.isQObjectAlive(view) + assert item.getViewWidget() is None + + +#if __name__ == '__main__': + #view = pg.PlotItem() + #vref = weakref.ref(view) + #item = pg.InfiniteLine() + #view.addItem(item) + #del view + #gc.collect() + + \ No newline at end of file diff --git a/pyqtgraph/graphicsItems/tests/test_ScatterPlotItem.py b/pyqtgraph/graphicsItems/tests/test_ScatterPlotItem.py new file mode 100644 index 00000000..8b0ebc8f --- /dev/null +++ b/pyqtgraph/graphicsItems/tests/test_ScatterPlotItem.py @@ -0,0 +1,86 @@ +import pyqtgraph as pg +import numpy as np +app = pg.mkQApp() +plot = pg.plot() +app.processEvents() + +# set view range equal to its bounding rect. +# This causes plots to look the same regardless of pxMode. +plot.setRange(rect=plot.boundingRect()) + + +def test_scatterplotitem(): + for i, pxMode in enumerate([True, False]): + for j, useCache in enumerate([True, False]): + s = pg.ScatterPlotItem() + s.opts['useCache'] = useCache + plot.addItem(s) + s.setData(x=np.array([10,40,20,30])+i*100, y=np.array([40,60,10,30])+j*100, pxMode=pxMode) + s.addPoints(x=np.array([60, 70])+i*100, y=np.array([60, 70])+j*100, size=[20, 30]) + + # Test uniform spot updates + s.setSize(10) + s.setBrush('r') + s.setPen('g') + s.setSymbol('+') + app.processEvents() + + # Test list spot updates + s.setSize([10] * 6) + s.setBrush([pg.mkBrush('r')] * 6) + s.setPen([pg.mkPen('g')] * 6) + s.setSymbol(['+'] * 6) + s.setPointData([s] * 6) + app.processEvents() + + # Test array spot updates + s.setSize(np.array([10] * 6)) + s.setBrush(np.array([pg.mkBrush('r')] * 6)) + s.setPen(np.array([pg.mkPen('g')] * 6)) + s.setSymbol(np.array(['+'] * 6)) + s.setPointData(np.array([s] * 6)) + app.processEvents() + + # Test per-spot updates + spot = s.points()[0] + spot.setSize(20) + spot.setBrush('b') + spot.setPen('g') + spot.setSymbol('o') + spot.setData(None) + app.processEvents() + + plot.clear() + + +def test_init_spots(): + spots = [ + {'x': 0, 'y': 1}, + {'pos': (1, 2), 'pen': None, 'brush': None, 'data': 'zzz'}, + ] + s = pg.ScatterPlotItem(spots=spots) + + # Check we can display without errors + plot.addItem(s) + app.processEvents() + plot.clear() + + # check data is correct + spots = s.points() + + defPen = pg.mkPen(pg.getConfigOption('foreground')) + + assert spots[0].pos().x() == 0 + assert spots[0].pos().y() == 1 + assert spots[0].pen() == defPen + assert spots[0].data() is None + + assert spots[1].pos().x() == 1 + assert spots[1].pos().y() == 2 + assert spots[1].pen() == pg.mkPen(None) + assert spots[1].brush() == pg.mkBrush(None) + assert spots[1].data() == 'zzz' + + +if __name__ == '__main__': + test_scatterplotitem() diff --git a/pyqtgraph/graphicsWindows.py b/pyqtgraph/graphicsWindows.py index 6e7d6305..1aa3f3f4 100644 --- a/pyqtgraph/graphicsWindows.py +++ b/pyqtgraph/graphicsWindows.py @@ -19,11 +19,14 @@ def mkQApp(): class GraphicsWindow(GraphicsLayoutWidget): + """ + Convenience subclass of :class:`GraphicsLayoutWidget + `. This class is intended for use from + the interactive python prompt. + """ def __init__(self, title=None, size=(800,600), **kargs): mkQApp() - #self.win = QtGui.QMainWindow() GraphicsLayoutWidget.__init__(self, **kargs) - #self.win.setCentralWidget(self) self.resize(*size) if title is not None: self.setWindowTitle(title) diff --git a/pyqtgraph/imageview/ImageView.py b/pyqtgraph/imageview/ImageView.py index 77f34419..65252cfe 100644 --- a/pyqtgraph/imageview/ImageView.py +++ b/pyqtgraph/imageview/ImageView.py @@ -12,32 +12,28 @@ Widget used for displaying 2D or 3D data. Features: - ROI plotting - Image normalization through a variety of methods """ -from pyqtgraph.Qt import QtCore, QtGui, USE_PYSIDE +import os, sys +import numpy as np +from ..Qt import QtCore, QtGui, USE_PYSIDE if USE_PYSIDE: from .ImageViewTemplate_pyside import * else: from .ImageViewTemplate_pyqt import * -from pyqtgraph.graphicsItems.ImageItem import * -from pyqtgraph.graphicsItems.ROI import * -from pyqtgraph.graphicsItems.LinearRegionItem import * -from pyqtgraph.graphicsItems.InfiniteLine import * -from pyqtgraph.graphicsItems.ViewBox import * -#from widgets import ROI -import sys -#from numpy import ndarray -import pyqtgraph.ptime as ptime -import numpy as np -import pyqtgraph.debug as debug +from ..graphicsItems.ImageItem import * +from ..graphicsItems.ROI import * +from ..graphicsItems.LinearRegionItem import * +from ..graphicsItems.InfiniteLine import * +from ..graphicsItems.ViewBox import * +from .. import ptime as ptime +from .. import debug as debug +from ..SignalProxy import SignalProxy -from pyqtgraph.SignalProxy import SignalProxy - -#try: - #import pyqtgraph.metaarray as metaarray - #HAVE_METAARRAY = True -#except: - #HAVE_METAARRAY = False +try: + from bottleneck import nanmin, nanmax +except ImportError: + from numpy import nanmin, nanmax class PlotROI(ROI): @@ -67,6 +63,16 @@ class ImageView(QtGui.QWidget): imv = pg.ImageView() imv.show() imv.setImage(data) + + **Keyboard interaction** + + * left/right arrows step forward/backward 1 frame when pressed, + seek at 20fps when held. + * up/down arrows seek at 100fps + * pgup/pgdn seek at 1000fps + * home/end seek immediately to the first/last frame + * space begins playing frames. If time values (in seconds) are given + for each frame, then playback is in realtime. """ sigTimeChanged = QtCore.Signal(object, object) sigProcessingChanged = QtCore.Signal(object) @@ -74,8 +80,31 @@ class ImageView(QtGui.QWidget): def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, *args): """ By default, this class creates an :class:`ImageItem ` to display image data - and a :class:`ViewBox ` to contain the ImageItem. Custom items may be given instead - by specifying the *view* and/or *imageItem* arguments. + and a :class:`ViewBox ` to contain the ImageItem. + + ============= ========================================================= + **Arguments** + parent (QWidget) Specifies the parent widget to which + this ImageView will belong. If None, then the ImageView + is created with no parent. + name (str) The name used to register both the internal ViewBox + and the PlotItem used to display ROI data. See the *name* + argument to :func:`ViewBox.__init__() + `. + view (ViewBox or PlotItem) If specified, this will be used + as the display area that contains the displayed image. + Any :class:`ViewBox `, + :class:`PlotItem `, or other + compatible object is acceptable. + imageItem (ImageItem) If specified, this object will be used to + display the image. Must be an instance of ImageItem + or other compatible object. + ============= ========================================================= + + Note: to display axis ticks inside the ImageView, instantiate it + with a PlotItem instance as its view:: + + pg.ImageView(view=pg.PlotItem()) """ QtGui.QWidget.__init__(self, parent, *args) self.levelMax = 4096 @@ -107,6 +136,8 @@ class ImageView(QtGui.QWidget): self.ui.histogram.setImageItem(self.imageItem) + self.menu = None + self.ui.normGroup.hide() self.roi = PlotROI(10) @@ -147,7 +178,8 @@ class ImageView(QtGui.QWidget): self.timeLine.sigPositionChanged.connect(self.timeLineChanged) self.ui.roiBtn.clicked.connect(self.roiClicked) self.roi.sigRegionChanged.connect(self.roiChanged) - self.ui.normBtn.toggled.connect(self.normToggled) + #self.ui.normBtn.toggled.connect(self.normToggled) + self.ui.menuBtn.clicked.connect(self.menuClicked) self.ui.normDivideRadio.clicked.connect(self.normRadioChanged) self.ui.normSubtractRadio.clicked.connect(self.normRadioChanged) self.ui.normOffRadio.clicked.connect(self.normRadioChanged) @@ -160,6 +192,7 @@ class ImageView(QtGui.QWidget): self.normRoi.sigRegionChangeFinished.connect(self.updateNorm) self.ui.roiPlot.registerPlot(self.name + '_ROI') + self.view.register(self.name) self.noRepeatKeys = [QtCore.Qt.Key_Right, QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown] @@ -190,14 +223,20 @@ class ImageView(QtGui.QWidget): image data. ================== ======================================================================= """ - prof = debug.Profiler('ImageView.setImage', disabled=True) + profiler = debug.Profiler() if hasattr(img, 'implements') and img.implements('MetaArray'): img = img.asarray() if not isinstance(img, np.ndarray): - raise Exception("Image must be specified as ndarray.") + required = ['dtype', 'max', 'min', 'ndim', 'shape', 'size'] + if not all([hasattr(img, attr) for attr in required]): + raise TypeError("Image must be NumPy array or any object " + "that provides compatible attributes/methods:\n" + " %s" % str(required)) + self.image = img + self.imageDisp = None if xvals is not None: self.tVals = xvals @@ -209,7 +248,7 @@ class ImageView(QtGui.QWidget): else: self.tVals = np.arange(img.shape[0]) - prof.mark('1') + profiler() if axes is None: if img.ndim == 2: @@ -234,13 +273,9 @@ class ImageView(QtGui.QWidget): for x in ['t', 'x', 'y', 'c']: self.axes[x] = self.axes.get(x, None) - prof.mark('2') - - self.imageDisp = None - - - prof.mark('3') - + + profiler() + self.currentIndex = 0 self.updateImage(autoHistogramRange=autoHistogramRange) if levels is None and autoLevels: @@ -250,9 +285,9 @@ class ImageView(QtGui.QWidget): if self.ui.roiBtn.isChecked(): self.roiChanged() - prof.mark('4') - - + + profiler() + if self.axes['t'] is not None: #self.ui.roiPlot.show() self.ui.roiPlot.setXRange(self.tVals.min(), self.tVals.max()) @@ -271,8 +306,8 @@ class ImageView(QtGui.QWidget): s.setBounds([start, stop]) #else: #self.ui.roiPlot.hide() - prof.mark('5') - + profiler() + self.imageItem.resetTransform() if scale is not None: self.imageItem.scale(*scale) @@ -280,14 +315,18 @@ class ImageView(QtGui.QWidget): self.imageItem.setPos(*pos) if transform is not None: self.imageItem.setTransform(transform) - prof.mark('6') - + + profiler() + if autoRange: self.autoRange() self.roiClicked() - prof.mark('7') - prof.finish() + profiler() + + def clear(self): + self.image = None + self.imageItem.clear() def play(self, rate): """Begin automatically stepping frames forward at the given rate (in fps). @@ -311,7 +350,7 @@ class ImageView(QtGui.QWidget): self.ui.histogram.setLevels(min, max) def autoRange(self): - """Auto scale and pan the view around the image.""" + """Auto scale and pan the view around the image such that the image fills the view.""" image = self.getProcessedImage() self.view.autoRange() @@ -322,11 +361,10 @@ class ImageView(QtGui.QWidget): if self.imageDisp is None: image = self.normalize(self.image) self.imageDisp = image - self.levelMin, self.levelMax = list(map(float, ImageView.quickMinMax(self.imageDisp))) + self.levelMin, self.levelMax = list(map(float, self.quickMinMax(self.imageDisp))) return self.imageDisp - def close(self): """Closes the widget nicely, making sure to clear the graphics scene and release memory.""" self.ui.roiPlot.close() @@ -378,7 +416,6 @@ class ImageView(QtGui.QWidget): else: QtGui.QWidget.keyReleaseEvent(self, ev) - def evalKeyState(self): if len(self.keysPressed) == 1: key = list(self.keysPressed.keys())[0] @@ -402,16 +439,13 @@ class ImageView(QtGui.QWidget): else: self.play(0) - def timeout(self): now = ptime.time() dt = now - self.lastPlayTime if dt < 0: return n = int(self.playRate * dt) - #print n, dt if n != 0: - #print n, dt, self.lastPlayTime self.lastPlayTime += (float(n)/self.playRate) if self.currentIndex+n > self.image.shape[0]: self.play(0) @@ -436,17 +470,14 @@ class ImageView(QtGui.QWidget): self.autoLevels() self.roiChanged() self.sigProcessingChanged.emit(self) - def updateNorm(self): if self.ui.normTimeRangeCheck.isChecked(): - #print "show!" self.normRgn.show() else: self.normRgn.hide() if self.ui.normROICheck.isChecked(): - #print "show!" self.normRoi.show() else: self.normRoi.hide() @@ -522,21 +553,25 @@ class ImageView(QtGui.QWidget): coords = coords - coords[:,0,np.newaxis] xvals = (coords**2).sum(axis=0) ** 0.5 self.roiCurve.setData(y=data, x=xvals) - - #self.ui.roiPlot.replot() - - @staticmethod - def quickMinMax(data): + def quickMinMax(self, data): + """ + Estimate the min/max values of *data* by subsampling. + """ while data.size > 1e6: ax = np.argmax(data.shape) sl = [slice(None)] * data.ndim sl[ax] = slice(None, None, 2) data = data[sl] - return data.min(), data.max() + return nanmin(data), nanmax(data) def normalize(self, image): + """ + Process *image* using the normalization options configured in the + control panel. + This can be repurposed to process any data through the same filter. + """ if self.ui.normOffRadio.isChecked(): return image @@ -643,3 +678,43 @@ class ImageView(QtGui.QWidget): def getHistogramWidget(self): """Return the HistogramLUTWidget for this ImageView""" return self.ui.histogram + + def export(self, fileName): + """ + Export data from the ImageView to a file, or to a stack of files if + the data is 3D. Saving an image stack will result in index numbers + being added to the file name. Images are saved as they would appear + onscreen, with levels and lookup table applied. + """ + img = self.getProcessedImage() + if self.hasTimeAxis(): + base, ext = os.path.splitext(fileName) + fmt = "%%s%%0%dd%%s" % int(np.log10(img.shape[0])+1) + for i in range(img.shape[0]): + self.imageItem.setImage(img[i], autoLevels=False) + self.imageItem.save(fmt % (base, i, ext)) + self.updateImage() + else: + self.imageItem.save(fileName) + + def exportClicked(self): + fileName = QtGui.QFileDialog.getSaveFileName() + if fileName == '': + return + self.export(fileName) + + def buildMenu(self): + self.menu = QtGui.QMenu() + self.normAction = QtGui.QAction("Normalization", self.menu) + self.normAction.setCheckable(True) + self.normAction.toggled.connect(self.normToggled) + self.menu.addAction(self.normAction) + self.exportAction = QtGui.QAction("Export", self.menu) + self.exportAction.triggered.connect(self.exportClicked) + self.menu.addAction(self.exportAction) + + def menuClicked(self): + if self.menu is None: + self.buildMenu() + self.menu.popup(QtGui.QCursor.pos()) + diff --git a/pyqtgraph/imageview/ImageViewTemplate.ui b/pyqtgraph/imageview/ImageViewTemplate.ui index 497c0c59..927bda30 100644 --- a/pyqtgraph/imageview/ImageViewTemplate.ui +++ b/pyqtgraph/imageview/ImageViewTemplate.ui @@ -53,7 +53,7 @@
- + 0 @@ -61,10 +61,7 @@ - Norm - - - true + Menu @@ -233,18 +230,18 @@ PlotWidget QWidget -
pyqtgraph.widgets.PlotWidget
+
..widgets.PlotWidget
1
GraphicsView QGraphicsView -
pyqtgraph.widgets.GraphicsView
+
..widgets.GraphicsView
HistogramLUTWidget QGraphicsView -
pyqtgraph.widgets.HistogramLUTWidget
+
..widgets.HistogramLUTWidget
diff --git a/pyqtgraph/imageview/ImageViewTemplate_pyqt.py b/pyqtgraph/imageview/ImageViewTemplate_pyqt.py index e6423276..e728b265 100644 --- a/pyqtgraph/imageview/ImageViewTemplate_pyqt.py +++ b/pyqtgraph/imageview/ImageViewTemplate_pyqt.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './imageview/ImageViewTemplate.ui' +# Form implementation generated from reading ui file 'ImageViewTemplate.ui' # -# Created: Sun Sep 9 14:41:30 2012 -# by: PyQt4 UI code generator 4.9.1 +# Created: Thu May 1 15:20:40 2014 +# by: PyQt4 UI code generator 4.10.4 # # WARNING! All changes made in this file will be lost! @@ -12,7 +12,16 @@ from PyQt4 import QtCore, QtGui try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_Form(object): def setupUi(self, Form): @@ -46,15 +55,14 @@ class Ui_Form(object): self.roiBtn.setCheckable(True) self.roiBtn.setObjectName(_fromUtf8("roiBtn")) self.gridLayout.addWidget(self.roiBtn, 1, 1, 1, 1) - self.normBtn = QtGui.QPushButton(self.layoutWidget) + self.menuBtn = QtGui.QPushButton(self.layoutWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.normBtn.sizePolicy().hasHeightForWidth()) - self.normBtn.setSizePolicy(sizePolicy) - self.normBtn.setCheckable(True) - self.normBtn.setObjectName(_fromUtf8("normBtn")) - self.gridLayout.addWidget(self.normBtn, 1, 2, 1, 1) + sizePolicy.setHeightForWidth(self.menuBtn.sizePolicy().hasHeightForWidth()) + self.menuBtn.setSizePolicy(sizePolicy) + self.menuBtn.setObjectName(_fromUtf8("menuBtn")) + self.gridLayout.addWidget(self.menuBtn, 1, 2, 1, 1) self.roiPlot = PlotWidget(self.splitter) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) @@ -138,23 +146,23 @@ class Ui_Form(object): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) - self.roiBtn.setText(QtGui.QApplication.translate("Form", "ROI", None, QtGui.QApplication.UnicodeUTF8)) - self.normBtn.setText(QtGui.QApplication.translate("Form", "Norm", None, QtGui.QApplication.UnicodeUTF8)) - self.normGroup.setTitle(QtGui.QApplication.translate("Form", "Normalization", None, QtGui.QApplication.UnicodeUTF8)) - self.normSubtractRadio.setText(QtGui.QApplication.translate("Form", "Subtract", None, QtGui.QApplication.UnicodeUTF8)) - self.normDivideRadio.setText(QtGui.QApplication.translate("Form", "Divide", None, QtGui.QApplication.UnicodeUTF8)) - self.label_5.setText(QtGui.QApplication.translate("Form", "Operation:", None, QtGui.QApplication.UnicodeUTF8)) - self.label_3.setText(QtGui.QApplication.translate("Form", "Mean:", None, QtGui.QApplication.UnicodeUTF8)) - self.label_4.setText(QtGui.QApplication.translate("Form", "Blur:", None, QtGui.QApplication.UnicodeUTF8)) - self.normROICheck.setText(QtGui.QApplication.translate("Form", "ROI", None, QtGui.QApplication.UnicodeUTF8)) - self.label_8.setText(QtGui.QApplication.translate("Form", "X", None, QtGui.QApplication.UnicodeUTF8)) - self.label_9.setText(QtGui.QApplication.translate("Form", "Y", None, QtGui.QApplication.UnicodeUTF8)) - self.label_10.setText(QtGui.QApplication.translate("Form", "T", None, QtGui.QApplication.UnicodeUTF8)) - self.normOffRadio.setText(QtGui.QApplication.translate("Form", "Off", None, QtGui.QApplication.UnicodeUTF8)) - self.normTimeRangeCheck.setText(QtGui.QApplication.translate("Form", "Time range", None, QtGui.QApplication.UnicodeUTF8)) - self.normFrameCheck.setText(QtGui.QApplication.translate("Form", "Frame", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(_translate("Form", "Form", None)) + self.roiBtn.setText(_translate("Form", "ROI", None)) + self.menuBtn.setText(_translate("Form", "Menu", None)) + self.normGroup.setTitle(_translate("Form", "Normalization", None)) + self.normSubtractRadio.setText(_translate("Form", "Subtract", None)) + self.normDivideRadio.setText(_translate("Form", "Divide", None)) + self.label_5.setText(_translate("Form", "Operation:", None)) + self.label_3.setText(_translate("Form", "Mean:", None)) + self.label_4.setText(_translate("Form", "Blur:", None)) + self.normROICheck.setText(_translate("Form", "ROI", None)) + self.label_8.setText(_translate("Form", "X", None)) + self.label_9.setText(_translate("Form", "Y", None)) + self.label_10.setText(_translate("Form", "T", None)) + self.normOffRadio.setText(_translate("Form", "Off", None)) + self.normTimeRangeCheck.setText(_translate("Form", "Time range", None)) + self.normFrameCheck.setText(_translate("Form", "Frame", None)) -from pyqtgraph.widgets.GraphicsView import GraphicsView -from pyqtgraph.widgets.PlotWidget import PlotWidget -from pyqtgraph.widgets.HistogramLUTWidget import HistogramLUTWidget +from ..widgets.HistogramLUTWidget import HistogramLUTWidget +from ..widgets.GraphicsView import GraphicsView +from ..widgets.PlotWidget import PlotWidget diff --git a/pyqtgraph/imageview/ImageViewTemplate_pyside.py b/pyqtgraph/imageview/ImageViewTemplate_pyside.py index c17bbfe1..6d6c9632 100644 --- a/pyqtgraph/imageview/ImageViewTemplate_pyside.py +++ b/pyqtgraph/imageview/ImageViewTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './imageview/ImageViewTemplate.ui' +# Form implementation generated from reading ui file 'ImageViewTemplate.ui' # -# Created: Sun Sep 9 14:41:31 2012 -# by: pyside-uic 0.2.13 running on PySide 1.1.0 +# Created: Thu May 1 15:20:42 2014 +# by: pyside-uic 0.2.15 running on PySide 1.2.1 # # WARNING! All changes made in this file will be lost! @@ -41,15 +41,14 @@ class Ui_Form(object): self.roiBtn.setCheckable(True) self.roiBtn.setObjectName("roiBtn") self.gridLayout.addWidget(self.roiBtn, 1, 1, 1, 1) - self.normBtn = QtGui.QPushButton(self.layoutWidget) + self.menuBtn = QtGui.QPushButton(self.layoutWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.normBtn.sizePolicy().hasHeightForWidth()) - self.normBtn.setSizePolicy(sizePolicy) - self.normBtn.setCheckable(True) - self.normBtn.setObjectName("normBtn") - self.gridLayout.addWidget(self.normBtn, 1, 2, 1, 1) + sizePolicy.setHeightForWidth(self.menuBtn.sizePolicy().hasHeightForWidth()) + self.menuBtn.setSizePolicy(sizePolicy) + self.menuBtn.setObjectName("menuBtn") + self.gridLayout.addWidget(self.menuBtn, 1, 2, 1, 1) self.roiPlot = PlotWidget(self.splitter) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) @@ -135,7 +134,7 @@ class Ui_Form(object): def retranslateUi(self, Form): Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) self.roiBtn.setText(QtGui.QApplication.translate("Form", "ROI", None, QtGui.QApplication.UnicodeUTF8)) - self.normBtn.setText(QtGui.QApplication.translate("Form", "Norm", None, QtGui.QApplication.UnicodeUTF8)) + self.menuBtn.setText(QtGui.QApplication.translate("Form", "Menu", None, QtGui.QApplication.UnicodeUTF8)) self.normGroup.setTitle(QtGui.QApplication.translate("Form", "Normalization", None, QtGui.QApplication.UnicodeUTF8)) self.normSubtractRadio.setText(QtGui.QApplication.translate("Form", "Subtract", None, QtGui.QApplication.UnicodeUTF8)) self.normDivideRadio.setText(QtGui.QApplication.translate("Form", "Divide", None, QtGui.QApplication.UnicodeUTF8)) @@ -150,6 +149,6 @@ class Ui_Form(object): self.normTimeRangeCheck.setText(QtGui.QApplication.translate("Form", "Time range", None, QtGui.QApplication.UnicodeUTF8)) self.normFrameCheck.setText(QtGui.QApplication.translate("Form", "Frame", None, QtGui.QApplication.UnicodeUTF8)) -from pyqtgraph.widgets.GraphicsView import GraphicsView -from pyqtgraph.widgets.PlotWidget import PlotWidget -from pyqtgraph.widgets.HistogramLUTWidget import HistogramLUTWidget +from ..widgets.HistogramLUTWidget import HistogramLUTWidget +from ..widgets.GraphicsView import GraphicsView +from ..widgets.PlotWidget import PlotWidget diff --git a/pyqtgraph/imageview/tests/test_imageview.py b/pyqtgraph/imageview/tests/test_imageview.py new file mode 100644 index 00000000..2ca1712c --- /dev/null +++ b/pyqtgraph/imageview/tests/test_imageview.py @@ -0,0 +1,11 @@ +import pyqtgraph as pg +import numpy as np + +app = pg.mkQApp() + +def test_nan_image(): + img = np.ones((10,10)) + img[0,0] = np.nan + v = pg.image(img) + app.processEvents() + v.window().close() diff --git a/pyqtgraph/metaarray/MetaArray.py b/pyqtgraph/metaarray/MetaArray.py index f55c60dc..9c3f5b8a 100644 --- a/pyqtgraph/metaarray/MetaArray.py +++ b/pyqtgraph/metaarray/MetaArray.py @@ -103,6 +103,14 @@ class MetaArray(object): """ version = '2' + + # Default hdf5 compression to use when writing + # 'gzip' is widely available and somewhat slow + # 'lzf' is faster, but generally not available outside h5py + # 'szip' is also faster, but lacks write support on windows + # (so by default, we use no compression) + # May also be a tuple (filter, opts), such as ('gzip', 3) + defaultCompression = None ## Types allowed as axis or column names nameTypes = [basestring, tuple] @@ -122,7 +130,7 @@ class MetaArray(object): if file is not None: self._data = None self.readFile(file, **kwargs) - if self._data is None: + if kwargs.get("readAllData", True) and self._data is None: raise Exception("File read failed: %s" % file) else: self._info = info @@ -720,25 +728,28 @@ class MetaArray(object): """ ## decide which read function to use - fd = open(filename, 'rb') - magic = fd.read(8) - if magic == '\x89HDF\r\n\x1a\n': - fd.close() - self._readHDF5(filename, **kwargs) - self._isHDF = True - else: - fd.seek(0) - meta = MetaArray._readMeta(fd) - if 'version' in meta: - ver = meta['version'] + with open(filename, 'rb') as fd: + magic = fd.read(8) + if magic == '\x89HDF\r\n\x1a\n': + fd.close() + self._readHDF5(filename, **kwargs) + self._isHDF = True else: - ver = 1 - rFuncName = '_readData%s' % str(ver) - if not hasattr(MetaArray, rFuncName): - raise Exception("This MetaArray library does not support array version '%s'" % ver) - rFunc = getattr(self, rFuncName) - rFunc(fd, meta, **kwargs) - self._isHDF = False + fd.seek(0) + meta = MetaArray._readMeta(fd) + + if not kwargs.get("readAllData", True): + self._data = np.empty(meta['shape'], dtype=meta['type']) + if 'version' in meta: + ver = meta['version'] + else: + ver = 1 + rFuncName = '_readData%s' % str(ver) + if not hasattr(MetaArray, rFuncName): + raise Exception("This MetaArray library does not support array version '%s'" % ver) + rFunc = getattr(self, rFuncName) + rFunc(fd, meta, **kwargs) + self._isHDF = False @staticmethod def _readMeta(fd): @@ -756,7 +767,7 @@ class MetaArray(object): #print ret return ret - def _readData1(self, fd, meta, mmap=False): + def _readData1(self, fd, meta, mmap=False, **kwds): ## Read array data from the file descriptor for MetaArray v1 files ## read in axis values for any axis that specifies a length frameSize = 1 @@ -766,16 +777,18 @@ class MetaArray(object): frameSize *= ax['values_len'] del ax['values_len'] del ax['values_type'] + self._info = meta['info'] + if not kwds.get("readAllData", True): + return ## the remaining data is the actual array if mmap: subarr = np.memmap(fd, dtype=meta['type'], mode='r', shape=meta['shape']) else: subarr = np.fromstring(fd.read(), dtype=meta['type']) subarr.shape = meta['shape'] - self._info = meta['info'] self._data = subarr - def _readData2(self, fd, meta, mmap=False, subset=None): + def _readData2(self, fd, meta, mmap=False, subset=None, **kwds): ## read in axis values dynAxis = None frameSize = 1 @@ -792,7 +805,10 @@ class MetaArray(object): frameSize *= ax['values_len'] del ax['values_len'] del ax['values_type'] - + self._info = meta['info'] + if not kwds.get("readAllData", True): + return + ## No axes are dynamic, just read the entire array in at once if dynAxis is None: #if rewriteDynamic is not None: @@ -929,7 +945,7 @@ class MetaArray(object): if proc == False: raise Exception('remote read failed') if proc == None: - import pyqtgraph.multiprocess as mp + from .. import multiprocess as mp #print "new process" proc = mp.Process(executable='/usr/bin/python') proc.setProxyOptions(deferGetattr=True) @@ -1027,10 +1043,18 @@ class MetaArray(object): def writeHDF5(self, fileName, **opts): ## default options for writing datasets + comp = self.defaultCompression + if isinstance(comp, tuple): + comp, copts = comp + else: + copts = None + dsOpts = { - 'compression': 'lzf', + 'compression': comp, 'chunks': True, } + if copts is not None: + dsOpts['compression_opts'] = copts ## if there is an appendable axis, then we can guess the desired chunk shape (optimized for appending) appAxis = opts.get('appendAxis', None) @@ -1471,4 +1495,4 @@ if __name__ == '__main__': ma2 = MetaArray(file=tf, mmap=True) print("\nArrays are equivalent:", (ma == ma2).all()) os.remove(tf) - \ No newline at end of file + diff --git a/pyqtgraph/multiprocess/parallelizer.py b/pyqtgraph/multiprocess/parallelizer.py index e96692e2..f4ddd95c 100644 --- a/pyqtgraph/multiprocess/parallelizer.py +++ b/pyqtgraph/multiprocess/parallelizer.py @@ -40,7 +40,7 @@ class Parallelize(object): def __init__(self, tasks=None, workers=None, block=True, progressDialog=None, randomReseed=True, **kwds): """ =============== =================================================================== - Arguments: + **Arguments:** tasks list of objects to be processed (Parallelize will determine how to distribute the tasks). If unspecified, then each worker will receive a single task with a unique id number. @@ -63,8 +63,8 @@ class Parallelize(object): self.showProgress = True if isinstance(progressDialog, basestring): progressDialog = {'labelText': progressDialog} - import pyqtgraph as pg - self.progressDlg = pg.ProgressDialog(**progressDialog) + from ..widgets.ProgressDialog import ProgressDialog + self.progressDlg = ProgressDialog(**progressDialog) if workers is None: workers = self.suggestedWorkerCount() diff --git a/pyqtgraph/multiprocess/processes.py b/pyqtgraph/multiprocess/processes.py index 4d32c999..0dfb80b9 100644 --- a/pyqtgraph/multiprocess/processes.py +++ b/pyqtgraph/multiprocess/processes.py @@ -1,12 +1,15 @@ -from .remoteproxy import RemoteEventHandler, ClosedError, NoResultError, LocalObjectProxy, ObjectProxy import subprocess, atexit, os, sys, time, random, socket, signal import multiprocessing.connection -import pyqtgraph as pg try: import cPickle as pickle except ImportError: import pickle +from .remoteproxy import RemoteEventHandler, ClosedError, NoResultError, LocalObjectProxy, ObjectProxy +from ..Qt import USE_PYSIDE +from ..util import cprint # color printing for debugging + + __all__ = ['Process', 'QtProcess', 'ForkedProcess', 'ClosedError', 'NoResultError'] class Process(RemoteEventHandler): @@ -34,28 +37,29 @@ class Process(RemoteEventHandler): return objects either by proxy or by value (if they are picklable). See ProxyObject for more information. """ - + _process_count = 1 # just used for assigning colors to each process for debugging + def __init__(self, name=None, target=None, executable=None, copySysPath=True, debug=False, timeout=20, wrapStdout=None): """ - ============ ============================================================= - Arguments: - name Optional name for this process used when printing messages - from the remote process. - target Optional function to call after starting remote process. - By default, this is startEventLoop(), which causes the remote - process to process requests from the parent process until it - is asked to quit. If you wish to specify a different target, - it must be picklable (bound methods are not). - copySysPath If True, copy the contents of sys.path to the remote process - debug If True, print detailed information about communication - with the child process. - wrapStdout If True (default on windows) then stdout and stderr from the - child process will be caught by the parent process and - forwarded to its stdout/stderr. This provides a workaround - for a python bug: http://bugs.python.org/issue3905 - but has the side effect that child output is significantly - delayed relative to the parent output. - ============ ============================================================= + ============== ============================================================= + **Arguments:** + name Optional name for this process used when printing messages + from the remote process. + target Optional function to call after starting remote process. + By default, this is startEventLoop(), which causes the remote + process to process requests from the parent process until it + is asked to quit. If you wish to specify a different target, + it must be picklable (bound methods are not). + copySysPath If True, copy the contents of sys.path to the remote process + debug If True, print detailed information about communication + with the child process. + wrapStdout If True (default on windows) then stdout and stderr from the + child process will be caught by the parent process and + forwarded to its stdout/stderr. This provides a workaround + for a python bug: http://bugs.python.org/issue3905 + but has the side effect that child output is significantly + delayed relative to the parent output. + ============== ============================================================= """ if target is None: target = startEventLoop @@ -63,7 +67,7 @@ class Process(RemoteEventHandler): name = str(self) if executable is None: executable = sys.executable - self.debug = debug + self.debug = 7 if debug is True else False # 7 causes printing in white ## random authentication key authkey = os.urandom(20) @@ -74,21 +78,20 @@ class Process(RemoteEventHandler): #print "key:", ' '.join([str(ord(x)) for x in authkey]) ## Listen for connection from remote process (and find free port number) - port = 10000 - while True: - try: - l = multiprocessing.connection.Listener(('localhost', int(port)), authkey=authkey) - break - except socket.error as ex: - if ex.errno != 98 and ex.errno != 10048: # unix=98, win=10048 - raise - port += 1 - + l = multiprocessing.connection.Listener(('localhost', 0), authkey=authkey) + port = l.address[1] ## start remote process, instruct it to run target function sysPath = sys.path if copySysPath else None bootstrap = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bootstrap.py')) self.debugMsg('Starting child process (%s %s)' % (executable, bootstrap)) + + # Decide on printing color for this process + if debug: + procDebug = (Process._process_count%6) + 1 # pick a color for this process to print in + Process._process_count += 1 + else: + procDebug = False if wrapStdout is None: wrapStdout = sys.platform.startswith('win') @@ -101,8 +104,8 @@ class Process(RemoteEventHandler): self.proc = subprocess.Popen((executable, bootstrap), stdin=subprocess.PIPE, stdout=stdout, stderr=stderr) ## to circumvent the bug and still make the output visible, we use ## background threads to pass data from pipes to stdout/stderr - self._stdoutForwarder = FileForwarder(self.proc.stdout, "stdout") - self._stderrForwarder = FileForwarder(self.proc.stderr, "stderr") + self._stdoutForwarder = FileForwarder(self.proc.stdout, "stdout", procDebug) + self._stderrForwarder = FileForwarder(self.proc.stderr, "stderr", procDebug) else: self.proc = subprocess.Popen((executable, bootstrap), stdin=subprocess.PIPE) @@ -118,8 +121,8 @@ class Process(RemoteEventHandler): ppid=pid, targetStr=targetStr, path=sysPath, - pyside=pg.Qt.USE_PYSIDE, - debug=debug + pyside=USE_PYSIDE, + debug=procDebug ) pickle.dump(data, self.proc.stdin) self.proc.stdin.close() @@ -135,8 +138,8 @@ class Process(RemoteEventHandler): continue else: raise - - RemoteEventHandler.__init__(self, conn, name+'_parent', pid=self.proc.pid, debug=debug) + + RemoteEventHandler.__init__(self, conn, name+'_parent', pid=self.proc.pid, debug=self.debug) self.debugMsg('Connected to child process.') atexit.register(self.join) @@ -166,10 +169,11 @@ class Process(RemoteEventHandler): def startEventLoop(name, port, authkey, ppid, debug=False): if debug: import os - print('[%d] connecting to server at port localhost:%d, authkey=%s..' % (os.getpid(), port, repr(authkey))) + cprint.cout(debug, '[%d] connecting to server at port localhost:%d, authkey=%s..\n' + % (os.getpid(), port, repr(authkey)), -1) conn = multiprocessing.connection.Client(('localhost', int(port)), authkey=authkey) if debug: - print('[%d] connected; starting remote proxy.' % os.getpid()) + cprint.cout(debug, '[%d] connected; starting remote proxy.\n' % os.getpid(), -1) global HANDLER #ppid = 0 if not hasattr(os, 'getppid') else os.getppid() HANDLER = RemoteEventHandler(conn, name, ppid, debug=debug) @@ -337,7 +341,7 @@ class RemoteQtEventHandler(RemoteEventHandler): RemoteEventHandler.__init__(self, *args, **kwds) def startEventTimer(self): - from pyqtgraph.Qt import QtGui, QtCore + from ..Qt import QtGui, QtCore self.timer = QtCore.QTimer() self.timer.timeout.connect(self.processRequests) self.timer.start(10) @@ -346,7 +350,7 @@ class RemoteQtEventHandler(RemoteEventHandler): try: RemoteEventHandler.processRequests(self) except ClosedError: - from pyqtgraph.Qt import QtGui, QtCore + from ..Qt import QtGui, QtCore QtGui.QApplication.instance().quit() self.timer.stop() #raise SystemExit @@ -379,17 +383,17 @@ class QtProcess(Process): def __init__(self, **kwds): if 'target' not in kwds: kwds['target'] = startQtEventLoop + from ..Qt import QtGui ## avoid module-level import to keep bootstrap snappy. self._processRequests = kwds.pop('processRequests', True) + if self._processRequests and QtGui.QApplication.instance() is None: + raise Exception("Must create QApplication before starting QtProcess, or use QtProcess(processRequests=False)") Process.__init__(self, **kwds) self.startEventTimer() def startEventTimer(self): - from pyqtgraph.Qt import QtGui, QtCore ## avoid module-level import to keep bootstrap snappy. + from ..Qt import QtCore ## avoid module-level import to keep bootstrap snappy. self.timer = QtCore.QTimer() if self._processRequests: - app = QtGui.QApplication.instance() - if app is None: - raise Exception("Must create QApplication before starting QtProcess, or use QtProcess(processRequests=False)") self.startRequestProcessing() def startRequestProcessing(self, interval=0.01): @@ -411,11 +415,11 @@ class QtProcess(Process): def startQtEventLoop(name, port, authkey, ppid, debug=False): if debug: import os - print('[%d] connecting to server at port localhost:%d, authkey=%s..' % (os.getpid(), port, repr(authkey))) + cprint.cout(debug, '[%d] connecting to server at port localhost:%d, authkey=%s..\n' % (os.getpid(), port, repr(authkey)), -1) conn = multiprocessing.connection.Client(('localhost', int(port)), authkey=authkey) if debug: - print('[%d] connected; starting remote proxy.' % os.getpid()) - from pyqtgraph.Qt import QtGui, QtCore + cprint.cout(debug, '[%d] connected; starting remote proxy.\n' % os.getpid(), -1) + from ..Qt import QtGui, QtCore #from PyQt4 import QtGui, QtCore app = QtGui.QApplication.instance() #print app @@ -444,11 +448,13 @@ class FileForwarder(threading.Thread): which ensures that the correct behavior is achieved even if sys.stdout/stderr are replaced at runtime. """ - def __init__(self, input, output): + def __init__(self, input, output, color): threading.Thread.__init__(self) self.input = input self.output = output self.lock = threading.Lock() + self.daemon = True + self.color = color self.start() def run(self): @@ -456,12 +462,12 @@ class FileForwarder(threading.Thread): while True: line = self.input.readline() with self.lock: - sys.stdout.write(line) + cprint.cout(self.color, line, -1) elif self.output == 'stderr': while True: line = self.input.readline() with self.lock: - sys.stderr.write(line) + cprint.cerr(self.color, line, -1) else: while True: line = self.input.readline() diff --git a/pyqtgraph/multiprocess/remoteproxy.py b/pyqtgraph/multiprocess/remoteproxy.py index eba42ef3..4f484b74 100644 --- a/pyqtgraph/multiprocess/remoteproxy.py +++ b/pyqtgraph/multiprocess/remoteproxy.py @@ -1,5 +1,6 @@ import os, time, sys, traceback, weakref import numpy as np +import threading try: import __builtin__ as builtins import cPickle as pickle @@ -7,6 +8,9 @@ except ImportError: import builtins import pickle +# color printing for debugging +from ..util import cprint + class ClosedError(Exception): """Raised when an event handler receives a request to close the connection or discovers that the connection has been closed.""" @@ -50,8 +54,10 @@ class RemoteEventHandler(object): ## status is either 'result' or 'error' ## if 'error', then result will be (exception, formatted exceprion) ## where exception may be None if it could not be passed through the Connection. + self.resultLock = threading.RLock() self.proxies = {} ## maps {weakref(proxy): proxyId}; used to inform the remote process when a proxy has been deleted. + self.proxyLock = threading.RLock() ## attributes that affect the behavior of the proxy. ## See ObjectProxy._setProxyOptions for description @@ -63,10 +69,15 @@ class RemoteEventHandler(object): 'deferGetattr': False, ## True, False 'noProxyTypes': [ type(None), str, int, float, tuple, list, dict, LocalObjectProxy, ObjectProxy ], } + self.optsLock = threading.RLock() self.nextRequestId = 0 self.exited = False + # Mutexes to help prevent issues when multiple threads access the same RemoteEventHandler + self.processLock = threading.RLock() + self.sendLock = threading.RLock() + RemoteEventHandler.handlers[pid] = self ## register this handler as the one communicating with pid @classmethod @@ -80,49 +91,62 @@ class RemoteEventHandler(object): def debugMsg(self, msg): if not self.debug: return - print("[%d] %s" % (os.getpid(), str(msg))) + cprint.cout(self.debug, "[%d] %s\n" % (os.getpid(), str(msg)), -1) def getProxyOption(self, opt): - return self.proxyOptions[opt] + with self.optsLock: + return self.proxyOptions[opt] def setProxyOptions(self, **kwds): """ Set the default behavior options for object proxies. See ObjectProxy._setProxyOptions for more info. """ - self.proxyOptions.update(kwds) + with self.optsLock: + self.proxyOptions.update(kwds) def processRequests(self): """Process all pending requests from the pipe, return after no more events are immediately available. (non-blocking) Returns the number of events processed. """ - if self.exited: - self.debugMsg(' processRequests: exited already; raise ClosedError.') - raise ClosedError() - - numProcessed = 0 - while self.conn.poll(): - try: - self.handleRequest() - numProcessed += 1 - except ClosedError: - self.debugMsg('processRequests: got ClosedError from handleRequest; setting exited=True.') - self.exited = True - raise - #except IOError as err: ## let handleRequest take care of this. - #self.debugMsg(' got IOError from handleRequest; try again.') - #if err.errno == 4: ## interrupted system call; try again - #continue - #else: - #raise - except: - print("Error in process %s" % self.name) - sys.excepthook(*sys.exc_info()) - - if numProcessed > 0: - self.debugMsg('processRequests: finished %d requests' % numProcessed) - return numProcessed + with self.processLock: + + if self.exited: + self.debugMsg(' processRequests: exited already; raise ClosedError.') + raise ClosedError() + + numProcessed = 0 + + while self.conn.poll(): + #try: + #poll = self.conn.poll() + #if not poll: + #break + #except IOError: # this can happen if the remote process dies. + ## might it also happen in other circumstances? + #raise ClosedError() + + try: + self.handleRequest() + numProcessed += 1 + except ClosedError: + self.debugMsg('processRequests: got ClosedError from handleRequest; setting exited=True.') + self.exited = True + raise + #except IOError as err: ## let handleRequest take care of this. + #self.debugMsg(' got IOError from handleRequest; try again.') + #if err.errno == 4: ## interrupted system call; try again + #continue + #else: + #raise + except: + print("Error in process %s" % self.name) + sys.excepthook(*sys.exc_info()) + + if numProcessed > 0: + self.debugMsg('processRequests: finished %d requests' % numProcessed) + return numProcessed def handleRequest(self): """Handle a single request from the remote process. @@ -180,9 +204,11 @@ class RemoteEventHandler(object): returnType = opts.get('returnType', 'auto') if cmd == 'result': - self.results[resultId] = ('result', opts['result']) + with self.resultLock: + self.results[resultId] = ('result', opts['result']) elif cmd == 'error': - self.results[resultId] = ('error', (opts['exception'], opts['excString'])) + with self.resultLock: + self.results[resultId] = ('error', (opts['exception'], opts['excString'])) elif cmd == 'getObjAttr': result = getattr(opts['obj'], opts['attr']) elif cmd == 'callObj': @@ -256,7 +282,9 @@ class RemoteEventHandler(object): self.debugMsg(" handleRequest: sending return value for %d: %s" % (reqId, str(result))) #print "returnValue:", returnValue, result if returnType == 'auto': - result = self.autoProxy(result, self.proxyOptions['noProxyTypes']) + with self.optsLock: + noProxyTypes = self.proxyOptions['noProxyTypes'] + result = self.autoProxy(result, noProxyTypes) elif returnType == 'proxy': result = LocalObjectProxy(result) @@ -299,23 +327,23 @@ class RemoteEventHandler(object): (The docstring has information that is nevertheless useful to the programmer as it describes the internal protocol used to communicate between processes) - ========== ==================================================================== - Arguments: - request String describing the type of request being sent (see below) - reqId Integer uniquely linking a result back to the request that generated - it. (most requests leave this blank) - callSync 'sync': return the actual result of the request - 'async': return a Request object which can be used to look up the - result later - 'off': return no result - timeout Time in seconds to wait for a response when callSync=='sync' - opts Extra arguments sent to the remote process that determine the way - the request will be handled (see below) - returnType 'proxy', 'value', or 'auto' - byteData If specified, this is a list of objects to be sent as byte messages - to the remote process. - This is used to send large arrays without the cost of pickling. - ========== ==================================================================== + ============== ==================================================================== + **Arguments:** + request String describing the type of request being sent (see below) + reqId Integer uniquely linking a result back to the request that generated + it. (most requests leave this blank) + callSync 'sync': return the actual result of the request + 'async': return a Request object which can be used to look up the + result later + 'off': return no result + timeout Time in seconds to wait for a response when callSync=='sync' + opts Extra arguments sent to the remote process that determine the way + the request will be handled (see below) + returnType 'proxy', 'value', or 'auto' + byteData If specified, this is a list of objects to be sent as byte messages + to the remote process. + This is used to send large arrays without the cost of pickling. + ============== ==================================================================== Description of request strings and options allowed for each: @@ -375,54 +403,59 @@ class RemoteEventHandler(object): traceback ============= ===================================================================== """ - #if len(kwds) > 0: - #print "Warning: send() ignored args:", kwds + if self.exited: + self.debugMsg(' send: exited already; raise ClosedError.') + raise ClosedError() + + with self.sendLock: + #if len(kwds) > 0: + #print "Warning: send() ignored args:", kwds + + if opts is None: + opts = {} - if opts is None: - opts = {} - - assert callSync in ['off', 'sync', 'async'], 'callSync must be one of "off", "sync", or "async"' - if reqId is None: - if callSync != 'off': ## requested return value; use the next available request ID - reqId = self.nextRequestId - self.nextRequestId += 1 - else: - ## If requestId is provided, this _must_ be a response to a previously received request. - assert request in ['result', 'error'] - - if returnType is not None: - opts['returnType'] = returnType + assert callSync in ['off', 'sync', 'async'], 'callSync must be one of "off", "sync", or "async"' + if reqId is None: + if callSync != 'off': ## requested return value; use the next available request ID + reqId = self.nextRequestId + self.nextRequestId += 1 + else: + ## If requestId is provided, this _must_ be a response to a previously received request. + assert request in ['result', 'error'] - #print os.getpid(), "send request:", request, reqId, opts - - ## double-pickle args to ensure that at least status and request ID get through - try: - optStr = pickle.dumps(opts) - except: - print("==== Error pickling this object: ====") - print(opts) - print("=======================================") - raise - - nByteMsgs = 0 - if byteData is not None: - nByteMsgs = len(byteData) + if returnType is not None: + opts['returnType'] = returnType + + #print os.getpid(), "send request:", request, reqId, opts + + ## double-pickle args to ensure that at least status and request ID get through + try: + optStr = pickle.dumps(opts) + except: + print("==== Error pickling this object: ====") + print(opts) + print("=======================================") + raise + + nByteMsgs = 0 + if byteData is not None: + nByteMsgs = len(byteData) + + ## Send primary request + request = (request, reqId, nByteMsgs, optStr) + self.debugMsg('send request: cmd=%s nByteMsgs=%d id=%s opts=%s' % (str(request[0]), nByteMsgs, str(reqId), str(opts))) + self.conn.send(request) + + ## follow up by sending byte messages + if byteData is not None: + for obj in byteData: ## Remote process _must_ be prepared to read the same number of byte messages! + self.conn.send_bytes(obj) + self.debugMsg(' sent %d byte messages' % len(byteData)) + + self.debugMsg(' call sync: %s' % callSync) + if callSync == 'off': + return - ## Send primary request - request = (request, reqId, nByteMsgs, optStr) - self.debugMsg('send request: cmd=%s nByteMsgs=%d id=%s opts=%s' % (str(request[0]), nByteMsgs, str(reqId), str(opts))) - self.conn.send(request) - - ## follow up by sending byte messages - if byteData is not None: - for obj in byteData: ## Remote process _must_ be prepared to read the same number of byte messages! - self.conn.send_bytes(obj) - self.debugMsg(' sent %d byte messages' % len(byteData)) - - self.debugMsg(' call sync: %s' % callSync) - if callSync == 'off': - return - req = Request(self, reqId, description=str(request), timeout=timeout) if callSync == 'async': return req @@ -434,20 +467,30 @@ class RemoteEventHandler(object): return req def close(self, callSync='off', noCleanup=False, **kwds): - self.send(request='close', opts=dict(noCleanup=noCleanup), callSync=callSync, **kwds) + try: + self.send(request='close', opts=dict(noCleanup=noCleanup), callSync=callSync, **kwds) + self.exited = True + except ClosedError: + pass def getResult(self, reqId): ## raises NoResultError if the result is not available yet #print self.results.keys(), os.getpid() - if reqId not in self.results: + with self.resultLock: + haveResult = reqId in self.results + + if not haveResult: try: self.processRequests() except ClosedError: ## even if remote connection has closed, we may have ## received new data during this call to processRequests() pass - if reqId not in self.results: - raise NoResultError() - status, result = self.results.pop(reqId) + + with self.resultLock: + if reqId not in self.results: + raise NoResultError() + status, result = self.results.pop(reqId) + if status == 'result': return result elif status == 'error': @@ -491,11 +534,13 @@ class RemoteEventHandler(object): args = list(args) ## Decide whether to send arguments by value or by proxy - noProxyTypes = opts.pop('noProxyTypes', None) - if noProxyTypes is None: - noProxyTypes = self.proxyOptions['noProxyTypes'] - - autoProxy = opts.pop('autoProxy', self.proxyOptions['autoProxy']) + with self.optsLock: + noProxyTypes = opts.pop('noProxyTypes', None) + if noProxyTypes is None: + noProxyTypes = self.proxyOptions['noProxyTypes'] + + autoProxy = opts.pop('autoProxy', self.proxyOptions['autoProxy']) + if autoProxy is True: args = [self.autoProxy(v, noProxyTypes) for v in args] for k, v in kwds.iteritems(): @@ -517,11 +562,14 @@ class RemoteEventHandler(object): return self.send(request='callObj', opts=dict(obj=obj, args=args, kwds=kwds), byteData=byteMsgs, **opts) def registerProxy(self, proxy): - ref = weakref.ref(proxy, self.deleteProxy) - self.proxies[ref] = proxy._proxyId + with self.proxyLock: + ref = weakref.ref(proxy, self.deleteProxy) + self.proxies[ref] = proxy._proxyId def deleteProxy(self, ref): - proxyId = self.proxies.pop(ref) + with self.proxyLock: + proxyId = self.proxies.pop(ref) + try: self.send(request='del', opts=dict(proxyId=proxyId), callSync='off') except IOError: ## if remote process has closed down, there is no need to send delete requests anymore @@ -576,7 +624,7 @@ class Request(object): return self._result if timeout is None: - timeout = self.timeout + timeout = self.timeout if block: start = time.time() diff --git a/pyqtgraph/opengl/GLGraphicsItem.py b/pyqtgraph/opengl/GLGraphicsItem.py index 9680fba7..12c5b707 100644 --- a/pyqtgraph/opengl/GLGraphicsItem.py +++ b/pyqtgraph/opengl/GLGraphicsItem.py @@ -1,5 +1,5 @@ -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph import Transform3D +from ..Qt import QtGui, QtCore +from .. import Transform3D from OpenGL.GL import * from OpenGL import GL @@ -28,8 +28,13 @@ GLOptions = { class GLGraphicsItem(QtCore.QObject): + _nextId = 0 + def __init__(self, parentItem=None): QtCore.QObject.__init__(self) + self._id = GLGraphicsItem._nextId + GLGraphicsItem._nextId += 1 + self.__parent = None self.__view = None self.__children = set() diff --git a/pyqtgraph/opengl/GLViewWidget.py b/pyqtgraph/opengl/GLViewWidget.py index 89fef92e..992aa73e 100644 --- a/pyqtgraph/opengl/GLViewWidget.py +++ b/pyqtgraph/opengl/GLViewWidget.py @@ -1,12 +1,14 @@ -from pyqtgraph.Qt import QtCore, QtGui, QtOpenGL +from ..Qt import QtCore, QtGui, QtOpenGL from OpenGL.GL import * import OpenGL.GL.framebufferobjects as glfbo import numpy as np -from pyqtgraph import Vector -import pyqtgraph.functions as fn +from .. import Vector +from .. import functions as fn ##Vector = QtGui.QVector3D +ShareWidget = None + class GLViewWidget(QtOpenGL.QGLWidget): """ Basic widget for displaying 3D data @@ -16,14 +18,14 @@ class GLViewWidget(QtOpenGL.QGLWidget): """ - ShareWidget = None - def __init__(self, parent=None): - if GLViewWidget.ShareWidget is None: + global ShareWidget + + if ShareWidget is None: ## create a dummy widget to allow sharing objects (textures, shaders, etc) between views - GLViewWidget.ShareWidget = QtOpenGL.QGLWidget() + ShareWidget = QtOpenGL.QGLWidget() - QtOpenGL.QGLWidget.__init__(self, parent, GLViewWidget.ShareWidget) + QtOpenGL.QGLWidget.__init__(self, parent, ShareWidget) self.setFocusPolicy(QtCore.Qt.ClickFocus) @@ -36,6 +38,7 @@ class GLViewWidget(QtOpenGL.QGLWidget): ## (rotation around z-axis 0 points along x-axis) 'viewport': None, ## glViewport params; None == whole widget } + self.setBackgroundColor('k') self.items = [] self.noRepeatKeys = [QtCore.Qt.Key_Right, QtCore.Qt.Key_Left, QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, QtCore.Qt.Key_PageUp, QtCore.Qt.Key_PageDown] self.keysPressed = {} @@ -64,9 +67,16 @@ class GLViewWidget(QtOpenGL.QGLWidget): def initializeGL(self): - glClearColor(0.0, 0.0, 0.0, 0.0) self.resizeGL(self.width(), self.height()) + def setBackgroundColor(self, *args, **kwds): + """ + Set the background color of the widget. Accepts the same arguments as + pg.mkColor(). + """ + self.opts['bgcolor'] = fn.mkColor(*args, **kwds) + self.update() + def getViewport(self): vp = self.opts['viewport'] if vp is None: @@ -129,6 +139,12 @@ class GLViewWidget(QtOpenGL.QGLWidget): return tr def itemsAt(self, region=None): + """ + Return a list of the items displayed in the region (x, y, w, h) + relative to the widget. + """ + region = (region[0], self.height()-(region[1]+region[3]), region[2], region[3]) + #buf = np.zeros(100000, dtype=np.uint) buf = glSelectBuffer(100000) try: @@ -140,12 +156,11 @@ class GLViewWidget(QtOpenGL.QGLWidget): finally: hits = glRenderMode(GL_RENDER) - + items = [(h.near, h.names[0]) for h in hits] items.sort(key=lambda i: i[0]) - return [self._itemNames[i[1]] for i in items] - + def paintGL(self, region=None, viewport=None, useItemNames=False): """ viewport specifies the arguments to glViewport. If None, then we use self.opts['viewport'] @@ -158,6 +173,8 @@ class GLViewWidget(QtOpenGL.QGLWidget): glViewport(*viewport) self.setProjection(region=region) self.setModelview() + bgcolor = self.opts['bgcolor'] + glClearColor(bgcolor.red(), bgcolor.green(), bgcolor.blue(), 1.0) glClear( GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT ) self.drawItemTree(useItemNames=useItemNames) @@ -175,12 +192,12 @@ class GLViewWidget(QtOpenGL.QGLWidget): try: glPushAttrib(GL_ALL_ATTRIB_BITS) if useItemNames: - glLoadName(id(i)) - self._itemNames[id(i)] = i + glLoadName(i._id) + self._itemNames[i._id] = i i.paint() except: - import pyqtgraph.debug - pyqtgraph.debug.printExc() + from .. import debug + debug.printExc() msg = "Error while drawing item %s." % str(item) ver = glGetString(GL_VERSION) if ver is not None: @@ -294,6 +311,17 @@ class GLViewWidget(QtOpenGL.QGLWidget): def mouseReleaseEvent(self, ev): pass + # Example item selection code: + #region = (ev.pos().x()-5, ev.pos().y()-5, 10, 10) + #print(self.itemsAt(region)) + + ## debugging code: draw the picking region + #glViewport(*self.getViewport()) + #glClear( GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT ) + #region = (region[0], self.height()-(region[1]+region[3]), region[2], region[3]) + #self.paintGL(region=region) + #self.swapBuffers() + def wheelEvent(self, ev): if (ev.modifiers() & QtCore.Qt.ControlModifier): @@ -345,7 +373,7 @@ class GLViewWidget(QtOpenGL.QGLWidget): ## Only to be called from within exception handler. ver = glGetString(GL_VERSION).split()[0] if int(ver.split('.')[0]) < 2: - import pyqtgraph.debug + from .. import debug pyqtgraph.debug.printExc() raise Exception(msg + " The original exception is printed above; however, pyqtgraph requires OpenGL version 2.0 or greater for many of its 3D features and your OpenGL version is %s. Installing updated display drivers may resolve this issue." % ver) else: diff --git a/pyqtgraph/opengl/MeshData.py b/pyqtgraph/opengl/MeshData.py index 71e566c9..5adf4b64 100644 --- a/pyqtgraph/opengl/MeshData.py +++ b/pyqtgraph/opengl/MeshData.py @@ -1,5 +1,5 @@ -from pyqtgraph.Qt import QtGui -import pyqtgraph.functions as fn +from ..Qt import QtGui +from .. import functions as fn import numpy as np class MeshData(object): @@ -23,18 +23,18 @@ class MeshData(object): def __init__(self, vertexes=None, faces=None, edges=None, vertexColors=None, faceColors=None): """ - ============= ===================================================== - Arguments - vertexes (Nv, 3) array of vertex coordinates. - If faces is not specified, then this will instead be - interpreted as (Nf, 3, 3) array of coordinates. - faces (Nf, 3) array of indexes into the vertex array. - edges [not available yet] - vertexColors (Nv, 4) array of vertex colors. - If faces is not specified, then this will instead be - interpreted as (Nf, 3, 4) array of colors. - faceColors (Nf, 4) array of face colors. - ============= ===================================================== + ============== ===================================================== + **Arguments:** + vertexes (Nv, 3) array of vertex coordinates. + If faces is not specified, then this will instead be + interpreted as (Nf, 3, 3) array of coordinates. + faces (Nf, 3) array of indexes into the vertex array. + edges [not available yet] + vertexColors (Nv, 4) array of vertex colors. + If faces is not specified, then this will instead be + interpreted as (Nf, 3, 4) array of colors. + faceColors (Nf, 4) array of face colors. + ============== ===================================================== All arguments are optional. """ @@ -84,64 +84,11 @@ class MeshData(object): if faceColors is not None: self.setFaceColors(faceColors) - #self.setFaces(vertexes=vertexes, faces=faces, vertexColors=vertexColors, faceColors=faceColors) - - - #def setFaces(self, vertexes=None, faces=None, vertexColors=None, faceColors=None): - #""" - #Set the faces in this data set. - #Data may be provided either as an Nx3x3 array of floats (9 float coordinate values per face):: - - #faces = [ [(x, y, z), (x, y, z), (x, y, z)], ... ] - - #or as an Nx3 array of ints (vertex integers) AND an Mx3 array of floats (3 float coordinate values per vertex):: - - #faces = [ (p1, p2, p3), ... ] - #vertexes = [ (x, y, z), ... ] - - #""" - #if not isinstance(vertexes, np.ndarray): - #vertexes = np.array(vertexes) - #if vertexes.dtype != np.float: - #vertexes = vertexes.astype(float) - #if faces is None: - #self._setIndexedFaces(vertexes, vertexColors, faceColors) - #else: - #self._setUnindexedFaces(faces, vertexes, vertexColors, faceColors) - ##print self.vertexes().shape - ##print self.faces().shape - - - #def setMeshColor(self, color): - #"""Set the color of the entire mesh. This removes any per-face or per-vertex colors.""" - #color = fn.Color(color) - #self._meshColor = color.glColor() - #self._vertexColors = None - #self._faceColors = None - - - #def __iter__(self): - #"""Iterate over all faces, yielding a list of three tuples [(position, normal, color), ...] for each face.""" - #vnorms = self.vertexNormals() - #vcolors = self.vertexColors() - #for i in range(self._faces.shape[0]): - #face = [] - #for j in [0,1,2]: - #vind = self._faces[i,j] - #pos = self._vertexes[vind] - #norm = vnorms[vind] - #if vcolors is None: - #color = self._meshColor - #else: - #color = vcolors[vind] - #face.append((pos, norm, color)) - #yield face - - #def __len__(self): - #return len(self._faces) - def faces(self): - """Return an array (Nf, 3) of vertex indexes, three per triangular face in the mesh.""" + """Return an array (Nf, 3) of vertex indexes, three per triangular face in the mesh. + + If faces have not been computed for this mesh, the function returns None. + """ return self._faces def edges(self): @@ -161,8 +108,6 @@ class MeshData(object): self.resetNormals() self._vertexColorsIndexedByFaces = None self._faceColorsIndexedByFaces = None - - def vertexes(self, indexed=None): """Return an array (N,3) of the positions of vertexes in the mesh. @@ -207,7 +152,6 @@ class MeshData(object): self._vertexNormalsIndexedByFaces = None self._faceNormals = None self._faceNormalsIndexedByFaces = None - def hasFaceIndexedData(self): """Return True if this object already has vertex positions indexed by face""" @@ -229,7 +173,6 @@ class MeshData(object): if v is not None: return True return False - def faceNormals(self, indexed=None): """ @@ -242,7 +185,6 @@ class MeshData(object): v = self.vertexes(indexed='faces') self._faceNormals = np.cross(v[:,1]-v[:,0], v[:,2]-v[:,0]) - if indexed is None: return self._faceNormals elif indexed == 'faces': @@ -266,7 +208,11 @@ class MeshData(object): vertFaces = self.vertexFaces() self._vertexNormals = np.empty(self._vertexes.shape, dtype=float) for vindex in xrange(self._vertexes.shape[0]): - norms = faceNorms[vertFaces[vindex]] ## get all face normals + faces = vertFaces[vindex] + if len(faces) == 0: + self._vertexNormals[vindex] = (0,0,0) + continue + norms = faceNorms[faces] ## get all face normals norm = norms.sum(axis=0) ## sum normals norm /= (norm**2).sum()**0.5 ## and re-normalize self._vertexNormals[vindex] = norm @@ -363,7 +309,6 @@ class MeshData(object): ## This is done by collapsing into a list of 'unique' vertexes (difference < 1e-14) ## I think generally this should be discouraged.. - faces = self._vertexesIndexedByFaces verts = {} ## used to remember the index of each vertex position self._faces = np.empty(faces.shape[:2], dtype=np.uint) @@ -403,12 +348,10 @@ class MeshData(object): Return list mapping each vertex index to a list of face indexes that use the vertex. """ if self._vertexFaces is None: - self._vertexFaces = [None] * len(self.vertexes()) + self._vertexFaces = [[] for i in xrange(len(self.vertexes()))] for i in xrange(self._faces.shape[0]): face = self._faces[i] for ind in face: - if self._vertexFaces[ind] is None: - self._vertexFaces[ind] = [] ## need a unique/empty list to fill self._vertexFaces[ind].append(i) return self._vertexFaces @@ -426,22 +369,35 @@ class MeshData(object): #pass def _computeEdges(self): - ## generate self._edges from self._faces - #print self._faces - nf = len(self._faces) - edges = np.empty(nf*3, dtype=[('i', np.uint, 2)]) - edges['i'][0:nf] = self._faces[:,:2] - edges['i'][nf:2*nf] = self._faces[:,1:3] - edges['i'][-nf:,0] = self._faces[:,2] - edges['i'][-nf:,1] = self._faces[:,0] - - # sort per-edge - mask = edges['i'][:,0] > edges['i'][:,1] - edges['i'][mask] = edges['i'][mask][:,::-1] - - # remove duplicate entries - self._edges = np.unique(edges)['i'] - #print self._edges + if not self.hasFaceIndexedData: + ## generate self._edges from self._faces + nf = len(self._faces) + edges = np.empty(nf*3, dtype=[('i', np.uint, 2)]) + edges['i'][0:nf] = self._faces[:,:2] + edges['i'][nf:2*nf] = self._faces[:,1:3] + edges['i'][-nf:,0] = self._faces[:,2] + edges['i'][-nf:,1] = self._faces[:,0] + + # sort per-edge + mask = edges['i'][:,0] > edges['i'][:,1] + edges['i'][mask] = edges['i'][mask][:,::-1] + + # remove duplicate entries + self._edges = np.unique(edges)['i'] + #print self._edges + elif self._vertexesIndexedByFaces is not None: + verts = self._vertexesIndexedByFaces + edges = np.empty((verts.shape[0], 3, 2), dtype=np.uint) + nf = verts.shape[0] + edges[:,0,0] = np.arange(nf) * 3 + edges[:,0,1] = edges[:,0,0] + 1 + edges[:,1,0] = edges[:,0,1] + edges[:,1,1] = edges[:,1,0] + 1 + edges[:,2,0] = edges[:,1,1] + edges[:,2,1] = edges[:,0,0] + self._edges = edges + else: + raise Exception("MeshData cannot generate edges--no faces in this data.") def save(self): @@ -516,4 +472,33 @@ class MeshData(object): return MeshData(vertexes=verts, faces=faces) - \ No newline at end of file + @staticmethod + def cylinder(rows, cols, radius=[1.0, 1.0], length=1.0, offset=False): + """ + Return a MeshData instance with vertexes and faces computed + for a cylindrical surface. + The cylinder may be tapered with different radii at each end (truncated cone) + """ + verts = np.empty((rows+1, cols, 3), dtype=float) + if isinstance(radius, int): + radius = [radius, radius] # convert to list + ## compute vertexes + th = np.linspace(2 * np.pi, 0, cols).reshape(1, cols) + r = np.linspace(radius[0],radius[1],num=rows+1, endpoint=True).reshape(rows+1, 1) # radius as a function of z + verts[...,2] = np.linspace(0, length, num=rows+1, endpoint=True).reshape(rows+1, 1) # z + if offset: + th = th + ((np.pi / cols) * np.arange(rows+1).reshape(rows+1,1)) ## rotate each row by 1/2 column + verts[...,0] = r * np.cos(th) # x = r cos(th) + verts[...,1] = r * np.sin(th) # y = r sin(th) + verts = verts.reshape((rows+1)*cols, 3) # just reshape: no redundant vertices... + ## compute faces + faces = np.empty((rows*cols*2, 3), dtype=np.uint) + rowtemplate1 = ((np.arange(cols).reshape(cols, 1) + np.array([[0, 1, 0]])) % cols) + np.array([[0, 0, cols]]) + rowtemplate2 = ((np.arange(cols).reshape(cols, 1) + np.array([[0, 1, 1]])) % cols) + np.array([[cols, 0, cols]]) + for row in range(rows): + start = row * cols * 2 + faces[start:start+cols] = rowtemplate1 + row * cols + faces[start+cols:start+(cols*2)] = rowtemplate2 + row * cols + + return MeshData(vertexes=verts, faces=faces) + diff --git a/pyqtgraph/opengl/__init__.py b/pyqtgraph/opengl/__init__.py index 5345e187..931003e4 100644 --- a/pyqtgraph/opengl/__init__.py +++ b/pyqtgraph/opengl/__init__.py @@ -1,28 +1,20 @@ from .GLViewWidget import GLViewWidget -from pyqtgraph import importAll -#import os -#def importAll(path): - #d = os.path.join(os.path.split(__file__)[0], path) - #files = [] - #for f in os.listdir(d): - #if os.path.isdir(os.path.join(d, f)) and f != '__pycache__': - #files.append(f) - #elif f[-3:] == '.py' and f != '__init__.py': - #files.append(f[:-3]) - - #for modName in files: - #mod = __import__(path+"."+modName, globals(), locals(), fromlist=['*']) - #if hasattr(mod, '__all__'): - #names = mod.__all__ - #else: - #names = [n for n in dir(mod) if n[0] != '_'] - #for k in names: - #if hasattr(mod, k): - #globals()[k] = getattr(mod, k) +## dynamic imports cause too many problems. +#from .. import importAll +#importAll('items', globals(), locals()) + +from .items.GLGridItem import * +from .items.GLBarGraphItem import * +from .items.GLScatterPlotItem import * +from .items.GLMeshItem import * +from .items.GLLinePlotItem import * +from .items.GLAxisItem import * +from .items.GLImageItem import * +from .items.GLSurfacePlotItem import * +from .items.GLBoxItem import * +from .items.GLVolumeItem import * -importAll('items', globals(), locals()) -\ from .MeshData import MeshData ## for backward compatibility: #MeshData.MeshData = MeshData ## breaks autodoc. diff --git a/pyqtgraph/opengl/glInfo.py b/pyqtgraph/opengl/glInfo.py index 28da1f69..84346d81 100644 --- a/pyqtgraph/opengl/glInfo.py +++ b/pyqtgraph/opengl/glInfo.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtCore, QtGui, QtOpenGL +from ..Qt import QtCore, QtGui, QtOpenGL from OpenGL.GL import * app = QtGui.QApplication([]) diff --git a/pyqtgraph/opengl/items/GLAxisItem.py b/pyqtgraph/opengl/items/GLAxisItem.py index 860ac497..989a44ca 100644 --- a/pyqtgraph/opengl/items/GLAxisItem.py +++ b/pyqtgraph/opengl/items/GLAxisItem.py @@ -1,6 +1,6 @@ from OpenGL.GL import * from .. GLGraphicsItem import GLGraphicsItem -from pyqtgraph import QtGui +from ... import QtGui __all__ = ['GLAxisItem'] @@ -45,7 +45,7 @@ class GLAxisItem(GLGraphicsItem): if self.antialias: glEnable(GL_LINE_SMOOTH) - glHint(GL_LINE_SMOOTH_HINT, GL_NICEST); + glHint(GL_LINE_SMOOTH_HINT, GL_NICEST) glBegin( GL_LINES ) diff --git a/pyqtgraph/opengl/items/GLBoxItem.py b/pyqtgraph/opengl/items/GLBoxItem.py index bc25afd1..f0a6ae6c 100644 --- a/pyqtgraph/opengl/items/GLBoxItem.py +++ b/pyqtgraph/opengl/items/GLBoxItem.py @@ -1,7 +1,7 @@ from OpenGL.GL import * from .. GLGraphicsItem import GLGraphicsItem -from pyqtgraph.Qt import QtGui -import pyqtgraph as pg +from ...Qt import QtGui +from ... import functions as fn __all__ = ['GLBoxItem'] @@ -38,7 +38,7 @@ class GLBoxItem(GLGraphicsItem): def setColor(self, *args): """Set the color of the box. Arguments are the same as those accepted by functions.mkColor()""" - self.__color = pg.Color(*args) + self.__color = fn.Color(*args) def color(self): return self.__color diff --git a/pyqtgraph/opengl/items/GLGridItem.py b/pyqtgraph/opengl/items/GLGridItem.py index 01a2b178..4d6bc9d6 100644 --- a/pyqtgraph/opengl/items/GLGridItem.py +++ b/pyqtgraph/opengl/items/GLGridItem.py @@ -1,6 +1,8 @@ +import numpy as np + from OpenGL.GL import * from .. GLGraphicsItem import GLGraphicsItem -from pyqtgraph import QtGui +from ... import QtGui __all__ = ['GLGridItem'] @@ -16,8 +18,9 @@ class GLGridItem(GLGraphicsItem): self.setGLOptions(glOptions) self.antialias = antialias if size is None: - size = QtGui.QVector3D(1,1,1) + size = QtGui.QVector3D(20,20,1) self.setSize(size=size) + self.setSpacing(1, 1, 1) def setSize(self, x=None, y=None, z=None, size=None): """ @@ -33,8 +36,22 @@ class GLGridItem(GLGraphicsItem): def size(self): return self.__size[:] - - + + def setSpacing(self, x=None, y=None, z=None, spacing=None): + """ + Set the spacing between grid lines. + Arguments can be x,y,z or spacing=QVector3D(). + """ + if spacing is not None: + x = spacing.x() + y = spacing.y() + z = spacing.z() + self.__spacing = [x,y,z] + self.update() + + def spacing(self): + return self.__spacing[:] + def paint(self): self.setupGLState() @@ -42,17 +59,20 @@ class GLGridItem(GLGraphicsItem): glEnable(GL_LINE_SMOOTH) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glHint(GL_LINE_SMOOTH_HINT, GL_NICEST); + glHint(GL_LINE_SMOOTH_HINT, GL_NICEST) glBegin( GL_LINES ) x,y,z = self.size() + xs,ys,zs = self.spacing() + xvals = np.arange(-x/2., x/2. + xs*0.001, xs) + yvals = np.arange(-y/2., y/2. + ys*0.001, ys) glColor4f(1, 1, 1, .3) - for x in range(-10, 11): - glVertex3f(x, -10, 0) - glVertex3f(x, 10, 0) - for y in range(-10, 11): - glVertex3f(-10, y, 0) - glVertex3f( 10, y, 0) + for x in xvals: + glVertex3f(x, yvals[0], 0) + glVertex3f(x, yvals[-1], 0) + for y in yvals: + glVertex3f(xvals[0], y, 0) + glVertex3f(xvals[-1], y, 0) glEnd() diff --git a/pyqtgraph/opengl/items/GLImageItem.py b/pyqtgraph/opengl/items/GLImageItem.py index aca68f3d..59ddaf6f 100644 --- a/pyqtgraph/opengl/items/GLImageItem.py +++ b/pyqtgraph/opengl/items/GLImageItem.py @@ -1,6 +1,6 @@ from OpenGL.GL import * from .. GLGraphicsItem import GLGraphicsItem -from pyqtgraph.Qt import QtGui +from ...Qt import QtGui import numpy as np __all__ = ['GLImageItem'] @@ -25,13 +25,21 @@ class GLImageItem(GLGraphicsItem): """ self.smooth = smooth - self.data = data + self._needUpdate = False GLGraphicsItem.__init__(self) + self.setData(data) self.setGLOptions(glOptions) def initializeGL(self): glEnable(GL_TEXTURE_2D) self.texture = glGenTextures(1) + + def setData(self, data): + self.data = data + self._needUpdate = True + self.update() + + def _updateTexture(self): glBindTexture(GL_TEXTURE_2D, self.texture) if self.smooth: glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) @@ -63,7 +71,8 @@ class GLImageItem(GLGraphicsItem): def paint(self): - + if self._needUpdate: + self._updateTexture() glEnable(GL_TEXTURE_2D) glBindTexture(GL_TEXTURE_2D, self.texture) diff --git a/pyqtgraph/opengl/items/GLLinePlotItem.py b/pyqtgraph/opengl/items/GLLinePlotItem.py index 23d227c9..f5cb7545 100644 --- a/pyqtgraph/opengl/items/GLLinePlotItem.py +++ b/pyqtgraph/opengl/items/GLLinePlotItem.py @@ -2,7 +2,7 @@ from OpenGL.GL import * from OpenGL.arrays import vbo from .. GLGraphicsItem import GLGraphicsItem from .. import shaders -from pyqtgraph import QtGui +from ... import QtGui import numpy as np __all__ = ['GLLinePlotItem'] @@ -16,6 +16,7 @@ class GLLinePlotItem(GLGraphicsItem): glopts = kwds.pop('glOptions', 'additive') self.setGLOptions(glopts) self.pos = None + self.mode = 'line_strip' self.width = 1. self.color = (1.0,1.0,1.0,1.0) self.setData(**kwds) @@ -27,7 +28,7 @@ class GLLinePlotItem(GLGraphicsItem): colors unchanged, etc. ==================== ================================================== - Arguments: + **Arguments:** ------------------------------------------------------------------------ pos (N,3) array of floats specifying point locations. color (N,4) array of floats (0.0-1.0) or @@ -35,9 +36,13 @@ class GLLinePlotItem(GLGraphicsItem): a single color for the entire item. width float specifying line width antialias enables smooth line drawing + mode 'lines': Each pair of vertexes draws a single line + segment. + 'line_strip': All vertexes are drawn as a + continuous set of line segments. ==================== ================================================== """ - args = ['pos', 'color', 'width', 'connected', 'antialias'] + args = ['pos', 'color', 'width', 'mode', 'antialias'] for k in kwds.keys(): if k not in args: raise Exception('Invalid keyword argument: %s (allowed arguments are %s)' % (k, str(args))) @@ -91,9 +96,15 @@ class GLLinePlotItem(GLGraphicsItem): glEnable(GL_LINE_SMOOTH) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glHint(GL_LINE_SMOOTH_HINT, GL_NICEST); + glHint(GL_LINE_SMOOTH_HINT, GL_NICEST) + + if self.mode == 'line_strip': + glDrawArrays(GL_LINE_STRIP, 0, int(self.pos.size / self.pos.shape[-1])) + elif self.mode == 'lines': + glDrawArrays(GL_LINES, 0, int(self.pos.size / self.pos.shape[-1])) + else: + raise Exception("Unknown line mode '%s'. (must be 'lines' or 'line_strip')" % self.mode) - glDrawArrays(GL_LINE_STRIP, 0, int(self.pos.size / self.pos.shape[-1])) finally: glDisableClientState(GL_COLOR_ARRAY) glDisableClientState(GL_VERTEX_ARRAY) diff --git a/pyqtgraph/opengl/items/GLMeshItem.py b/pyqtgraph/opengl/items/GLMeshItem.py index 5b245e64..55e75942 100644 --- a/pyqtgraph/opengl/items/GLMeshItem.py +++ b/pyqtgraph/opengl/items/GLMeshItem.py @@ -1,9 +1,9 @@ from OpenGL.GL import * from .. GLGraphicsItem import GLGraphicsItem from .. MeshData import MeshData -from pyqtgraph.Qt import QtGui -import pyqtgraph as pg +from ...Qt import QtGui from .. import shaders +from ... import functions as fn import numpy as np @@ -19,7 +19,7 @@ class GLMeshItem(GLGraphicsItem): def __init__(self, **kwds): """ ============== ===================================================== - Arguments + **Arguments:** meshdata MeshData object from which to determine geometry for this item. color Default face color used if no vertex or face colors @@ -153,8 +153,12 @@ class GLMeshItem(GLGraphicsItem): self.colors = md.faceColors(indexed='faces') if self.opts['drawEdges']: - self.edges = md.edges() - self.edgeVerts = md.vertexes() + if not md.hasFaceIndexedData(): + self.edges = md.edges() + self.edgeVerts = md.vertexes() + else: + self.edges = md.edges() + self.edgeVerts = md.vertexes(indexed='faces') return def paint(self): @@ -177,7 +181,7 @@ class GLMeshItem(GLGraphicsItem): if self.colors is None: color = self.opts['color'] if isinstance(color, QtGui.QColor): - glColor4f(*pg.glColor(color)) + glColor4f(*fn.glColor(color)) else: glColor4f(*color) else: @@ -209,7 +213,7 @@ class GLMeshItem(GLGraphicsItem): if self.edgeColors is None: color = self.opts['edgeColor'] if isinstance(color, QtGui.QColor): - glColor4f(*pg.glColor(color)) + glColor4f(*fn.glColor(color)) else: glColor4f(*color) else: diff --git a/pyqtgraph/opengl/items/GLScatterPlotItem.py b/pyqtgraph/opengl/items/GLScatterPlotItem.py index b02a9dda..dc4b298a 100644 --- a/pyqtgraph/opengl/items/GLScatterPlotItem.py +++ b/pyqtgraph/opengl/items/GLScatterPlotItem.py @@ -2,7 +2,7 @@ from OpenGL.GL import * from OpenGL.arrays import vbo from .. GLGraphicsItem import GLGraphicsItem from .. import shaders -from pyqtgraph import QtGui +from ... import QtGui import numpy as np __all__ = ['GLScatterPlotItem'] @@ -28,8 +28,7 @@ class GLScatterPlotItem(GLGraphicsItem): colors unchanged, etc. ==================== ================================================== - Arguments: - ------------------------------------------------------------------------ + **Arguments:** pos (N,3) array of floats specifying point locations. color (N,4) array of floats (0.0-1.0) specifying spot colors OR a tuple of floats specifying @@ -60,14 +59,15 @@ class GLScatterPlotItem(GLGraphicsItem): w = 64 def fn(x,y): r = ((x-w/2.)**2 + (y-w/2.)**2) ** 0.5 - return 200 * (w/2. - np.clip(r, w/2.-1.0, w/2.)) + return 255 * (w/2. - np.clip(r, w/2.-1.0, w/2.)) pData = np.empty((w, w, 4)) pData[:] = 255 pData[:,:,3] = np.fromfunction(fn, pData.shape[:2]) #print pData.shape, pData.min(), pData.max() pData = pData.astype(np.ubyte) - self.pointTexture = glGenTextures(1) + if getattr(self, "pointTexture", None) is None: + self.pointTexture = glGenTextures(1) glActiveTexture(GL_TEXTURE0) glEnable(GL_TEXTURE_2D) glBindTexture(GL_TEXTURE_2D, self.pointTexture) diff --git a/pyqtgraph/opengl/items/GLSurfacePlotItem.py b/pyqtgraph/opengl/items/GLSurfacePlotItem.py index 88d50fac..e39ef3bb 100644 --- a/pyqtgraph/opengl/items/GLSurfacePlotItem.py +++ b/pyqtgraph/opengl/items/GLSurfacePlotItem.py @@ -1,8 +1,7 @@ from OpenGL.GL import * from .GLMeshItem import GLMeshItem from .. MeshData import MeshData -from pyqtgraph.Qt import QtGui -import pyqtgraph as pg +from ...Qt import QtGui import numpy as np @@ -37,14 +36,14 @@ class GLSurfacePlotItem(GLMeshItem): """ Update the data in this surface plot. - ========== ===================================================================== - Arguments - x,y 1D arrays of values specifying the x,y positions of vertexes in the - grid. If these are omitted, then the values will be assumed to be - integers. - z 2D array of height values for each grid vertex. - colors (width, height, 4) array of vertex colors. - ========== ===================================================================== + ============== ===================================================================== + **Arguments:** + x,y 1D arrays of values specifying the x,y positions of vertexes in the + grid. If these are omitted, then the values will be assumed to be + integers. + z 2D array of height values for each grid vertex. + colors (width, height, 4) array of vertex colors. + ============== ===================================================================== All arguments are optional. diff --git a/pyqtgraph/opengl/items/GLVolumeItem.py b/pyqtgraph/opengl/items/GLVolumeItem.py index 4980239d..cbe22db9 100644 --- a/pyqtgraph/opengl/items/GLVolumeItem.py +++ b/pyqtgraph/opengl/items/GLVolumeItem.py @@ -1,7 +1,8 @@ from OpenGL.GL import * from .. GLGraphicsItem import GLGraphicsItem -from pyqtgraph.Qt import QtGui +from ...Qt import QtGui import numpy as np +from ... import debug __all__ = ['GLVolumeItem'] @@ -25,13 +26,22 @@ class GLVolumeItem(GLGraphicsItem): self.sliceDensity = sliceDensity self.smooth = smooth - self.data = data + self.data = None + self._needUpload = False + self.texture = None GLGraphicsItem.__init__(self) self.setGLOptions(glOptions) + self.setData(data) - def initializeGL(self): + def setData(self, data): + self.data = data + self._needUpload = True + self.update() + + def _uploadData(self): glEnable(GL_TEXTURE_3D) - self.texture = glGenTextures(1) + if self.texture is None: + self.texture = glGenTextures(1) glBindTexture(GL_TEXTURE_3D, self.texture) if self.smooth: glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) @@ -60,9 +70,16 @@ class GLVolumeItem(GLGraphicsItem): glNewList(l, GL_COMPILE) self.drawVolume(ax, d) glEndList() - - + + self._needUpload = False + def paint(self): + if self.data is None: + return + + if self._needUpload: + self._uploadData() + self.setupGLState() glEnable(GL_TEXTURE_3D) diff --git a/pyqtgraph/ordereddict.py b/pyqtgraph/ordereddict.py index 5b0303f5..7242b506 100644 --- a/pyqtgraph/ordereddict.py +++ b/pyqtgraph/ordereddict.py @@ -1,127 +1,127 @@ -# Copyright (c) 2009 Raymond Hettinger -# -# Permission is hereby granted, free of charge, to any person -# obtaining a copy of this software and associated documentation files -# (the "Software"), to deal in the Software without restriction, -# including without limitation the rights to use, copy, modify, merge, -# publish, distribute, sublicense, and/or sell copies of the Software, -# and to permit persons to whom the Software is furnished to do so, -# subject to the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -# OTHER DEALINGS IN THE SOFTWARE. - -from UserDict import DictMixin - -class OrderedDict(dict, DictMixin): - - def __init__(self, *args, **kwds): - if len(args) > 1: - raise TypeError('expected at most 1 arguments, got %d' % len(args)) - try: - self.__end - except AttributeError: - self.clear() - self.update(*args, **kwds) - - def clear(self): - self.__end = end = [] - end += [None, end, end] # sentinel node for doubly linked list - self.__map = {} # key --> [key, prev, next] - dict.clear(self) - - def __setitem__(self, key, value): - if key not in self: - end = self.__end - curr = end[1] - curr[2] = end[1] = self.__map[key] = [key, curr, end] - dict.__setitem__(self, key, value) - - def __delitem__(self, key): - dict.__delitem__(self, key) - key, prev, next = self.__map.pop(key) - prev[2] = next - next[1] = prev - - def __iter__(self): - end = self.__end - curr = end[2] - while curr is not end: - yield curr[0] - curr = curr[2] - - def __reversed__(self): - end = self.__end - curr = end[1] - while curr is not end: - yield curr[0] - curr = curr[1] - - def popitem(self, last=True): - if not self: - raise KeyError('dictionary is empty') - if last: - key = reversed(self).next() - else: - key = iter(self).next() - value = self.pop(key) - return key, value - - def __reduce__(self): - items = [[k, self[k]] for k in self] - tmp = self.__map, self.__end - del self.__map, self.__end - inst_dict = vars(self).copy() - self.__map, self.__end = tmp - if inst_dict: - return (self.__class__, (items,), inst_dict) - return self.__class__, (items,) - - def keys(self): - return list(self) - - setdefault = DictMixin.setdefault - update = DictMixin.update - pop = DictMixin.pop - values = DictMixin.values - items = DictMixin.items - iterkeys = DictMixin.iterkeys - itervalues = DictMixin.itervalues - iteritems = DictMixin.iteritems - - def __repr__(self): - if not self: - return '%s()' % (self.__class__.__name__,) - return '%s(%r)' % (self.__class__.__name__, self.items()) - - def copy(self): - return self.__class__(self) - - @classmethod - def fromkeys(cls, iterable, value=None): - d = cls() - for key in iterable: - d[key] = value - return d - - def __eq__(self, other): - if isinstance(other, OrderedDict): - if len(self) != len(other): - return False - for p, q in zip(self.items(), other.items()): - if p != q: - return False - return True - return dict.__eq__(self, other) - - def __ne__(self, other): - return not self == other +# Copyright (c) 2009 Raymond Hettinger +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +from UserDict import DictMixin + +class OrderedDict(dict, DictMixin): + + def __init__(self, *args, **kwds): + if len(args) > 1: + raise TypeError('expected at most 1 arguments, got %d' % len(args)) + try: + self.__end + except AttributeError: + self.clear() + self.update(*args, **kwds) + + def clear(self): + self.__end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.__map = {} # key --> [key, prev, next] + dict.clear(self) + + def __setitem__(self, key, value): + if key not in self: + end = self.__end + curr = end[1] + curr[2] = end[1] = self.__map[key] = [key, curr, end] + dict.__setitem__(self, key, value) + + def __delitem__(self, key): + dict.__delitem__(self, key) + key, prev, next = self.__map.pop(key) + prev[2] = next + next[1] = prev + + def __iter__(self): + end = self.__end + curr = end[2] + while curr is not end: + yield curr[0] + curr = curr[2] + + def __reversed__(self): + end = self.__end + curr = end[1] + while curr is not end: + yield curr[0] + curr = curr[1] + + def popitem(self, last=True): + if not self: + raise KeyError('dictionary is empty') + if last: + key = reversed(self).next() + else: + key = iter(self).next() + value = self.pop(key) + return key, value + + def __reduce__(self): + items = [[k, self[k]] for k in self] + tmp = self.__map, self.__end + del self.__map, self.__end + inst_dict = vars(self).copy() + self.__map, self.__end = tmp + if inst_dict: + return (self.__class__, (items,), inst_dict) + return self.__class__, (items,) + + def keys(self): + return list(self) + + setdefault = DictMixin.setdefault + update = DictMixin.update + pop = DictMixin.pop + values = DictMixin.values + items = DictMixin.items + iterkeys = DictMixin.iterkeys + itervalues = DictMixin.itervalues + iteritems = DictMixin.iteritems + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, self.items()) + + def copy(self): + return self.__class__(self) + + @classmethod + def fromkeys(cls, iterable, value=None): + d = cls() + for key in iterable: + d[key] = value + return d + + def __eq__(self, other): + if isinstance(other, OrderedDict): + if len(self) != len(other): + return False + for p, q in zip(self.items(), other.items()): + if p != q: + return False + return True + return dict.__eq__(self, other) + + def __ne__(self, other): + return not self == other diff --git a/pyqtgraph/parametertree/Parameter.py b/pyqtgraph/parametertree/Parameter.py index 9a7ece25..5f37ccdc 100644 --- a/pyqtgraph/parametertree/Parameter.py +++ b/pyqtgraph/parametertree/Parameter.py @@ -1,6 +1,7 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore import os, weakref, re -from pyqtgraph.pgcollections import OrderedDict +from ..pgcollections import OrderedDict +from ..python2_3 import asUnicode from .ParameterItem import ParameterItem PARAM_TYPES = {} @@ -13,7 +14,9 @@ def registerParameterType(name, cls, override=False): PARAM_TYPES[name] = cls PARAM_NAMES[cls] = name - +def __reload__(old): + PARAM_TYPES.update(old.get('PARAM_TYPES', {})) + PARAM_NAMES.update(old.get('PARAM_NAMES', {})) class Parameter(QtCore.QObject): """ @@ -46,6 +49,7 @@ class Parameter(QtCore.QObject): including during editing. sigChildAdded(self, child, index) Emitted when a child is added sigChildRemoved(self, child) Emitted when a child is removed + sigRemoved(self) Emitted when this parameter is removed sigParentChanged(self, parent) Emitted when this parameter's parent has changed sigLimitsChanged(self, limits) Emitted when this parameter's limits have changed sigDefaultChanged(self, default) Emitted when this parameter's default value has changed @@ -61,6 +65,7 @@ class Parameter(QtCore.QObject): sigChildAdded = QtCore.Signal(object, object, object) ## self, child, index sigChildRemoved = QtCore.Signal(object, object) ## self, child + sigRemoved = QtCore.Signal(object) ## self sigParentChanged = QtCore.Signal(object, object) ## self, parent sigLimitsChanged = QtCore.Signal(object, object) ## self, limits sigDefaultChanged = QtCore.Signal(object, object) ## self, default @@ -107,33 +112,39 @@ class Parameter(QtCore.QObject): Parameter instance, the options available to this method are also allowed by most Parameter subclasses. - ================= ========================================================= - Keyword Arguments - name The name to give this Parameter. This is the name that - will appear in the left-most column of a ParameterTree - for this Parameter. - value The value to initially assign to this Parameter. - default The default value for this Parameter (most Parameters - provide an option to 'reset to default'). - children A list of children for this Parameter. Children - may be given either as a Parameter instance or as a - dictionary to pass to Parameter.create(). In this way, - it is possible to specify complex hierarchies of - Parameters from a single nested data structure. - readonly If True, the user will not be allowed to edit this - Parameter. (default=False) - enabled If False, any widget(s) for this parameter will appear - disabled. (default=True) - visible If False, the Parameter will not appear when displayed - in a ParameterTree. (default=True) - renamable If True, the user may rename this Parameter. - (default=False) - removable If True, the user may remove this Parameter. - (default=False) - expanded If True, the Parameter will appear expanded when - displayed in a ParameterTree (its children will be - visible). (default=True) - ================= ========================================================= + ======================= ========================================================= + **Keyword Arguments:** + name The name to give this Parameter. This is the name that + will appear in the left-most column of a ParameterTree + for this Parameter. + value The value to initially assign to this Parameter. + default The default value for this Parameter (most Parameters + provide an option to 'reset to default'). + children A list of children for this Parameter. Children + may be given either as a Parameter instance or as a + dictionary to pass to Parameter.create(). In this way, + it is possible to specify complex hierarchies of + Parameters from a single nested data structure. + readonly If True, the user will not be allowed to edit this + Parameter. (default=False) + enabled If False, any widget(s) for this parameter will appear + disabled. (default=True) + visible If False, the Parameter will not appear when displayed + in a ParameterTree. (default=True) + renamable If True, the user may rename this Parameter. + (default=False) + removable If True, the user may remove this Parameter. + (default=False) + expanded If True, the Parameter will appear expanded when + displayed in a ParameterTree (its children will be + visible). (default=True) + title (str or None) If specified, then the parameter will be + displayed to the user using this string as its name. + However, the parameter will still be referred to + internally using the *name* specified above. Note that + this option is not compatible with renamable=True. + (default=None; added in version 0.9.9) + ======================= ========================================================= """ @@ -148,6 +159,7 @@ class Parameter(QtCore.QObject): 'removable': False, 'strictNaming': False, # forces name to be usable as a python variable 'expanded': True, + 'title': None, #'limits': None, ## This is a bad plan--each parameter type may have a different data type for limits. } self.opts.update(opts) @@ -266,16 +278,27 @@ class Parameter(QtCore.QObject): vals[ch.name()] = (ch.value(), ch.getValues()) return vals - def saveState(self): + def saveState(self, filter=None): """ Return a structure representing the entire state of the parameter tree. - The tree state may be restored from this structure using restoreState() + The tree state may be restored from this structure using restoreState(). + + If *filter* is set to 'user', then only user-settable data will be included in the + returned state. """ - state = self.opts.copy() - state['children'] = OrderedDict([(ch.name(), ch.saveState()) for ch in self]) - if state['type'] is None: - global PARAM_NAMES - state['type'] = PARAM_NAMES.get(type(self), None) + if filter is None: + state = self.opts.copy() + if state['type'] is None: + global PARAM_NAMES + state['type'] = PARAM_NAMES.get(type(self), None) + elif filter == 'user': + state = {'value': self.value()} + else: + raise ValueError("Unrecognized filter argument: '%s'" % filter) + + ch = OrderedDict([(ch.name(), ch.saveState(filter=filter)) for ch in self]) + if len(ch) > 0: + state['children'] = ch return state def restoreState(self, state, recursive=True, addChildren=True, removeChildren=True, blockSignals=True): @@ -293,8 +316,11 @@ class Parameter(QtCore.QObject): ## list of children may be stored either as list or dict. if isinstance(childState, dict): - childState = childState.values() - + cs = [] + for k,v in childState.items(): + cs.append(v.copy()) + cs[-1].setdefault('name', k) + childState = cs if blockSignals: self.blockTreeChangeSignal() @@ -311,14 +337,14 @@ class Parameter(QtCore.QObject): for ch in childState: name = ch['name'] - typ = ch['type'] + #typ = ch.get('type', None) #print('child: %s, %s' % (self.name()+'.'+name, typ)) - ## First, see if there is already a child with this name and type + ## First, see if there is already a child with this name gotChild = False for i, ch2 in enumerate(self.childs[ptr:]): #print " ", ch2.name(), ch2.type() - if ch2.name() != name or not ch2.isType(typ): + if ch2.name() != name: # or not ch2.isType(typ): continue gotChild = True #print " found it" @@ -393,15 +419,22 @@ class Parameter(QtCore.QObject): Note that the value of the parameter can *always* be changed by calling setValue(). """ - return not self.opts.get('readonly', False) + return not self.readonly() def setWritable(self, writable=True): """Set whether this Parameter should be editable by the user. (This is exactly the opposite of setReadonly).""" self.setOpts(readonly=not writable) + def readonly(self): + """ + Return True if this parameter is read-only. (this is the opposite of writable()) + """ + return self.opts.get('readonly', False) + def setReadonly(self, readonly=True): - """Set whether this Parameter's value may be edited by the user.""" + """Set whether this Parameter's value may be edited by the user + (this is the opposite of setWritable()).""" self.setOpts(readonly=readonly) def setOpts(self, **opts): @@ -453,11 +486,20 @@ class Parameter(QtCore.QObject): return ParameterItem(self, depth=depth) - def addChild(self, child): - """Add another parameter to the end of this parameter's child list.""" - return self.insertChild(len(self.childs), child) + def addChild(self, child, autoIncrementName=None): + """ + Add another parameter to the end of this parameter's child list. + + See insertChild() for a description of the *autoIncrementName* + argument. + """ + return self.insertChild(len(self.childs), child, autoIncrementName=autoIncrementName) def addChildren(self, children): + """ + Add a list or dict of children to this parameter. This method calls + addChild once for each value in *children*. + """ ## If children was specified as dict, then assume keys are the names. if isinstance(children, dict): ch2 = [] @@ -473,19 +515,24 @@ class Parameter(QtCore.QObject): self.addChild(chOpts) - def insertChild(self, pos, child): + def insertChild(self, pos, child, autoIncrementName=None): """ Insert a new child at pos. If pos is a Parameter, then insert at the position of that Parameter. If child is a dict, then a parameter is constructed using :func:`Parameter.create `. + + By default, the child's 'autoIncrementName' option determines whether + the name will be adjusted to avoid prior name collisions. This + behavior may be overridden by specifying the *autoIncrementName* + argument. This argument was added in version 0.9.9. """ if isinstance(child, dict): child = Parameter.create(**child) name = child.name() if name in self.names and child is not self.names[name]: - if child.opts.get('autoIncrementName', False): + if autoIncrementName is True or (autoIncrementName is None and child.opts.get('autoIncrementName', False)): name = self.incrementName(name) child.setName(name) else: @@ -516,7 +563,7 @@ class Parameter(QtCore.QObject): self.sigChildRemoved.emit(self, child) try: child.sigTreeStateChanged.disconnect(self.treeStateChanged) - except TypeError: ## already disconnected + except (TypeError, RuntimeError): ## already disconnected pass def clearChildren(self): @@ -550,6 +597,7 @@ class Parameter(QtCore.QObject): if parent is None: raise Exception("Cannot remove; no parent.") parent.removeChild(self) + self.sigRemoved.emit(self) def incrementName(self, name): ## return an unused name by adding a number to the name given @@ -590,9 +638,12 @@ class Parameter(QtCore.QObject): names = (names,) return self.param(*names).setValue(value) - def param(self, *names): + def child(self, *names): """Return a child parameter. - Accepts the name of the child or a tuple (path, to, child)""" + Accepts the name of the child or a tuple (path, to, child) + + Added in version 0.9.9. Ealier versions used the 'param' method, which is still + implemented for backward compatibility.""" try: param = self.names[names[0]] except KeyError: @@ -603,8 +654,12 @@ class Parameter(QtCore.QObject): else: return param + def param(self, *names): + # for backward compatibility. + return self.child(*names) + def __repr__(self): - return "<%s '%s' at 0x%x>" % (self.__class__.__name__, self.name(), id(self)) + return asUnicode("<%s '%s' at 0x%x>") % (self.__class__.__name__, self.name(), id(self)) def __getattr__(self, attr): ## Leaving this undocumented because I might like to remove it in the future.. @@ -675,13 +730,13 @@ class Parameter(QtCore.QObject): """ Called when the state of any sub-parameter has changed. - ========== ================================================================ - Arguments: - param The immediate child whose tree state has changed. - note that the change may have originated from a grandchild. - changes List of tuples describing all changes that have been made - in this event: (param, changeDescr, data) - ========== ================================================================ + ============== ================================================================ + **Arguments:** + param The immediate child whose tree state has changed. + note that the change may have originated from a grandchild. + changes List of tuples describing all changes that have been made + in this event: (param, changeDescr, data) + ============== ================================================================ This function can be extended to react to tree state changes. """ @@ -692,7 +747,8 @@ class Parameter(QtCore.QObject): if self.blockTreeChangeEmit == 0: changes = self.treeStateChanges self.treeStateChanges = [] - self.sigTreeStateChanged.emit(self, changes) + if len(changes) > 0: + self.sigTreeStateChanged.emit(self, changes) class SignalBlocker(object): diff --git a/pyqtgraph/parametertree/ParameterItem.py b/pyqtgraph/parametertree/ParameterItem.py index 46499fd3..c149c411 100644 --- a/pyqtgraph/parametertree/ParameterItem.py +++ b/pyqtgraph/parametertree/ParameterItem.py @@ -1,4 +1,5 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore +from ..python2_3 import asUnicode import os, weakref, re class ParameterItem(QtGui.QTreeWidgetItem): @@ -15,8 +16,11 @@ class ParameterItem(QtGui.QTreeWidgetItem): """ def __init__(self, param, depth=0): - QtGui.QTreeWidgetItem.__init__(self, [param.name(), '']) - + title = param.opts.get('title', None) + if title is None: + title = param.name() + QtGui.QTreeWidgetItem.__init__(self, [title, '']) + self.param = param self.param.registerItem(self) ## let parameter know this item is connected to it (for debugging) self.depth = depth @@ -30,7 +34,6 @@ class ParameterItem(QtGui.QTreeWidgetItem): param.sigOptionsChanged.connect(self.optsChanged) param.sigParentChanged.connect(self.parentChanged) - opts = param.opts ## Generate context menu for renaming/removing parameter @@ -38,6 +41,8 @@ class ParameterItem(QtGui.QTreeWidgetItem): self.contextMenu.addSeparator() flags = QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled if opts.get('renamable', False): + if param.opts.get('title', None) is not None: + raise Exception("Cannot make parameter with both title != None and renamable == True.") flags |= QtCore.Qt.ItemIsEditable self.contextMenu.addAction('Rename').triggered.connect(self.editName) if opts.get('removable', False): @@ -107,15 +112,15 @@ class ParameterItem(QtGui.QTreeWidgetItem): self.contextMenu.popup(ev.globalPos()) def columnChangedEvent(self, col): - """Called when the text in a column has been edited. + """Called when the text in a column has been edited (or otherwise changed). By default, we only use changes to column 0 to rename the parameter. """ - if col == 0: + if col == 0 and (self.param.opts.get('title', None) is None): if self.ignoreNameColumnChange: return try: - newName = self.param.setName(str(self.text(col))) - except: + newName = self.param.setName(asUnicode(self.text(col))) + except Exception: self.setText(0, self.param.name()) raise @@ -127,8 +132,9 @@ class ParameterItem(QtGui.QTreeWidgetItem): def nameChanged(self, param, name): ## called when the parameter's name has changed. - self.setText(0, name) - + if self.param.opts.get('title', None) is None: + self.setText(0, name) + def limitsChanged(self, param, limits): """Called when the parameter's limits have changed""" pass diff --git a/pyqtgraph/parametertree/ParameterSystem.py b/pyqtgraph/parametertree/ParameterSystem.py new file mode 100644 index 00000000..33bb2de8 --- /dev/null +++ b/pyqtgraph/parametertree/ParameterSystem.py @@ -0,0 +1,127 @@ +from .parameterTypes import GroupParameter +from .. import functions as fn +from .SystemSolver import SystemSolver + + +class ParameterSystem(GroupParameter): + """ + ParameterSystem is a subclass of GroupParameter that manages a tree of + sub-parameters with a set of interdependencies--changing any one parameter + may affect other parameters in the system. + + See parametertree/SystemSolver for more information. + + NOTE: This API is experimental and may change substantially across minor + version numbers. + """ + def __init__(self, *args, **kwds): + GroupParameter.__init__(self, *args, **kwds) + self._system = None + self._fixParams = [] # all auto-generated 'fixed' params + sys = kwds.pop('system', None) + if sys is not None: + self.setSystem(sys) + self._ignoreChange = [] # params whose changes should be ignored temporarily + self.sigTreeStateChanged.connect(self.updateSystem) + + def setSystem(self, sys): + self._system = sys + + # auto-generate defaults to match child parameters + defaults = {} + vals = {} + for param in self: + name = param.name() + constraints = '' + if hasattr(sys, '_' + name): + constraints += 'n' + + if not param.readonly(): + constraints += 'f' + if 'n' in constraints: + ch = param.addChild(dict(name='fixed', type='bool', value=False)) + self._fixParams.append(ch) + param.setReadonly(True) + param.setOpts(expanded=False) + else: + vals[name] = param.value() + ch = param.addChild(dict(name='fixed', type='bool', value=True, readonly=True)) + #self._fixParams.append(ch) + + defaults[name] = [None, param.type(), None, constraints] + + sys.defaultState.update(defaults) + sys.reset() + for name, value in vals.items(): + setattr(sys, name, value) + + self.updateAllParams() + + def updateSystem(self, param, changes): + changes = [ch for ch in changes if ch[0] not in self._ignoreChange] + + #resets = [ch[0] for ch in changes if ch[1] == 'setToDefault'] + sets = [ch[0] for ch in changes if ch[1] == 'value'] + #for param in resets: + #setattr(self._system, param.name(), None) + + for param in sets: + #if param in resets: + #continue + + #if param in self._fixParams: + #param.parent().setWritable(param.value()) + #else: + if param in self._fixParams: + parent = param.parent() + if param.value(): + setattr(self._system, parent.name(), parent.value()) + else: + setattr(self._system, parent.name(), None) + else: + setattr(self._system, param.name(), param.value()) + + self.updateAllParams() + + def updateAllParams(self): + try: + self.sigTreeStateChanged.disconnect(self.updateSystem) + for name, state in self._system._vars.items(): + param = self.child(name) + try: + v = getattr(self._system, name) + if self._system._vars[name][2] is None: + self.updateParamState(self.child(name), 'autoSet') + param.setValue(v) + else: + self.updateParamState(self.child(name), 'fixed') + except RuntimeError: + self.updateParamState(param, 'autoUnset') + finally: + self.sigTreeStateChanged.connect(self.updateSystem) + + def updateParamState(self, param, state): + if state == 'autoSet': + bg = fn.mkBrush((200, 255, 200, 255)) + bold = False + readonly = True + elif state == 'autoUnset': + bg = fn.mkBrush(None) + bold = False + readonly = False + elif state == 'fixed': + bg = fn.mkBrush('y') + bold = True + readonly = False + + param.setReadonly(readonly) + + #for item in param.items: + #item.setBackground(0, bg) + #f = item.font(0) + #f.setWeight(f.Bold if bold else f.Normal) + #item.setFont(0, f) + + + + diff --git a/pyqtgraph/parametertree/ParameterTree.py b/pyqtgraph/parametertree/ParameterTree.py index 866875e5..ef7c1030 100644 --- a/pyqtgraph/parametertree/ParameterTree.py +++ b/pyqtgraph/parametertree/ParameterTree.py @@ -1,5 +1,5 @@ -from pyqtgraph.Qt import QtCore, QtGui -from pyqtgraph.widgets.TreeWidget import TreeWidget +from ..Qt import QtCore, QtGui +from ..widgets.TreeWidget import TreeWidget import os, weakref, re from .ParameterItem import ParameterItem #import functions as fn @@ -7,9 +7,16 @@ from .ParameterItem import ParameterItem class ParameterTree(TreeWidget): - """Widget used to display or control data from a ParameterSet""" + """Widget used to display or control data from a hierarchy of Parameters""" def __init__(self, parent=None, showHeader=True): + """ + ============== ======================================================== + **Arguments:** + parent (QWidget) An optional parent widget + showHeader (bool) If True, then the QTreeView header is displayed. + ============== ======================================================== + """ TreeWidget.__init__(self, parent) self.setVerticalScrollMode(self.ScrollPerPixel) self.setHorizontalScrollMode(self.ScrollPerPixel) @@ -25,10 +32,35 @@ class ParameterTree(TreeWidget): self.setRootIsDecorated(False) def setParameters(self, param, showTop=True): + """ + Set the top-level :class:`Parameter ` + to be displayed in this ParameterTree. + + If *showTop* is False, then the top-level parameter is hidden and only + its children will be visible. This is a convenience method equivalent + to:: + + tree.clear() + tree.addParameters(param, showTop) + """ self.clear() self.addParameters(param, showTop=showTop) def addParameters(self, param, root=None, depth=0, showTop=True): + """ + Adds one top-level :class:`Parameter ` + to the view. + + ============== ========================================================== + **Arguments:** + param The :class:`Parameter ` + to add. + root The item within the tree to which *param* should be added. + By default, *param* is added as a top-level item. + showTop If False, then *param* will be hidden, and only its + children will be visible in the tree. + ============== ========================================================== + """ item = param.makeTreeItem(depth=depth) if root is None: root = self.invisibleRootItem() @@ -45,11 +77,14 @@ class ParameterTree(TreeWidget): self.addParameters(ch, root=item, depth=depth+1) def clear(self): - self.invisibleRootItem().takeChildren() - + """ + Remove all parameters from the tree. + """ + self.invisibleRootItem().takeChildren() def focusNext(self, item, forward=True): - ## Give input focus to the next (or previous) item after 'item' + """Give input focus to the next (or previous) item after *item* + """ while True: parent = item.parent() if parent is None: diff --git a/pyqtgraph/parametertree/SystemSolver.py b/pyqtgraph/parametertree/SystemSolver.py new file mode 100644 index 00000000..0a889dfa --- /dev/null +++ b/pyqtgraph/parametertree/SystemSolver.py @@ -0,0 +1,381 @@ +from collections import OrderedDict +import numpy as np + +class SystemSolver(object): + """ + This abstract class is used to formalize and manage user interaction with a + complex system of equations (related to "constraint satisfaction problems"). + It is often the case that devices must be controlled + through a large number of free variables, and interactions between these + variables make the system difficult to manage and conceptualize as a user + interface. This class does _not_ attempt to numerically solve the system + of equations. Rather, it provides a framework for subdividing the system + into manageable pieces and specifying closed-form solutions to these small + pieces. + + For an example, see the simple Camera class below. + + Theory of operation: Conceptualize the system as 1) a set of variables + whose values may be either user-specified or automatically generated, and + 2) a set of functions that define *how* each variable should be generated. + When a variable is accessed (as an instance attribute), the solver first + checks to see if it already has a value (either user-supplied, or cached + from a previous calculation). If it does not, then the solver calls a + method on itself (the method must be named `_variableName`) that will + either return the calculated value (which usually involves acccessing + other variables in the system), or raise RuntimeError if it is unable to + calculate the value (usually because the user has not provided sufficient + input to fully constrain the system). + + Each method that calculates a variable value may include multiple + try/except blocks, so that if one method generates a RuntimeError, it may + fall back on others. + In this way, the system may be solved by recursively searching the tree of + possible relationships between variables. This allows the user flexibility + in deciding which variables are the most important to specify, while + avoiding the apparent combinatorial explosion of calculation pathways + that must be considered by the developer. + + Solved values are cached for efficiency, and automatically cleared when + a state change invalidates the cache. The rules for this are simple: any + time a value is set, it invalidates the cache *unless* the previous value + was None (which indicates that no other variable has yet requested that + value). More complex cache management may be defined in subclasses. + + + Subclasses must define: + + 1) The *defaultState* class attribute: This is a dict containing a + description of the variables in the system--their default values, + data types, and the ways they can be constrained. The format is:: + + { name: [value, type, constraint, allowed_constraints], ...} + + * *value* is the default value. May be None if it has not been specified + yet. + * *type* may be float, int, bool, np.ndarray, ... + * *constraint* may be None, single value, or (min, max) + * None indicates that the value is not constrained--it may be + automatically generated if the value is requested. + * *allowed_constraints* is a string composed of (n)one, (f)ixed, and (r)ange. + + Note: do not put mutable objects inside defaultState! + + 2) For each variable that may be automatically determined, a method must + be defined with the name `_variableName`. This method may either return + the + """ + + defaultState = OrderedDict() + + def __init__(self): + self.__dict__['_vars'] = OrderedDict() + self.__dict__['_currentGets'] = set() + self.reset() + + def reset(self): + """ + Reset all variables in the solver to their default state. + """ + self._currentGets.clear() + for k in self.defaultState: + self._vars[k] = self.defaultState[k][:] + + def __getattr__(self, name): + if name in self._vars: + return self.get(name) + raise AttributeError(name) + + def __setattr__(self, name, value): + """ + Set the value of a state variable. + If None is given for the value, then the constraint will also be set to None. + If a tuple is given for a scalar variable, then the tuple is used as a range constraint instead of a value. + Otherwise, the constraint is set to 'fixed'. + + """ + # First check this is a valid attribute + if name in self._vars: + if value is None: + self.set(name, value, None) + elif isinstance(value, tuple) and self._vars[name][1] is not np.ndarray: + self.set(name, None, value) + else: + self.set(name, value, 'fixed') + else: + # also allow setting any other pre-existing attribute + if hasattr(self, name): + object.__setattr__(self, name, value) + else: + raise AttributeError(name) + + def get(self, name): + """ + Return the value for parameter *name*. + + If the value has not been specified, then attempt to compute it from + other interacting parameters. + + If no value can be determined, then raise RuntimeError. + """ + if name in self._currentGets: + raise RuntimeError("Cyclic dependency while calculating '%s'." % name) + self._currentGets.add(name) + try: + v = self._vars[name][0] + if v is None: + cfunc = getattr(self, '_' + name, None) + if cfunc is None: + v = None + else: + v = cfunc() + if v is None: + raise RuntimeError("Parameter '%s' is not specified." % name) + v = self.set(name, v) + finally: + self._currentGets.remove(name) + + return v + + def set(self, name, value=None, constraint=True): + """ + Set a variable *name* to *value*. The actual set value is returned (in + some cases, the value may be cast into another type). + + If *value* is None, then the value is left to be determined in the + future. At any time, the value may be re-assigned arbitrarily unless + a constraint is given. + + If *constraint* is True (the default), then supplying a value that + violates a previously specified constraint will raise an exception. + + If *constraint* is 'fixed', then the value is set (if provided) and + the variable will not be updated automatically in the future. + + If *constraint* is a tuple, then the value is constrained to be within the + given (min, max). Either constraint may be None to disable + it. In some cases, a constraint cannot be satisfied automatically, + and the user will be forced to resolve the constraint manually. + + If *constraint* is None, then any constraints are removed for the variable. + """ + var = self._vars[name] + if constraint is None: + if 'n' not in var[3]: + raise TypeError("Empty constraints not allowed for '%s'" % name) + var[2] = constraint + elif constraint == 'fixed': + if 'f' not in var[3]: + raise TypeError("Fixed constraints not allowed for '%s'" % name) + var[2] = constraint + elif isinstance(constraint, tuple): + if 'r' not in var[3]: + raise TypeError("Range constraints not allowed for '%s'" % name) + assert len(constraint) == 2 + var[2] = constraint + elif constraint is not True: + raise TypeError("constraint must be None, True, 'fixed', or tuple. (got %s)" % constraint) + + # type checking / massaging + if var[1] is np.ndarray: + value = np.array(value, dtype=float) + elif var[1] in (int, float, tuple) and value is not None: + value = var[1](value) + + # constraint checks + if constraint is True and not self.check_constraint(name, value): + raise ValueError("Setting %s = %s violates constraint %s" % (name, value, var[2])) + + # invalidate other dependent values + if var[0] is not None: + # todo: we can make this more clever..(and might need to) + # we just know that a value of None cannot have dependencies + # (because if anyone else had asked for this value, it wouldn't be + # None anymore) + self.resetUnfixed() + + var[0] = value + return value + + def check_constraint(self, name, value): + c = self._vars[name][2] + if c is None or value is None: + return True + if isinstance(c, tuple): + return ((c[0] is None or c[0] <= value) and + (c[1] is None or c[1] >= value)) + else: + return value == c + + def saveState(self): + """ + Return a serializable description of the solver's current state. + """ + state = OrderedDict() + for name, var in self._vars.items(): + state[name] = (var[0], var[2]) + return state + + def restoreState(self, state): + """ + Restore the state of all values and constraints in the solver. + """ + self.reset() + for name, var in state.items(): + self.set(name, var[0], var[1]) + + def resetUnfixed(self): + """ + For any variable that does not have a fixed value, reset + its value to None. + """ + for var in self._vars.values(): + if var[2] != 'fixed': + var[0] = None + + def solve(self): + for k in self._vars: + getattr(self, k) + + def __repr__(self): + state = OrderedDict() + for name, var in self._vars.items(): + if var[2] == 'fixed': + state[name] = var[0] + state = ', '.join(["%s=%s" % (n, v) for n,v in state.items()]) + return "<%s %s>" % (self.__class__.__name__, state) + + + + + +if __name__ == '__main__': + + class Camera(SystemSolver): + """ + Consider a simple SLR camera. The variables we will consider that + affect the camera's behavior while acquiring a photo are aperture, shutter speed, + ISO, and flash (of course there are many more, but let's keep the example simple). + + In rare cases, the user wants to manually specify each of these variables and + no more work needs to be done to take the photo. More often, the user wants to + specify more interesting constraints like depth of field, overall exposure, + or maximum allowed ISO value. + + If we add a simple light meter measurement into this system and an 'exposure' + variable that indicates the desired exposure (0 is "perfect", -1 is one stop + darker, etc), then the system of equations governing the camera behavior would + have the following variables: + + aperture, shutter, iso, flash, exposure, light meter + + The first four variables are the "outputs" of the system (they directly drive + the camera), the last is a constant (the camera itself cannot affect the + reading on the light meter), and 'exposure' specifies a desired relationship + between other variables in the system. + + So the question is: how can I formalize a system like this as a user interface? + Typical cameras have a fairly limited approach: provide the user with a list + of modes, each of which defines a particular set of constraints. For example: + + manual: user provides aperture, shutter, iso, and flash + aperture priority: user provides aperture and exposure, camera selects + iso, shutter, and flash automatically + shutter priority: user provides shutter and exposure, camera selects + iso, aperture, and flash + program: user specifies exposure, camera selects all other variables + automatically + action: camera selects all variables while attempting to maximize + shutter speed + portrait: camera selects all variables while attempting to minimize + aperture + + A more general approach might allow the user to provide more explicit + constraints on each variable (for example: I want a shutter speed of 1/30 or + slower, an ISO no greater than 400, an exposure between -1 and 1, and the + smallest aperture possible given all other constraints) and have the camera + solve the system of equations, with a warning if no solution is found. This + is exactly what we will implement in this example class. + """ + + defaultState = OrderedDict([ + # Field stop aperture + ('aperture', [None, float, None, 'nf']), + # Duration that shutter is held open. + ('shutter', [None, float, None, 'nf']), + # ISO (sensitivity) value. 100, 200, 400, 800, 1600.. + ('iso', [None, int, None, 'nf']), + + # Flash is a value indicating the brightness of the flash. A table + # is used to decide on "balanced" settings for each flash level: + # 0: no flash + # 1: s=1/60, a=2.0, iso=100 + # 2: s=1/60, a=4.0, iso=100 ..and so on.. + ('flash', [None, float, None, 'nf']), + + # exposure is a value indicating how many stops brighter (+1) or + # darker (-1) the photographer would like the photo to appear from + # the 'balanced' settings indicated by the light meter (see below). + ('exposure', [None, float, None, 'f']), + + # Let's define this as an external light meter (not affected by + # aperture) with logarithmic output. We arbitrarily choose the + # following settings as "well balanced" for each light meter value: + # -1: s=1/60, a=2.0, iso=100 + # 0: s=1/60, a=4.0, iso=100 + # 1: s=1/120, a=4.0, iso=100 ..and so on.. + # Note that the only allowed constraint mode is (f)ixed, since the + # camera never _computes_ the light meter value, it only reads it. + ('lightMeter', [None, float, None, 'f']), + + # Indicates the camera's final decision on how it thinks the photo will + # look, given the chosen settings. This value is _only_ determined + # automatically. + ('balance', [None, float, None, 'n']), + ]) + + def _aperture(self): + """ + Determine aperture automatically under a variety of conditions. + """ + iso = self.iso + exp = self.exposure + light = self.lightMeter + + try: + # shutter-priority mode + sh = self.shutter # this raises RuntimeError if shutter has not + # been specified + ap = 4.0 * (sh / (1./60.)) * (iso / 100.) * (2 ** exp) * (2 ** light) + ap = np.clip(ap, 2.0, 16.0) + except RuntimeError: + # program mode; we can select a suitable shutter + # value at the same time. + sh = (1./60.) + raise + + + + return ap + + def _balance(self): + iso = self.iso + light = self.lightMeter + sh = self.shutter + ap = self.aperture + fl = self.flash + + bal = (4.0 / ap) * (sh / (1./60.)) * (iso / 100.) * (2 ** light) + return np.log2(bal) + + camera = Camera() + + camera.iso = 100 + camera.exposure = 0 + camera.lightMeter = 2 + camera.shutter = 1./60. + camera.flash = 0 + + camera.solve() + print(camera.saveState()) + \ No newline at end of file diff --git a/pyqtgraph/parametertree/__init__.py b/pyqtgraph/parametertree/__init__.py index acdb7a37..722410d5 100644 --- a/pyqtgraph/parametertree/__init__.py +++ b/pyqtgraph/parametertree/__init__.py @@ -1,5 +1,5 @@ from .Parameter import Parameter, registerParameterType from .ParameterTree import ParameterTree from .ParameterItem import ParameterItem - +from .ParameterSystem import ParameterSystem, SystemSolver from . import parameterTypes as types \ No newline at end of file diff --git a/pyqtgraph/parametertree/parameterTypes.py b/pyqtgraph/parametertree/parameterTypes.py index 3300171f..7b1c5ee6 100644 --- a/pyqtgraph/parametertree/parameterTypes.py +++ b/pyqtgraph/parametertree/parameterTypes.py @@ -1,14 +1,14 @@ -from pyqtgraph.Qt import QtCore, QtGui -from pyqtgraph.python2_3 import asUnicode +from ..Qt import QtCore, QtGui +from ..python2_3 import asUnicode from .Parameter import Parameter, registerParameterType from .ParameterItem import ParameterItem -from pyqtgraph.widgets.SpinBox import SpinBox -from pyqtgraph.widgets.ColorButton import ColorButton -#from pyqtgraph.widgets.GradientWidget import GradientWidget ## creates import loop -import pyqtgraph as pg -import pyqtgraph.pixmaps as pixmaps +from ..widgets.SpinBox import SpinBox +from ..widgets.ColorButton import ColorButton +#from ..widgets.GradientWidget import GradientWidget ## creates import loop +from .. import pixmaps as pixmaps +from .. import functions as fn import os -from pyqtgraph.pgcollections import OrderedDict +from ..pgcollections import OrderedDict class WidgetParameterItem(ParameterItem): """ @@ -18,16 +18,16 @@ class WidgetParameterItem(ParameterItem): * simple widget for editing value (displayed instead of label when item is selected) * button that resets value to default - ================= ============================================================= - Registered Types: - int Displays a :class:`SpinBox ` in integer - mode. - float Displays a :class:`SpinBox `. - bool Displays a QCheckBox - str Displays a QLineEdit - color Displays a :class:`ColorButton ` - colormap Displays a :class:`GradientWidget ` - ================= ============================================================= + ========================== ============================================================= + **Registered Types:** + int Displays a :class:`SpinBox ` in integer + mode. + float Displays a :class:`SpinBox `. + bool Displays a QCheckBox + str Displays a QLineEdit + color Displays a :class:`ColorButton ` + colormap Displays a :class:`GradientWidget ` + ========================== ============================================================= This class can be subclassed by overriding makeWidget() to provide a custom widget. """ @@ -78,6 +78,7 @@ class WidgetParameterItem(ParameterItem): ## no starting value was given; use whatever the widget has self.widgetValueChanged() + self.updateDefaultBtn() def makeWidget(self): """ @@ -125,6 +126,7 @@ class WidgetParameterItem(ParameterItem): w.sigChanged = w.toggled w.value = w.isChecked w.setValue = w.setChecked + w.setEnabled(not opts.get('readonly', False)) self.hideWidget = False elif t == 'str': w = QtGui.QLineEdit() @@ -140,8 +142,9 @@ class WidgetParameterItem(ParameterItem): w.setValue = w.setColor self.hideWidget = False w.setFlat(True) + w.setEnabled(not opts.get('readonly', False)) elif t == 'colormap': - from pyqtgraph.widgets.GradientWidget import GradientWidget ## need this here to avoid import loop + from ..widgets.GradientWidget import GradientWidget ## need this here to avoid import loop w = GradientWidget(orientation='bottom') w.sigChanged = w.sigGradientChangeFinished w.sigChanging = w.sigGradientChanged @@ -189,6 +192,9 @@ class WidgetParameterItem(ParameterItem): def updateDefaultBtn(self): ## enable/disable default btn self.defaultBtn.setEnabled(not self.param.valueIsDefault() and self.param.writable()) + + # hide / show + self.defaultBtn.setVisible(not self.param.readonly()) def updateDisplayLabel(self, value=None): """Update the display label to reflect the value of the parameter.""" @@ -208,12 +214,14 @@ class WidgetParameterItem(ParameterItem): val = self.widget.value() newVal = self.param.setValue(val) - def widgetValueChanging(self): + def widgetValueChanging(self, *args): """ Called when the widget's value is changing, but not finalized. For example: editing text before pressing enter or changing focus. """ - pass + # This is a bit sketchy: assume the last argument of each signal is + # the value.. + self.param.sigValueChanging.emit(self.param, args[-1]) def selected(self, sel): """Called when this item has been selected (sel=True) OR deselected (sel=False)""" @@ -230,6 +238,8 @@ class WidgetParameterItem(ParameterItem): self.widget.show() self.displayLabel.hide() self.widget.setFocus(QtCore.Qt.OtherFocusReason) + if isinstance(self.widget, SpinBox): + self.widget.selectNumber() # select the numerical portion of the text for quick editing def hideEditor(self): self.widget.hide() @@ -272,6 +282,8 @@ class WidgetParameterItem(ParameterItem): if 'readonly' in opts: self.updateDefaultBtn() + if isinstance(self.widget, (QtGui.QCheckBox,ColorButton)): + self.widget.setEnabled(not opts['readonly']) ## If widget is a SpinBox, pass options straight through if isinstance(self.widget, SpinBox): @@ -279,6 +291,9 @@ class WidgetParameterItem(ParameterItem): opts['suffix'] = opts['units'] self.widget.setOpts(**opts) self.updateDisplayLabel() + + + class EventProxy(QtCore.QObject): def __init__(self, qobj, callback): @@ -304,11 +319,11 @@ class SimpleParameter(Parameter): self.saveState = self.saveColorState def colorValue(self): - return pg.mkColor(Parameter.value(self)) + return fn.mkColor(Parameter.value(self)) - def saveColorState(self): - state = Parameter.saveState(self) - state['value'] = pg.colorTuple(self.value()) + def saveColorState(self, *args, **kwds): + state = Parameter.saveState(self, *args, **kwds) + state['value'] = fn.colorTuple(self.value()) return state @@ -530,8 +545,7 @@ class ListParameter(Parameter): self.forward, self.reverse = self.mapping(limits) Parameter.setLimits(self, limits) - #print self.name(), self.value(), limits - if len(self.reverse) > 0 and self.value() not in self.reverse[0]: + if len(self.reverse[0]) > 0 and self.value() not in self.reverse[0]: self.setValue(self.reverse[0][0]) #def addItem(self, name, value=None): @@ -634,6 +648,7 @@ class TextParameterItem(WidgetParameterItem): def makeWidget(self): self.textBox = QtGui.QTextEdit() self.textBox.setMaximumHeight(100) + self.textBox.setReadOnly(self.param.opts.get('readonly', False)) self.textBox.value = lambda: str(self.textBox.toPlainText()) self.textBox.setValue = self.textBox.setPlainText self.textBox.sigChanged = self.textBox.textChanged diff --git a/pyqtgraph/parametertree/tests/test_parametertypes.py b/pyqtgraph/parametertree/tests/test_parametertypes.py new file mode 100644 index 00000000..c7cd2cb3 --- /dev/null +++ b/pyqtgraph/parametertree/tests/test_parametertypes.py @@ -0,0 +1,18 @@ +import pyqtgraph.parametertree as pt +import pyqtgraph as pg +app = pg.mkQApp() + +def test_opts(): + paramSpec = [ + dict(name='bool', type='bool', readonly=True), + dict(name='color', type='color', readonly=True), + ] + + param = pt.Parameter.create(name='params', type='group', children=paramSpec) + tree = pt.ParameterTree() + tree.setParameters(param) + + assert param.param('bool').items.keys()[0].widget.isEnabled() is False + assert param.param('color').items.keys()[0].widget.isEnabled() is False + + diff --git a/pyqtgraph/pixmaps/__init__.py b/pyqtgraph/pixmaps/__init__.py index 42bd3276..c26e4a6b 100644 --- a/pyqtgraph/pixmaps/__init__.py +++ b/pyqtgraph/pixmaps/__init__.py @@ -1,26 +1,26 @@ -""" -Allows easy loading of pixmaps used in UI elements. -Provides support for frozen environments as well. -""" - -import os, sys, pickle -from ..functions import makeQImage -from ..Qt import QtGui -if sys.version_info[0] == 2: - from . import pixmapData_2 as pixmapData -else: - from . import pixmapData_3 as pixmapData - - -def getPixmap(name): - """ - Return a QPixmap corresponding to the image file with the given name. - (eg. getPixmap('auto') loads pyqtgraph/pixmaps/auto.png) - """ - key = name+'.png' - data = pixmapData.pixmapData[key] - if isinstance(data, basestring) or isinstance(data, bytes): - pixmapData.pixmapData[key] = pickle.loads(data) - arr = pixmapData.pixmapData[key] - return QtGui.QPixmap(makeQImage(arr, alpha=True)) - +""" +Allows easy loading of pixmaps used in UI elements. +Provides support for frozen environments as well. +""" + +import os, sys, pickle +from ..functions import makeQImage +from ..Qt import QtGui +if sys.version_info[0] == 2: + from . import pixmapData_2 as pixmapData +else: + from . import pixmapData_3 as pixmapData + + +def getPixmap(name): + """ + Return a QPixmap corresponding to the image file with the given name. + (eg. getPixmap('auto') loads pyqtgraph/pixmaps/auto.png) + """ + key = name+'.png' + data = pixmapData.pixmapData[key] + if isinstance(data, basestring) or isinstance(data, bytes): + pixmapData.pixmapData[key] = pickle.loads(data) + arr = pixmapData.pixmapData[key] + return QtGui.QPixmap(makeQImage(arr, alpha=True)) + diff --git a/pyqtgraph/pixmaps/compile.py b/pyqtgraph/pixmaps/compile.py index ae07d487..fa0d2408 100644 --- a/pyqtgraph/pixmaps/compile.py +++ b/pyqtgraph/pixmaps/compile.py @@ -1,19 +1,19 @@ -import numpy as np -from PyQt4 import QtGui -import os, pickle, sys - -path = os.path.abspath(os.path.split(__file__)[0]) -pixmaps = {} -for f in os.listdir(path): - if not f.endswith('.png'): - continue - print(f) - img = QtGui.QImage(os.path.join(path, f)) - ptr = img.bits() - ptr.setsize(img.byteCount()) - arr = np.asarray(ptr).reshape(img.height(), img.width(), 4).transpose(1,0,2) - pixmaps[f] = pickle.dumps(arr) -ver = sys.version_info[0] -fh = open(os.path.join(path, 'pixmapData_%d.py' %ver), 'w') -fh.write("import numpy as np; pixmapData=%s" % repr(pixmaps)) - +import numpy as np +from PyQt4 import QtGui +import os, pickle, sys + +path = os.path.abspath(os.path.split(__file__)[0]) +pixmaps = {} +for f in os.listdir(path): + if not f.endswith('.png'): + continue + print(f) + img = QtGui.QImage(os.path.join(path, f)) + ptr = img.bits() + ptr.setsize(img.byteCount()) + arr = np.asarray(ptr).reshape(img.height(), img.width(), 4).transpose(1,0,2) + pixmaps[f] = pickle.dumps(arr) +ver = sys.version_info[0] +fh = open(os.path.join(path, 'pixmapData_%d.py' %ver), 'w') +fh.write("import numpy as np; pixmapData=%s" % repr(pixmaps)) + diff --git a/pyqtgraph/python2_3.py b/pyqtgraph/python2_3.py index 2182d3a1..b1c46f26 100644 --- a/pyqtgraph/python2_3.py +++ b/pyqtgraph/python2_3.py @@ -1,5 +1,5 @@ """ -Helper functions which smooth out the differences between python 2 and 3. +Helper functions that smooth out the differences between python 2 and 3. """ import sys diff --git a/pyqtgraph/tests/test_exit_crash.py b/pyqtgraph/tests/test_exit_crash.py new file mode 100644 index 00000000..69181f21 --- /dev/null +++ b/pyqtgraph/tests/test_exit_crash.py @@ -0,0 +1,38 @@ +import os, sys, subprocess, tempfile +import pyqtgraph as pg + + +code = """ +import sys +sys.path.insert(0, '{path}') +import pyqtgraph as pg +app = pg.mkQApp() +w = pg.{classname}({args}) +""" + + +def test_exit_crash(): + # For each Widget subclass, run a simple python script that creates an + # instance and then shuts down. The intent is to check for segmentation + # faults when each script exits. + tmp = tempfile.mktemp(".py") + path = os.path.dirname(pg.__file__) + + initArgs = { + 'CheckTable': "[]", + 'ProgressDialog': '"msg"', + 'VerticalLabel': '"msg"', + } + + for name in dir(pg): + obj = getattr(pg, name) + if not isinstance(obj, type) or not issubclass(obj, pg.QtGui.QWidget): + continue + + print name + argstr = initArgs.get(name, "") + open(tmp, 'w').write(code.format(path=path, classname=name, args=argstr)) + proc = subprocess.Popen([sys.executable, tmp]) + assert proc.wait() == 0 + + os.remove(tmp) diff --git a/pyqtgraph/tests/test_functions.py b/pyqtgraph/tests/test_functions.py new file mode 100644 index 00000000..f622dd87 --- /dev/null +++ b/pyqtgraph/tests/test_functions.py @@ -0,0 +1,81 @@ +import pyqtgraph as pg +import numpy as np +from numpy.testing import assert_array_almost_equal, assert_almost_equal + +np.random.seed(12345) + +def testSolve3D(): + p1 = np.array([[0,0,0,1], + [1,0,0,1], + [0,1,0,1], + [0,0,1,1]], dtype=float) + + # transform points through random matrix + tr = np.random.normal(size=(4, 4)) + tr[3] = (0,0,0,1) + p2 = np.dot(tr, p1.T).T[:,:3] + + # solve to see if we can recover the transformation matrix. + tr2 = pg.solve3DTransform(p1, p2) + + assert_array_almost_equal(tr[:3], tr2[:3]) + + +def test_interpolateArray(): + data = np.array([[ 1., 2., 4. ], + [ 10., 20., 40. ], + [ 100., 200., 400.]]) + + x = np.array([[ 0.3, 0.6], + [ 1. , 1. ], + [ 0.5, 1. ], + [ 0.5, 2.5], + [ 10. , 10. ]]) + + result = pg.interpolateArray(data, x) + + #import scipy.ndimage + #spresult = scipy.ndimage.map_coordinates(data, x.T, order=1) + spresult = np.array([ 5.92, 20. , 11. , 0. , 0. ]) # generated with the above line + + assert_array_almost_equal(result, spresult) + + # test mapping when x.shape[-1] < data.ndim + x = np.array([[ 0.3, 0], + [ 0.3, 1], + [ 0.3, 2]]) + + r1 = pg.interpolateArray(data, x) + r2 = pg.interpolateArray(data, x[0,:1]) + assert_array_almost_equal(r1, r2) + + + # test mapping 2D array of locations + x = np.array([[[0.5, 0.5], [0.5, 1.0], [0.5, 1.5]], + [[1.5, 0.5], [1.5, 1.0], [1.5, 1.5]]]) + + r1 = pg.interpolateArray(data, x) + #r2 = scipy.ndimage.map_coordinates(data, x.transpose(2,0,1), order=1) + r2 = np.array([[ 8.25, 11. , 16.5 ], # generated with the above line + [ 82.5 , 110. , 165. ]]) + + assert_array_almost_equal(r1, r2) + +def test_subArray(): + a = np.array([0, 0, 111, 112, 113, 0, 121, 122, 123, 0, 0, 0, 211, 212, 213, 0, 221, 222, 223, 0, 0, 0, 0]) + b = pg.subArray(a, offset=2, shape=(2,2,3), stride=(10,4,1)) + c = np.array([[[111,112,113], [121,122,123]], [[211,212,213], [221,222,223]]]) + assert np.all(b == c) + + # operate over first axis; broadcast over the rest + aa = np.vstack([a, a/100.]).T + cc = np.empty(c.shape + (2,)) + cc[..., 0] = c + cc[..., 1] = c / 100. + bb = pg.subArray(aa, offset=2, shape=(2,2,3), stride=(10,4,1)) + assert np.all(bb == cc) + + + +if __name__ == '__main__': + test_interpolateArray() \ No newline at end of file diff --git a/pyqtgraph/tests/test_qt.py b/pyqtgraph/tests/test_qt.py new file mode 100644 index 00000000..729bf695 --- /dev/null +++ b/pyqtgraph/tests/test_qt.py @@ -0,0 +1,23 @@ +import pyqtgraph as pg +import gc, os + +app = pg.mkQApp() + +def test_isQObjectAlive(): + o1 = pg.QtCore.QObject() + o2 = pg.QtCore.QObject() + o2.setParent(o1) + del o1 + gc.collect() + assert not pg.Qt.isQObjectAlive(o2) + + +def test_loadUiType(): + path = os.path.dirname(__file__) + formClass, baseClass = pg.Qt.loadUiType(os.path.join(path, 'uictest.ui')) + w = baseClass() + ui = formClass() + ui.setupUi(w) + w.show() + app.processEvents() + diff --git a/pyqtgraph/tests/test_ref_cycles.py b/pyqtgraph/tests/test_ref_cycles.py new file mode 100644 index 00000000..0284852c --- /dev/null +++ b/pyqtgraph/tests/test_ref_cycles.py @@ -0,0 +1,77 @@ +""" +Test for unwanted reference cycles + +""" +import pyqtgraph as pg +import numpy as np +import gc, weakref +app = pg.mkQApp() + +def assert_alldead(refs): + for ref in refs: + assert ref() is None + +def qObjectTree(root): + """Return root and its entire tree of qobject children""" + childs = [root] + for ch in pg.QtCore.QObject.children(root): + childs += qObjectTree(ch) + return childs + +def mkrefs(*objs): + """Return a list of weakrefs to each object in *objs. + QObject instances are expanded to include all child objects. + """ + allObjs = {} + for obj in objs: + if isinstance(obj, pg.QtCore.QObject): + obj = qObjectTree(obj) + else: + obj = [obj] + for o in obj: + allObjs[id(o)] = o + + return map(weakref.ref, allObjs.values()) + +def test_PlotWidget(): + def mkobjs(*args, **kwds): + w = pg.PlotWidget(*args, **kwds) + data = pg.np.array([1,5,2,4,3]) + c = w.plot(data, name='stuff') + w.addLegend() + + # test that connections do not keep objects alive + w.plotItem.vb.sigRangeChanged.connect(mkrefs) + app.focusChanged.connect(w.plotItem.vb.invertY) + + # return weakrefs to a bunch of objects that should die when the scope exits. + return mkrefs(w, c, data, w.plotItem, w.plotItem.vb, w.plotItem.getMenu(), w.plotItem.getAxis('left')) + + for i in range(5): + assert_alldead(mkobjs()) + +def test_ImageView(): + def mkobjs(): + iv = pg.ImageView() + data = np.zeros((10,10,5)) + iv.setImage(data) + + return mkrefs(iv, iv.imageItem, iv.view, iv.ui.histogram, data) + + for i in range(5): + assert_alldead(mkobjs()) + +def test_GraphicsWindow(): + def mkobjs(): + w = pg.GraphicsWindow() + p1 = w.addPlot() + v1 = w.addViewBox() + return mkrefs(w, p1, v1) + + for i in range(5): + assert_alldead(mkobjs()) + + + +if __name__ == '__main__': + ot = test_PlotItem() diff --git a/pyqtgraph/tests/test_srttransform3d.py b/pyqtgraph/tests/test_srttransform3d.py new file mode 100644 index 00000000..88aa1581 --- /dev/null +++ b/pyqtgraph/tests/test_srttransform3d.py @@ -0,0 +1,39 @@ +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np +from numpy.testing import assert_array_almost_equal, assert_almost_equal + +testPoints = np.array([ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [-1, -1, 0], + [0, -1, -1]]) + + +def testMatrix(): + """ + SRTTransform3D => Transform3D => SRTTransform3D + """ + tr = pg.SRTTransform3D() + tr.setRotate(45, (0, 0, 1)) + tr.setScale(0.2, 0.4, 1) + tr.setTranslate(10, 20, 40) + assert tr.getRotation() == (45, QtGui.QVector3D(0, 0, 1)) + assert tr.getScale() == QtGui.QVector3D(0.2, 0.4, 1) + assert tr.getTranslation() == QtGui.QVector3D(10, 20, 40) + + tr2 = pg.Transform3D(tr) + assert np.all(tr.matrix() == tr2.matrix()) + + # This is the most important test: + # The transition from Transform3D to SRTTransform3D is a tricky one. + tr3 = pg.SRTTransform3D(tr2) + assert_array_almost_equal(tr.matrix(), tr3.matrix()) + assert_almost_equal(tr3.getRotation()[0], tr.getRotation()[0]) + assert_array_almost_equal(tr3.getRotation()[1], tr.getRotation()[1]) + assert_array_almost_equal(tr3.getScale(), tr.getScale()) + assert_array_almost_equal(tr3.getTranslation(), tr.getTranslation()) + + diff --git a/pyqtgraph/tests/test_stability.py b/pyqtgraph/tests/test_stability.py new file mode 100644 index 00000000..a64e30e4 --- /dev/null +++ b/pyqtgraph/tests/test_stability.py @@ -0,0 +1,160 @@ +""" +PyQt/PySide stress test: + +Create lots of random widgets and graphics items, connect them together randomly, +the tear them down repeatedly. + +The purpose of this is to attempt to generate segmentation faults. +""" +from PyQt4.QtTest import QTest +import pyqtgraph as pg +from random import seed, randint +import sys, gc, weakref + +app = pg.mkQApp() + +seed(12345) + +widgetTypes = [ + pg.PlotWidget, + pg.ImageView, + pg.GraphicsView, + pg.QtGui.QWidget, + pg.QtGui.QTreeWidget, + pg.QtGui.QPushButton, + ] + +itemTypes = [ + pg.PlotCurveItem, + pg.ImageItem, + pg.PlotDataItem, + pg.ViewBox, + pg.QtGui.QGraphicsRectItem + ] + +widgets = [] +items = [] +allWidgets = weakref.WeakSet() + + +def crashtest(): + global allWidgets + try: + gc.disable() + actions = [ + createWidget, + #setParent, + forgetWidget, + showWidget, + processEvents, + #raiseException, + #addReference, + ] + + thread = WorkThread() + thread.start() + + while True: + try: + action = randItem(actions) + action() + print('[%d widgets alive, %d zombie]' % (len(allWidgets), len(allWidgets) - len(widgets))) + except KeyboardInterrupt: + print("Caught interrupt; send another to exit.") + try: + for i in range(100): + QTest.qWait(100) + except KeyboardInterrupt: + thread.terminate() + break + except: + sys.excepthook(*sys.exc_info()) + finally: + gc.enable() + + + +class WorkThread(pg.QtCore.QThread): + '''Intended to give the gc an opportunity to run from a non-gui thread.''' + def run(self): + i = 0 + while True: + i += 1 + if (i % 1000000) == 0: + print('--worker--') + + +def randItem(items): + return items[randint(0, len(items)-1)] + +def p(msg): + print(msg) + sys.stdout.flush() + +def createWidget(): + p('create widget') + global widgets, allWidgets + if len(widgets) > 50: + return + widget = randItem(widgetTypes)() + widget.setWindowTitle(widget.__class__.__name__) + widgets.append(widget) + allWidgets.add(widget) + p(" %s" % widget) + return widget + +def setParent(): + p('set parent') + global widgets + if len(widgets) < 2: + return + child = parent = None + while child is parent: + child = randItem(widgets) + parent = randItem(widgets) + p(" %s parent of %s" % (parent, child)) + child.setParent(parent) + +def forgetWidget(): + p('forget widget') + global widgets + if len(widgets) < 1: + return + widget = randItem(widgets) + p(' %s' % widget) + widgets.remove(widget) + +def showWidget(): + p('show widget') + global widgets + if len(widgets) < 1: + return + widget = randItem(widgets) + p(' %s' % widget) + widget.show() + +def processEvents(): + p('process events') + QTest.qWait(25) + +class TstException(Exception): + pass + +def raiseException(): + p('raise exception') + raise TstException("A test exception") + +def addReference(): + p('add reference') + global widgets + if len(widgets) < 1: + return + obj1 = randItem(widgets) + obj2 = randItem(widgets) + p(' %s -> %s' % (obj1, obj2)) + obj1._testref = obj2 + + + +if __name__ == '__main__': + test_stability() \ No newline at end of file diff --git a/pyqtgraph/tests/uictest.ui b/pyqtgraph/tests/uictest.ui new file mode 100644 index 00000000..25d14f2b --- /dev/null +++ b/pyqtgraph/tests/uictest.ui @@ -0,0 +1,53 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + 10 + 10 + 120 + 80 + + + + + + + 10 + 110 + 120 + 80 + + + + + + + PlotWidget + QWidget +
pyqtgraph
+ 1 +
+ + ImageView + QWidget +
pyqtgraph
+ 1 +
+
+ + +
diff --git a/tests/__init__.py b/pyqtgraph/util/__init__.py similarity index 100% rename from tests/__init__.py rename to pyqtgraph/util/__init__.py diff --git a/pyqtgraph/util/colorama/LICENSE.txt b/pyqtgraph/util/colorama/LICENSE.txt new file mode 100644 index 00000000..5f567799 --- /dev/null +++ b/pyqtgraph/util/colorama/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright (c) 2010 Jonathan Hartley +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holders, nor those of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/pyqtgraph/util/colorama/README.txt b/pyqtgraph/util/colorama/README.txt new file mode 100644 index 00000000..8910ba5b --- /dev/null +++ b/pyqtgraph/util/colorama/README.txt @@ -0,0 +1,304 @@ +Download and docs: + http://pypi.python.org/pypi/colorama +Development: + http://code.google.com/p/colorama +Discussion group: + https://groups.google.com/forum/#!forum/python-colorama + +Description +=========== + +Makes ANSI escape character sequences for producing colored terminal text and +cursor positioning work under MS Windows. + +ANSI escape character sequences have long been used to produce colored terminal +text and cursor positioning on Unix and Macs. Colorama makes this work on +Windows, too, by wrapping stdout, stripping ANSI sequences it finds (which +otherwise show up as gobbledygook in your output), and converting them into the +appropriate win32 calls to modify the state of the terminal. On other platforms, +Colorama does nothing. + +Colorama also provides some shortcuts to help generate ANSI sequences +but works fine in conjunction with any other ANSI sequence generation library, +such as Termcolor (http://pypi.python.org/pypi/termcolor.) + +This has the upshot of providing a simple cross-platform API for printing +colored terminal text from Python, and has the happy side-effect that existing +applications or libraries which use ANSI sequences to produce colored output on +Linux or Macs can now also work on Windows, simply by calling +``colorama.init()``. + +An alternative approach is to install 'ansi.sys' on Windows machines, which +provides the same behaviour for all applications running in terminals. Colorama +is intended for situations where that isn't easy (e.g. maybe your app doesn't +have an installer.) + +Demo scripts in the source code repository prints some colored text using +ANSI sequences. Compare their output under Gnome-terminal's built in ANSI +handling, versus on Windows Command-Prompt using Colorama: + +.. image:: http://colorama.googlecode.com/hg/screenshots/ubuntu-demo.png + :width: 661 + :height: 357 + :alt: ANSI sequences on Ubuntu under gnome-terminal. + +.. image:: http://colorama.googlecode.com/hg/screenshots/windows-demo.png + :width: 668 + :height: 325 + :alt: Same ANSI sequences on Windows, using Colorama. + +These screengrabs show that Colorama on Windows does not support ANSI 'dim +text': it looks the same as 'normal text'. + + +License +======= + +Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. + + +Dependencies +============ + +None, other than Python. Tested on Python 2.5.5, 2.6.5, 2.7, 3.1.2, and 3.2 + +Usage +===== + +Initialisation +-------------- + +Applications should initialise Colorama using:: + + from colorama import init + init() + +If you are on Windows, the call to ``init()`` will start filtering ANSI escape +sequences out of any text sent to stdout or stderr, and will replace them with +equivalent Win32 calls. + +Calling ``init()`` has no effect on other platforms (unless you request other +optional functionality, see keyword args below.) The intention is that +applications can call ``init()`` unconditionally on all platforms, after which +ANSI output should just work. + +To stop using colorama before your program exits, simply call ``deinit()``. +This will restore stdout and stderr to their original values, so that Colorama +is disabled. To start using Colorama again, call ``reinit()``, which wraps +stdout and stderr again, but is cheaper to call than doing ``init()`` all over +again. + + +Colored Output +-------------- + +Cross-platform printing of colored text can then be done using Colorama's +constant shorthand for ANSI escape sequences:: + + from colorama import Fore, Back, Style + print(Fore.RED + 'some red text') + print(Back.GREEN + 'and with a green background') + print(Style.DIM + 'and in dim text') + print(Fore.RESET + Back.RESET + Style.RESET_ALL) + print('back to normal now') + +or simply by manually printing ANSI sequences from your own code:: + + print('/033[31m' + 'some red text') + print('/033[30m' # and reset to default color) + +or Colorama can be used happily in conjunction with existing ANSI libraries +such as Termcolor:: + + from colorama import init + from termcolor import colored + + # use Colorama to make Termcolor work on Windows too + init() + + # then use Termcolor for all colored text output + print(colored('Hello, World!', 'green', 'on_red')) + +Available formatting constants are:: + + Fore: BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, RESET. + Back: BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, RESET. + Style: DIM, NORMAL, BRIGHT, RESET_ALL + +Style.RESET_ALL resets foreground, background and brightness. Colorama will +perform this reset automatically on program exit. + + +Cursor Positioning +------------------ + +ANSI codes to reposition the cursor are supported. See demos/demo06.py for +an example of how to generate them. + + +Init Keyword Args +----------------- + +``init()`` accepts some kwargs to override default behaviour. + +init(autoreset=False): + If you find yourself repeatedly sending reset sequences to turn off color + changes at the end of every print, then ``init(autoreset=True)`` will + automate that:: + + from colorama import init + init(autoreset=True) + print(Fore.RED + 'some red text') + print('automatically back to default color again') + +init(strip=None): + Pass ``True`` or ``False`` to override whether ansi codes should be + stripped from the output. The default behaviour is to strip if on Windows. + +init(convert=None): + Pass ``True`` or ``False`` to override whether to convert ansi codes in the + output into win32 calls. The default behaviour is to convert if on Windows + and output is to a tty (terminal). + +init(wrap=True): + On Windows, colorama works by replacing ``sys.stdout`` and ``sys.stderr`` + with proxy objects, which override the .write() method to do their work. If + this wrapping causes you problems, then this can be disabled by passing + ``init(wrap=False)``. The default behaviour is to wrap if autoreset or + strip or convert are True. + + When wrapping is disabled, colored printing on non-Windows platforms will + continue to work as normal. To do cross-platform colored output, you can + use Colorama's ``AnsiToWin32`` proxy directly:: + + import sys + from colorama import init, AnsiToWin32 + init(wrap=False) + stream = AnsiToWin32(sys.stderr).stream + + # Python 2 + print >>stream, Fore.BLUE + 'blue text on stderr' + + # Python 3 + print(Fore.BLUE + 'blue text on stderr', file=stream) + + +Status & Known Problems +======================= + +I've personally only tested it on WinXP (CMD, Console2), Ubuntu +(gnome-terminal, xterm), and OSX. + +Some presumably valid ANSI sequences aren't recognised (see details below) +but to my knowledge nobody has yet complained about this. Puzzling. + +See outstanding issues and wishlist at: +http://code.google.com/p/colorama/issues/list + +If anything doesn't work for you, or doesn't do what you expected or hoped for, +I'd love to hear about it on that issues list, would be delighted by patches, +and would be happy to grant commit access to anyone who submits a working patch +or two. + + +Recognised ANSI Sequences +========================= + +ANSI sequences generally take the form: + + ESC [ ; ... + +Where is an integer, and is a single letter. Zero or more +params are passed to a . If no params are passed, it is generally +synonymous with passing a single zero. No spaces exist in the sequence, they +have just been inserted here to make it easy to read. + +The only ANSI sequences that colorama converts into win32 calls are:: + + ESC [ 0 m # reset all (colors and brightness) + ESC [ 1 m # bright + ESC [ 2 m # dim (looks same as normal brightness) + ESC [ 22 m # normal brightness + + # FOREGROUND: + ESC [ 30 m # black + ESC [ 31 m # red + ESC [ 32 m # green + ESC [ 33 m # yellow + ESC [ 34 m # blue + ESC [ 35 m # magenta + ESC [ 36 m # cyan + ESC [ 37 m # white + ESC [ 39 m # reset + + # BACKGROUND + ESC [ 40 m # black + ESC [ 41 m # red + ESC [ 42 m # green + ESC [ 43 m # yellow + ESC [ 44 m # blue + ESC [ 45 m # magenta + ESC [ 46 m # cyan + ESC [ 47 m # white + ESC [ 49 m # reset + + # cursor positioning + ESC [ y;x H # position cursor at x across, y down + + # clear the screen + ESC [ mode J # clear the screen. Only mode 2 (clear entire screen) + # is supported. It should be easy to add other modes, + # let me know if that would be useful. + +Multiple numeric params to the 'm' command can be combined into a single +sequence, eg:: + + ESC [ 36 ; 45 ; 1 m # bright cyan text on magenta background + +All other ANSI sequences of the form ``ESC [ ; ... `` +are silently stripped from the output on Windows. + +Any other form of ANSI sequence, such as single-character codes or alternative +initial characters, are not recognised nor stripped. It would be cool to add +them though. Let me know if it would be useful for you, via the issues on +google code. + + +Development +=========== + +Help and fixes welcome! Ask Jonathan for commit rights, you'll get them. + +Running tests requires: + +- Michael Foord's 'mock' module to be installed. +- Tests are written using the 2010 era updates to 'unittest', and require to + be run either using Python2.7 or greater, or else to have Michael Foord's + 'unittest2' module installed. + +unittest2 test discovery doesn't work for colorama, so I use 'nose':: + + nosetests -s + +The -s is required because 'nosetests' otherwise applies a proxy of its own to +stdout, which confuses the unit tests. + + +Contact +======= + +Created by Jonathan Hartley, tartley@tartley.com + + +Thanks +====== +| Ben Hoyt, for a magnificent fix under 64-bit Windows. +| Jesse@EmptySquare for submitting a fix for examples in the README. +| User 'jamessp', an observant documentation fix for cursor positioning. +| User 'vaal1239', Dave Mckee & Lackner Kristof for a tiny but much-needed Win7 fix. +| Julien Stuyck, for wisely suggesting Python3 compatible updates to README. +| Daniel Griffith for multiple fabulous patches. +| Oscar Lesta for valuable fix to stop ANSI chars being sent to non-tty output. +| Roger Binns, for many suggestions, valuable feedback, & bug reports. +| Tim Golden for thought and much appreciated feedback on the initial idea. + diff --git a/pyqtgraph/util/colorama/__init__.py b/pyqtgraph/util/colorama/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyqtgraph/util/colorama/win32.py b/pyqtgraph/util/colorama/win32.py new file mode 100644 index 00000000..c86ce180 --- /dev/null +++ b/pyqtgraph/util/colorama/win32.py @@ -0,0 +1,137 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. + +# from winbase.h +STDOUT = -11 +STDERR = -12 + +try: + from ctypes import windll + from ctypes import wintypes +except ImportError: + windll = None + SetConsoleTextAttribute = lambda *_: None +else: + from ctypes import ( + byref, Structure, c_char, c_short, c_int, c_uint32, c_ushort, c_void_p, POINTER + ) + + class CONSOLE_SCREEN_BUFFER_INFO(Structure): + """struct in wincon.h.""" + _fields_ = [ + ("dwSize", wintypes._COORD), + ("dwCursorPosition", wintypes._COORD), + ("wAttributes", wintypes.WORD), + ("srWindow", wintypes.SMALL_RECT), + ("dwMaximumWindowSize", wintypes._COORD), + ] + def __str__(self): + return '(%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d)' % ( + self.dwSize.Y, self.dwSize.X + , self.dwCursorPosition.Y, self.dwCursorPosition.X + , self.wAttributes + , self.srWindow.Top, self.srWindow.Left, self.srWindow.Bottom, self.srWindow.Right + , self.dwMaximumWindowSize.Y, self.dwMaximumWindowSize.X + ) + + _GetStdHandle = windll.kernel32.GetStdHandle + _GetStdHandle.argtypes = [ + wintypes.DWORD, + ] + _GetStdHandle.restype = wintypes.HANDLE + + _GetConsoleScreenBufferInfo = windll.kernel32.GetConsoleScreenBufferInfo + _GetConsoleScreenBufferInfo.argtypes = [ + wintypes.HANDLE, + c_void_p, + #POINTER(CONSOLE_SCREEN_BUFFER_INFO), + ] + _GetConsoleScreenBufferInfo.restype = wintypes.BOOL + + _SetConsoleTextAttribute = windll.kernel32.SetConsoleTextAttribute + _SetConsoleTextAttribute.argtypes = [ + wintypes.HANDLE, + wintypes.WORD, + ] + _SetConsoleTextAttribute.restype = wintypes.BOOL + + _SetConsoleCursorPosition = windll.kernel32.SetConsoleCursorPosition + _SetConsoleCursorPosition.argtypes = [ + wintypes.HANDLE, + c_int, + #wintypes._COORD, + ] + _SetConsoleCursorPosition.restype = wintypes.BOOL + + _FillConsoleOutputCharacterA = windll.kernel32.FillConsoleOutputCharacterA + _FillConsoleOutputCharacterA.argtypes = [ + wintypes.HANDLE, + c_char, + wintypes.DWORD, + wintypes._COORD, + POINTER(wintypes.DWORD), + ] + _FillConsoleOutputCharacterA.restype = wintypes.BOOL + + _FillConsoleOutputAttribute = windll.kernel32.FillConsoleOutputAttribute + _FillConsoleOutputAttribute.argtypes = [ + wintypes.HANDLE, + wintypes.WORD, + wintypes.DWORD, + c_int, + #wintypes._COORD, + POINTER(wintypes.DWORD), + ] + _FillConsoleOutputAttribute.restype = wintypes.BOOL + + handles = { + STDOUT: _GetStdHandle(STDOUT), + STDERR: _GetStdHandle(STDERR), + } + + def GetConsoleScreenBufferInfo(stream_id=STDOUT): + handle = handles[stream_id] + csbi = CONSOLE_SCREEN_BUFFER_INFO() + success = _GetConsoleScreenBufferInfo( + handle, byref(csbi)) + return csbi + + def SetConsoleTextAttribute(stream_id, attrs): + handle = handles[stream_id] + return _SetConsoleTextAttribute(handle, attrs) + + def SetConsoleCursorPosition(stream_id, position): + position = wintypes._COORD(*position) + # If the position is out of range, do nothing. + if position.Y <= 0 or position.X <= 0: + return + # Adjust for Windows' SetConsoleCursorPosition: + # 1. being 0-based, while ANSI is 1-based. + # 2. expecting (x,y), while ANSI uses (y,x). + adjusted_position = wintypes._COORD(position.Y - 1, position.X - 1) + # Adjust for viewport's scroll position + sr = GetConsoleScreenBufferInfo(STDOUT).srWindow + adjusted_position.Y += sr.Top + adjusted_position.X += sr.Left + # Resume normal processing + handle = handles[stream_id] + return _SetConsoleCursorPosition(handle, adjusted_position) + + def FillConsoleOutputCharacter(stream_id, char, length, start): + handle = handles[stream_id] + char = c_char(char) + length = wintypes.DWORD(length) + num_written = wintypes.DWORD(0) + # Note that this is hard-coded for ANSI (vs wide) bytes. + success = _FillConsoleOutputCharacterA( + handle, char, length, start, byref(num_written)) + return num_written.value + + def FillConsoleOutputAttribute(stream_id, attr, length, start): + ''' FillConsoleOutputAttribute( hConsole, csbi.wAttributes, dwConSize, coordScreen, &cCharsWritten )''' + handle = handles[stream_id] + attribute = wintypes.WORD(attr) + length = wintypes.DWORD(length) + num_written = wintypes.DWORD(0) + # Note that this is hard-coded for ANSI (vs wide) bytes. + return _FillConsoleOutputAttribute( + handle, attribute, length, start, byref(num_written)) diff --git a/pyqtgraph/util/colorama/winterm.py b/pyqtgraph/util/colorama/winterm.py new file mode 100644 index 00000000..9c1c8185 --- /dev/null +++ b/pyqtgraph/util/colorama/winterm.py @@ -0,0 +1,120 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +from . import win32 + + +# from wincon.h +class WinColor(object): + BLACK = 0 + BLUE = 1 + GREEN = 2 + CYAN = 3 + RED = 4 + MAGENTA = 5 + YELLOW = 6 + GREY = 7 + +# from wincon.h +class WinStyle(object): + NORMAL = 0x00 # dim text, dim background + BRIGHT = 0x08 # bright text, dim background + + +class WinTerm(object): + + def __init__(self): + self._default = win32.GetConsoleScreenBufferInfo(win32.STDOUT).wAttributes + self.set_attrs(self._default) + self._default_fore = self._fore + self._default_back = self._back + self._default_style = self._style + + def get_attrs(self): + return self._fore + self._back * 16 + self._style + + def set_attrs(self, value): + self._fore = value & 7 + self._back = (value >> 4) & 7 + self._style = value & WinStyle.BRIGHT + + def reset_all(self, on_stderr=None): + self.set_attrs(self._default) + self.set_console(attrs=self._default) + + def fore(self, fore=None, on_stderr=False): + if fore is None: + fore = self._default_fore + self._fore = fore + self.set_console(on_stderr=on_stderr) + + def back(self, back=None, on_stderr=False): + if back is None: + back = self._default_back + self._back = back + self.set_console(on_stderr=on_stderr) + + def style(self, style=None, on_stderr=False): + if style is None: + style = self._default_style + self._style = style + self.set_console(on_stderr=on_stderr) + + def set_console(self, attrs=None, on_stderr=False): + if attrs is None: + attrs = self.get_attrs() + handle = win32.STDOUT + if on_stderr: + handle = win32.STDERR + win32.SetConsoleTextAttribute(handle, attrs) + + def get_position(self, handle): + position = win32.GetConsoleScreenBufferInfo(handle).dwCursorPosition + # Because Windows coordinates are 0-based, + # and win32.SetConsoleCursorPosition expects 1-based. + position.X += 1 + position.Y += 1 + return position + + def set_cursor_position(self, position=None, on_stderr=False): + if position is None: + #I'm not currently tracking the position, so there is no default. + #position = self.get_position() + return + handle = win32.STDOUT + if on_stderr: + handle = win32.STDERR + win32.SetConsoleCursorPosition(handle, position) + + def cursor_up(self, num_rows=0, on_stderr=False): + if num_rows == 0: + return + handle = win32.STDOUT + if on_stderr: + handle = win32.STDERR + position = self.get_position(handle) + adjusted_position = (position.Y - num_rows, position.X) + self.set_cursor_position(adjusted_position, on_stderr) + + def erase_data(self, mode=0, on_stderr=False): + # 0 (or None) should clear from the cursor to the end of the screen. + # 1 should clear from the cursor to the beginning of the screen. + # 2 should clear the entire screen. (And maybe move cursor to (1,1)?) + # + # At the moment, I only support mode 2. From looking at the API, it + # should be possible to calculate a different number of bytes to clear, + # and to do so relative to the cursor position. + if mode[0] not in (2,): + return + handle = win32.STDOUT + if on_stderr: + handle = win32.STDERR + # here's where we'll home the cursor + coord_screen = win32.COORD(0,0) + csbi = win32.GetConsoleScreenBufferInfo(handle) + # get the number of character cells in the current buffer + dw_con_size = csbi.dwSize.X * csbi.dwSize.Y + # fill the entire screen with blanks + win32.FillConsoleOutputCharacter(handle, ' ', dw_con_size, coord_screen) + # now set the buffer's attributes accordingly + win32.FillConsoleOutputAttribute(handle, self.get_attrs(), dw_con_size, coord_screen ) + # put the cursor at (0, 0) + win32.SetConsoleCursorPosition(handle, (coord_screen.X, coord_screen.Y)) diff --git a/pyqtgraph/util/cprint.py b/pyqtgraph/util/cprint.py new file mode 100644 index 00000000..e88bfd1a --- /dev/null +++ b/pyqtgraph/util/cprint.py @@ -0,0 +1,101 @@ +""" +Cross-platform color text printing + +Based on colorama (see pyqtgraph/util/colorama/README.txt) +""" +import sys, re + +from .colorama.winterm import WinTerm, WinColor, WinStyle +from .colorama.win32 import windll + +_WIN = sys.platform.startswith('win') +if windll is not None: + winterm = WinTerm() +else: + _WIN = False + +def winset(reset=False, fore=None, back=None, style=None, stderr=False): + if reset: + winterm.reset_all() + if fore is not None: + winterm.fore(fore, stderr) + if back is not None: + winterm.back(back, stderr) + if style is not None: + winterm.style(style, stderr) + +ANSI = {} +WIN = {} +for i,color in enumerate(['BLACK', 'RED', 'GREEN', 'YELLOW', 'BLUE', 'MAGENTA', 'CYAN', 'WHITE']): + globals()[color] = i + globals()['BR_' + color] = i + 8 + globals()['BACK_' + color] = i + 40 + ANSI[i] = "\033[%dm" % (30+i) + ANSI[i+8] = "\033[2;%dm" % (30+i) + ANSI[i+40] = "\033[%dm" % (40+i) + color = 'GREY' if color == 'WHITE' else color + WIN[i] = {'fore': getattr(WinColor, color), 'style': WinStyle.NORMAL} + WIN[i+8] = {'fore': getattr(WinColor, color), 'style': WinStyle.BRIGHT} + WIN[i+40] = {'back': getattr(WinColor, color)} + +RESET = -1 +ANSI[RESET] = "\033[0m" +WIN[RESET] = {'reset': True} + + +def cprint(stream, *args, **kwds): + """ + Print with color. Examples:: + + # colors are BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE + cprint('stdout', RED, 'This is in red. ', RESET, 'and this is normal\n') + + # Adding BR_ before the color manes it bright + cprint('stdout', BR_GREEN, 'This is bright green.\n', RESET) + + # Adding BACK_ changes background color + cprint('stderr', BACK_BLUE, WHITE, 'This is white-on-blue.', -1) + + # Integers 0-7 for normal, 8-15 for bright, and 40-47 for background. + # -1 to reset. + cprint('stderr', 1, 'This is in red.', -1) + + """ + if isinstance(stream, basestring): + stream = kwds.get('stream', 'stdout') + err = stream == 'stderr' + stream = getattr(sys, stream) + else: + err = kwds.get('stderr', False) + + if hasattr(stream, 'isatty') and stream.isatty(): + if _WIN: + # convert to win32 calls + for arg in args: + if isinstance(arg, basestring): + stream.write(arg) + else: + kwds = WIN[arg] + winset(stderr=err, **kwds) + else: + # convert to ANSI + for arg in args: + if isinstance(arg, basestring): + stream.write(arg) + else: + stream.write(ANSI[arg]) + else: + # ignore colors + for arg in args: + if isinstance(arg, basestring): + stream.write(arg) + +def cout(*args): + """Shorthand for cprint('stdout', ...)""" + cprint('stdout', *args) + +def cerr(*args): + """Shorthand for cprint('stderr', ...)""" + cprint('stderr', *args) + + diff --git a/pyqtgraph/util/garbage_collector.py b/pyqtgraph/util/garbage_collector.py new file mode 100644 index 00000000..979e66c5 --- /dev/null +++ b/pyqtgraph/util/garbage_collector.py @@ -0,0 +1,50 @@ +import gc + +from ..Qt import QtCore + +class GarbageCollector(object): + ''' + Disable automatic garbage collection and instead collect manually + on a timer. + + This is done to ensure that garbage collection only happens in the GUI + thread, as otherwise Qt can crash. + + Credit: Erik Janssens + Source: http://pydev.blogspot.com/2014/03/should-python-garbage-collector-be.html + ''' + + def __init__(self, interval=1.0, debug=False): + self.debug = debug + if debug: + gc.set_debug(gc.DEBUG_LEAK) + + self.timer = QtCore.QTimer() + self.timer.timeout.connect(self.check) + + self.threshold = gc.get_threshold() + gc.disable() + self.timer.start(interval * 1000) + + def check(self): + #return self.debug_cycles() # uncomment to just debug cycles + l0, l1, l2 = gc.get_count() + if self.debug: + print('gc_check called:', l0, l1, l2) + if l0 > self.threshold[0]: + num = gc.collect(0) + if self.debug: + print('collecting gen 0, found: %d unreachable' % num) + if l1 > self.threshold[1]: + num = gc.collect(1) + if self.debug: + print('collecting gen 1, found: %d unreachable' % num) + if l2 > self.threshold[2]: + num = gc.collect(2) + if self.debug: + print('collecting gen 2, found: %d unreachable' % num) + + def debug_cycles(self): + gc.collect() + for obj in gc.garbage: + print (obj, repr(obj), type(obj)) diff --git a/pyqtgraph/util/lru_cache.py b/pyqtgraph/util/lru_cache.py new file mode 100644 index 00000000..9c04abf3 --- /dev/null +++ b/pyqtgraph/util/lru_cache.py @@ -0,0 +1,121 @@ +import operator +import sys +import itertools + + +_IS_PY3 = sys.version_info[0] == 3 + +class LRUCache(object): + ''' + This LRU cache should be reasonable for short collections (until around 100 items), as it does a + sort on the items if the collection would become too big (so, it is very fast for getting and + setting but when its size would become higher than the max size it does one sort based on the + internal time to decide which items should be removed -- which should be Ok if the resizeTo + isn't too close to the maxSize so that it becomes an operation that doesn't happen all the + time). + ''' + + def __init__(self, maxSize=100, resizeTo=70): + ''' + ============== ========================================================= + **Arguments:** + maxSize (int) This is the maximum size of the cache. When some + item is added and the cache would become bigger than + this, it's resized to the value passed on resizeTo. + resizeTo (int) When a resize operation happens, this is the size + of the final cache. + ============== ========================================================= + ''' + assert resizeTo < maxSize + self.maxSize = maxSize + self.resizeTo = resizeTo + self._counter = 0 + self._dict = {} + if _IS_PY3: + self._nextTime = itertools.count(0).__next__ + else: + self._nextTime = itertools.count(0).next + + def __getitem__(self, key): + item = self._dict[key] + item[2] = self._nextTime() + return item[1] + + def __len__(self): + return len(self._dict) + + def __setitem__(self, key, value): + item = self._dict.get(key) + if item is None: + if len(self._dict) + 1 > self.maxSize: + self._resizeTo() + + item = [key, value, self._nextTime()] + self._dict[key] = item + else: + item[1] = value + item[2] = self._nextTime() + + def __delitem__(self, key): + del self._dict[key] + + def get(self, key, default=None): + try: + return self[key] + except KeyError: + return default + + def clear(self): + self._dict.clear() + + if _IS_PY3: + def values(self): + return [i[1] for i in self._dict.values()] + + def keys(self): + return [x[0] for x in self._dict.values()] + + def _resizeTo(self): + ordered = sorted(self._dict.values(), key=operator.itemgetter(2))[:self.resizeTo] + for i in ordered: + del self._dict[i[0]] + + def iteritems(self, accessTime=False): + ''' + :param bool accessTime: + If True sorts the returned items by the internal access time. + ''' + if accessTime: + for x in sorted(self._dict.values(), key=operator.itemgetter(2)): + yield x[0], x[1] + else: + for x in self._dict.items(): + yield x[0], x[1] + + else: + def values(self): + return [i[1] for i in self._dict.itervalues()] + + def keys(self): + return [x[0] for x in self._dict.itervalues()] + + + def _resizeTo(self): + ordered = sorted(self._dict.itervalues(), key=operator.itemgetter(2))[:self.resizeTo] + for i in ordered: + del self._dict[i[0]] + + def iteritems(self, accessTime=False): + ''' + ============= ====================================================== + **Arguments** + accessTime (bool) If True sorts the returned items by the + internal access time. + ============= ====================================================== + ''' + if accessTime: + for x in sorted(self._dict.itervalues(), key=operator.itemgetter(2)): + yield x[0], x[1] + else: + for x in self._dict.iteritems(): + yield x[0], x[1] diff --git a/pyqtgraph/util/mutex.py b/pyqtgraph/util/mutex.py new file mode 100644 index 00000000..4a193127 --- /dev/null +++ b/pyqtgraph/util/mutex.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +from ..Qt import QtCore +import traceback + +class Mutex(QtCore.QMutex): + """ + Subclass of QMutex that provides useful debugging information during + deadlocks--tracebacks are printed for both the code location that is + attempting to lock the mutex as well as the location that has already + acquired the lock. + + Also provides __enter__ and __exit__ methods for use in "with" statements. + """ + def __init__(self, *args, **kargs): + if kargs.get('recursive', False): + args = (QtCore.QMutex.Recursive,) + QtCore.QMutex.__init__(self, *args) + self.l = QtCore.QMutex() ## for serializing access to self.tb + self.tb = [] + self.debug = True ## True to enable debugging functions + + def tryLock(self, timeout=None, id=None): + if timeout is None: + locked = QtCore.QMutex.tryLock(self) + else: + locked = QtCore.QMutex.tryLock(self, timeout) + + if self.debug and locked: + self.l.lock() + try: + if id is None: + self.tb.append(''.join(traceback.format_stack()[:-1])) + else: + self.tb.append(" " + str(id)) + #print 'trylock', self, len(self.tb) + finally: + self.l.unlock() + return locked + + def lock(self, id=None): + c = 0 + waitTime = 5000 # in ms + while True: + if self.tryLock(waitTime, id): + break + c += 1 + if self.debug: + self.l.lock() + try: + print("Waiting for mutex lock (%0.1f sec). Traceback follows:" + % (c*waitTime/1000.)) + traceback.print_stack() + if len(self.tb) > 0: + print("Mutex is currently locked from:\n") + print(self.tb[-1]) + else: + print("Mutex is currently locked from [???]") + finally: + self.l.unlock() + #print 'lock', self, len(self.tb) + + def unlock(self): + QtCore.QMutex.unlock(self) + if self.debug: + self.l.lock() + try: + #print 'unlock', self, len(self.tb) + if len(self.tb) > 0: + self.tb.pop() + else: + raise Exception("Attempt to unlock mutex before it has been locked") + finally: + self.l.unlock() + + def depth(self): + self.l.lock() + n = len(self.tb) + self.l.unlock() + return n + + def traceback(self): + self.l.lock() + try: + ret = self.tb[:] + finally: + self.l.unlock() + return ret + + def __exit__(self, *args): + self.unlock() + + def __enter__(self): + self.lock() + return self \ No newline at end of file diff --git a/pyqtgraph/util/pil_fix.py b/pyqtgraph/util/pil_fix.py new file mode 100644 index 00000000..da1c52b3 --- /dev/null +++ b/pyqtgraph/util/pil_fix.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +""" +Importing this module installs support for 16-bit images in PIL. +This works by patching objects in the PIL namespace; no files are +modified. +""" + +from PIL import Image + +if Image.VERSION == '1.1.7': + Image._MODE_CONV["I;16"] = ('%su2' % Image._ENDIAN, None) + Image._fromarray_typemap[((1, 1), " ndmax: + raise ValueError("Too many dimensions.") + + size = shape[:2][::-1] + if strides is not None: + obj = obj.tostring() + + return frombuffer(mode, size, obj, "raw", mode, 0, 1) + + Image.fromarray=fromarray \ No newline at end of file diff --git a/pyqtgraph/util/tests/test_lru_cache.py b/pyqtgraph/util/tests/test_lru_cache.py new file mode 100644 index 00000000..c0cf9f8a --- /dev/null +++ b/pyqtgraph/util/tests/test_lru_cache.py @@ -0,0 +1,50 @@ +from pyqtgraph.util.lru_cache import LRUCache + +def testLRU(): + lru = LRUCache(2, 1) + # check twice + checkLru(lru) + checkLru(lru) + +def checkLru(lru): + lru[1] = 1 + lru[2] = 2 + lru[3] = 3 + + assert len(lru) == 2 + assert set([2, 3]) == set(lru.keys()) + assert set([2, 3]) == set(lru.values()) + + lru[2] = 2 + assert set([2, 3]) == set(lru.values()) + + lru[1] = 1 + set([2, 1]) == set(lru.values()) + + #Iterates from the used in the last access to others based on access time. + assert [(2, 2), (1, 1)] == list(lru.iteritems(accessTime=True)) + lru[2] = 2 + assert [(1, 1), (2, 2)] == list(lru.iteritems(accessTime=True)) + + del lru[2] + assert [(1, 1), ] == list(lru.iteritems(accessTime=True)) + + lru[2] = 2 + assert [(1, 1), (2, 2)] == list(lru.iteritems(accessTime=True)) + + _a = lru[1] + assert [(2, 2), (1, 1)] == list(lru.iteritems(accessTime=True)) + + _a = lru[2] + assert [(1, 1), (2, 2)] == list(lru.iteritems(accessTime=True)) + + assert lru.get(2) == 2 + assert lru.get(3) == None + assert [(1, 1), (2, 2)] == list(lru.iteritems(accessTime=True)) + + lru.clear() + assert [] == list(lru.iteritems()) + + +if __name__ == '__main__': + testLRU() diff --git a/pyqtgraph/widgets/BusyCursor.py b/pyqtgraph/widgets/BusyCursor.py index b013dda0..d99fe589 100644 --- a/pyqtgraph/widgets/BusyCursor.py +++ b/pyqtgraph/widgets/BusyCursor.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore __all__ = ['BusyCursor'] diff --git a/pyqtgraph/widgets/CheckTable.py b/pyqtgraph/widgets/CheckTable.py index dd33fd75..22015126 100644 --- a/pyqtgraph/widgets/CheckTable.py +++ b/pyqtgraph/widgets/CheckTable.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from . import VerticalLabel __all__ = ['CheckTable'] diff --git a/pyqtgraph/widgets/ColorButton.py b/pyqtgraph/widgets/ColorButton.py index ee91801a..a0bb0c8e 100644 --- a/pyqtgraph/widgets/ColorButton.py +++ b/pyqtgraph/widgets/ColorButton.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph.functions as functions +from ..Qt import QtGui, QtCore +from .. import functions as functions __all__ = ['ColorButton'] @@ -11,7 +11,7 @@ class ColorButton(QtGui.QPushButton): Button displaying a color and allowing the user to select a new color. ====================== ============================================================ - **Signals**: + **Signals:** sigColorChanging(self) emitted whenever a new color is picked in the color dialog sigColorChanged(self) emitted when the selected color is accepted (user clicks OK) ====================== ============================================================ diff --git a/pyqtgraph/widgets/ColorMapWidget.py b/pyqtgraph/widgets/ColorMapWidget.py index 26539d7e..f6e28960 100644 --- a/pyqtgraph/widgets/ColorMapWidget.py +++ b/pyqtgraph/widgets/ColorMapWidget.py @@ -1,8 +1,8 @@ -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph.parametertree as ptree +from ..Qt import QtGui, QtCore +from .. import parametertree as ptree import numpy as np -from pyqtgraph.pgcollections import OrderedDict -import pyqtgraph.functions as fn +from ..pgcollections import OrderedDict +from .. import functions as fn __all__ = ['ColorMapWidget'] @@ -19,8 +19,8 @@ class ColorMapWidget(ptree.ParameterTree): """ sigColorMapChanged = QtCore.Signal(object) - def __init__(self): - ptree.ParameterTree.__init__(self, showHeader=False) + def __init__(self, parent=None): + ptree.ParameterTree.__init__(self, parent=parent, showHeader=False) self.params = ColorMapParameter() self.setParameters(self.params) @@ -32,6 +32,15 @@ class ColorMapWidget(ptree.ParameterTree): def mapChanged(self): self.sigColorMapChanged.emit(self) + + def widgetGroupInterface(self): + return (self.sigColorMapChanged, self.saveState, self.restoreState) + + def saveState(self): + return self.params.saveState() + + def restoreState(self, state): + self.params.restoreState(state) class ColorMapParameter(ptree.types.GroupParameter): @@ -48,9 +57,11 @@ class ColorMapParameter(ptree.types.GroupParameter): def addNew(self, name): mode = self.fields[name].get('mode', 'range') if mode == 'range': - self.addChild(RangeColorMapItem(name, self.fields[name])) + item = RangeColorMapItem(name, self.fields[name]) elif mode == 'enum': - self.addChild(EnumColorMapItem(name, self.fields[name])) + item = EnumColorMapItem(name, self.fields[name]) + self.addChild(item) + return item def fieldNames(self): return self.fields.keys() @@ -86,15 +97,18 @@ class ColorMapParameter(ptree.types.GroupParameter): """ Return an array of colors corresponding to *data*. - ========= ================================================================= - Arguments - data A numpy record array where the fields in data.dtype match those - defined by a prior call to setFields(). - mode Either 'byte' or 'float'. For 'byte', the method returns an array - of dtype ubyte with values scaled 0-255. For 'float', colors are - returned as 0.0-1.0 float values. - ========= ================================================================= + ============== ================================================================= + **Arguments:** + data A numpy record array where the fields in data.dtype match those + defined by a prior call to setFields(). + mode Either 'byte' or 'float'. For 'byte', the method returns an array + of dtype ubyte with values scaled 0-255. For 'float', colors are + returned as 0.0-1.0 float values. + ============== ================================================================= """ + if isinstance(data, dict): + data = np.array([tuple(data.values())], dtype=[(k, float) for k in data.keys()]) + colors = np.zeros((len(data),4)) for item in self.children(): if not item['Enabled']: @@ -126,8 +140,26 @@ class ColorMapParameter(ptree.types.GroupParameter): return colors + def saveState(self): + items = OrderedDict() + for item in self: + itemState = item.saveState(filter='user') + itemState['field'] = item.fieldName + items[item.name()] = itemState + state = {'fields': self.fields, 'items': items} + return state + + def restoreState(self, state): + if 'fields' in state: + self.setFields(state['fields']) + for itemState in state['items']: + item = self.addNew(itemState['field']) + item.restoreState(itemState) + class RangeColorMapItem(ptree.types.SimpleParameter): + mapType = 'range' + def __init__(self, name, opts): self.fieldName = name units = opts.get('units', '') @@ -151,8 +183,6 @@ class RangeColorMapItem(ptree.types.SimpleParameter): def map(self, data): data = data[self.fieldName] - - scaled = np.clip((data-self['Min']) / (self['Max']-self['Min']), 0, 1) cmap = self.value() colors = cmap.map(scaled, mode='float') @@ -162,10 +192,11 @@ class RangeColorMapItem(ptree.types.SimpleParameter): nanColor = (nanColor.red()/255., nanColor.green()/255., nanColor.blue()/255., nanColor.alpha()/255.) colors[mask] = nanColor - return colors - + return colors class EnumColorMapItem(ptree.types.GroupParameter): + mapType = 'enum' + def __init__(self, name, opts): self.fieldName = name vals = opts.get('values', []) diff --git a/pyqtgraph/widgets/ComboBox.py b/pyqtgraph/widgets/ComboBox.py index 1884648c..5cf6f918 100644 --- a/pyqtgraph/widgets/ComboBox.py +++ b/pyqtgraph/widgets/ComboBox.py @@ -1,41 +1,217 @@ -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.SignalProxy import SignalProxy - +from ..Qt import QtGui, QtCore +from ..SignalProxy import SignalProxy +import sys +from ..pgcollections import OrderedDict +from ..python2_3 import asUnicode class ComboBox(QtGui.QComboBox): """Extends QComboBox to add extra functionality. - - updateList() - updates the items in the comboBox while blocking signals, remembers and resets to the previous values if it's still in the list + + * Handles dict mappings -- user selects a text key, and the ComboBox indicates + the selected value. + * Requires item strings to be unique + * Remembers selected value if list is cleared and subsequently repopulated + * setItems() replaces the items in the ComboBox and blocks signals if the + value ultimately does not change. """ def __init__(self, parent=None, items=None, default=None): QtGui.QComboBox.__init__(self, parent) + self.currentIndexChanged.connect(self.indexChanged) + self._ignoreIndexChange = False #self.value = default + if 'darwin' in sys.platform: ## because MacOSX can show names that are wider than the comboBox + self.setSizeAdjustPolicy(QtGui.QComboBox.AdjustToMinimumContentsLength) + #self.setMinimumContentsLength(10) + self._chosenText = None + self._items = OrderedDict() if items is not None: - self.addItems(items) + self.setItems(items) if default is not None: self.setValue(default) def setValue(self, value): - ind = self.findText(value) + """Set the selected item to the first one having the given value.""" + text = None + for k,v in self._items.items(): + if v == value: + text = k + break + if text is None: + raise ValueError(value) + + self.setText(text) + + def setText(self, text): + """Set the selected item to the first one having the given text.""" + ind = self.findText(text) if ind == -1: - return + raise ValueError(text) #self.value = value - self.setCurrentIndex(ind) - - def updateList(self, items): - prevVal = str(self.currentText()) - try: + self.setCurrentIndex(ind) + + def value(self): + """ + If items were given as a list of strings, then return the currently + selected text. If items were given as a dict, then return the value + corresponding to the currently selected key. If the combo list is empty, + return None. + """ + if self.count() == 0: + return None + text = asUnicode(self.currentText()) + return self._items[text] + + def ignoreIndexChange(func): + # Decorator that prevents updates to self._chosenText + def fn(self, *args, **kwds): + prev = self._ignoreIndexChange + self._ignoreIndexChange = True + try: + ret = func(self, *args, **kwds) + finally: + self._ignoreIndexChange = prev + return ret + return fn + + def blockIfUnchanged(func): + # decorator that blocks signal emission during complex operations + # and emits currentIndexChanged only if the value has actually + # changed at the end. + def fn(self, *args, **kwds): + prevVal = self.value() + blocked = self.signalsBlocked() self.blockSignals(True) + try: + ret = func(self, *args, **kwds) + finally: + self.blockSignals(blocked) + + # only emit if the value has changed + if self.value() != prevVal: + self.currentIndexChanged.emit(self.currentIndex()) + + return ret + return fn + + @ignoreIndexChange + @blockIfUnchanged + def setItems(self, items): + """ + *items* may be a list or a dict. + If a dict is given, then the keys are used to populate the combo box + and the values will be used for both value() and setValue(). + """ + prevVal = self.value() + + self.blockSignals(True) + try: self.clear() self.addItems(items) - self.setValue(prevVal) - finally: self.blockSignals(False) - if str(self.currentText()) != prevVal: + # only emit if we were not able to re-set the original value + if self.value() != prevVal: self.currentIndexChanged.emit(self.currentIndex()) - \ No newline at end of file + + def items(self): + return self.items.copy() + + def updateList(self, items): + # for backward compatibility + return self.setItems(items) + + def indexChanged(self, index): + # current index has changed; need to remember new 'chosen text' + if self._ignoreIndexChange: + return + self._chosenText = asUnicode(self.currentText()) + + def setCurrentIndex(self, index): + QtGui.QComboBox.setCurrentIndex(self, index) + + def itemsChanged(self): + # try to set the value to the last one selected, if it is available. + if self._chosenText is not None: + try: + self.setText(self._chosenText) + except ValueError: + pass + + @ignoreIndexChange + def insertItem(self, *args): + raise NotImplementedError() + #QtGui.QComboBox.insertItem(self, *args) + #self.itemsChanged() + + @ignoreIndexChange + def insertItems(self, *args): + raise NotImplementedError() + #QtGui.QComboBox.insertItems(self, *args) + #self.itemsChanged() + + @ignoreIndexChange + def addItem(self, *args, **kwds): + # Need to handle two different function signatures for QComboBox.addItem + try: + if isinstance(args[0], basestring): + text = args[0] + if len(args) == 2: + value = args[1] + else: + value = kwds.get('value', text) + else: + text = args[1] + if len(args) == 3: + value = args[2] + else: + value = kwds.get('value', text) + + except IndexError: + raise TypeError("First or second argument of addItem must be a string.") + + if text in self._items: + raise Exception('ComboBox already has item named "%s".' % text) + + self._items[text] = value + QtGui.QComboBox.addItem(self, *args) + self.itemsChanged() + + def setItemValue(self, name, value): + if name not in self._items: + self.addItem(name, value) + else: + self._items[name] = value + + @ignoreIndexChange + @blockIfUnchanged + def addItems(self, items): + if isinstance(items, list): + texts = items + items = dict([(x, x) for x in items]) + elif isinstance(items, dict): + texts = list(items.keys()) + else: + raise TypeError("items argument must be list or dict (got %s)." % type(items)) + + for t in texts: + if t in self._items: + raise Exception('ComboBox already has item named "%s".' % t) + + + for k,v in items.items(): + self._items[k] = v + QtGui.QComboBox.addItems(self, list(texts)) + + self.itemsChanged() + + @ignoreIndexChange + def clear(self): + self._items = OrderedDict() + QtGui.QComboBox.clear(self) + self.itemsChanged() + diff --git a/pyqtgraph/widgets/DataFilterWidget.py b/pyqtgraph/widgets/DataFilterWidget.py index c94f6c68..cae8be86 100644 --- a/pyqtgraph/widgets/DataFilterWidget.py +++ b/pyqtgraph/widgets/DataFilterWidget.py @@ -1,8 +1,8 @@ -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph.parametertree as ptree +from ..Qt import QtGui, QtCore +from .. import parametertree as ptree import numpy as np -from pyqtgraph.pgcollections import OrderedDict -import pyqtgraph as pg +from ..pgcollections import OrderedDict +from .. import functions as fn __all__ = ['DataFilterWidget'] @@ -108,7 +108,7 @@ class RangeFilterItem(ptree.types.SimpleParameter): return mask def describe(self): - return "%s < %s < %s" % (pg.siFormat(self['Min'], suffix=self.units), self.fieldName, pg.siFormat(self['Max'], suffix=self.units)) + return "%s < %s < %s" % (fn.siFormat(self['Min'], suffix=self.units), self.fieldName, fn.siFormat(self['Max'], suffix=self.units)) class EnumFilterItem(ptree.types.SimpleParameter): def __init__(self, name, opts): diff --git a/pyqtgraph/widgets/DataTreeWidget.py b/pyqtgraph/widgets/DataTreeWidget.py index a6b5cac8..29e60319 100644 --- a/pyqtgraph/widgets/DataTreeWidget.py +++ b/pyqtgraph/widgets/DataTreeWidget.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.pgcollections import OrderedDict +from ..Qt import QtGui, QtCore +from ..pgcollections import OrderedDict import types, traceback import numpy as np @@ -57,7 +57,7 @@ class DataTreeWidget(QtGui.QTreeWidget): } if isinstance(data, dict): - for k in data: + for k in data.keys(): self.buildTree(data[k], node, str(k)) elif isinstance(data, list) or isinstance(data, tuple): for i in range(len(data)): diff --git a/pyqtgraph/widgets/FeedbackButton.py b/pyqtgraph/widgets/FeedbackButton.py index f788f4b6..30114d4e 100644 --- a/pyqtgraph/widgets/FeedbackButton.py +++ b/pyqtgraph/widgets/FeedbackButton.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui __all__ = ['FeedbackButton'] diff --git a/pyqtgraph/widgets/FileDialog.py b/pyqtgraph/widgets/FileDialog.py index 33b838a2..faa0994c 100644 --- a/pyqtgraph/widgets/FileDialog.py +++ b/pyqtgraph/widgets/FileDialog.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore import sys __all__ = ['FileDialog'] diff --git a/pyqtgraph/widgets/GradientWidget.py b/pyqtgraph/widgets/GradientWidget.py index 1723a94b..ce0cbeb9 100644 --- a/pyqtgraph/widgets/GradientWidget.py +++ b/pyqtgraph/widgets/GradientWidget.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .GraphicsView import GraphicsView -from pyqtgraph.graphicsItems.GradientEditorItem import GradientEditorItem +from ..graphicsItems.GradientEditorItem import GradientEditorItem import weakref import numpy as np -__all__ = ['TickSlider', 'GradientWidget', 'BlackWhiteSlider'] +__all__ = ['GradientWidget'] class GradientWidget(GraphicsView): diff --git a/pyqtgraph/widgets/GraphicsLayoutWidget.py b/pyqtgraph/widgets/GraphicsLayoutWidget.py index 1e667278..ec7b9e0d 100644 --- a/pyqtgraph/widgets/GraphicsLayoutWidget.py +++ b/pyqtgraph/widgets/GraphicsLayoutWidget.py @@ -1,12 +1,30 @@ -from pyqtgraph.Qt import QtGui -from pyqtgraph.graphicsItems.GraphicsLayout import GraphicsLayout +from ..Qt import QtGui +from ..graphicsItems.GraphicsLayout import GraphicsLayout from .GraphicsView import GraphicsView __all__ = ['GraphicsLayoutWidget'] class GraphicsLayoutWidget(GraphicsView): + """ + Convenience class consisting of a :class:`GraphicsView + ` with a single :class:`GraphicsLayout + ` as its central item. + + This class wraps several methods from its internal GraphicsLayout: + :func:`nextRow ` + :func:`nextColumn ` + :func:`addPlot ` + :func:`addViewBox ` + :func:`addItem ` + :func:`getItem ` + :func:`addLabel ` + :func:`addLayout ` + :func:`removeItem ` + :func:`itemIndex ` + :func:`clear ` + """ def __init__(self, parent=None, **kargs): GraphicsView.__init__(self, parent) self.ci = GraphicsLayout(**kargs) - for n in ['nextRow', 'nextCol', 'nextColumn', 'addPlot', 'addViewBox', 'addItem', 'getItem', 'addLabel', 'addLayout', 'addLabel', 'addViewBox', 'removeItem', 'itemIndex', 'clear']: + for n in ['nextRow', 'nextCol', 'nextColumn', 'addPlot', 'addViewBox', 'addItem', 'getItem', 'addLayout', 'addLabel', 'removeItem', 'itemIndex', 'clear']: setattr(self, n, getattr(self.ci, n)) self.setCentralItem(self.ci) diff --git a/pyqtgraph/widgets/GraphicsView.py b/pyqtgraph/widgets/GraphicsView.py index fb535929..4062be94 100644 --- a/pyqtgraph/widgets/GraphicsView.py +++ b/pyqtgraph/widgets/GraphicsView.py @@ -5,23 +5,22 @@ Copyright 2010 Luke Campagnola Distributed under MIT/X11 license. See license.txt for more infomation. """ -from pyqtgraph.Qt import QtCore, QtGui -import pyqtgraph as pg +from ..Qt import QtCore, QtGui, USE_PYSIDE try: - from pyqtgraph.Qt import QtOpenGL + from ..Qt import QtOpenGL HAVE_OPENGL = True except ImportError: HAVE_OPENGL = False -from pyqtgraph.Point import Point +from ..Point import Point import sys, os from .FileDialog import FileDialog -from pyqtgraph.GraphicsScene import GraphicsScene +from ..GraphicsScene import GraphicsScene import numpy as np -import pyqtgraph.functions as fn -import pyqtgraph.debug as debug -import pyqtgraph +from .. import functions as fn +from .. import debug as debug +from .. import getConfigOption __all__ = ['GraphicsView'] @@ -41,8 +40,8 @@ class GraphicsView(QtGui.QGraphicsView): The view can be panned using the middle mouse button and scaled using the right mouse button if enabled via enableMouse() (but ordinarily, we use ViewBox for this functionality).""" - sigRangeChanged = QtCore.Signal(object, object) - sigTransformChanged = QtCore.Signal(object) + sigDeviceRangeChanged = QtCore.Signal(object, object) + sigDeviceTransformChanged = QtCore.Signal(object) sigMouseReleased = QtCore.Signal(object) sigSceneMouseMoved = QtCore.Signal(object) #sigRegionChanged = QtCore.Signal(object) @@ -51,29 +50,36 @@ class GraphicsView(QtGui.QGraphicsView): def __init__(self, parent=None, useOpenGL=None, background='default'): """ - ============ ============================================================ - Arguments: - parent Optional parent widget - useOpenGL If True, the GraphicsView will use OpenGL to do all of its - rendering. This can improve performance on some systems, - but may also introduce bugs (the combination of - QGraphicsView and QGLWidget is still an 'experimental' - feature of Qt) - background Set the background color of the GraphicsView. Accepts any - single argument accepted by - :func:`mkColor `. By - default, the background color is determined using the - 'backgroundColor' configuration option (see - :func:`setConfigOption `. - ============ ============================================================ + ============== ============================================================ + **Arguments:** + parent Optional parent widget + useOpenGL If True, the GraphicsView will use OpenGL to do all of its + rendering. This can improve performance on some systems, + but may also introduce bugs (the combination of + QGraphicsView and QGLWidget is still an 'experimental' + feature of Qt) + background Set the background color of the GraphicsView. Accepts any + single argument accepted by + :func:`mkColor `. By + default, the background color is determined using the + 'backgroundColor' configuration option (see + :func:`setConfigOption `. + ============== ============================================================ """ self.closed = False QtGui.QGraphicsView.__init__(self, parent) + # This connects a cleanup function to QApplication.aboutToQuit. It is + # called from here because we have no good way to react when the + # QApplication is created by the user. + # See pyqtgraph.__init__.py + from .. import _connectCleanup + _connectCleanup() + if useOpenGL is None: - useOpenGL = pyqtgraph.getConfigOption('useOpenGL') + useOpenGL = getConfigOption('useOpenGL') self.useOpenGL(useOpenGL) @@ -103,12 +109,13 @@ class GraphicsView(QtGui.QGraphicsView): self.currentItem = None self.clearMouse() self.updateMatrix() - self.sceneObj = GraphicsScene() + # GraphicsScene must have parent or expect crashes! + self.sceneObj = GraphicsScene(parent=self) self.setScene(self.sceneObj) ## Workaround for PySide crash ## This ensures that the scene will outlive the view. - if pyqtgraph.Qt.USE_PYSIDE: + if USE_PYSIDE: self.sceneObj._view_ref_workaround = self ## by default we set up a central widget with a grid layout. @@ -138,13 +145,12 @@ class GraphicsView(QtGui.QGraphicsView): """ self._background = background if background == 'default': - background = pyqtgraph.getConfigOption('background') + background = getConfigOption('background') brush = fn.mkBrush(background) self.setBackgroundBrush(brush) def paintEvent(self, ev): self.scene().prepareForPaint() - #print "GV: paint", ev.rect() return QtGui.QGraphicsView.paintEvent(self, ev) def render(self, *args, **kwds): @@ -220,8 +226,8 @@ class GraphicsView(QtGui.QGraphicsView): else: self.fitInView(self.range, QtCore.Qt.IgnoreAspectRatio) - self.sigRangeChanged.emit(self, self.range) - self.sigTransformChanged.emit(self) + self.sigDeviceRangeChanged.emit(self, self.range) + self.sigDeviceTransformChanged.emit(self) if propagate: for v in self.lockedViewports: @@ -288,7 +294,7 @@ class GraphicsView(QtGui.QGraphicsView): image.setPxMode(True) try: self.sigScaleChanged.disconnect(image.setScaledMode) - except TypeError: + except (TypeError, RuntimeError): pass tl = image.sceneBoundingRect().topLeft() w = self.size().width() * pxSize[0] @@ -369,14 +375,14 @@ class GraphicsView(QtGui.QGraphicsView): delta = Point(np.clip(delta[0], -50, 50), np.clip(-delta[1], -50, 50)) scale = 1.01 ** delta self.scale(scale[0], scale[1], center=self.mapToScene(self.mousePressPos)) - self.sigRangeChanged.emit(self, self.range) + self.sigDeviceRangeChanged.emit(self, self.range) elif ev.buttons() in [QtCore.Qt.MidButton, QtCore.Qt.LeftButton]: ## Allow panning by left or mid button. px = self.pixelSize() tr = -delta * px self.translate(tr[0], tr[1]) - self.sigRangeChanged.emit(self, self.range) + self.sigDeviceRangeChanged.emit(self, self.range) def pixelSize(self): """Return vector with the length and width of one view pixel in scene coordinates""" diff --git a/pyqtgraph/widgets/HistogramLUTWidget.py b/pyqtgraph/widgets/HistogramLUTWidget.py index cbe8eb61..9aec837c 100644 --- a/pyqtgraph/widgets/HistogramLUTWidget.py +++ b/pyqtgraph/widgets/HistogramLUTWidget.py @@ -3,9 +3,9 @@ Widget displaying an image histogram along with gradient editor. Can be used to This is a wrapper around HistogramLUTItem """ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .GraphicsView import GraphicsView -from pyqtgraph.graphicsItems.HistogramLUTItem import HistogramLUTItem +from ..graphicsItems.HistogramLUTItem import HistogramLUTItem __all__ = ['HistogramLUTWidget'] diff --git a/pyqtgraph/widgets/JoystickButton.py b/pyqtgraph/widgets/JoystickButton.py index 201a957a..6f73c8dc 100644 --- a/pyqtgraph/widgets/JoystickButton.py +++ b/pyqtgraph/widgets/JoystickButton.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore __all__ = ['JoystickButton'] diff --git a/pyqtgraph/widgets/LayoutWidget.py b/pyqtgraph/widgets/LayoutWidget.py index f567ad74..65d04d3f 100644 --- a/pyqtgraph/widgets/LayoutWidget.py +++ b/pyqtgraph/widgets/LayoutWidget.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore __all__ = ['LayoutWidget'] class LayoutWidget(QtGui.QWidget): diff --git a/pyqtgraph/widgets/MatplotlibWidget.py b/pyqtgraph/widgets/MatplotlibWidget.py index 6a22c973..959e188a 100644 --- a/pyqtgraph/widgets/MatplotlibWidget.py +++ b/pyqtgraph/widgets/MatplotlibWidget.py @@ -1,4 +1,4 @@ -from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE +from ..Qt import QtGui, QtCore, USE_PYSIDE import matplotlib if USE_PYSIDE: diff --git a/pyqtgraph/widgets/MultiPlotWidget.py b/pyqtgraph/widgets/MultiPlotWidget.py index 400bee92..d1f56034 100644 --- a/pyqtgraph/widgets/MultiPlotWidget.py +++ b/pyqtgraph/widgets/MultiPlotWidget.py @@ -4,28 +4,43 @@ MultiPlotWidget.py - Convenience class--GraphicsView widget displaying a MultiP Copyright 2010 Luke Campagnola Distributed under MIT/X11 license. See license.txt for more infomation. """ - +from ..Qt import QtCore from .GraphicsView import GraphicsView -import pyqtgraph.graphicsItems.MultiPlotItem as MultiPlotItem +from ..graphicsItems import MultiPlotItem as MultiPlotItem __all__ = ['MultiPlotWidget'] class MultiPlotWidget(GraphicsView): - """Widget implementing a graphicsView with a single PlotItem inside.""" + """Widget implementing a graphicsView with a single MultiPlotItem inside.""" def __init__(self, parent=None): + self.minPlotHeight = 50 + self.mPlotItem = MultiPlotItem.MultiPlotItem() GraphicsView.__init__(self, parent) self.enableMouse(False) - self.mPlotItem = MultiPlotItem.MultiPlotItem() self.setCentralItem(self.mPlotItem) ## Explicitly wrap methods from mPlotItem #for m in ['setData']: #setattr(self, m, getattr(self.mPlotItem, m)) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) def __getattr__(self, attr): ## implicitly wrap methods from plotItem if hasattr(self.mPlotItem, attr): m = getattr(self.mPlotItem, attr) if hasattr(m, '__call__'): return m - raise NameError(attr) + raise AttributeError(attr) + + def setMinimumPlotHeight(self, min): + """Set the minimum height for each sub-plot displayed. + + If the total height of all plots is greater than the height of the + widget, then a scroll bar will appear to provide access to the entire + set of plots. + + Added in version 0.9.9 + """ + self.minPlotHeight = min + self.resizeEvent(None) def widgetGroupInterface(self): return (None, MultiPlotWidget.saveState, MultiPlotWidget.restoreState) @@ -42,4 +57,22 @@ class MultiPlotWidget(GraphicsView): self.mPlotItem.close() self.mPlotItem = None self.setParent(None) - GraphicsView.close(self) \ No newline at end of file + GraphicsView.close(self) + + def setRange(self, *args, **kwds): + GraphicsView.setRange(self, *args, **kwds) + if self.centralWidget is not None: + r = self.range + minHeight = len(self.mPlotItem.plots) * self.minPlotHeight + if r.height() < minHeight: + r.setHeight(minHeight) + r.setWidth(r.width() - self.verticalScrollBar().width()) + self.centralWidget.setGeometry(r) + + def resizeEvent(self, ev): + if self.closed: + return + if self.autoPixelRange: + self.range = QtCore.QRectF(0, 0, self.size().width(), self.size().height()) + MultiPlotWidget.setRange(self, self.range, padding=0, disableAutoPixel=False) ## we do this because some subclasses like to redefine setRange in an incompatible way. + self.updateMatrix() diff --git a/pyqtgraph/widgets/PathButton.py b/pyqtgraph/widgets/PathButton.py index 7950a53d..52c60e20 100644 --- a/pyqtgraph/widgets/PathButton.py +++ b/pyqtgraph/widgets/PathButton.py @@ -1,5 +1,6 @@ -from pyqtgraph.Qt import QtGui, QtCore -import pyqtgraph as pg +from ..Qt import QtGui, QtCore +from .. import functions as fn + __all__ = ['PathButton'] @@ -20,10 +21,10 @@ class PathButton(QtGui.QPushButton): def setBrush(self, brush): - self.brush = pg.mkBrush(brush) + self.brush = fn.mkBrush(brush) - def setPen(self, pen): - self.pen = pg.mkPen(pen) + def setPen(self, *args, **kwargs): + self.pen = fn.mkPen(*args, **kwargs) def setPath(self, path): self.path = path @@ -45,6 +46,5 @@ class PathButton(QtGui.QPushButton): p.setBrush(self.brush) p.drawPath(self.path) p.end() - + - \ No newline at end of file diff --git a/pyqtgraph/widgets/PlotWidget.py b/pyqtgraph/widgets/PlotWidget.py index 7b3c685c..e27bce60 100644 --- a/pyqtgraph/widgets/PlotWidget.py +++ b/pyqtgraph/widgets/PlotWidget.py @@ -5,14 +5,16 @@ Copyright 2010 Luke Campagnola Distributed under MIT/X11 license. See license.txt for more infomation. """ -from pyqtgraph.Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui from .GraphicsView import * -from pyqtgraph.graphicsItems.PlotItem import * +from ..graphicsItems.PlotItem import * __all__ = ['PlotWidget'] class PlotWidget(GraphicsView): - #sigRangeChanged = QtCore.Signal(object, object) ## already defined in GraphicsView + # signals wrapped from PlotItem / ViewBox + sigRangeChanged = QtCore.Signal(object, object) + sigTransformChanged = QtCore.Signal(object) """ :class:`GraphicsView ` widget with a single @@ -33,6 +35,7 @@ class PlotWidget(GraphicsView): :func:`enableAutoRange `, :func:`disableAutoRange `, :func:`setAspectLocked `, + :func:`setLimits `, :func:`register `, :func:`unregister ` @@ -52,7 +55,10 @@ class PlotWidget(GraphicsView): self.setCentralItem(self.plotItem) ## Explicitly wrap methods from plotItem ## NOTE: If you change this list, update the documentation above as well. - for m in ['addItem', 'removeItem', 'autoRange', 'clear', 'setXRange', 'setYRange', 'setRange', 'setAspectLocked', 'setMouseEnabled', 'setXLink', 'setYLink', 'enableAutoRange', 'disableAutoRange', 'register', 'unregister', 'viewRect']: + for m in ['addItem', 'removeItem', 'autoRange', 'clear', 'setXRange', + 'setYRange', 'setRange', 'setAspectLocked', 'setMouseEnabled', + 'setXLink', 'setYLink', 'enableAutoRange', 'disableAutoRange', + 'setLimits', 'register', 'unregister', 'viewRect']: setattr(self, m, getattr(self.plotItem, m)) #QtCore.QObject.connect(self.plotItem, QtCore.SIGNAL('viewChanged'), self.viewChanged) self.plotItem.sigRangeChanged.connect(self.viewRangeChanged) diff --git a/pyqtgraph/widgets/ProgressDialog.py b/pyqtgraph/widgets/ProgressDialog.py index 0f55e227..8c669be4 100644 --- a/pyqtgraph/widgets/ProgressDialog.py +++ b/pyqtgraph/widgets/ProgressDialog.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore __all__ = ['ProgressDialog'] class ProgressDialog(QtGui.QProgressDialog): diff --git a/pyqtgraph/widgets/RawImageWidget.py b/pyqtgraph/widgets/RawImageWidget.py index 517f4f99..970b570b 100644 --- a/pyqtgraph/widgets/RawImageWidget.py +++ b/pyqtgraph/widgets/RawImageWidget.py @@ -1,12 +1,12 @@ -from pyqtgraph.Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui try: - from pyqtgraph.Qt import QtOpenGL + from ..Qt import QtOpenGL from OpenGL.GL import * HAVE_OPENGL = True except ImportError: HAVE_OPENGL = False -import pyqtgraph.functions as fn +from .. import functions as fn import numpy as np class RawImageWidget(QtGui.QWidget): diff --git a/pyqtgraph/widgets/RemoteGraphicsView.py b/pyqtgraph/widgets/RemoteGraphicsView.py index d44fd1c3..75ce90b0 100644 --- a/pyqtgraph/widgets/RemoteGraphicsView.py +++ b/pyqtgraph/widgets/RemoteGraphicsView.py @@ -1,9 +1,9 @@ -from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE +from ..Qt import QtGui, QtCore, USE_PYSIDE if not USE_PYSIDE: import sip -import pyqtgraph.multiprocess as mp -import pyqtgraph as pg +from .. import multiprocess as mp from .GraphicsView import GraphicsView +from .. import CONFIG_OPTIONS import numpy as np import mmap, tempfile, ctypes, atexit, sys, random @@ -36,7 +36,7 @@ class RemoteGraphicsView(QtGui.QWidget): self._proc = mp.QtProcess(**kwds) self.pg = self._proc._import('pyqtgraph') - self.pg.setConfigOptions(**self.pg.CONFIG_OPTIONS) + self.pg.setConfigOptions(**CONFIG_OPTIONS) rpgRemote = self._proc._import('pyqtgraph.widgets.RemoteGraphicsView') self._view = rpgRemote.Renderer(*args, **remoteKwds) self._view._setProxyOptions(deferGetattr=True) @@ -108,7 +108,7 @@ class RemoteGraphicsView(QtGui.QWidget): return QtGui.QWidget.mouseMoveEvent(self, ev) def wheelEvent(self, ev): - self._view.wheelEvent(ev.pos(), ev.globalPos(), ev.delta(), int(ev.buttons()), int(ev.modifiers()), ev.orientation(), _callSync='off') + self._view.wheelEvent(ev.pos(), ev.globalPos(), ev.delta(), int(ev.buttons()), int(ev.modifiers()), int(ev.orientation()), _callSync='off') ev.accept() return QtGui.QWidget.wheelEvent(self, ev) @@ -243,6 +243,7 @@ class Renderer(GraphicsView): def wheelEvent(self, pos, gpos, d, btns, mods, ori): btns = QtCore.Qt.MouseButtons(btns) mods = QtCore.Qt.KeyboardModifiers(mods) + ori = (None, QtCore.Qt.Horizontal, QtCore.Qt.Vertical)[ori] return GraphicsView.wheelEvent(self, QtGui.QWheelEvent(pos, gpos, d, btns, mods, ori)) def keyEvent(self, typ, mods, text, autorep, count): diff --git a/pyqtgraph/widgets/ScatterPlotWidget.py b/pyqtgraph/widgets/ScatterPlotWidget.py index e9e24dd7..02f260ca 100644 --- a/pyqtgraph/widgets/ScatterPlotWidget.py +++ b/pyqtgraph/widgets/ScatterPlotWidget.py @@ -1,12 +1,13 @@ -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from .PlotWidget import PlotWidget from .DataFilterWidget import DataFilterParameter from .ColorMapWidget import ColorMapParameter -import pyqtgraph.parametertree as ptree -import pyqtgraph.functions as fn +from .. import parametertree as ptree +from .. import functions as fn +from .. import getConfigOption +from ..graphicsItems.TextItem import TextItem import numpy as np -from pyqtgraph.pgcollections import OrderedDict -import pyqtgraph as pg +from ..pgcollections import OrderedDict __all__ = ['ScatterPlotWidget'] @@ -48,9 +49,9 @@ class ScatterPlotWidget(QtGui.QSplitter): self.ctrlPanel.addWidget(self.ptree) self.addWidget(self.plot) - bg = pg.mkColor(pg.getConfigOption('background')) + bg = fn.mkColor(getConfigOption('background')) bg.setAlpha(150) - self.filterText = pg.TextItem(border=pg.getConfigOption('foreground'), color=bg) + self.filterText = TextItem(border=getConfigOption('foreground'), color=bg) self.filterText.setPos(60,20) self.filterText.setParentItem(self.plot.plotItem) @@ -193,7 +194,7 @@ class ScatterPlotWidget(QtGui.QSplitter): imax = int(xy[ax].max()) if len(xy[ax]) > 0 else 0 for i in range(imax+1): keymask = xy[ax] == i - scatter = pg.pseudoScatter(xy[1-ax][keymask], bidir=True) + scatter = fn.pseudoScatter(xy[1-ax][keymask], bidir=True) if len(scatter) == 0: continue smax = np.abs(scatter).max() diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index 57e4f1ed..47101405 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.python2_3 import asUnicode -from pyqtgraph.SignalProxy import SignalProxy +from ..Qt import QtGui, QtCore +from ..python2_3 import asUnicode +from ..SignalProxy import SignalProxy -import pyqtgraph.functions as fn +from .. import functions as fn from math import log from decimal import Decimal as D ## Use decimal to avoid accumulating floating-point errors from decimal import * @@ -47,29 +47,29 @@ class SpinBox(QtGui.QAbstractSpinBox): """ ============== ======================================================================== **Arguments:** - parent Sets the parent widget for this SpinBox (optional) - value (float/int) initial value + parent Sets the parent widget for this SpinBox (optional). Default is None. + value (float/int) initial value. Default is 0.0. bounds (min,max) Minimum and maximum values allowed in the SpinBox. - Either may be None to leave the value unbounded. - suffix (str) suffix (units) to display after the numerical value + Either may be None to leave the value unbounded. By default, values are unbounded. + suffix (str) suffix (units) to display after the numerical value. By default, suffix is an empty str. siPrefix (bool) If True, then an SI prefix is automatically prepended to the units and the value is scaled accordingly. For example, if value=0.003 and suffix='V', then the SpinBox will display - "300 mV" (but a call to SpinBox.value will still return 0.003). + "300 mV" (but a call to SpinBox.value will still return 0.003). Default is False. step (float) The size of a single step. This is used when clicking the up/ down arrows, when rolling the mouse wheel, or when pressing keyboard arrows while the widget has keyboard focus. Note that the interpretation of this value is different when specifying - the 'dec' argument. + the 'dec' argument. Default is 0.01. dec (bool) If True, then the step value will be adjusted to match the current size of the variable (for example, a value of 15 might step in increments of 1 whereas a value of 1500 would step in increments of 100). In this case, the 'step' argument is interpreted *relative* to the current value. The most common - 'step' values when dec=True are 0.1, 0.2, 0.5, and 1.0. + 'step' values when dec=True are 0.1, 0.2, 0.5, and 1.0. Default is False. minStep (float) When dec=True, this specifies the minimum allowable step size. - int (bool) if True, the value is forced to integer type - decimals (int) Number of decimal values to display + int (bool) if True, the value is forced to integer type. Default is False + decimals (int) Number of decimal values to display. Default is 2. ============== ======================================================================== """ QtGui.QAbstractSpinBox.__init__(self, parent) @@ -233,6 +233,21 @@ class SpinBox(QtGui.QAbstractSpinBox): def setDecimals(self, decimals): self.setOpts(decimals=decimals) + + def selectNumber(self): + """ + Select the numerical portion of the text to allow quick editing by the user. + """ + le = self.lineEdit() + text = asUnicode(le.text()) + if self.opts['suffix'] == '': + le.setSelection(0, len(text)) + else: + try: + index = text.index(' ') + except ValueError: + return + le.setSelection(0, index) def value(self): """ diff --git a/pyqtgraph/widgets/TableWidget.py b/pyqtgraph/widgets/TableWidget.py index 8ffe7291..69085a20 100644 --- a/pyqtgraph/widgets/TableWidget.py +++ b/pyqtgraph/widgets/TableWidget.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore -from pyqtgraph.python2_3 import asUnicode +from ..Qt import QtGui, QtCore +from ..python2_3 import asUnicode import numpy as np try: @@ -9,7 +9,28 @@ try: except ImportError: HAVE_METAARRAY = False + __all__ = ['TableWidget'] + + +def _defersort(fn): + def defersort(self, *args, **kwds): + # may be called recursively; only the first call needs to block sorting + setSorting = False + if self._sorting is None: + self._sorting = self.isSortingEnabled() + setSorting = True + self.setSortingEnabled(False) + try: + return fn(self, *args, **kwds) + finally: + if setSorting: + self.setSortingEnabled(self._sorting) + self._sorting = None + + return defersort + + class TableWidget(QtGui.QTableWidget): """Extends QTableWidget with some useful functions for automatic data handling and copy / export context menu. Can automatically format and display a variety @@ -18,14 +39,45 @@ class TableWidget(QtGui.QTableWidget): """ def __init__(self, *args, **kwds): + """ + All positional arguments are passed to QTableWidget.__init__(). + + ===================== ================================================= + **Keyword Arguments** + editable (bool) If True, cells in the table can be edited + by the user. Default is False. + sortable (bool) If True, the table may be soted by + clicking on column headers. Note that this also + causes rows to appear initially shuffled until + a sort column is selected. Default is True. + *(added in version 0.9.9)* + ===================== ================================================= + """ + QtGui.QTableWidget.__init__(self, *args) + + self.itemClass = TableWidgetItem + self.setVerticalScrollMode(self.ScrollPerPixel) self.setSelectionMode(QtGui.QAbstractItemView.ContiguousSelection) self.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred) - self.setSortingEnabled(True) self.clear() - editable = kwds.get('editable', False) - self.setEditable(editable) + + kwds.setdefault('sortable', True) + kwds.setdefault('editable', False) + self.setEditable(kwds.pop('editable')) + self.setSortingEnabled(kwds.pop('sortable')) + + if len(kwds) > 0: + raise TypeError("Invalid keyword arguments '%s'" % kwds.keys()) + + self._sorting = None # used when temporarily disabling sorting + + self._formats = {None: None} # stores per-column formats and entire table format + self.sortModes = {} # stores per-column sort mode + + self.itemChanged.connect(self.handleItemChanged) + self.contextMenu = QtGui.QMenu() self.contextMenu.addAction('Copy Selection').triggered.connect(self.copySel) self.contextMenu.addAction('Copy All').triggered.connect(self.copyAll) @@ -40,6 +92,7 @@ class TableWidget(QtGui.QTableWidget): self.items = [] self.setRowCount(0) self.setColumnCount(0) + self.sortModes = {} def setData(self, data): """Set the data displayed in the table. @@ -56,12 +109,16 @@ class TableWidget(QtGui.QTableWidget): self.appendData(data) self.resizeColumnsToContents() + @_defersort def appendData(self, data): - """Types allowed: - 1 or 2D numpy array or metaArray - 1D numpy record array - list-of-lists, list-of-dicts or dict-of-lists """ + Add new rows to the table. + + See :func:`setData() ` for accepted + data types. + """ + startRow = self.rowCount() + fn0, header0 = self.iteratorFn(data) if fn0 is None: self.clear() @@ -80,42 +137,88 @@ class TableWidget(QtGui.QTableWidget): self.setColumnCount(len(firstVals)) if not self.verticalHeadersSet and header0 is not None: - self.setRowCount(len(header0)) - self.setVerticalHeaderLabels(header0) + labels = [self.verticalHeaderItem(i).text() for i in range(self.rowCount())] + self.setRowCount(startRow + len(header0)) + self.setVerticalHeaderLabels(labels + header0) self.verticalHeadersSet = True if not self.horizontalHeadersSet and header1 is not None: self.setHorizontalHeaderLabels(header1) self.horizontalHeadersSet = True - self.setRow(0, firstVals) - i = 1 + i = startRow + self.setRow(i, firstVals) for row in it0: - self.setRow(i, [x for x in fn1(row)]) i += 1 + self.setRow(i, [x for x in fn1(row)]) + + if self._sorting and self.horizontalHeader().sortIndicatorSection() >= self.columnCount(): + self.sortByColumn(0, QtCore.Qt.AscendingOrder) def setEditable(self, editable=True): self.editable = editable for item in self.items: item.setEditable(editable) - + + def setFormat(self, format, column=None): + """ + Specify the default text formatting for the entire table, or for a + single column if *column* is specified. + + If a string is specified, it is used as a format string for converting + float values (and all other types are converted using str). If a + function is specified, it will be called with the item as its only + argument and must return a string. Setting format = None causes the + default formatter to be used instead. + + Added in version 0.9.9. + + """ + if format is not None and not isinstance(format, basestring) and not callable(format): + raise ValueError("Format argument must string, callable, or None. (got %s)" % format) + + self._formats[column] = format + + + if column is None: + # update format of all items that do not have a column format + # specified + for c in range(self.columnCount()): + if self._formats.get(c, None) is None: + for r in range(self.rowCount()): + item = self.item(r, c) + if item is None: + continue + item.setFormat(format) + else: + # set all items in the column to use this format, or the default + # table format if None was specified. + if format is None: + format = self._formats[None] + for r in range(self.rowCount()): + item = self.item(r, column) + if item is None: + continue + item.setFormat(format) + + def iteratorFn(self, data): ## Return 1) a function that will provide an iterator for data and 2) a list of header strings if isinstance(data, list) or isinstance(data, tuple): return lambda d: d.__iter__(), None elif isinstance(data, dict): - return lambda d: iter(d.values()), list(map(str, data.keys())) + return lambda d: iter(d.values()), list(map(asUnicode, data.keys())) elif HAVE_METAARRAY and (hasattr(data, 'implements') and data.implements('MetaArray')): if data.axisHasColumns(0): - header = [str(data.columnName(0, i)) for i in range(data.shape[0])] + header = [asUnicode(data.columnName(0, i)) for i in range(data.shape[0])] elif data.axisHasValues(0): - header = list(map(str, data.xvals(0))) + header = list(map(asUnicode, data.xvals(0))) else: header = None return self.iterFirstAxis, header elif isinstance(data, np.ndarray): return self.iterFirstAxis, None elif isinstance(data, np.void): - return self.iterate, list(map(str, data.dtype.names)) + return self.iterate, list(map(asUnicode, data.dtype.names)) elif data is None: return (None,None) else: @@ -135,21 +238,50 @@ class TableWidget(QtGui.QTableWidget): def appendRow(self, data): self.appendData([data]) + @_defersort def addRow(self, vals): row = self.rowCount() self.setRowCount(row + 1) self.setRow(row, vals) + @_defersort def setRow(self, row, vals): if row > self.rowCount() - 1: self.setRowCount(row + 1) for col in range(len(vals)): val = vals[col] - item = TableWidgetItem(val) + item = self.itemClass(val, row) item.setEditable(self.editable) + sortMode = self.sortModes.get(col, None) + if sortMode is not None: + item.setSortMode(sortMode) + format = self._formats.get(col, self._formats[None]) + item.setFormat(format) self.items.append(item) self.setItem(row, col, item) + item.setValue(val) # Required--the text-change callback is invoked + # when we call setItem. + def setSortMode(self, column, mode): + """ + Set the mode used to sort *column*. + + ============== ======================================================== + **Sort Modes** + value Compares item.value if available; falls back to text + comparison. + text Compares item.text() + index Compares by the order in which items were inserted. + ============== ======================================================== + + Added in version 0.9.9 + """ + for r in range(self.rowCount()): + item = self.item(r, column) + if hasattr(item, 'setSortMode'): + item.setSortMode(mode) + self.sortModes[column] = mode + def sizeHint(self): # based on http://stackoverflow.com/a/7195443/54056 width = sum(self.columnWidth(i) for i in range(self.columnCount())) @@ -173,7 +305,6 @@ class TableWidget(QtGui.QTableWidget): rows = list(range(self.rowCount())) columns = list(range(self.columnCount())) - data = [] if self.horizontalHeadersSet: row = [] @@ -222,7 +353,6 @@ class TableWidget(QtGui.QTableWidget): if fileName == '': return open(fileName, 'w').write(data) - def contextMenuEvent(self, ev): self.contextMenu.popup(ev.globalPos()) @@ -234,25 +364,111 @@ class TableWidget(QtGui.QTableWidget): else: ev.ignore() + def handleItemChanged(self, item): + item.itemChanged() + + class TableWidgetItem(QtGui.QTableWidgetItem): - def __init__(self, val): - if isinstance(val, float) or isinstance(val, np.floating): - s = "%0.3g" % val - else: - s = str(val) - QtGui.QTableWidgetItem.__init__(self, s) - self.value = val + def __init__(self, val, index, format=None): + QtGui.QTableWidgetItem.__init__(self, '') + self._blockValueChange = False + self._format = None + self._defaultFormat = '%0.3g' + self.sortMode = 'value' + self.index = index flags = QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled self.setFlags(flags) + self.setValue(val) + self.setFormat(format) def setEditable(self, editable): + """ + Set whether this item is user-editable. + """ if editable: self.setFlags(self.flags() | QtCore.Qt.ItemIsEditable) else: self.setFlags(self.flags() & ~QtCore.Qt.ItemIsEditable) + + def setSortMode(self, mode): + """ + Set the mode used to sort this item against others in its column. + + ============== ======================================================== + **Sort Modes** + value Compares item.value if available; falls back to text + comparison. + text Compares item.text() + index Compares by the order in which items were inserted. + ============== ======================================================== + """ + modes = ('value', 'text', 'index', None) + if mode not in modes: + raise ValueError('Sort mode must be one of %s' % str(modes)) + self.sortMode = mode + + def setFormat(self, fmt): + """Define the conversion from item value to displayed text. + + If a string is specified, it is used as a format string for converting + float values (and all other types are converted using str). If a + function is specified, it will be called with the item as its only + argument and must return a string. + + Added in version 0.9.9. + """ + if fmt is not None and not isinstance(fmt, basestring) and not callable(fmt): + raise ValueError("Format argument must string, callable, or None. (got %s)" % fmt) + self._format = fmt + self._updateText() + + def _updateText(self): + self._blockValueChange = True + try: + self._text = self.format() + self.setText(self._text) + finally: + self._blockValueChange = False + + def setValue(self, value): + self.value = value + self._updateText() + + def itemChanged(self): + """Called when the data of this item has changed.""" + if self.text() != self._text: + self.textChanged() + + def textChanged(self): + """Called when this item's text has changed for any reason.""" + self._text = self.text() + + if self._blockValueChange: + # text change was result of value or format change; do not + # propagate. + return + + try: + + self.value = type(self.value)(self.text()) + except ValueError: + self.value = str(self.text()) + + def format(self): + if callable(self._format): + return self._format(self) + if isinstance(self.value, (float, np.floating)): + if self._format is None: + return self._defaultFormat % self.value + else: + return self._format % self.value + else: + return asUnicode(self.value) def __lt__(self, other): - if hasattr(other, 'value'): + if self.sortMode == 'index' and hasattr(other, 'index'): + return self.index < other.index + if self.sortMode == 'value' and hasattr(other, 'value'): return self.value < other.value else: return self.text() < other.text() diff --git a/pyqtgraph/widgets/TreeWidget.py b/pyqtgraph/widgets/TreeWidget.py index 97fbe953..ec2c35cf 100644 --- a/pyqtgraph/widgets/TreeWidget.py +++ b/pyqtgraph/widgets/TreeWidget.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore from weakref import * __all__ = ['TreeWidget', 'TreeWidgetItem'] diff --git a/pyqtgraph/widgets/ValueLabel.py b/pyqtgraph/widgets/ValueLabel.py index 7f6fa84b..4e5b3011 100644 --- a/pyqtgraph/widgets/ValueLabel.py +++ b/pyqtgraph/widgets/ValueLabel.py @@ -1,6 +1,6 @@ -from pyqtgraph.Qt import QtCore, QtGui -from pyqtgraph.ptime import time -import pyqtgraph as pg +from ..Qt import QtCore, QtGui +from ..ptime import time +from .. import functions as fn from functools import reduce __all__ = ['ValueLabel'] @@ -16,18 +16,18 @@ class ValueLabel(QtGui.QLabel): def __init__(self, parent=None, suffix='', siPrefix=False, averageTime=0, formatStr=None): """ - ============ ================================================================================== - Arguments - suffix (str or None) The suffix to place after the value - siPrefix (bool) Whether to add an SI prefix to the units and display a scaled value - averageTime (float) The length of time in seconds to average values. If this value - is 0, then no averaging is performed. As this value increases - the display value will appear to change more slowly and smoothly. - formatStr (str) Optionally, provide a format string to use when displaying text. The text - will be generated by calling formatStr.format(value=, avgValue=, suffix=) - (see Python documentation on str.format) - This option is not compatible with siPrefix - ============ ================================================================================== + ============== ================================================================================== + **Arguments:** + suffix (str or None) The suffix to place after the value + siPrefix (bool) Whether to add an SI prefix to the units and display a scaled value + averageTime (float) The length of time in seconds to average values. If this value + is 0, then no averaging is performed. As this value increases + the display value will appear to change more slowly and smoothly. + formatStr (str) Optionally, provide a format string to use when displaying text. The text + will be generated by calling formatStr.format(value=, avgValue=, suffix=) + (see Python documentation on str.format) + This option is not compatible with siPrefix + ============== ================================================================================== """ QtGui.QLabel.__init__(self, parent) self.values = [] @@ -67,7 +67,7 @@ class ValueLabel(QtGui.QLabel): avg = self.averageValue() val = self.values[-1][1] if self.siPrefix: - return pg.siFormat(avg, suffix=self.suffix) + return fn.siFormat(avg, suffix=self.suffix) else: return self.formatStr.format(value=val, avgValue=avg, suffix=self.suffix) diff --git a/pyqtgraph/widgets/VerticalLabel.py b/pyqtgraph/widgets/VerticalLabel.py index fa45ae5d..c8cc80bd 100644 --- a/pyqtgraph/widgets/VerticalLabel.py +++ b/pyqtgraph/widgets/VerticalLabel.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from pyqtgraph.Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore __all__ = ['VerticalLabel'] #class VerticalLabel(QtGui.QLabel): diff --git a/pyqtgraph/widgets/tests/test_combobox.py b/pyqtgraph/widgets/tests/test_combobox.py new file mode 100644 index 00000000..f511331c --- /dev/null +++ b/pyqtgraph/widgets/tests/test_combobox.py @@ -0,0 +1,44 @@ +import pyqtgraph as pg +pg.mkQApp() + +def test_combobox(): + cb = pg.ComboBox() + items = {'a': 1, 'b': 2, 'c': 3} + cb.setItems(items) + cb.setValue(2) + assert str(cb.currentText()) == 'b' + assert cb.value() == 2 + + # Clear item list; value should be None + cb.clear() + assert cb.value() == None + + # Reset item list; value should be set automatically + cb.setItems(items) + assert cb.value() == 2 + + # Clear item list; repopulate with same names and new values + items = {'a': 4, 'b': 5, 'c': 6} + cb.clear() + cb.setItems(items) + assert cb.value() == 5 + + # Set list instead of dict + cb.setItems(list(items.keys())) + assert str(cb.currentText()) == 'b' + + cb.setValue('c') + assert cb.value() == str(cb.currentText()) + assert cb.value() == 'c' + + cb.setItemValue('c', 7) + assert cb.value() == 7 + + +if __name__ == '__main__': + cb = pg.ComboBox() + cb.show() + cb.setItems({'': None, 'a': 1, 'b': 2, 'c': 3}) + def fn(ind): + print("New value: %s" % cb.value()) + cb.currentIndexChanged.connect(fn) \ No newline at end of file diff --git a/pyqtgraph/widgets/tests/test_tablewidget.py b/pyqtgraph/widgets/tests/test_tablewidget.py new file mode 100644 index 00000000..11416430 --- /dev/null +++ b/pyqtgraph/widgets/tests/test_tablewidget.py @@ -0,0 +1,128 @@ +import pyqtgraph as pg +import numpy as np +from pyqtgraph.pgcollections import OrderedDict + +app = pg.mkQApp() + + +listOfTuples = [('text_%d' % i, i, i/9.) for i in range(12)] +listOfLists = [list(row) for row in listOfTuples] +plainArray = np.array(listOfLists, dtype=object) +recordArray = np.array(listOfTuples, dtype=[('string', object), + ('integer', int), + ('floating', float)]) +dictOfLists = OrderedDict([(name, list(recordArray[name])) for name in recordArray.dtype.names]) +listOfDicts = [OrderedDict([(name, rec[name]) for name in recordArray.dtype.names]) for rec in recordArray] +transposed = [[row[col] for row in listOfTuples] for col in range(len(listOfTuples[0]))] + +def assertTableData(table, data): + assert len(data) == table.rowCount() + rows = list(range(table.rowCount())) + columns = list(range(table.columnCount())) + for r in rows: + assert len(data[r]) == table.columnCount() + row = [] + for c in columns: + item = table.item(r, c) + if item is not None: + row.append(item.value) + else: + row.append(None) + assert row == list(data[r]) + + +def test_TableWidget(): + w = pg.TableWidget(sortable=False) + + # Test all input data types + w.setData(listOfTuples) + assertTableData(w, listOfTuples) + + w.setData(listOfLists) + assertTableData(w, listOfTuples) + + w.setData(plainArray) + assertTableData(w, listOfTuples) + + w.setData(recordArray) + assertTableData(w, listOfTuples) + + w.setData(dictOfLists) + assertTableData(w, transposed) + + w.appendData(dictOfLists) + assertTableData(w, transposed * 2) + + w.setData(listOfDicts) + assertTableData(w, listOfTuples) + + w.appendData(listOfDicts) + assertTableData(w, listOfTuples * 2) + + # Test sorting + w.setData(listOfTuples) + w.sortByColumn(0, pg.QtCore.Qt.AscendingOrder) + assertTableData(w, sorted(listOfTuples, key=lambda a: a[0])) + + w.sortByColumn(1, pg.QtCore.Qt.AscendingOrder) + assertTableData(w, sorted(listOfTuples, key=lambda a: a[1])) + + w.sortByColumn(2, pg.QtCore.Qt.AscendingOrder) + assertTableData(w, sorted(listOfTuples, key=lambda a: a[2])) + + w.setSortMode(1, 'text') + w.sortByColumn(1, pg.QtCore.Qt.AscendingOrder) + assertTableData(w, sorted(listOfTuples, key=lambda a: str(a[1]))) + + w.setSortMode(1, 'index') + w.sortByColumn(1, pg.QtCore.Qt.AscendingOrder) + assertTableData(w, listOfTuples) + + # Test formatting + item = w.item(0, 2) + assert item.text() == ('%0.3g' % item.value) + + w.setFormat('%0.6f') + assert item.text() == ('%0.6f' % item.value) + + w.setFormat('X%0.7f', column=2) + assert isinstance(item.value, float) + assert item.text() == ('X%0.7f' % item.value) + + # test setting items that do not exist yet + w.setFormat('X%0.7f', column=3) + + # test append uses correct formatting + w.appendRow(('x', 10, 7.3)) + item = w.item(w.rowCount()-1, 2) + assert isinstance(item.value, float) + assert item.text() == ('X%0.7f' % item.value) + + # test reset back to defaults + w.setFormat(None, column=2) + assert isinstance(item.value, float) + assert item.text() == ('%0.6f' % item.value) + + w.setFormat(None) + assert isinstance(item.value, float) + assert item.text() == ('%0.3g' % item.value) + + # test function formatter + def fmt(item): + if isinstance(item.value, float): + return "%d %f" % (item.index, item.value) + else: + return pg.asUnicode(item.value) + w.setFormat(fmt) + assert isinstance(item.value, float) + assert isinstance(item.index, int) + assert item.text() == ("%d %f" % (item.index, item.value)) + + + +if __name__ == '__main__': + w = pg.TableWidget(editable=True) + w.setData(listOfTuples) + w.resize(600, 600) + w.show() + diff --git a/setup.py b/setup.py index 055b74e8..4c1a6aca 100644 --- a/setup.py +++ b/setup.py @@ -1,23 +1,4 @@ -from distutils.core import setup -import distutils.dir_util -import os - -## generate list of all sub-packages -path = os.path.abspath(os.path.dirname(__file__)) -n = len(path.split(os.path.sep)) -subdirs = [i[0].split(os.path.sep)[n:] for i in os.walk(os.path.join(path, 'pyqtgraph')) if '__init__.py' in i[2]] -all_packages = ['.'.join(p) for p in subdirs] + ['pyqtgraph.examples'] - - -## Make sure build directory is clean before installing -buildPath = os.path.join(path, 'build') -if os.path.isdir(buildPath): - distutils.dir_util.remove_tree(buildPath) - -setup(name='pyqtgraph', - version='', - description='Scientific Graphics and GUI Library for Python', - long_description="""\ +DESCRIPTION = """\ PyQtGraph is a pure-python graphics and GUI library built on PyQt4/PySide and numpy. @@ -25,14 +6,16 @@ It is intended for use in mathematics / scientific / engineering applications. Despite being written entirely in python, the library is very fast due to its heavy leverage of numpy for number crunching, Qt's GraphicsView framework for 2D display, and OpenGL for 3D display. -""", +""" + +setupOpts = dict( + name='pyqtgraph', + description='Scientific Graphics and GUI Library for Python', + long_description=DESCRIPTION, license='MIT', url='http://www.pyqtgraph.org', author='Luke Campagnola', author_email='luke.campagnola@gmail.com', - packages=all_packages, - package_dir={'pyqtgraph.examples': 'examples'}, ## install examples along with the rest of the source - #package_data={'pyqtgraph': ['graphicsItems/PlotItem/*.png']}, classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 2", @@ -48,9 +31,100 @@ heavy leverage of numpy for number crunching, Qt's GraphicsView framework for "Topic :: Scientific/Engineering :: Visualization", "Topic :: Software Development :: User Interfaces", ], - install_requires = [ - 'numpy', - 'scipy', - ], +) + + +from distutils.core import setup +import distutils.dir_util +import os, sys, re +try: + # just avoids warning about install_requires + import setuptools +except ImportError: + pass + +path = os.path.split(__file__)[0] +sys.path.insert(0, os.path.join(path, 'tools')) +import setupHelpers as helpers + +## generate list of all sub-packages +allPackages = (helpers.listAllPackages(pkgroot='pyqtgraph') + + ['pyqtgraph.'+x for x in helpers.listAllPackages(pkgroot='examples')]) + +## Decide what version string to use in the build +version, forcedVersion, gitVersion, initVersion = helpers.getVersionStrings(pkg='pyqtgraph') + + +import distutils.command.build + +class Build(distutils.command.build.build): + """ + * Clear build path before building + * Set version string in __init__ after building + """ + def run(self): + global path, version, initVersion, forcedVersion + global buildVersion + + ## Make sure build directory is clean + buildPath = os.path.join(path, self.build_lib) + if os.path.isdir(buildPath): + distutils.dir_util.remove_tree(buildPath) + + ret = distutils.command.build.build.run(self) + + # If the version in __init__ is different from the automatically-generated + # version string, then we will update __init__ in the build directory + if initVersion == version: + return ret + + try: + initfile = os.path.join(buildPath, 'pyqtgraph', '__init__.py') + data = open(initfile, 'r').read() + open(initfile, 'w').write(re.sub(r"__version__ = .*", "__version__ = '%s'" % version, data)) + buildVersion = version + except: + if forcedVersion: + raise + buildVersion = initVersion + sys.stderr.write("Warning: Error occurred while setting version string in build path. " + "Installation will use the original version string " + "%s instead.\n" % (initVersion) + ) + sys.excepthook(*sys.exc_info()) + return ret + +import distutils.command.install + +class Install(distutils.command.install.install): + """ + * Check for previously-installed version before installing + """ + def run(self): + name = self.config_vars['dist_name'] + path = self.install_libbase + if os.path.exists(path) and name in os.listdir(path): + raise Exception("It appears another version of %s is already " + "installed at %s; remove this before installing." + % (name, path)) + print("Installing to %s" % path) + return distutils.command.install.install.run(self) + +setup( + version=version, + cmdclass={'build': Build, + 'install': Install, + 'deb': helpers.DebCommand, + 'test': helpers.TestCommand, + 'debug': helpers.DebugCommand, + 'mergetest': helpers.MergeTestCommand, + 'style': helpers.StyleCommand}, + packages=allPackages, + package_dir={'pyqtgraph.examples': 'examples'}, ## install examples along with the rest of the source + package_data={'pyqtgraph.examples': ['optics/*.gz', 'relativity/presets/*.cfg']}, + install_requires = [ + 'numpy', + ], + **setupOpts ) diff --git a/tests/svg.py b/tests/svg.py deleted file mode 100644 index 7c26833e..00000000 --- a/tests/svg.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -SVG export test -""" -import test -import pyqtgraph as pg -app = pg.mkQApp() - -class SVGTest(test.TestCase): - #def test_plotscene(self): - #pg.setConfigOption('foreground', (0,0,0)) - #w = pg.GraphicsWindow() - #w.show() - #p1 = w.addPlot() - #p2 = w.addPlot() - #p1.plot([1,3,2,3,1,6,9,8,4,2,3,5,3], pen={'color':'k'}) - #p1.setXRange(0,5) - #p2.plot([1,5,2,3,4,6,1,2,4,2,3,5,3], pen={'color':'k', 'cosmetic':False, 'width': 0.3}) - #app.processEvents() - #app.processEvents() - - #ex = pg.exporters.SVGExporter.SVGExporter(w.scene()) - #ex.export(fileName='test.svg') - - - def test_simple(self): - scene = pg.QtGui.QGraphicsScene() - #rect = pg.QtGui.QGraphicsRectItem(0, 0, 100, 100) - #scene.addItem(rect) - #rect.setPos(20,20) - #rect.translate(50, 50) - #rect.rotate(30) - #rect.scale(0.5, 0.5) - - #rect1 = pg.QtGui.QGraphicsRectItem(0, 0, 100, 100) - #rect1.setParentItem(rect) - #rect1.setFlag(rect1.ItemIgnoresTransformations) - #rect1.setPos(20, 20) - #rect1.scale(2,2) - - #el1 = pg.QtGui.QGraphicsEllipseItem(0, 0, 100, 100) - #el1.setParentItem(rect1) - ##grp = pg.ItemGroup() - #grp.setParentItem(rect) - #grp.translate(200,0) - ##grp.rotate(30) - - #rect2 = pg.QtGui.QGraphicsRectItem(0, 0, 100, 25) - #rect2.setFlag(rect2.ItemClipsChildrenToShape) - #rect2.setParentItem(grp) - #rect2.setPos(0,25) - #rect2.rotate(30) - #el = pg.QtGui.QGraphicsEllipseItem(0, 0, 100, 50) - #el.translate(10,-5) - #el.scale(0.5,2) - #el.setParentItem(rect2) - - grp2 = pg.ItemGroup() - scene.addItem(grp2) - grp2.scale(100,100) - - rect3 = pg.QtGui.QGraphicsRectItem(0,0,2,2) - rect3.setPen(pg.mkPen(width=1, cosmetic=False)) - grp2.addItem(rect3) - - ex = pg.exporters.SVGExporter.SVGExporter(scene) - ex.export(fileName='test.svg') - - -if __name__ == '__main__': - test.unittest.main() \ No newline at end of file diff --git a/tests/test.py b/tests/test.py deleted file mode 100644 index f24a7d42..00000000 --- a/tests/test.py +++ /dev/null @@ -1,8 +0,0 @@ -import unittest -import os, sys -## make sure this instance of pyqtgraph gets imported first -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -## all tests should be defined with this class so we have the option to tweak it later. -class TestCase(unittest.TestCase): - pass \ No newline at end of file diff --git a/tools/debian/changelog b/tools/debian/changelog deleted file mode 100644 index 1edf45f3..00000000 --- a/tools/debian/changelog +++ /dev/null @@ -1,5 +0,0 @@ -python-pyqtgraph (0.9.1-1) UNRELEASED; urgency=low - - * Initial release. - - -- Luke Sat, 29 Dec 2012 01:07:23 -0500 diff --git a/tools/debian/compat b/tools/debian/compat deleted file mode 100644 index 45a4fb75..00000000 --- a/tools/debian/compat +++ /dev/null @@ -1 +0,0 @@ -8 diff --git a/tools/debian/control b/tools/debian/control deleted file mode 100644 index 7ab6f28a..00000000 --- a/tools/debian/control +++ /dev/null @@ -1,18 +0,0 @@ -Source: python-pyqtgraph -Maintainer: Luke Campagnola -Section: python -Priority: optional -Standards-Version: 3.9.3 -Build-Depends: debhelper (>= 8) - -Package: python-pyqtgraph -Architecture: all -Homepage: http://luke.campagnola.me/code/pyqtgraph -Depends: python (>= 2.6), python-support (>= 0.90), python-qt4 | python-pyside, python-scipy, python-numpy, ${misc:Depends} -Suggests: python-opengl, python-qt4-gl -Description: Scientific Graphics and GUI Library for Python - PyQtGraph is a pure-python graphics and GUI library built on PyQt4 and numpy. - It is intended for use in mathematics / scientific / engineering applications. - Despite being written entirely in python, the library is very fast due to its - heavy leverage of numpy for number crunching and Qt's GraphicsView framework - for fast display. diff --git a/tools/debian/copyright b/tools/debian/copyright deleted file mode 100644 index 22791ae3..00000000 --- a/tools/debian/copyright +++ /dev/null @@ -1,10 +0,0 @@ -Copyright (c) 2012 University of North Carolina at Chapel Hill -Luke Campagnola ('luke.campagnola@%s.com' % 'gmail') - -The MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/tools/debian/files b/tools/debian/files deleted file mode 100644 index 4af05533..00000000 --- a/tools/debian/files +++ /dev/null @@ -1 +0,0 @@ -python-pyqtgraph_0.9.1-1_all.deb python optional diff --git a/tools/debian/postrm b/tools/debian/postrm deleted file mode 100755 index e1eae9f2..00000000 --- a/tools/debian/postrm +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -e -#DEBHELPER# -rm -rf /usr/lib/python2.7/dist-packages/pyqtgraph diff --git a/tools/debian/rules b/tools/debian/rules deleted file mode 100755 index 2d33f6ac..00000000 --- a/tools/debian/rules +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/make -f - -%: - dh $@ diff --git a/tools/debian/source/format b/tools/debian/source/format deleted file mode 100644 index 163aaf8d..00000000 --- a/tools/debian/source/format +++ /dev/null @@ -1 +0,0 @@ -3.0 (quilt) diff --git a/tools/generateChangelog.py b/tools/generateChangelog.py index 0c8bf3e6..3dcd692d 100644 --- a/tools/generateChangelog.py +++ b/tools/generateChangelog.py @@ -1,66 +1,80 @@ -from subprocess import check_output -import re, time +import re, time, sys -def run(cmd): - return check_output(cmd, shell=True) -tags = run('bzr tags') -versions = [] -for tag in tags.split('\n'): - if tag.strip() == '': - continue - ver, rev = re.split(r'\s+', tag) - if ver.startswith('pyqtgraph-'): - versions.append(ver) +def generateDebianChangelog(package, logFile, version, maintainer): + """ + ------- Convert CHANGELOG format like: + pyqtgraph-0.9.1 2012-12-29 -for i in range(len(versions)-1)[::-1]: - log = run('bzr log -r tag:%s..tag:%s' % (versions[i], versions[i+1])) - changes = [] - times = [] - inmsg = False - for line in log.split('\n'): - if line.startswith('message:'): - inmsg = True - continue - elif line.startswith('-----------------------'): - inmsg = False - continue - - if inmsg: - changes.append(line) + - change + - change + + + -------- to debian changelog format: + python-pyqtgraph (0.9.1-1) UNRELEASED; urgency=low + + * Initial release. + + -- Luke Sat, 29 Dec 2012 01:07:23 -0500 + + + *package* is the name of the python package. + *logFile* is the CHANGELOG file to read; must have the format described above. + *version* will be used to check that the most recent log entry corresponds + to the current package version. + *maintainer* should be string like "Luke ". + """ + releases = [] + current_version = None + current_log = None + current_date = None + for line in open(logFile).readlines(): + match = re.match(package+r'-(\d+\.\d+\.\d+(\.\d+)?)\s*(\d+-\d+-\d+)\s*$', line) + if match is None: + if current_log is not None: + current_log.append(line) else: - m = re.match(r'timestamp:\s+(.*)$', line) - if m is not None: - times.append(m.groups()[0]) + if current_log is not None: + releases.append((current_version, current_log, current_date)) + current_version, current_date = match.groups()[0], match.groups()[2] + #sys.stderr.write("Found release %s\n" % current_version) + current_log = [] - citime = time.strptime(times[0][:-6], '%a %Y-%m-%d %H:%M:%S') + if releases[0][0] != version: + raise Exception("Latest release in changelog (%s) does not match current release (%s)\n" % (releases[0][0], version)) + + output = [] + for release, changes, date in releases: + date = time.strptime(date, '%Y-%m-%d') + changeset = [ + "python-%s (%s-1) UNRELEASED; urgency=low\n" % (package, release), + "\n"] + changes + [ + " -- %s %s -0%d00\n" % (maintainer, time.strftime('%a, %d %b %Y %H:%M:%S', date), time.timezone/3600), + "\n" ] - print "python-pyqtgraph (%s-1) UNRELEASED; urgency=low" % versions[i+1].split('-')[1] - print "" - for line in changes: - for n in range(len(line)): - if line[n] != ' ': - n += 1 - break - - words = line.split(' ') - nextline = '' - for w in words: - if len(w) + len(nextline) > 79: - print nextline - nextline = (' '*n) + w + # remove consecutive blank lines except between releases + clean = "" + lastBlank = True + for line in changeset: + if line.strip() == '': + if lastBlank: + continue + else: + clean += line + lastBlank = True else: - nextline += ' ' + w - print nextline - #print '\n'.join(changes) - print "" - print " -- Luke %s -0%d00" % (time.strftime('%a, %d %b %Y %H:%M:%S', citime), time.timezone/3600) - #print " -- Luke %s -0%d00" % (times[0], time.timezone/3600) - print "" + clean += line + lastBlank = False + + output.append(clean) + output.append("") + return "\n".join(output) + "\n" -print """python-pyqtgraph (0.9.0-1) UNRELEASED; urgency=low - * Initial release. - - -- Luke Thu, 27 Dec 2012 02:46:26 -0500""" +if __name__ == '__main__': + if len(sys.argv) < 5: + sys.stderr.write('Usage: generateChangelog.py package_name log_file version "Maintainer "\n') + sys.exit(-1) + + print(generateDebianChangelog(*sys.argv[1:])) diff --git a/tools/rebuildUi.py b/tools/rebuildUi.py index 1e4cbf9c..36f4d34c 100644 --- a/tools/rebuildUi.py +++ b/tools/rebuildUi.py @@ -15,7 +15,7 @@ for path, sd, files in os.walk('.'): py = os.path.join(path, base + '_pyqt.py') if not os.path.exists(py) or os.stat(ui).st_mtime > os.stat(py).st_mtime: os.system('%s %s > %s' % (pyqtuic, ui, py)) - print(py) + print(py) py = os.path.join(path, base + '_pyside.py') if not os.path.exists(py) or os.stat(ui).st_mtime > os.stat(py).st_mtime: diff --git a/tools/setVersion.py b/tools/setVersion.py new file mode 100644 index 00000000..b62aca01 --- /dev/null +++ b/tools/setVersion.py @@ -0,0 +1,26 @@ +import re, os, sys + +version = sys.argv[1] + +replace = [ + ("pyqtgraph/__init__.py", r"__version__ = .*", "__version__ = '%s'" % version), + #("setup.py", r" version=.*,", " version='%s'," % version), # setup.py automatically detects version + ("doc/source/conf.py", r"version = .*", "version = '%s'" % version), + ("doc/source/conf.py", r"release = .*", "release = '%s'" % version), + #("tools/debian/control", r"^Version: .*", "Version: %s" % version) + ] + +path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') + +for filename, search, sub in replace: + filename = os.path.join(path, filename) + data = open(filename, 'r').read() + if re.search(search, data) is None: + print('Error: Search expression "%s" not found in file %s.' % (search, filename)) + os._exit(1) + open(filename, 'w').write(re.sub(search, sub, data)) + +print("Updated version strings to %s" % version) + + + diff --git a/tools/setupHelpers.py b/tools/setupHelpers.py new file mode 100644 index 00000000..b308b226 --- /dev/null +++ b/tools/setupHelpers.py @@ -0,0 +1,560 @@ +# -*- coding: utf-8 -*- +import os, sys, re +try: + from subprocess import check_output, check_call +except ImportError: + import subprocess as sp + def check_output(*args, **kwds): + kwds['stdout'] = sp.PIPE + proc = sp.Popen(*args, **kwds) + output = proc.stdout.read() + proc.wait() + if proc.returncode != 0: + ex = Exception("Process had nonzero return value %d" % proc.returncode) + ex.returncode = proc.returncode + ex.output = output + raise ex + return output + +# Maximum allowed repository size difference (in kB) following merge. +# This is used to prevent large files from being inappropriately added to +# the repository history. +MERGE_SIZE_LIMIT = 100 + +# Paths that are checked for style by flake and flake_diff +FLAKE_CHECK_PATHS = ['pyqtgraph', 'examples', 'tools'] + +# Flake style checks -- mandatory, recommended, optional +# See: http://pep8.readthedocs.org/en/1.4.6/intro.html +# and https://flake8.readthedocs.org/en/2.0/warnings.html +FLAKE_MANDATORY = set([ + 'E101', # indentation contains mixed spaces and tabs + 'E112', # expected an indented block + 'E122', # continuation line missing indentation or outdented + 'E125', # continuation line does not distinguish itself from next line + 'E133', # closing bracket is missing indentation + + 'E223', # tab before operator + 'E224', # tab after operator + 'E242', # tab after ‘,’ + 'E273', # tab after keyword + 'E274', # tab before keyword + + 'E901', # SyntaxError or IndentationError + 'E902', # IOError + + 'W191', # indentation contains tabs + + 'W601', # .has_key() is deprecated, use ‘in’ + 'W602', # deprecated form of raising exception + 'W603', # ‘<>’ is deprecated, use ‘!=’ + 'W604', # backticks are deprecated, use ‘repr()’ + ]) + +FLAKE_RECOMMENDED = set([ + 'E124', # closing bracket does not match visual indentation + 'E231', # missing whitespace after ‘,’ + + 'E211', # whitespace before ‘(‘ + 'E261', # at least two spaces before inline comment + 'E271', # multiple spaces after keyword + 'E272', # multiple spaces before keyword + 'E304', # blank lines found after function decorator + + 'F401', # module imported but unused + 'F402', # import module from line N shadowed by loop variable + 'F403', # ‘from module import *’ used; unable to detect undefined names + 'F404', # future import(s) name after other statements + + 'E501', # line too long (82 > 79 characters) + 'E502', # the backslash is redundant between brackets + + 'E702', # multiple statements on one line (semicolon) + 'E703', # statement ends with a semicolon + 'E711', # comparison to None should be ‘if cond is None:’ + 'E712', # comparison to True should be ‘if cond is True:’ or ‘if cond:’ + 'E721', # do not compare types, use ‘isinstance()’ + + 'F811', # redefinition of unused name from line N + 'F812', # list comprehension redefines name from line N + 'F821', # undefined name name + 'F822', # undefined name name in __all__ + 'F823', # local variable name ... referenced before assignment + 'F831', # duplicate argument name in function definition + 'F841', # local variable name is assigned to but never used + + 'W292', # no newline at end of file + + ]) + +FLAKE_OPTIONAL = set([ + 'E121', # continuation line indentation is not a multiple of four + 'E123', # closing bracket does not match indentation of opening bracket + 'E126', # continuation line over-indented for hanging indent + 'E127', # continuation line over-indented for visual indent + 'E128', # continuation line under-indented for visual indent + + 'E201', # whitespace after ‘(‘ + 'E202', # whitespace before ‘)’ + 'E203', # whitespace before ‘:’ + 'E221', # multiple spaces before operator + 'E222', # multiple spaces after operator + 'E225', # missing whitespace around operator + 'E227', # missing whitespace around bitwise or shift operator + 'E226', # missing whitespace around arithmetic operator + 'E228', # missing whitespace around modulo operator + 'E241', # multiple spaces after ‘,’ + 'E251', # unexpected spaces around keyword / parameter equals + 'E262', # inline comment should start with ‘# ‘ + + 'E301', # expected 1 blank line, found 0 + 'E302', # expected 2 blank lines, found 0 + 'E303', # too many blank lines (3) + + 'E401', # multiple imports on one line + + 'E701', # multiple statements on one line (colon) + + 'W291', # trailing whitespace + 'W293', # blank line contains whitespace + + 'W391', # blank line at end of file + ]) + +FLAKE_IGNORE = set([ + # 111 and 113 are ignored because they appear to be broken. + 'E111', # indentation is not a multiple of four + 'E113', # unexpected indentation + ]) + + +#def checkStyle(): + #try: + #out = check_output(['flake8', '--select=%s' % FLAKE_TESTS, '--statistics', 'pyqtgraph/']) + #ret = 0 + #print("All style checks OK.") + #except Exception as e: + #out = e.output + #ret = e.returncode + #print(out.decode('utf-8')) + #return ret + + +def checkStyle(): + """ Run flake8, checking only lines that are modified since the last + git commit. """ + test = [ 1,2,3 ] + + # First check _all_ code against mandatory error codes + print('flake8: check all code against mandatory error set...') + errors = ','.join(FLAKE_MANDATORY) + cmd = ['flake8', '--select=' + errors] + FLAKE_CHECK_PATHS + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) + #ret = proc.wait() + output = proc.stdout.read().decode('utf-8') + ret = proc.wait() + printFlakeOutput(output) + + # Check for DOS newlines + print('check line endings in all files...') + count = 0 + allowedEndings = set([None, '\n']) + for path, dirs, files in os.walk('.'): + for f in files: + if os.path.splitext(f)[1] not in ('.py', '.rst'): + continue + filename = os.path.join(path, f) + fh = open(filename, 'U') + x = fh.readlines() + endings = set(fh.newlines if isinstance(fh.newlines, tuple) else (fh.newlines,)) + endings -= allowedEndings + if len(endings) > 0: + print("\033[0;31m" + "File has invalid line endings: %s" % filename + "\033[0m") + ret = ret | 2 + count += 1 + print('checked line endings in %d files' % count) + + + # Next check new code with optional error codes + print('flake8: check new code against recommended error set...') + diff = subprocess.check_output(['git', 'diff']) + proc = subprocess.Popen(['flake8', '--diff', #'--show-source', + '--ignore=' + errors], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE) + proc.stdin.write(diff) + proc.stdin.close() + output = proc.stdout.read().decode('utf-8') + ret |= printFlakeOutput(output) + + if ret == 0: + print('style test passed.') + else: + print('style test failed: %d' % ret) + return ret + +def printFlakeOutput(text): + """ Print flake output, colored by error category. + Return 2 if there were any mandatory errors, + 1 if only recommended / optional errors, and + 0 if only optional errors. + """ + ret = 0 + gotError = False + for line in text.split('\n'): + m = re.match(r'[^\:]+\:\d+\:\d+\: (\w+) .*', line) + if m is None: + print(line) + else: + gotError = True + error = m.group(1) + if error in FLAKE_MANDATORY: + print("\033[0;31m" + line + "\033[0m") + ret |= 2 + elif error in FLAKE_RECOMMENDED: + print("\033[0;33m" + line + "\033[0m") + #ret |= 1 + elif error in FLAKE_OPTIONAL: + print("\033[0;32m" + line + "\033[0m") + elif error in FLAKE_IGNORE: + continue + else: + print("\033[0;36m" + line + "\033[0m") + if not gotError: + print(" [ no errors ]\n") + return ret + + + +def unitTests(): + """ + Run all unit tests (using py.test) + Return the exit code. + """ + try: + if sys.version[0] == '3': + out = check_output('PYTHONPATH=. py.test-3', shell=True) + else: + out = check_output('PYTHONPATH=. py.test', shell=True) + ret = 0 + except Exception as e: + out = e.output + ret = e.returncode + print(out.decode('utf-8')) + return ret + + +def checkMergeSize(sourceBranch=None, targetBranch=None, sourceRepo=None, targetRepo=None): + """ + Check that a git merge would not increase the repository size by MERGE_SIZE_LIMIT. + """ + if sourceBranch is None: + sourceBranch = getGitBranch() + sourceRepo = '..' + + if targetBranch is None: + if sourceBranch == 'develop': + targetBranch = 'develop' + targetRepo = 'https://github.com/pyqtgraph/pyqtgraph.git' + else: + targetBranch = 'develop' + targetRepo = '..' + + workingDir = '__merge-test-clone' + env = dict(TARGET_BRANCH=targetBranch, + SOURCE_BRANCH=sourceBranch, + TARGET_REPO=targetRepo, + SOURCE_REPO=sourceRepo, + WORKING_DIR=workingDir, + ) + + print("Testing merge size difference:\n" + " SOURCE: {SOURCE_REPO} {SOURCE_BRANCH}\n" + " TARGET: {TARGET_BRANCH} {TARGET_REPO}".format(**env)) + + setup = """ + mkdir {WORKING_DIR} && cd {WORKING_DIR} && + git init && git remote add -t {TARGET_BRANCH} target {TARGET_REPO} && + git fetch target {TARGET_BRANCH} && + git checkout -qf target/{TARGET_BRANCH} && + git gc -q --aggressive + """.format(**env) + + checkSize = """ + cd {WORKING_DIR} && + du -s . | sed -e "s/\t.*//" + """.format(**env) + + merge = """ + cd {WORKING_DIR} && + git pull -q {SOURCE_REPO} {SOURCE_BRANCH} && + git gc -q --aggressive + """.format(**env) + + try: + print("Check out target branch:\n" + setup) + check_call(setup, shell=True) + targetSize = int(check_output(checkSize, shell=True)) + print("TARGET SIZE: %d kB" % targetSize) + print("Merge source branch:\n" + merge) + check_call(merge, shell=True) + mergeSize = int(check_output(checkSize, shell=True)) + print("MERGE SIZE: %d kB" % mergeSize) + + diff = mergeSize - targetSize + if diff <= MERGE_SIZE_LIMIT: + print("DIFFERENCE: %d kB [OK]" % diff) + return 0 + else: + print("\033[0;31m" + "DIFFERENCE: %d kB [exceeds %d kB]" % (diff, MERGE_SIZE_LIMIT) + "\033[0m") + return 2 + finally: + if os.path.isdir(workingDir): + shutil.rmtree(workingDir) + + +def mergeTests(): + ret = checkMergeSize() + ret |= unitTests() + ret |= checkStyle() + if ret == 0: + print("\033[0;32m" + "\nAll merge tests passed." + "\033[0m") + else: + print("\033[0;31m" + "\nMerge tests failed." + "\033[0m") + return ret + + +def listAllPackages(pkgroot): + path = os.getcwd() + n = len(path.split(os.path.sep)) + subdirs = [i[0].split(os.path.sep)[n:] for i in os.walk(os.path.join(path, pkgroot)) if '__init__.py' in i[2]] + return ['.'.join(p) for p in subdirs] + + +def getInitVersion(pkgroot): + """Return the version string defined in __init__.py""" + path = os.getcwd() + initfile = os.path.join(path, pkgroot, '__init__.py') + init = open(initfile).read() + m = re.search(r'__version__ = (\S+)\n', init) + if m is None or len(m.groups()) != 1: + raise Exception("Cannot determine __version__ from init file: '%s'!" % initfile) + version = m.group(1).strip('\'\"') + return version + +def gitCommit(name): + """Return the commit ID for the given name.""" + commit = check_output(['git', 'show', name], universal_newlines=True).split('\n')[0] + assert commit[:7] == 'commit ' + return commit[7:] + +def getGitVersion(tagPrefix): + """Return a version string with information about this git checkout. + If the checkout is an unmodified, tagged commit, then return the tag version. + If this is not a tagged commit, return version-branch_name-commit_id. + If this checkout has been modified, append "+" to the version. + """ + path = os.getcwd() + if not os.path.isdir(os.path.join(path, '.git')): + return None + + # Find last tag matching "tagPrefix.*" + tagNames = check_output(['git', 'tag'], universal_newlines=True).strip().split('\n') + while True: + if len(tagNames) == 0: + raise Exception("Could not determine last tagged version.") + lastTagName = tagNames.pop() + if re.match(tagPrefix+r'\d+\.\d+.*', lastTagName): + break + gitVersion = lastTagName.replace(tagPrefix, '') + + # is this commit an unchanged checkout of the last tagged version? + lastTag = gitCommit(lastTagName) + head = gitCommit('HEAD') + if head != lastTag: + branch = getGitBranch() + gitVersion = gitVersion + "-%s-%s" % (branch, head[:10]) + + # any uncommitted modifications? + modified = False + status = check_output(['git', 'status', '--porcelain'], universal_newlines=True).strip().split('\n') + for line in status: + if line != '' and line[:2] != '??': + modified = True + break + + if modified: + gitVersion = gitVersion + '+' + + return gitVersion + +def getGitBranch(): + m = re.search(r'\* (.*)', check_output(['git', 'branch'], universal_newlines=True)) + if m is None: + return '' + else: + return m.group(1) + +def getVersionStrings(pkg): + """ + Returns 4 version strings: + + * the version string to use for this build, + * version string requested with --force-version (or None) + * version string that describes the current git checkout (or None). + * version string in the pkg/__init__.py, + + The first return value is (forceVersion or gitVersion or initVersion). + """ + + ## Determine current version string from __init__.py + initVersion = getInitVersion(pkgroot='pyqtgraph') + + ## If this is a git checkout, try to generate a more descriptive version string + try: + gitVersion = getGitVersion(tagPrefix='pyqtgraph-') + except: + gitVersion = None + sys.stderr.write("This appears to be a git checkout, but an error occurred " + "while attempting to determine a version string for the " + "current commit.\n") + sys.excepthook(*sys.exc_info()) + + # See whether a --force-version flag was given + forcedVersion = None + for i,arg in enumerate(sys.argv): + if arg.startswith('--force-version'): + if arg == '--force-version': + forcedVersion = sys.argv[i+1] + sys.argv.pop(i) + sys.argv.pop(i) + elif arg.startswith('--force-version='): + forcedVersion = sys.argv[i].replace('--force-version=', '') + sys.argv.pop(i) + + + ## Finally decide on a version string to use: + if forcedVersion is not None: + version = forcedVersion + elif gitVersion is not None and getGitBranch() != 'debian': # ignore git version if this is debian branch + version = gitVersion + sys.stderr.write("Detected git commit; will use version string: '%s'\n" % version) + else: + version = initVersion + + return version, forcedVersion, gitVersion, initVersion + + +from distutils.core import Command +import shutil, subprocess +from generateChangelog import generateDebianChangelog + +class DebCommand(Command): + description = "build .deb package using `debuild -us -uc`" + maintainer = "Luke Campagnola " + debTemplate = "debian" + debDir = "deb_build" + + user_options = [] + + def initialize_options(self): + self.cwd = None + + def finalize_options(self): + self.cwd = os.getcwd() + + def run(self): + version = self.distribution.get_version() + pkgName = self.distribution.get_name() + debName = "python-" + pkgName + debDir = self.debDir + + assert os.getcwd() == self.cwd, 'Must be in package root: %s' % self.cwd + + if os.path.isdir(debDir): + raise Exception('DEB build dir already exists: "%s"' % debDir) + sdist = "dist/%s-%s.tar.gz" % (pkgName, version) + if not os.path.isfile(sdist): + raise Exception("No source distribution; run `setup.py sdist` first.") + + # copy sdist to build directory and extract + os.mkdir(debDir) + renamedSdist = '%s_%s.orig.tar.gz' % (debName, version) + print("copy %s => %s" % (sdist, os.path.join(debDir, renamedSdist))) + shutil.copy(sdist, os.path.join(debDir, renamedSdist)) + print("cd %s; tar -xzf %s" % (debDir, renamedSdist)) + if os.system("cd %s; tar -xzf %s" % (debDir, renamedSdist)) != 0: + raise Exception("Error extracting source distribution.") + buildDir = '%s/%s-%s' % (debDir, pkgName, version) + + # copy debian control structure + print("copytree %s => %s" % (self.debTemplate, buildDir+'/debian')) + shutil.copytree(self.debTemplate, buildDir+'/debian') + + # Write new changelog + chlog = generateDebianChangelog(pkgName, 'CHANGELOG', version, self.maintainer) + print("write changelog %s" % buildDir+'/debian/changelog') + open(buildDir+'/debian/changelog', 'w').write(chlog) + + # build package + print('cd %s; debuild -us -uc' % buildDir) + if os.system('cd %s; debuild -us -uc' % buildDir) != 0: + raise Exception("Error during debuild.") + + +class DebugCommand(Command): + """Just for learning about distutils.""" + description = "" + user_options = [] + def initialize_options(self): + pass + def finalize_options(self): + pass + def run(self): + global cmd + cmd = self + print(self.distribution.name) + print(self.distribution.version) + + +class TestCommand(Command): + description = "Run all package tests and exit immediately with informative return code." + user_options = [] + + def run(self): + sys.exit(unitTests()) + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + +class StyleCommand(Command): + description = "Check all code for style, exit immediately with informative return code." + user_options = [] + + def run(self): + sys.exit(checkStyle()) + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + +class MergeTestCommand(Command): + description = "Run all tests needed to determine whether the current code is suitable for merge." + user_options = [] + + def run(self): + sys.exit(mergeTests()) + + def initialize_options(self): + pass + + def finalize_options(self): + pass +