diff -Nru ginga-3.0.0/ah_bootstrap.py ginga-3.1.0/ah_bootstrap.py --- ginga-3.0.0/ah_bootstrap.py 2019-07-31 04:01:10.000000000 +0000 +++ ginga-3.1.0/ah_bootstrap.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,1010 +0,0 @@ -""" -This bootstrap module contains code for ensuring that the astropy_helpers -package will be importable by the time the setup.py script runs. It also -includes some workarounds to ensure that a recent-enough version of setuptools -is being used for the installation. - -This module should be the first thing imported in the setup.py of distributions -that make use of the utilities in astropy_helpers. If the distribution ships -with its own copy of astropy_helpers, this module will first attempt to import -from the shipped copy. However, it will also check PyPI to see if there are -any bug-fix releases on top of the current version that may be useful to get -past platform-specific bugs that have been fixed. When running setup.py, use -the ``--offline`` command-line option to disable the auto-upgrade checks. - -When this module is imported or otherwise executed it automatically calls a -main function that attempts to read the project's setup.cfg file, which it -checks for a configuration section called ``[ah_bootstrap]`` the presences of -that section, and options therein, determine the next step taken: If it -contains an option called ``auto_use`` with a value of ``True``, it will -automatically call the main function of this module called -`use_astropy_helpers` (see that function's docstring for full details). -Otherwise no further action is taken and by default the system-installed version -of astropy-helpers will be used (however, ``ah_bootstrap.use_astropy_helpers`` -may be called manually from within the setup.py script). - -This behavior can also be controlled using the ``--auto-use`` and -``--no-auto-use`` command-line flags. For clarity, an alias for -``--no-auto-use`` is ``--use-system-astropy-helpers``, and we recommend using -the latter if needed. - -Additional options in the ``[ah_boostrap]`` section of setup.cfg have the same -names as the arguments to `use_astropy_helpers`, and can be used to configure -the bootstrap script when ``auto_use = True``. - -See https://github.com/astropy/astropy-helpers for more details, and for the -latest version of this module. -""" - -import contextlib -import errno -import io -import locale -import os -import re -import subprocess as sp -import sys - -from distutils import log -from distutils.debug import DEBUG - -from configparser import ConfigParser, RawConfigParser - -import pkg_resources - -from setuptools import Distribution -from setuptools.package_index import PackageIndex - -# This is the minimum Python version required for astropy-helpers -__minimum_python_version__ = (3, 5) - -# TODO: Maybe enable checking for a specific version of astropy_helpers? -DIST_NAME = 'astropy-helpers' -PACKAGE_NAME = 'astropy_helpers' -UPPER_VERSION_EXCLUSIVE = None - -# Defaults for other options -DOWNLOAD_IF_NEEDED = True -INDEX_URL = 'https://pypi.python.org/simple' -USE_GIT = True -OFFLINE = False -AUTO_UPGRADE = True - -# A list of all the configuration options and their required types -CFG_OPTIONS = [ - ('auto_use', bool), ('path', str), ('download_if_needed', bool), - ('index_url', str), ('use_git', bool), ('offline', bool), - ('auto_upgrade', bool) -] - -# Start off by parsing the setup.cfg file - -SETUP_CFG = ConfigParser() - -if os.path.exists('setup.cfg'): - - try: - SETUP_CFG.read('setup.cfg') - except Exception as e: - if DEBUG: - raise - - log.error( - "Error reading setup.cfg: {0!r}\n{1} will not be " - "automatically bootstrapped and package installation may fail." - "\n{2}".format(e, PACKAGE_NAME, _err_help_msg)) - -# We used package_name in the package template for a while instead of name -if SETUP_CFG.has_option('metadata', 'name'): - parent_package = SETUP_CFG.get('metadata', 'name') -elif SETUP_CFG.has_option('metadata', 'package_name'): - parent_package = SETUP_CFG.get('metadata', 'package_name') -else: - parent_package = None - -if SETUP_CFG.has_option('options', 'python_requires'): - - python_requires = SETUP_CFG.get('options', 'python_requires') - - # The python_requires key has a syntax that can be parsed by SpecifierSet - # in the packaging package. However, we don't want to have to depend on that - # package, so instead we can use setuptools (which bundles packaging). We - # have to add 'python' to parse it with Requirement. - - from pkg_resources import Requirement - req = Requirement.parse('python' + python_requires) - - # We want the Python version as a string, which we can get from the platform module - import platform - # strip off trailing '+' incase this is a dev install of python - python_version = platform.python_version().strip('+') - # allow pre-releases to count as 'new enough' - if not req.specifier.contains(python_version, True): - if parent_package is None: - message = "ERROR: Python {} is required by this package\n".format(req.specifier) - else: - message = "ERROR: Python {} is required by {}\n".format(req.specifier, parent_package) - sys.stderr.write(message) - sys.exit(1) - -if sys.version_info < __minimum_python_version__: - - if parent_package is None: - message = "ERROR: Python {} or later is required by astropy-helpers\n".format( - __minimum_python_version__) - else: - message = "ERROR: Python {} or later is required by astropy-helpers for {}\n".format( - __minimum_python_version__, parent_package) - - sys.stderr.write(message) - sys.exit(1) - -_str_types = (str, bytes) - - -# What follows are several import statements meant to deal with install-time -# issues with either missing or misbehaving pacakges (including making sure -# setuptools itself is installed): - -# Check that setuptools 30.3 or later is present -from distutils.version import LooseVersion - -try: - import setuptools - assert LooseVersion(setuptools.__version__) >= LooseVersion('30.3') -except (ImportError, AssertionError): - sys.stderr.write("ERROR: setuptools 30.3 or later is required by astropy-helpers\n") - sys.exit(1) - -# typing as a dependency for 1.6.1+ Sphinx causes issues when imported after -# initializing submodule with ah_boostrap.py -# See discussion and references in -# https://github.com/astropy/astropy-helpers/issues/302 - -try: - import typing # noqa -except ImportError: - pass - - -# Note: The following import is required as a workaround to -# https://github.com/astropy/astropy-helpers/issues/89; if we don't import this -# module now, it will get cleaned up after `run_setup` is called, but that will -# later cause the TemporaryDirectory class defined in it to stop working when -# used later on by setuptools -try: - import setuptools.py31compat # noqa -except ImportError: - pass - - -# matplotlib can cause problems if it is imported from within a call of -# run_setup(), because in some circumstances it will try to write to the user's -# home directory, resulting in a SandboxViolation. See -# https://github.com/matplotlib/matplotlib/pull/4165 -# Making sure matplotlib, if it is available, is imported early in the setup -# process can mitigate this (note importing matplotlib.pyplot has the same -# issue) -try: - import matplotlib - matplotlib.use('Agg') - import matplotlib.pyplot -except: - # Ignore if this fails for *any* reason* - pass - - -# End compatibility imports... - - -class _Bootstrapper(object): - """ - Bootstrapper implementation. See ``use_astropy_helpers`` for parameter - documentation. - """ - - def __init__(self, path=None, index_url=None, use_git=None, offline=None, - download_if_needed=None, auto_upgrade=None): - - if path is None: - path = PACKAGE_NAME - - if not (isinstance(path, _str_types) or path is False): - raise TypeError('path must be a string or False') - - if not isinstance(path, str): - fs_encoding = sys.getfilesystemencoding() - path = path.decode(fs_encoding) # path to unicode - - self.path = path - - # Set other option attributes, using defaults where necessary - self.index_url = index_url if index_url is not None else INDEX_URL - self.offline = offline if offline is not None else OFFLINE - - # If offline=True, override download and auto-upgrade - if self.offline: - download_if_needed = False - auto_upgrade = False - - self.download = (download_if_needed - if download_if_needed is not None - else DOWNLOAD_IF_NEEDED) - self.auto_upgrade = (auto_upgrade - if auto_upgrade is not None else AUTO_UPGRADE) - - # If this is a release then the .git directory will not exist so we - # should not use git. - git_dir_exists = os.path.exists(os.path.join(os.path.dirname(__file__), '.git')) - if use_git is None and not git_dir_exists: - use_git = False - - self.use_git = use_git if use_git is not None else USE_GIT - # Declared as False by default--later we check if astropy-helpers can be - # upgraded from PyPI, but only if not using a source distribution (as in - # the case of import from a git submodule) - self.is_submodule = False - - @classmethod - def main(cls, argv=None): - if argv is None: - argv = sys.argv - - config = cls.parse_config() - config.update(cls.parse_command_line(argv)) - - auto_use = config.pop('auto_use', False) - bootstrapper = cls(**config) - - if auto_use: - # Run the bootstrapper, otherwise the setup.py is using the old - # use_astropy_helpers() interface, in which case it will run the - # bootstrapper manually after reconfiguring it. - bootstrapper.run() - - return bootstrapper - - @classmethod - def parse_config(cls): - - if not SETUP_CFG.has_section('ah_bootstrap'): - return {} - - config = {} - - for option, type_ in CFG_OPTIONS: - if not SETUP_CFG.has_option('ah_bootstrap', option): - continue - - if type_ is bool: - value = SETUP_CFG.getboolean('ah_bootstrap', option) - else: - value = SETUP_CFG.get('ah_bootstrap', option) - - config[option] = value - - return config - - @classmethod - def parse_command_line(cls, argv=None): - if argv is None: - argv = sys.argv - - config = {} - - # For now we just pop recognized ah_bootstrap options out of the - # arg list. This is imperfect; in the unlikely case that a setup.py - # custom command or even custom Distribution class defines an argument - # of the same name then we will break that. However there's a catch22 - # here that we can't just do full argument parsing right here, because - # we don't yet know *how* to parse all possible command-line arguments. - if '--no-git' in argv: - config['use_git'] = False - argv.remove('--no-git') - - if '--offline' in argv: - config['offline'] = True - argv.remove('--offline') - - if '--auto-use' in argv: - config['auto_use'] = True - argv.remove('--auto-use') - - if '--no-auto-use' in argv: - config['auto_use'] = False - argv.remove('--no-auto-use') - - if '--use-system-astropy-helpers' in argv: - config['auto_use'] = False - argv.remove('--use-system-astropy-helpers') - - return config - - def run(self): - strategies = ['local_directory', 'local_file', 'index'] - dist = None - - # First, remove any previously imported versions of astropy_helpers; - # this is necessary for nested installs where one package's installer - # is installing another package via setuptools.sandbox.run_setup, as in - # the case of setup_requires - for key in list(sys.modules): - try: - if key == PACKAGE_NAME or key.startswith(PACKAGE_NAME + '.'): - del sys.modules[key] - except AttributeError: - # Sometimes mysterious non-string things can turn up in - # sys.modules - continue - - # Check to see if the path is a submodule - self.is_submodule = self._check_submodule() - - for strategy in strategies: - method = getattr(self, 'get_{0}_dist'.format(strategy)) - dist = method() - if dist is not None: - break - else: - raise _AHBootstrapSystemExit( - "No source found for the {0!r} package; {0} must be " - "available and importable as a prerequisite to building " - "or installing this package.".format(PACKAGE_NAME)) - - # This is a bit hacky, but if astropy_helpers was loaded from a - # directory/submodule its Distribution object gets a "precedence" of - # "DEVELOP_DIST". However, in other cases it gets a precedence of - # "EGG_DIST". However, when activing the distribution it will only be - # placed early on sys.path if it is treated as an EGG_DIST, so always - # do that - dist = dist.clone(precedence=pkg_resources.EGG_DIST) - - # Otherwise we found a version of astropy-helpers, so we're done - # Just active the found distribution on sys.path--if we did a - # download this usually happens automatically but it doesn't hurt to - # do it again - # Note: Adding the dist to the global working set also activates it - # (makes it importable on sys.path) by default. - - try: - pkg_resources.working_set.add(dist, replace=True) - except TypeError: - # Some (much) older versions of setuptools do not have the - # replace=True option here. These versions are old enough that all - # bets may be off anyways, but it's easy enough to work around just - # in case... - if dist.key in pkg_resources.working_set.by_key: - del pkg_resources.working_set.by_key[dist.key] - pkg_resources.working_set.add(dist) - - @property - def config(self): - """ - A `dict` containing the options this `_Bootstrapper` was configured - with. - """ - - return dict((optname, getattr(self, optname)) - for optname, _ in CFG_OPTIONS if hasattr(self, optname)) - - def get_local_directory_dist(self): - """ - Handle importing a vendored package from a subdirectory of the source - distribution. - """ - - if not os.path.isdir(self.path): - return - - log.info('Attempting to import astropy_helpers from {0} {1!r}'.format( - 'submodule' if self.is_submodule else 'directory', - self.path)) - - dist = self._directory_import() - - if dist is None: - log.warn( - 'The requested path {0!r} for importing {1} does not ' - 'exist, or does not contain a copy of the {1} ' - 'package.'.format(self.path, PACKAGE_NAME)) - elif self.auto_upgrade and not self.is_submodule: - # A version of astropy-helpers was found on the available path, but - # check to see if a bugfix release is available on PyPI - upgrade = self._do_upgrade(dist) - if upgrade is not None: - dist = upgrade - - return dist - - def get_local_file_dist(self): - """ - Handle importing from a source archive; this also uses setup_requires - but points easy_install directly to the source archive. - """ - - if not os.path.isfile(self.path): - return - - log.info('Attempting to unpack and import astropy_helpers from ' - '{0!r}'.format(self.path)) - - try: - dist = self._do_download(find_links=[self.path]) - except Exception as e: - if DEBUG: - raise - - log.warn( - 'Failed to import {0} from the specified archive {1!r}: ' - '{2}'.format(PACKAGE_NAME, self.path, str(e))) - dist = None - - if dist is not None and self.auto_upgrade: - # A version of astropy-helpers was found on the available path, but - # check to see if a bugfix release is available on PyPI - upgrade = self._do_upgrade(dist) - if upgrade is not None: - dist = upgrade - - return dist - - def get_index_dist(self): - if not self.download: - log.warn('Downloading {0!r} disabled.'.format(DIST_NAME)) - return None - - log.warn( - "Downloading {0!r}; run setup.py with the --offline option to " - "force offline installation.".format(DIST_NAME)) - - try: - dist = self._do_download() - except Exception as e: - if DEBUG: - raise - log.warn( - 'Failed to download and/or install {0!r} from {1!r}:\n' - '{2}'.format(DIST_NAME, self.index_url, str(e))) - dist = None - - # No need to run auto-upgrade here since we've already presumably - # gotten the most up-to-date version from the package index - return dist - - def _directory_import(self): - """ - Import astropy_helpers from the given path, which will be added to - sys.path. - - Must return True if the import succeeded, and False otherwise. - """ - - # Return True on success, False on failure but download is allowed, and - # otherwise raise SystemExit - path = os.path.abspath(self.path) - - # Use an empty WorkingSet rather than the man - # pkg_resources.working_set, since on older versions of setuptools this - # will invoke a VersionConflict when trying to install an upgrade - ws = pkg_resources.WorkingSet([]) - ws.add_entry(path) - dist = ws.by_key.get(DIST_NAME) - - if dist is None: - # We didn't find an egg-info/dist-info in the given path, but if a - # setup.py exists we can generate it - setup_py = os.path.join(path, 'setup.py') - if os.path.isfile(setup_py): - # We use subprocess instead of run_setup from setuptools to - # avoid segmentation faults - see the following for more details: - # https://github.com/cython/cython/issues/2104 - sp.check_output([sys.executable, 'setup.py', 'egg_info'], cwd=path) - - for dist in pkg_resources.find_distributions(path, True): - # There should be only one... - return dist - - return dist - - def _do_download(self, version='', find_links=None): - if find_links: - allow_hosts = '' - index_url = None - else: - allow_hosts = None - index_url = self.index_url - - # Annoyingly, setuptools will not handle other arguments to - # Distribution (such as options) before handling setup_requires, so it - # is not straightforward to programmatically augment the arguments which - # are passed to easy_install - class _Distribution(Distribution): - def get_option_dict(self, command_name): - opts = Distribution.get_option_dict(self, command_name) - if command_name == 'easy_install': - if find_links is not None: - opts['find_links'] = ('setup script', find_links) - if index_url is not None: - opts['index_url'] = ('setup script', index_url) - if allow_hosts is not None: - opts['allow_hosts'] = ('setup script', allow_hosts) - return opts - - if version: - req = '{0}=={1}'.format(DIST_NAME, version) - else: - if UPPER_VERSION_EXCLUSIVE is None: - req = DIST_NAME - else: - req = '{0}<{1}'.format(DIST_NAME, UPPER_VERSION_EXCLUSIVE) - - attrs = {'setup_requires': [req]} - - # NOTE: we need to parse the config file (e.g. setup.cfg) to make sure - # it honours the options set in the [easy_install] section, and we need - # to explicitly fetch the requirement eggs as setup_requires does not - # get honored in recent versions of setuptools: - # https://github.com/pypa/setuptools/issues/1273 - - try: - - context = _verbose if DEBUG else _silence - with context(): - dist = _Distribution(attrs=attrs) - try: - dist.parse_config_files(ignore_option_errors=True) - dist.fetch_build_eggs(req) - except TypeError: - # On older versions of setuptools, ignore_option_errors - # doesn't exist, and the above two lines are not needed - # so we can just continue - pass - - # If the setup_requires succeeded it will have added the new dist to - # the main working_set - return pkg_resources.working_set.by_key.get(DIST_NAME) - except Exception as e: - if DEBUG: - raise - - msg = 'Error retrieving {0} from {1}:\n{2}' - if find_links: - source = find_links[0] - elif index_url != INDEX_URL: - source = index_url - else: - source = 'PyPI' - - raise Exception(msg.format(DIST_NAME, source, repr(e))) - - def _do_upgrade(self, dist): - # Build up a requirement for a higher bugfix release but a lower minor - # release (so API compatibility is guaranteed) - next_version = _next_version(dist.parsed_version) - - req = pkg_resources.Requirement.parse( - '{0}>{1},<{2}'.format(DIST_NAME, dist.version, next_version)) - - package_index = PackageIndex(index_url=self.index_url) - - upgrade = package_index.obtain(req) - - if upgrade is not None: - return self._do_download(version=upgrade.version) - - def _check_submodule(self): - """ - Check if the given path is a git submodule. - - See the docstrings for ``_check_submodule_using_git`` and - ``_check_submodule_no_git`` for further details. - """ - - if (self.path is None or - (os.path.exists(self.path) and not os.path.isdir(self.path))): - return False - - if self.use_git: - return self._check_submodule_using_git() - else: - return self._check_submodule_no_git() - - def _check_submodule_using_git(self): - """ - Check if the given path is a git submodule. If so, attempt to initialize - and/or update the submodule if needed. - - This function makes calls to the ``git`` command in subprocesses. The - ``_check_submodule_no_git`` option uses pure Python to check if the given - path looks like a git submodule, but it cannot perform updates. - """ - - cmd = ['git', 'submodule', 'status', '--', self.path] - - try: - log.info('Running `{0}`; use the --no-git option to disable git ' - 'commands'.format(' '.join(cmd))) - returncode, stdout, stderr = run_cmd(cmd) - except _CommandNotFound: - # The git command simply wasn't found; this is most likely the - # case on user systems that don't have git and are simply - # trying to install the package from PyPI or a source - # distribution. Silently ignore this case and simply don't try - # to use submodules - return False - - stderr = stderr.strip() - - if returncode != 0 and stderr: - # Unfortunately the return code alone cannot be relied on, as - # earlier versions of git returned 0 even if the requested submodule - # does not exist - - # This is a warning that occurs in perl (from running git submodule) - # which only occurs with a malformatted locale setting which can - # happen sometimes on OSX. See again - # https://github.com/astropy/astropy/issues/2749 - perl_warning = ('perl: warning: Falling back to the standard locale ' - '("C").') - if not stderr.strip().endswith(perl_warning): - # Some other unknown error condition occurred - log.warn('git submodule command failed ' - 'unexpectedly:\n{0}'.format(stderr)) - return False - - # Output of `git submodule status` is as follows: - # - # 1: Status indicator: '-' for submodule is uninitialized, '+' if - # submodule is initialized but is not at the commit currently indicated - # in .gitmodules (and thus needs to be updated), or 'U' if the - # submodule is in an unstable state (i.e. has merge conflicts) - # - # 2. SHA-1 hash of the current commit of the submodule (we don't really - # need this information but it's useful for checking that the output is - # correct) - # - # 3. The output of `git describe` for the submodule's current commit - # hash (this includes for example what branches the commit is on) but - # only if the submodule is initialized. We ignore this information for - # now - _git_submodule_status_re = re.compile( - r'^(?P[+-U ])(?P[0-9a-f]{40}) ' - r'(?P\S+)( .*)?$') - - # The stdout should only contain one line--the status of the - # requested submodule - m = _git_submodule_status_re.match(stdout) - if m: - # Yes, the path *is* a git submodule - self._update_submodule(m.group('submodule'), m.group('status')) - return True - else: - log.warn( - 'Unexpected output from `git submodule status`:\n{0}\n' - 'Will attempt import from {1!r} regardless.'.format( - stdout, self.path)) - return False - - def _check_submodule_no_git(self): - """ - Like ``_check_submodule_using_git``, but simply parses the .gitmodules file - to determine if the supplied path is a git submodule, and does not exec any - subprocesses. - - This can only determine if a path is a submodule--it does not perform - updates, etc. This function may need to be updated if the format of the - .gitmodules file is changed between git versions. - """ - - gitmodules_path = os.path.abspath('.gitmodules') - - if not os.path.isfile(gitmodules_path): - return False - - # This is a minimal reader for gitconfig-style files. It handles a few of - # the quirks that make gitconfig files incompatible with ConfigParser-style - # files, but does not support the full gitconfig syntax (just enough - # needed to read a .gitmodules file). - gitmodules_fileobj = io.StringIO() - - # Must use io.open for cross-Python-compatible behavior wrt unicode - with io.open(gitmodules_path) as f: - for line in f: - # gitconfig files are more flexible with leading whitespace; just - # go ahead and remove it - line = line.lstrip() - - # comments can start with either # or ; - if line and line[0] in (':', ';'): - continue - - gitmodules_fileobj.write(line) - - gitmodules_fileobj.seek(0) - - cfg = RawConfigParser() - - try: - cfg.readfp(gitmodules_fileobj) - except Exception as exc: - log.warn('Malformatted .gitmodules file: {0}\n' - '{1} cannot be assumed to be a git submodule.'.format( - exc, self.path)) - return False - - for section in cfg.sections(): - if not cfg.has_option(section, 'path'): - continue - - submodule_path = cfg.get(section, 'path').rstrip(os.sep) - - if submodule_path == self.path.rstrip(os.sep): - return True - - return False - - def _update_submodule(self, submodule, status): - if status == ' ': - # The submodule is up to date; no action necessary - return - elif status == '-': - if self.offline: - raise _AHBootstrapSystemExit( - "Cannot initialize the {0} submodule in --offline mode; " - "this requires being able to clone the submodule from an " - "online repository.".format(submodule)) - cmd = ['update', '--init'] - action = 'Initializing' - elif status == '+': - cmd = ['update'] - action = 'Updating' - if self.offline: - cmd.append('--no-fetch') - elif status == 'U': - raise _AHBootstrapSystemExit( - 'Error: Submodule {0} contains unresolved merge conflicts. ' - 'Please complete or abandon any changes in the submodule so that ' - 'it is in a usable state, then try again.'.format(submodule)) - else: - log.warn('Unknown status {0!r} for git submodule {1!r}. Will ' - 'attempt to use the submodule as-is, but try to ensure ' - 'that the submodule is in a clean state and contains no ' - 'conflicts or errors.\n{2}'.format(status, submodule, - _err_help_msg)) - return - - err_msg = None - cmd = ['git', 'submodule'] + cmd + ['--', submodule] - log.warn('{0} {1} submodule with: `{2}`'.format( - action, submodule, ' '.join(cmd))) - - try: - log.info('Running `{0}`; use the --no-git option to disable git ' - 'commands'.format(' '.join(cmd))) - returncode, stdout, stderr = run_cmd(cmd) - except OSError as e: - err_msg = str(e) - else: - if returncode != 0: - err_msg = stderr - - if err_msg is not None: - log.warn('An unexpected error occurred updating the git submodule ' - '{0!r}:\n{1}\n{2}'.format(submodule, err_msg, - _err_help_msg)) - -class _CommandNotFound(OSError): - """ - An exception raised when a command run with run_cmd is not found on the - system. - """ - - -def run_cmd(cmd): - """ - Run a command in a subprocess, given as a list of command-line - arguments. - - Returns a ``(returncode, stdout, stderr)`` tuple. - """ - - try: - p = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE) - # XXX: May block if either stdout or stderr fill their buffers; - # however for the commands this is currently used for that is - # unlikely (they should have very brief output) - stdout, stderr = p.communicate() - except OSError as e: - if DEBUG: - raise - - if e.errno == errno.ENOENT: - msg = 'Command not found: `{0}`'.format(' '.join(cmd)) - raise _CommandNotFound(msg, cmd) - else: - raise _AHBootstrapSystemExit( - 'An unexpected error occurred when running the ' - '`{0}` command:\n{1}'.format(' '.join(cmd), str(e))) - - - # Can fail of the default locale is not configured properly. See - # https://github.com/astropy/astropy/issues/2749. For the purposes under - # consideration 'latin1' is an acceptable fallback. - try: - stdio_encoding = locale.getdefaultlocale()[1] or 'latin1' - except ValueError: - # Due to an OSX oddity locale.getdefaultlocale() can also crash - # depending on the user's locale/language settings. See: - # http://bugs.python.org/issue18378 - stdio_encoding = 'latin1' - - # Unlikely to fail at this point but even then let's be flexible - if not isinstance(stdout, str): - stdout = stdout.decode(stdio_encoding, 'replace') - if not isinstance(stderr, str): - stderr = stderr.decode(stdio_encoding, 'replace') - - return (p.returncode, stdout, stderr) - - -def _next_version(version): - """ - Given a parsed version from pkg_resources.parse_version, returns a new - version string with the next minor version. - - Examples - ======== - >>> _next_version(pkg_resources.parse_version('1.2.3')) - '1.3.0' - """ - - if hasattr(version, 'base_version'): - # New version parsing from setuptools >= 8.0 - if version.base_version: - parts = version.base_version.split('.') - else: - parts = [] - else: - parts = [] - for part in version: - if part.startswith('*'): - break - parts.append(part) - - parts = [int(p) for p in parts] - - if len(parts) < 3: - parts += [0] * (3 - len(parts)) - - major, minor, micro = parts[:3] - - return '{0}.{1}.{2}'.format(major, minor + 1, 0) - - -class _DummyFile(object): - """A noop writeable object.""" - - errors = '' # Required for Python 3.x - encoding = 'utf-8' - - def write(self, s): - pass - - def flush(self): - pass - - -@contextlib.contextmanager -def _verbose(): - yield - -@contextlib.contextmanager -def _silence(): - """A context manager that silences sys.stdout and sys.stderr.""" - - old_stdout = sys.stdout - old_stderr = sys.stderr - sys.stdout = _DummyFile() - sys.stderr = _DummyFile() - exception_occurred = False - try: - yield - except: - exception_occurred = True - # Go ahead and clean up so that exception handling can work normally - sys.stdout = old_stdout - sys.stderr = old_stderr - raise - - if not exception_occurred: - sys.stdout = old_stdout - sys.stderr = old_stderr - - -_err_help_msg = """ -If the problem persists consider installing astropy_helpers manually using pip -(`pip install astropy_helpers`) or by manually downloading the source archive, -extracting it, and installing by running `python setup.py install` from the -root of the extracted source code. -""" - - -class _AHBootstrapSystemExit(SystemExit): - def __init__(self, *args): - if not args: - msg = 'An unknown problem occurred bootstrapping astropy_helpers.' - else: - msg = args[0] - - msg += '\n' + _err_help_msg - - super(_AHBootstrapSystemExit, self).__init__(msg, *args[1:]) - - -BOOTSTRAPPER = _Bootstrapper.main() - - -def use_astropy_helpers(**kwargs): - """ - Ensure that the `astropy_helpers` module is available and is importable. - This supports automatic submodule initialization if astropy_helpers is - included in a project as a git submodule, or will download it from PyPI if - necessary. - - Parameters - ---------- - - path : str or None, optional - A filesystem path relative to the root of the project's source code - that should be added to `sys.path` so that `astropy_helpers` can be - imported from that path. - - If the path is a git submodule it will automatically be initialized - and/or updated. - - The path may also be to a ``.tar.gz`` archive of the astropy_helpers - source distribution. In this case the archive is automatically - unpacked and made temporarily available on `sys.path` as a ``.egg`` - archive. - - If `None` skip straight to downloading. - - download_if_needed : bool, optional - If the provided filesystem path is not found an attempt will be made to - download astropy_helpers from PyPI. It will then be made temporarily - available on `sys.path` as a ``.egg`` archive (using the - ``setup_requires`` feature of setuptools. If the ``--offline`` option - is given at the command line the value of this argument is overridden - to `False`. - - index_url : str, optional - If provided, use a different URL for the Python package index than the - main PyPI server. - - use_git : bool, optional - If `False` no git commands will be used--this effectively disables - support for git submodules. If the ``--no-git`` option is given at the - command line the value of this argument is overridden to `False`. - - auto_upgrade : bool, optional - By default, when installing a package from a non-development source - distribution ah_boostrap will try to automatically check for patch - releases to astropy-helpers on PyPI and use the patched version over - any bundled versions. Setting this to `False` will disable that - functionality. If the ``--offline`` option is given at the command line - the value of this argument is overridden to `False`. - - offline : bool, optional - If `False` disable all actions that require an internet connection, - including downloading packages from the package index and fetching - updates to any git submodule. Defaults to `True`. - """ - - global BOOTSTRAPPER - - config = BOOTSTRAPPER.config - config.update(**kwargs) - - # Create a new bootstrapper with the updated configuration and run it - BOOTSTRAPPER = _Bootstrapper(**config) - BOOTSTRAPPER.run() diff -Nru ginga-3.0.0/astropy_helpers/ah_bootstrap.py ginga-3.1.0/astropy_helpers/ah_bootstrap.py --- ginga-3.0.0/astropy_helpers/ah_bootstrap.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/ah_bootstrap.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,1010 +0,0 @@ -""" -This bootstrap module contains code for ensuring that the astropy_helpers -package will be importable by the time the setup.py script runs. It also -includes some workarounds to ensure that a recent-enough version of setuptools -is being used for the installation. - -This module should be the first thing imported in the setup.py of distributions -that make use of the utilities in astropy_helpers. If the distribution ships -with its own copy of astropy_helpers, this module will first attempt to import -from the shipped copy. However, it will also check PyPI to see if there are -any bug-fix releases on top of the current version that may be useful to get -past platform-specific bugs that have been fixed. When running setup.py, use -the ``--offline`` command-line option to disable the auto-upgrade checks. - -When this module is imported or otherwise executed it automatically calls a -main function that attempts to read the project's setup.cfg file, which it -checks for a configuration section called ``[ah_bootstrap]`` the presences of -that section, and options therein, determine the next step taken: If it -contains an option called ``auto_use`` with a value of ``True``, it will -automatically call the main function of this module called -`use_astropy_helpers` (see that function's docstring for full details). -Otherwise no further action is taken and by default the system-installed version -of astropy-helpers will be used (however, ``ah_bootstrap.use_astropy_helpers`` -may be called manually from within the setup.py script). - -This behavior can also be controlled using the ``--auto-use`` and -``--no-auto-use`` command-line flags. For clarity, an alias for -``--no-auto-use`` is ``--use-system-astropy-helpers``, and we recommend using -the latter if needed. - -Additional options in the ``[ah_boostrap]`` section of setup.cfg have the same -names as the arguments to `use_astropy_helpers`, and can be used to configure -the bootstrap script when ``auto_use = True``. - -See https://github.com/astropy/astropy-helpers for more details, and for the -latest version of this module. -""" - -import contextlib -import errno -import io -import locale -import os -import re -import subprocess as sp -import sys - -from distutils import log -from distutils.debug import DEBUG - -from configparser import ConfigParser, RawConfigParser - -import pkg_resources - -from setuptools import Distribution -from setuptools.package_index import PackageIndex - -# This is the minimum Python version required for astropy-helpers -__minimum_python_version__ = (3, 5) - -# TODO: Maybe enable checking for a specific version of astropy_helpers? -DIST_NAME = 'astropy-helpers' -PACKAGE_NAME = 'astropy_helpers' -UPPER_VERSION_EXCLUSIVE = None - -# Defaults for other options -DOWNLOAD_IF_NEEDED = True -INDEX_URL = 'https://pypi.python.org/simple' -USE_GIT = True -OFFLINE = False -AUTO_UPGRADE = True - -# A list of all the configuration options and their required types -CFG_OPTIONS = [ - ('auto_use', bool), ('path', str), ('download_if_needed', bool), - ('index_url', str), ('use_git', bool), ('offline', bool), - ('auto_upgrade', bool) -] - -# Start off by parsing the setup.cfg file - -SETUP_CFG = ConfigParser() - -if os.path.exists('setup.cfg'): - - try: - SETUP_CFG.read('setup.cfg') - except Exception as e: - if DEBUG: - raise - - log.error( - "Error reading setup.cfg: {0!r}\n{1} will not be " - "automatically bootstrapped and package installation may fail." - "\n{2}".format(e, PACKAGE_NAME, _err_help_msg)) - -# We used package_name in the package template for a while instead of name -if SETUP_CFG.has_option('metadata', 'name'): - parent_package = SETUP_CFG.get('metadata', 'name') -elif SETUP_CFG.has_option('metadata', 'package_name'): - parent_package = SETUP_CFG.get('metadata', 'package_name') -else: - parent_package = None - -if SETUP_CFG.has_option('options', 'python_requires'): - - python_requires = SETUP_CFG.get('options', 'python_requires') - - # The python_requires key has a syntax that can be parsed by SpecifierSet - # in the packaging package. However, we don't want to have to depend on that - # package, so instead we can use setuptools (which bundles packaging). We - # have to add 'python' to parse it with Requirement. - - from pkg_resources import Requirement - req = Requirement.parse('python' + python_requires) - - # We want the Python version as a string, which we can get from the platform module - import platform - # strip off trailing '+' incase this is a dev install of python - python_version = platform.python_version().strip('+') - # allow pre-releases to count as 'new enough' - if not req.specifier.contains(python_version, True): - if parent_package is None: - message = "ERROR: Python {} is required by this package\n".format(req.specifier) - else: - message = "ERROR: Python {} is required by {}\n".format(req.specifier, parent_package) - sys.stderr.write(message) - sys.exit(1) - -if sys.version_info < __minimum_python_version__: - - if parent_package is None: - message = "ERROR: Python {} or later is required by astropy-helpers\n".format( - __minimum_python_version__) - else: - message = "ERROR: Python {} or later is required by astropy-helpers for {}\n".format( - __minimum_python_version__, parent_package) - - sys.stderr.write(message) - sys.exit(1) - -_str_types = (str, bytes) - - -# What follows are several import statements meant to deal with install-time -# issues with either missing or misbehaving pacakges (including making sure -# setuptools itself is installed): - -# Check that setuptools 30.3 or later is present -from distutils.version import LooseVersion - -try: - import setuptools - assert LooseVersion(setuptools.__version__) >= LooseVersion('30.3') -except (ImportError, AssertionError): - sys.stderr.write("ERROR: setuptools 30.3 or later is required by astropy-helpers\n") - sys.exit(1) - -# typing as a dependency for 1.6.1+ Sphinx causes issues when imported after -# initializing submodule with ah_boostrap.py -# See discussion and references in -# https://github.com/astropy/astropy-helpers/issues/302 - -try: - import typing # noqa -except ImportError: - pass - - -# Note: The following import is required as a workaround to -# https://github.com/astropy/astropy-helpers/issues/89; if we don't import this -# module now, it will get cleaned up after `run_setup` is called, but that will -# later cause the TemporaryDirectory class defined in it to stop working when -# used later on by setuptools -try: - import setuptools.py31compat # noqa -except ImportError: - pass - - -# matplotlib can cause problems if it is imported from within a call of -# run_setup(), because in some circumstances it will try to write to the user's -# home directory, resulting in a SandboxViolation. See -# https://github.com/matplotlib/matplotlib/pull/4165 -# Making sure matplotlib, if it is available, is imported early in the setup -# process can mitigate this (note importing matplotlib.pyplot has the same -# issue) -try: - import matplotlib - matplotlib.use('Agg') - import matplotlib.pyplot -except: - # Ignore if this fails for *any* reason* - pass - - -# End compatibility imports... - - -class _Bootstrapper(object): - """ - Bootstrapper implementation. See ``use_astropy_helpers`` for parameter - documentation. - """ - - def __init__(self, path=None, index_url=None, use_git=None, offline=None, - download_if_needed=None, auto_upgrade=None): - - if path is None: - path = PACKAGE_NAME - - if not (isinstance(path, _str_types) or path is False): - raise TypeError('path must be a string or False') - - if not isinstance(path, str): - fs_encoding = sys.getfilesystemencoding() - path = path.decode(fs_encoding) # path to unicode - - self.path = path - - # Set other option attributes, using defaults where necessary - self.index_url = index_url if index_url is not None else INDEX_URL - self.offline = offline if offline is not None else OFFLINE - - # If offline=True, override download and auto-upgrade - if self.offline: - download_if_needed = False - auto_upgrade = False - - self.download = (download_if_needed - if download_if_needed is not None - else DOWNLOAD_IF_NEEDED) - self.auto_upgrade = (auto_upgrade - if auto_upgrade is not None else AUTO_UPGRADE) - - # If this is a release then the .git directory will not exist so we - # should not use git. - git_dir_exists = os.path.exists(os.path.join(os.path.dirname(__file__), '.git')) - if use_git is None and not git_dir_exists: - use_git = False - - self.use_git = use_git if use_git is not None else USE_GIT - # Declared as False by default--later we check if astropy-helpers can be - # upgraded from PyPI, but only if not using a source distribution (as in - # the case of import from a git submodule) - self.is_submodule = False - - @classmethod - def main(cls, argv=None): - if argv is None: - argv = sys.argv - - config = cls.parse_config() - config.update(cls.parse_command_line(argv)) - - auto_use = config.pop('auto_use', False) - bootstrapper = cls(**config) - - if auto_use: - # Run the bootstrapper, otherwise the setup.py is using the old - # use_astropy_helpers() interface, in which case it will run the - # bootstrapper manually after reconfiguring it. - bootstrapper.run() - - return bootstrapper - - @classmethod - def parse_config(cls): - - if not SETUP_CFG.has_section('ah_bootstrap'): - return {} - - config = {} - - for option, type_ in CFG_OPTIONS: - if not SETUP_CFG.has_option('ah_bootstrap', option): - continue - - if type_ is bool: - value = SETUP_CFG.getboolean('ah_bootstrap', option) - else: - value = SETUP_CFG.get('ah_bootstrap', option) - - config[option] = value - - return config - - @classmethod - def parse_command_line(cls, argv=None): - if argv is None: - argv = sys.argv - - config = {} - - # For now we just pop recognized ah_bootstrap options out of the - # arg list. This is imperfect; in the unlikely case that a setup.py - # custom command or even custom Distribution class defines an argument - # of the same name then we will break that. However there's a catch22 - # here that we can't just do full argument parsing right here, because - # we don't yet know *how* to parse all possible command-line arguments. - if '--no-git' in argv: - config['use_git'] = False - argv.remove('--no-git') - - if '--offline' in argv: - config['offline'] = True - argv.remove('--offline') - - if '--auto-use' in argv: - config['auto_use'] = True - argv.remove('--auto-use') - - if '--no-auto-use' in argv: - config['auto_use'] = False - argv.remove('--no-auto-use') - - if '--use-system-astropy-helpers' in argv: - config['auto_use'] = False - argv.remove('--use-system-astropy-helpers') - - return config - - def run(self): - strategies = ['local_directory', 'local_file', 'index'] - dist = None - - # First, remove any previously imported versions of astropy_helpers; - # this is necessary for nested installs where one package's installer - # is installing another package via setuptools.sandbox.run_setup, as in - # the case of setup_requires - for key in list(sys.modules): - try: - if key == PACKAGE_NAME or key.startswith(PACKAGE_NAME + '.'): - del sys.modules[key] - except AttributeError: - # Sometimes mysterious non-string things can turn up in - # sys.modules - continue - - # Check to see if the path is a submodule - self.is_submodule = self._check_submodule() - - for strategy in strategies: - method = getattr(self, 'get_{0}_dist'.format(strategy)) - dist = method() - if dist is not None: - break - else: - raise _AHBootstrapSystemExit( - "No source found for the {0!r} package; {0} must be " - "available and importable as a prerequisite to building " - "or installing this package.".format(PACKAGE_NAME)) - - # This is a bit hacky, but if astropy_helpers was loaded from a - # directory/submodule its Distribution object gets a "precedence" of - # "DEVELOP_DIST". However, in other cases it gets a precedence of - # "EGG_DIST". However, when activing the distribution it will only be - # placed early on sys.path if it is treated as an EGG_DIST, so always - # do that - dist = dist.clone(precedence=pkg_resources.EGG_DIST) - - # Otherwise we found a version of astropy-helpers, so we're done - # Just active the found distribution on sys.path--if we did a - # download this usually happens automatically but it doesn't hurt to - # do it again - # Note: Adding the dist to the global working set also activates it - # (makes it importable on sys.path) by default. - - try: - pkg_resources.working_set.add(dist, replace=True) - except TypeError: - # Some (much) older versions of setuptools do not have the - # replace=True option here. These versions are old enough that all - # bets may be off anyways, but it's easy enough to work around just - # in case... - if dist.key in pkg_resources.working_set.by_key: - del pkg_resources.working_set.by_key[dist.key] - pkg_resources.working_set.add(dist) - - @property - def config(self): - """ - A `dict` containing the options this `_Bootstrapper` was configured - with. - """ - - return dict((optname, getattr(self, optname)) - for optname, _ in CFG_OPTIONS if hasattr(self, optname)) - - def get_local_directory_dist(self): - """ - Handle importing a vendored package from a subdirectory of the source - distribution. - """ - - if not os.path.isdir(self.path): - return - - log.info('Attempting to import astropy_helpers from {0} {1!r}'.format( - 'submodule' if self.is_submodule else 'directory', - self.path)) - - dist = self._directory_import() - - if dist is None: - log.warn( - 'The requested path {0!r} for importing {1} does not ' - 'exist, or does not contain a copy of the {1} ' - 'package.'.format(self.path, PACKAGE_NAME)) - elif self.auto_upgrade and not self.is_submodule: - # A version of astropy-helpers was found on the available path, but - # check to see if a bugfix release is available on PyPI - upgrade = self._do_upgrade(dist) - if upgrade is not None: - dist = upgrade - - return dist - - def get_local_file_dist(self): - """ - Handle importing from a source archive; this also uses setup_requires - but points easy_install directly to the source archive. - """ - - if not os.path.isfile(self.path): - return - - log.info('Attempting to unpack and import astropy_helpers from ' - '{0!r}'.format(self.path)) - - try: - dist = self._do_download(find_links=[self.path]) - except Exception as e: - if DEBUG: - raise - - log.warn( - 'Failed to import {0} from the specified archive {1!r}: ' - '{2}'.format(PACKAGE_NAME, self.path, str(e))) - dist = None - - if dist is not None and self.auto_upgrade: - # A version of astropy-helpers was found on the available path, but - # check to see if a bugfix release is available on PyPI - upgrade = self._do_upgrade(dist) - if upgrade is not None: - dist = upgrade - - return dist - - def get_index_dist(self): - if not self.download: - log.warn('Downloading {0!r} disabled.'.format(DIST_NAME)) - return None - - log.warn( - "Downloading {0!r}; run setup.py with the --offline option to " - "force offline installation.".format(DIST_NAME)) - - try: - dist = self._do_download() - except Exception as e: - if DEBUG: - raise - log.warn( - 'Failed to download and/or install {0!r} from {1!r}:\n' - '{2}'.format(DIST_NAME, self.index_url, str(e))) - dist = None - - # No need to run auto-upgrade here since we've already presumably - # gotten the most up-to-date version from the package index - return dist - - def _directory_import(self): - """ - Import astropy_helpers from the given path, which will be added to - sys.path. - - Must return True if the import succeeded, and False otherwise. - """ - - # Return True on success, False on failure but download is allowed, and - # otherwise raise SystemExit - path = os.path.abspath(self.path) - - # Use an empty WorkingSet rather than the man - # pkg_resources.working_set, since on older versions of setuptools this - # will invoke a VersionConflict when trying to install an upgrade - ws = pkg_resources.WorkingSet([]) - ws.add_entry(path) - dist = ws.by_key.get(DIST_NAME) - - if dist is None: - # We didn't find an egg-info/dist-info in the given path, but if a - # setup.py exists we can generate it - setup_py = os.path.join(path, 'setup.py') - if os.path.isfile(setup_py): - # We use subprocess instead of run_setup from setuptools to - # avoid segmentation faults - see the following for more details: - # https://github.com/cython/cython/issues/2104 - sp.check_output([sys.executable, 'setup.py', 'egg_info'], cwd=path) - - for dist in pkg_resources.find_distributions(path, True): - # There should be only one... - return dist - - return dist - - def _do_download(self, version='', find_links=None): - if find_links: - allow_hosts = '' - index_url = None - else: - allow_hosts = None - index_url = self.index_url - - # Annoyingly, setuptools will not handle other arguments to - # Distribution (such as options) before handling setup_requires, so it - # is not straightforward to programmatically augment the arguments which - # are passed to easy_install - class _Distribution(Distribution): - def get_option_dict(self, command_name): - opts = Distribution.get_option_dict(self, command_name) - if command_name == 'easy_install': - if find_links is not None: - opts['find_links'] = ('setup script', find_links) - if index_url is not None: - opts['index_url'] = ('setup script', index_url) - if allow_hosts is not None: - opts['allow_hosts'] = ('setup script', allow_hosts) - return opts - - if version: - req = '{0}=={1}'.format(DIST_NAME, version) - else: - if UPPER_VERSION_EXCLUSIVE is None: - req = DIST_NAME - else: - req = '{0}<{1}'.format(DIST_NAME, UPPER_VERSION_EXCLUSIVE) - - attrs = {'setup_requires': [req]} - - # NOTE: we need to parse the config file (e.g. setup.cfg) to make sure - # it honours the options set in the [easy_install] section, and we need - # to explicitly fetch the requirement eggs as setup_requires does not - # get honored in recent versions of setuptools: - # https://github.com/pypa/setuptools/issues/1273 - - try: - - context = _verbose if DEBUG else _silence - with context(): - dist = _Distribution(attrs=attrs) - try: - dist.parse_config_files(ignore_option_errors=True) - dist.fetch_build_eggs(req) - except TypeError: - # On older versions of setuptools, ignore_option_errors - # doesn't exist, and the above two lines are not needed - # so we can just continue - pass - - # If the setup_requires succeeded it will have added the new dist to - # the main working_set - return pkg_resources.working_set.by_key.get(DIST_NAME) - except Exception as e: - if DEBUG: - raise - - msg = 'Error retrieving {0} from {1}:\n{2}' - if find_links: - source = find_links[0] - elif index_url != INDEX_URL: - source = index_url - else: - source = 'PyPI' - - raise Exception(msg.format(DIST_NAME, source, repr(e))) - - def _do_upgrade(self, dist): - # Build up a requirement for a higher bugfix release but a lower minor - # release (so API compatibility is guaranteed) - next_version = _next_version(dist.parsed_version) - - req = pkg_resources.Requirement.parse( - '{0}>{1},<{2}'.format(DIST_NAME, dist.version, next_version)) - - package_index = PackageIndex(index_url=self.index_url) - - upgrade = package_index.obtain(req) - - if upgrade is not None: - return self._do_download(version=upgrade.version) - - def _check_submodule(self): - """ - Check if the given path is a git submodule. - - See the docstrings for ``_check_submodule_using_git`` and - ``_check_submodule_no_git`` for further details. - """ - - if (self.path is None or - (os.path.exists(self.path) and not os.path.isdir(self.path))): - return False - - if self.use_git: - return self._check_submodule_using_git() - else: - return self._check_submodule_no_git() - - def _check_submodule_using_git(self): - """ - Check if the given path is a git submodule. If so, attempt to initialize - and/or update the submodule if needed. - - This function makes calls to the ``git`` command in subprocesses. The - ``_check_submodule_no_git`` option uses pure Python to check if the given - path looks like a git submodule, but it cannot perform updates. - """ - - cmd = ['git', 'submodule', 'status', '--', self.path] - - try: - log.info('Running `{0}`; use the --no-git option to disable git ' - 'commands'.format(' '.join(cmd))) - returncode, stdout, stderr = run_cmd(cmd) - except _CommandNotFound: - # The git command simply wasn't found; this is most likely the - # case on user systems that don't have git and are simply - # trying to install the package from PyPI or a source - # distribution. Silently ignore this case and simply don't try - # to use submodules - return False - - stderr = stderr.strip() - - if returncode != 0 and stderr: - # Unfortunately the return code alone cannot be relied on, as - # earlier versions of git returned 0 even if the requested submodule - # does not exist - - # This is a warning that occurs in perl (from running git submodule) - # which only occurs with a malformatted locale setting which can - # happen sometimes on OSX. See again - # https://github.com/astropy/astropy/issues/2749 - perl_warning = ('perl: warning: Falling back to the standard locale ' - '("C").') - if not stderr.strip().endswith(perl_warning): - # Some other unknown error condition occurred - log.warn('git submodule command failed ' - 'unexpectedly:\n{0}'.format(stderr)) - return False - - # Output of `git submodule status` is as follows: - # - # 1: Status indicator: '-' for submodule is uninitialized, '+' if - # submodule is initialized but is not at the commit currently indicated - # in .gitmodules (and thus needs to be updated), or 'U' if the - # submodule is in an unstable state (i.e. has merge conflicts) - # - # 2. SHA-1 hash of the current commit of the submodule (we don't really - # need this information but it's useful for checking that the output is - # correct) - # - # 3. The output of `git describe` for the submodule's current commit - # hash (this includes for example what branches the commit is on) but - # only if the submodule is initialized. We ignore this information for - # now - _git_submodule_status_re = re.compile( - r'^(?P[+-U ])(?P[0-9a-f]{40}) ' - r'(?P\S+)( .*)?$') - - # The stdout should only contain one line--the status of the - # requested submodule - m = _git_submodule_status_re.match(stdout) - if m: - # Yes, the path *is* a git submodule - self._update_submodule(m.group('submodule'), m.group('status')) - return True - else: - log.warn( - 'Unexpected output from `git submodule status`:\n{0}\n' - 'Will attempt import from {1!r} regardless.'.format( - stdout, self.path)) - return False - - def _check_submodule_no_git(self): - """ - Like ``_check_submodule_using_git``, but simply parses the .gitmodules file - to determine if the supplied path is a git submodule, and does not exec any - subprocesses. - - This can only determine if a path is a submodule--it does not perform - updates, etc. This function may need to be updated if the format of the - .gitmodules file is changed between git versions. - """ - - gitmodules_path = os.path.abspath('.gitmodules') - - if not os.path.isfile(gitmodules_path): - return False - - # This is a minimal reader for gitconfig-style files. It handles a few of - # the quirks that make gitconfig files incompatible with ConfigParser-style - # files, but does not support the full gitconfig syntax (just enough - # needed to read a .gitmodules file). - gitmodules_fileobj = io.StringIO() - - # Must use io.open for cross-Python-compatible behavior wrt unicode - with io.open(gitmodules_path) as f: - for line in f: - # gitconfig files are more flexible with leading whitespace; just - # go ahead and remove it - line = line.lstrip() - - # comments can start with either # or ; - if line and line[0] in (':', ';'): - continue - - gitmodules_fileobj.write(line) - - gitmodules_fileobj.seek(0) - - cfg = RawConfigParser() - - try: - cfg.readfp(gitmodules_fileobj) - except Exception as exc: - log.warn('Malformatted .gitmodules file: {0}\n' - '{1} cannot be assumed to be a git submodule.'.format( - exc, self.path)) - return False - - for section in cfg.sections(): - if not cfg.has_option(section, 'path'): - continue - - submodule_path = cfg.get(section, 'path').rstrip(os.sep) - - if submodule_path == self.path.rstrip(os.sep): - return True - - return False - - def _update_submodule(self, submodule, status): - if status == ' ': - # The submodule is up to date; no action necessary - return - elif status == '-': - if self.offline: - raise _AHBootstrapSystemExit( - "Cannot initialize the {0} submodule in --offline mode; " - "this requires being able to clone the submodule from an " - "online repository.".format(submodule)) - cmd = ['update', '--init'] - action = 'Initializing' - elif status == '+': - cmd = ['update'] - action = 'Updating' - if self.offline: - cmd.append('--no-fetch') - elif status == 'U': - raise _AHBootstrapSystemExit( - 'Error: Submodule {0} contains unresolved merge conflicts. ' - 'Please complete or abandon any changes in the submodule so that ' - 'it is in a usable state, then try again.'.format(submodule)) - else: - log.warn('Unknown status {0!r} for git submodule {1!r}. Will ' - 'attempt to use the submodule as-is, but try to ensure ' - 'that the submodule is in a clean state and contains no ' - 'conflicts or errors.\n{2}'.format(status, submodule, - _err_help_msg)) - return - - err_msg = None - cmd = ['git', 'submodule'] + cmd + ['--', submodule] - log.warn('{0} {1} submodule with: `{2}`'.format( - action, submodule, ' '.join(cmd))) - - try: - log.info('Running `{0}`; use the --no-git option to disable git ' - 'commands'.format(' '.join(cmd))) - returncode, stdout, stderr = run_cmd(cmd) - except OSError as e: - err_msg = str(e) - else: - if returncode != 0: - err_msg = stderr - - if err_msg is not None: - log.warn('An unexpected error occurred updating the git submodule ' - '{0!r}:\n{1}\n{2}'.format(submodule, err_msg, - _err_help_msg)) - -class _CommandNotFound(OSError): - """ - An exception raised when a command run with run_cmd is not found on the - system. - """ - - -def run_cmd(cmd): - """ - Run a command in a subprocess, given as a list of command-line - arguments. - - Returns a ``(returncode, stdout, stderr)`` tuple. - """ - - try: - p = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.PIPE) - # XXX: May block if either stdout or stderr fill their buffers; - # however for the commands this is currently used for that is - # unlikely (they should have very brief output) - stdout, stderr = p.communicate() - except OSError as e: - if DEBUG: - raise - - if e.errno == errno.ENOENT: - msg = 'Command not found: `{0}`'.format(' '.join(cmd)) - raise _CommandNotFound(msg, cmd) - else: - raise _AHBootstrapSystemExit( - 'An unexpected error occurred when running the ' - '`{0}` command:\n{1}'.format(' '.join(cmd), str(e))) - - - # Can fail of the default locale is not configured properly. See - # https://github.com/astropy/astropy/issues/2749. For the purposes under - # consideration 'latin1' is an acceptable fallback. - try: - stdio_encoding = locale.getdefaultlocale()[1] or 'latin1' - except ValueError: - # Due to an OSX oddity locale.getdefaultlocale() can also crash - # depending on the user's locale/language settings. See: - # http://bugs.python.org/issue18378 - stdio_encoding = 'latin1' - - # Unlikely to fail at this point but even then let's be flexible - if not isinstance(stdout, str): - stdout = stdout.decode(stdio_encoding, 'replace') - if not isinstance(stderr, str): - stderr = stderr.decode(stdio_encoding, 'replace') - - return (p.returncode, stdout, stderr) - - -def _next_version(version): - """ - Given a parsed version from pkg_resources.parse_version, returns a new - version string with the next minor version. - - Examples - ======== - >>> _next_version(pkg_resources.parse_version('1.2.3')) - '1.3.0' - """ - - if hasattr(version, 'base_version'): - # New version parsing from setuptools >= 8.0 - if version.base_version: - parts = version.base_version.split('.') - else: - parts = [] - else: - parts = [] - for part in version: - if part.startswith('*'): - break - parts.append(part) - - parts = [int(p) for p in parts] - - if len(parts) < 3: - parts += [0] * (3 - len(parts)) - - major, minor, micro = parts[:3] - - return '{0}.{1}.{2}'.format(major, minor + 1, 0) - - -class _DummyFile(object): - """A noop writeable object.""" - - errors = '' # Required for Python 3.x - encoding = 'utf-8' - - def write(self, s): - pass - - def flush(self): - pass - - -@contextlib.contextmanager -def _verbose(): - yield - -@contextlib.contextmanager -def _silence(): - """A context manager that silences sys.stdout and sys.stderr.""" - - old_stdout = sys.stdout - old_stderr = sys.stderr - sys.stdout = _DummyFile() - sys.stderr = _DummyFile() - exception_occurred = False - try: - yield - except: - exception_occurred = True - # Go ahead and clean up so that exception handling can work normally - sys.stdout = old_stdout - sys.stderr = old_stderr - raise - - if not exception_occurred: - sys.stdout = old_stdout - sys.stderr = old_stderr - - -_err_help_msg = """ -If the problem persists consider installing astropy_helpers manually using pip -(`pip install astropy_helpers`) or by manually downloading the source archive, -extracting it, and installing by running `python setup.py install` from the -root of the extracted source code. -""" - - -class _AHBootstrapSystemExit(SystemExit): - def __init__(self, *args): - if not args: - msg = 'An unknown problem occurred bootstrapping astropy_helpers.' - else: - msg = args[0] - - msg += '\n' + _err_help_msg - - super(_AHBootstrapSystemExit, self).__init__(msg, *args[1:]) - - -BOOTSTRAPPER = _Bootstrapper.main() - - -def use_astropy_helpers(**kwargs): - """ - Ensure that the `astropy_helpers` module is available and is importable. - This supports automatic submodule initialization if astropy_helpers is - included in a project as a git submodule, or will download it from PyPI if - necessary. - - Parameters - ---------- - - path : str or None, optional - A filesystem path relative to the root of the project's source code - that should be added to `sys.path` so that `astropy_helpers` can be - imported from that path. - - If the path is a git submodule it will automatically be initialized - and/or updated. - - The path may also be to a ``.tar.gz`` archive of the astropy_helpers - source distribution. In this case the archive is automatically - unpacked and made temporarily available on `sys.path` as a ``.egg`` - archive. - - If `None` skip straight to downloading. - - download_if_needed : bool, optional - If the provided filesystem path is not found an attempt will be made to - download astropy_helpers from PyPI. It will then be made temporarily - available on `sys.path` as a ``.egg`` archive (using the - ``setup_requires`` feature of setuptools. If the ``--offline`` option - is given at the command line the value of this argument is overridden - to `False`. - - index_url : str, optional - If provided, use a different URL for the Python package index than the - main PyPI server. - - use_git : bool, optional - If `False` no git commands will be used--this effectively disables - support for git submodules. If the ``--no-git`` option is given at the - command line the value of this argument is overridden to `False`. - - auto_upgrade : bool, optional - By default, when installing a package from a non-development source - distribution ah_boostrap will try to automatically check for patch - releases to astropy-helpers on PyPI and use the patched version over - any bundled versions. Setting this to `False` will disable that - functionality. If the ``--offline`` option is given at the command line - the value of this argument is overridden to `False`. - - offline : bool, optional - If `False` disable all actions that require an internet connection, - including downloading packages from the package index and fetching - updates to any git submodule. Defaults to `True`. - """ - - global BOOTSTRAPPER - - config = BOOTSTRAPPER.config - config.update(**kwargs) - - # Create a new bootstrapper with the updated configuration and run it - BOOTSTRAPPER = _Bootstrapper(**config) - BOOTSTRAPPER.run() diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/commands/build_ext.py ginga-3.1.0/astropy_helpers/astropy_helpers/commands/build_ext.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/commands/build_ext.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/commands/build_ext.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,206 +0,0 @@ -import errno -import os -import shutil - -from distutils.core import Extension -from distutils.ccompiler import get_default_compiler -from distutils.command.build_ext import build_ext as DistutilsBuildExt - -from ..distutils_helpers import get_main_package_directory -from ..utils import get_numpy_include_path, import_file - -__all__ = ['AstropyHelpersBuildExt'] - - -def should_build_with_cython(previous_cython_version, is_release): - """ - Returns the previously used Cython version (or 'unknown' if not - previously built) if Cython should be used to build extension modules from - pyx files. - """ - - # Only build with Cython if, of course, Cython is installed, we're in a - # development version (i.e. not release) or the Cython-generated source - # files haven't been created yet (cython_version == 'unknown'). The latter - # case can happen even when release is True if checking out a release tag - # from the repository - have_cython = False - try: - from Cython import __version__ as cython_version # noqa - have_cython = True - except ImportError: - pass - - if have_cython and (not is_release or previous_cython_version == 'unknown'): - return cython_version - else: - return False - - -class AstropyHelpersBuildExt(DistutilsBuildExt): - """ - A custom 'build_ext' command that allows for manipulating some of the C - extension options at build time. - """ - - _uses_cython = False - _force_rebuild = False - - def __new__(cls, value, **kwargs): - - # NOTE: we need to wait until AstropyHelpersBuildExt is initialized to - # import setuptools.command.build_ext because when that package is - # imported, setuptools tries to import Cython - and if it's not found - # it will affect the rest of the build process. This is an issue because - # if we import that module at the top of this one, setup_requires won't - # have been honored yet, so Cython may not yet be available - and if we - # import build_ext too soon, it will think Cython is not available even - # if it is then intalled when setup_requires is processed. To get around - # this we dynamically create a new class that inherits from the - # setuptools build_ext, and by this point setup_requires has been - # processed. - - from setuptools.command.build_ext import build_ext as SetuptoolsBuildExt - - class FinalBuildExt(AstropyHelpersBuildExt, SetuptoolsBuildExt): - pass - - new_type = type(cls.__name__, (FinalBuildExt,), dict(cls.__dict__)) - obj = SetuptoolsBuildExt.__new__(new_type) - obj.__init__(value) - - return obj - - def finalize_options(self): - - # First let's find the package folder, then we can check if the - # version and cython_version are accessible - self.package_dir = get_main_package_directory(self.distribution) - - version = import_file(os.path.join(self.package_dir, 'version.py'), - name='version').version - self.is_release = 'dev' not in version - - try: - self.previous_cython_version = import_file(os.path.join(self.package_dir, - 'cython_version.py'), - name='cython_version').cython_version - except (FileNotFoundError, ImportError): - self.previous_cython_version = 'unknown' - - self._uses_cython = should_build_with_cython(self.previous_cython_version, self.is_release) - - # Add a copy of the _compiler.so module as well, but only if there - # are in fact C modules to compile (otherwise there's no reason to - # include a record of the compiler used). Note that self.extensions - # may not be set yet, but self.distribution.ext_modules is where any - # extension modules passed to setup() can be found - extensions = self.distribution.ext_modules - if extensions: - build_py = self.get_finalized_command('build_py') - package_dir = build_py.get_package_dir(self.package_dir) - src_path = os.path.relpath( - os.path.join(os.path.dirname(__file__), 'src')) - shutil.copy(os.path.join(src_path, 'compiler.c'), - os.path.join(package_dir, '_compiler.c')) - ext = Extension(self.package_dir + '.compiler_version', - [os.path.join(package_dir, '_compiler.c')]) - extensions.insert(0, ext) - - super().finalize_options() - - # If we are using Cython, then make sure we re-build if the version - # of Cython that is installed is different from the version last - # used to generate the C files. - if self._uses_cython and self._uses_cython != self.previous_cython_version: - self._force_rebuild = True - - # Regardless of the value of the '--force' option, force a rebuild - # if the debug flag changed from the last build - if self._force_rebuild: - self.force = True - - def run(self): - - # For extensions that require 'numpy' in their include dirs, - # replace 'numpy' with the actual paths - np_include = None - for extension in self.extensions: - if 'numpy' in extension.include_dirs: - if np_include is None: - np_include = get_numpy_include_path() - idx = extension.include_dirs.index('numpy') - extension.include_dirs.insert(idx, np_include) - extension.include_dirs.remove('numpy') - - self._check_cython_sources(extension) - - # Note that setuptools automatically uses Cython to discover and - # build extensions if available, so we don't have to explicitly call - # e.g. cythonize. - - super().run() - - # Update cython_version.py if building with Cython - - if self._uses_cython and self._uses_cython != self.previous_cython_version: - build_py = self.get_finalized_command('build_py') - package_dir = build_py.get_package_dir(self.package_dir) - cython_py = os.path.join(package_dir, 'cython_version.py') - with open(cython_py, 'w') as f: - f.write('# Generated file; do not modify\n') - f.write('cython_version = {0!r}\n'.format(self._uses_cython)) - - if os.path.isdir(self.build_lib): - # The build/lib directory may not exist if the build_py - # command was not previously run, which may sometimes be - # the case - self.copy_file(cython_py, - os.path.join(self.build_lib, cython_py), - preserve_mode=False) - - def _check_cython_sources(self, extension): - """ - Where relevant, make sure that the .c files associated with .pyx - modules are present (if building without Cython installed). - """ - - # Determine the compiler we'll be using - if self.compiler is None: - compiler = get_default_compiler() - else: - compiler = self.compiler - - # Replace .pyx with C-equivalents, unless c files are missing - for jdx, src in enumerate(extension.sources): - base, ext = os.path.splitext(src) - pyxfn = base + '.pyx' - cfn = base + '.c' - cppfn = base + '.cpp' - - if not os.path.isfile(pyxfn): - continue - - if self._uses_cython: - extension.sources[jdx] = pyxfn - else: - if os.path.isfile(cfn): - extension.sources[jdx] = cfn - elif os.path.isfile(cppfn): - extension.sources[jdx] = cppfn - else: - msg = ( - 'Could not find C/C++ file {0}.(c/cpp) for Cython ' - 'file {1} when building extension {2}. Cython ' - 'must be installed to build from a git ' - 'checkout.'.format(base, pyxfn, extension.name)) - raise IOError(errno.ENOENT, msg, cfn) - - # Cython (at least as of 0.29.2) uses deprecated Numpy API features - # the use of which produces a few warnings when compiling. - # These additional flags should squelch those warnings. - # TODO: Feel free to remove this if/when a Cython update - # removes use of the deprecated Numpy API - if compiler == 'unix': - extension.extra_compile_args.extend([ - '-Wp,-w', '-Wno-unused-function']) diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/commands/build_sphinx.py ginga-3.1.0/astropy_helpers/astropy_helpers/commands/build_sphinx.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/commands/build_sphinx.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/commands/build_sphinx.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,252 +0,0 @@ -from __future__ import print_function - -import os -import pkgutil -import re -import shutil -import subprocess -import sys -from distutils.version import LooseVersion - -from distutils import log - -from sphinx import __version__ as sphinx_version -from sphinx.setup_command import BuildDoc as SphinxBuildDoc - -SPHINX_LT_16 = LooseVersion(sphinx_version) < LooseVersion('1.6') -SPHINX_LT_17 = LooseVersion(sphinx_version) < LooseVersion('1.7') - -SUBPROCESS_TEMPLATE = """ -import os -import sys - -{build_main} - -os.chdir({srcdir!r}) - -{sys_path_inserts} - -for builder in {builders!r}: - retcode = build_main(argv={argv!r} + ['-b', builder, '.', os.path.join({output_dir!r}, builder)]) - if retcode != 0: - sys.exit(retcode) -""" - - -def ensure_sphinx_astropy_installed(): - """ - Make sure that sphinx-astropy is available. - - This returns the available version of sphinx-astropy as well as any - paths that should be added to sys.path for sphinx-astropy to be available. - """ - # We've split out the Sphinx part of astropy-helpers into sphinx-astropy - # but we want it to be auto-installed seamlessly for anyone using - # build_docs. We check if it's already installed, and if not, we install - # it to a local .eggs directory and add the eggs to the path (these - # have to each be added to the path, we can't add them by simply adding - # .eggs to the path) - sys_path_inserts = [] - sphinx_astropy_version = None - try: - from sphinx_astropy import __version__ as sphinx_astropy_version # noqa - except ImportError: - raise ImportError("sphinx-astropy needs to be installed to build" - "the documentation.") - - return sphinx_astropy_version, sys_path_inserts - - -class AstropyBuildDocs(SphinxBuildDoc): - """ - A version of the ``build_docs`` command that uses the version of Astropy - that is built by the setup ``build`` command, rather than whatever is - installed on the system. To build docs against the installed version, run - ``make html`` in the ``astropy/docs`` directory. - """ - - description = 'Build Sphinx documentation for Astropy environment' - user_options = SphinxBuildDoc.user_options[:] - user_options.append( - ('warnings-returncode', 'w', - 'Parses the sphinx output and sets the return code to 1 if there ' - 'are any warnings. Note that this will cause the sphinx log to ' - 'only update when it completes, rather than continuously as is ' - 'normally the case.')) - user_options.append( - ('clean-docs', 'l', - 'Completely clean previous builds, including ' - 'automodapi-generated files before building new ones')) - user_options.append( - ('no-intersphinx', 'n', - 'Skip intersphinx, even if conf.py says to use it')) - user_options.append( - ('open-docs-in-browser', 'o', - 'Open the docs in a browser (using the webbrowser module) if the ' - 'build finishes successfully.')) - - boolean_options = SphinxBuildDoc.boolean_options[:] - boolean_options.append('warnings-returncode') - boolean_options.append('clean-docs') - boolean_options.append('no-intersphinx') - boolean_options.append('open-docs-in-browser') - - _self_iden_rex = re.compile(r"self\.([^\d\W][\w]+)", re.UNICODE) - - def initialize_options(self): - SphinxBuildDoc.initialize_options(self) - self.clean_docs = False - self.no_intersphinx = False - self.open_docs_in_browser = False - self.warnings_returncode = False - self.traceback = False - - def finalize_options(self): - - # This has to happen before we call the parent class's finalize_options - if self.build_dir is None: - self.build_dir = 'docs/_build' - - SphinxBuildDoc.finalize_options(self) - - # Clear out previous sphinx builds, if requested - if self.clean_docs: - - dirstorm = [os.path.join(self.source_dir, 'api'), - os.path.join(self.source_dir, 'generated')] - - dirstorm.append(self.build_dir) - - for d in dirstorm: - if os.path.isdir(d): - log.info('Cleaning directory ' + d) - shutil.rmtree(d) - else: - log.info('Not cleaning directory ' + d + ' because ' - 'not present or not a directory') - - def run(self): - - # TODO: Break this method up into a few more subroutines and - # document them better - import webbrowser - - from urllib.request import pathname2url - - # This is used at the very end of `run` to decide if sys.exit should - # be called. If it's None, it won't be. - retcode = None - - # Now make sure Astropy is built and determine where it was built - build_cmd = self.reinitialize_command('build') - build_cmd.inplace = 0 - self.run_command('build') - build_cmd = self.get_finalized_command('build') - build_cmd_path = os.path.abspath(build_cmd.build_lib) - - ah_importer = pkgutil.get_importer('astropy_helpers') - if ah_importer is None: - ah_path = '.' - else: - ah_path = os.path.abspath(ah_importer.path) - - if SPHINX_LT_17: - build_main = 'from sphinx import build_main' - else: - build_main = 'from sphinx.cmd.build import build_main' - - # We need to make sure sphinx-astropy is installed - sphinx_astropy_version, extra_paths = ensure_sphinx_astropy_installed() - - sys_path_inserts = [build_cmd_path, ah_path] + extra_paths - sys_path_inserts = os.linesep.join(['sys.path.insert(0, {0!r})'.format(path) for path in sys_path_inserts]) - - argv = [] - - if self.warnings_returncode: - argv.append('-W') - - if self.no_intersphinx: - # Note, if sphinx_astropy_version is None, this could indicate an - # old version of setuptools, but sphinx-astropy is likely ok, so - # we can proceed. - if sphinx_astropy_version is None or LooseVersion(sphinx_astropy_version) >= LooseVersion('1.1'): - argv.extend(['-D', 'disable_intersphinx=1']) - else: - log.warn('The -n option to disable intersphinx requires ' - 'sphinx-astropy>=1.1. Ignoring.') - - # We now need to adjust the flags based on the parent class's options - - if self.fresh_env: - argv.append('-E') - - if self.all_files: - argv.append('-a') - - if getattr(self, 'pdb', False): - argv.append('-P') - - if getattr(self, 'nitpicky', False): - argv.append('-n') - - if self.traceback: - argv.append('-T') - - # The default verbosity level is 1, so in that case we just don't add a flag - if self.verbose == 0: - argv.append('-q') - elif self.verbose > 1: - argv.append('-v') - - if SPHINX_LT_17: - argv.insert(0, 'sphinx-build') - - if isinstance(self.builder, str): - builders = [self.builder] - else: - builders = self.builder - - subproccode = SUBPROCESS_TEMPLATE.format(build_main=build_main, - srcdir=self.source_dir, - sys_path_inserts=sys_path_inserts, - builders=builders, - argv=argv, - output_dir=os.path.abspath(self.build_dir)) - - log.debug('Starting subprocess of {0} with python code:\n{1}\n' - '[CODE END])'.format(sys.executable, subproccode)) - - proc = subprocess.Popen([sys.executable], stdin=subprocess.PIPE) - proc.communicate(subproccode.encode('utf-8')) - if proc.returncode != 0: - retcode = proc.returncode - - if retcode is None: - if self.open_docs_in_browser: - if self.builder == 'html': - absdir = os.path.abspath(self.builder_target_dir) - index_path = os.path.join(absdir, 'index.html') - fileurl = 'file://' + pathname2url(index_path) - webbrowser.open(fileurl) - else: - log.warn('open-docs-in-browser option was given, but ' - 'the builder is not html! Ignoring.') - - # Here we explicitly check proc.returncode since we only want to output - # this for cases where the return code really wasn't 0. - if proc.returncode: - log.warn('Sphinx Documentation subprocess failed with return ' - 'code ' + str(proc.returncode)) - - if retcode is not None: - # this is potentially dangerous in that there might be something - # after the call to `setup` in `setup.py`, and exiting here will - # prevent that from running. But there's no other apparent way - # to signal what the return code should be. - sys.exit(retcode) - - -class AstropyBuildSphinx(AstropyBuildDocs): # pragma: no cover - def run(self): - AstropyBuildDocs.run(self) diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/commands/_dummy.py ginga-3.1.0/astropy_helpers/astropy_helpers/commands/_dummy.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/commands/_dummy.py 2018-11-26 18:47:54.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/commands/_dummy.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,73 +0,0 @@ -""" -Provides a base class for a 'dummy' setup.py command that has no functionality -(probably due to a missing requirement). This dummy command can raise an -exception when it is run, explaining to the user what dependencies must be met -to use this command. - -The reason this is at all tricky is that we want the command to be able to -provide this message even when the user passes arguments to the command. If we -don't know ahead of time what arguments the command can take, this is -difficult, because distutils does not allow unknown arguments to be passed to a -setup.py command. This hacks around that restriction to provide a useful error -message even when a user passes arguments to the dummy implementation of a -command. - -Use this like: - - try: - from some_dependency import SetupCommand - except ImportError: - from ._dummy import _DummyCommand - - class SetupCommand(_DummyCommand): - description = \ - 'Implementation of SetupCommand from some_dependency; ' - 'some_dependency must be installed to run this command' - - # This is the message that will be raised when a user tries to - # run this command--define it as a class attribute. - error_msg = \ - "The 'setup_command' command requires the some_dependency " - "package to be installed and importable." -""" - -import sys -from setuptools import Command -from distutils.errors import DistutilsArgError -from textwrap import dedent - - -class _DummyCommandMeta(type): - """ - Causes an exception to be raised on accessing attributes of a command class - so that if ``./setup.py command_name`` is run with additional command-line - options we can provide a useful error message instead of the default that - tells users the options are unrecognized. - """ - - def __init__(cls, name, bases, members): - if bases == (Command, object): - # This is the _DummyCommand base class, presumably - return - - if not hasattr(cls, 'description'): - raise TypeError( - "_DummyCommand subclass must have a 'description' " - "attribute.") - - if not hasattr(cls, 'error_msg'): - raise TypeError( - "_DummyCommand subclass must have an 'error_msg' " - "attribute.") - - def __getattribute__(cls, attr): - if attr in ('description', 'error_msg'): - # Allow cls.description to work so that `./setup.py - # --help-commands` still works - return super(_DummyCommandMeta, cls).__getattribute__(attr) - - raise DistutilsArgError(cls.error_msg) - - -class _DummyCommand(Command, object, metaclass=_DummyCommandMeta): - pass diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/commands/src/compiler.c ginga-3.1.0/astropy_helpers/astropy_helpers/commands/src/compiler.c --- ginga-3.0.0/astropy_helpers/astropy_helpers/commands/src/compiler.c 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/commands/src/compiler.c 1970-01-01 00:00:00.000000000 +0000 @@ -1,107 +0,0 @@ -#include - -/*************************************************************************** - * Macros for determining the compiler version. - * - * These are borrowed from boost, and majorly abridged to include only - * the compilers we care about. - ***************************************************************************/ - -#define STRINGIZE(X) DO_STRINGIZE(X) -#define DO_STRINGIZE(X) #X - -#if defined __clang__ -/* Clang C++ emulates GCC, so it has to appear early. */ -# define COMPILER "Clang version " __clang_version__ - -#elif defined(__INTEL_COMPILER) || defined(__ICL) || defined(__ICC) || defined(__ECC) -/* Intel */ -# if defined(__INTEL_COMPILER) -# define INTEL_VERSION __INTEL_COMPILER -# elif defined(__ICL) -# define INTEL_VERSION __ICL -# elif defined(__ICC) -# define INTEL_VERSION __ICC -# elif defined(__ECC) -# define INTEL_VERSION __ECC -# endif -# define COMPILER "Intel C compiler version " STRINGIZE(INTEL_VERSION) - -#elif defined(__GNUC__) -/* gcc */ -# define COMPILER "GCC version " __VERSION__ - -#elif defined(__SUNPRO_CC) -/* Sun Workshop Compiler */ -# define COMPILER "Sun compiler version " STRINGIZE(__SUNPRO_CC) - -#elif defined(_MSC_VER) -/* Microsoft Visual C/C++ - Must be last since other compilers define _MSC_VER for compatibility as well */ -# if _MSC_VER < 1200 -# define COMPILER_VERSION 5.0 -# elif _MSC_VER < 1300 -# define COMPILER_VERSION 6.0 -# elif _MSC_VER == 1300 -# define COMPILER_VERSION 7.0 -# elif _MSC_VER == 1310 -# define COMPILER_VERSION 7.1 -# elif _MSC_VER == 1400 -# define COMPILER_VERSION 8.0 -# elif _MSC_VER == 1500 -# define COMPILER_VERSION 9.0 -# elif _MSC_VER == 1600 -# define COMPILER_VERSION 10.0 -# else -# define COMPILER_VERSION _MSC_VER -# endif -# define COMPILER "Microsoft Visual C++ version " STRINGIZE(COMPILER_VERSION) - -#else -/* Fallback */ -# define COMPILER "Unknown compiler" - -#endif - - -/*************************************************************************** - * Module-level - ***************************************************************************/ - -struct module_state { -/* The Sun compiler can't handle empty structs */ -#if defined(__SUNPRO_C) || defined(_MSC_VER) - int _dummy; -#endif -}; - -static struct PyModuleDef moduledef = { - PyModuleDef_HEAD_INIT, - "compiler_version", - NULL, - sizeof(struct module_state), - NULL, - NULL, - NULL, - NULL, - NULL -}; - -#define INITERROR return NULL - -PyMODINIT_FUNC -PyInit_compiler_version(void) - - -{ - PyObject* m; - - m = PyModule_Create(&moduledef); - - if (m == NULL) - INITERROR; - - PyModule_AddStringConstant(m, "compiler", COMPILER); - - return m; -} diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/commands/test.py ginga-3.1.0/astropy_helpers/astropy_helpers/commands/test.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/commands/test.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/commands/test.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,40 +0,0 @@ -""" -Different implementations of the ``./setup.py test`` command depending on -what's locally available. - -If Astropy v1.1 or later is available it should be possible to import -AstropyTest from ``astropy.tests.command``. Otherwise there is a skeleton -implementation that allows users to at least discover the ``./setup.py test`` -command and learn that they need Astropy to run it. -""" - -import os -from ..utils import import_file - -# Previously these except statements caught only ImportErrors, but there are -# some other obscure exceptional conditions that can occur when importing -# astropy.tests (at least on older versions) that can cause these imports to -# fail - -try: - - # If we are testing astropy itself, we need to use import_file to avoid - # actually importing astropy (just the file we need). - command_file = os.path.join('astropy', 'tests', 'command.py') - if os.path.exists(command_file): - AstropyTest = import_file(command_file, 'astropy_tests_command').AstropyTest - else: - import astropy # noqa - from astropy.tests.command import AstropyTest - -except Exception: - - # No astropy at all--provide the dummy implementation - from ._dummy import _DummyCommand - - class AstropyTest(_DummyCommand): - command_name = 'test' - description = 'Run the tests for this package' - error_msg = ( - "The 'test' command requires the astropy package to be " - "installed and importable.") diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/conftest.py ginga-3.1.0/astropy_helpers/astropy_helpers/conftest.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/conftest.py 2018-11-26 18:47:54.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/conftest.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,57 +0,0 @@ -# This file contains settings for pytest that are specific to astropy-helpers. -# Since we run many of the tests in sub-processes, we need to collect coverage -# data inside each subprocess and then combine it into a single .coverage file. -# To do this we set up a list which run_setup appends coverage objects to. -# This is not intended to be used by packages other than astropy-helpers. - -import os -import glob - -try: - from coverage import CoverageData -except ImportError: - HAS_COVERAGE = False -else: - HAS_COVERAGE = True - -if HAS_COVERAGE: - SUBPROCESS_COVERAGE = [] - - -def pytest_configure(config): - if HAS_COVERAGE: - SUBPROCESS_COVERAGE.clear() - - -def pytest_unconfigure(config): - - if HAS_COVERAGE: - - # We create an empty coverage data object - combined_cdata = CoverageData() - - # Add all files from astropy_helpers to make sure we compute the total - # coverage, not just the coverage of the files that have non-zero - # coverage. - - lines = {} - for filename in glob.glob(os.path.join('astropy_helpers', '**', '*.py'), recursive=True): - lines[os.path.abspath(filename)] = [] - - for cdata in SUBPROCESS_COVERAGE: - # For each CoverageData object, we go through all the files and - # change the filename from one which might be a temporary path - # to the local filename. We then only keep files that actually - # exist. - for filename in cdata.measured_files(): - try: - pos = filename.rindex('astropy_helpers') - except ValueError: - continue - short_filename = filename[pos:] - if os.path.exists(short_filename): - lines[os.path.abspath(short_filename)].extend(cdata.lines(filename)) - - combined_cdata.add_lines(lines) - - combined_cdata.write_file('.coverage.subprocess') diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/distutils_helpers.py ginga-3.1.0/astropy_helpers/astropy_helpers/distutils_helpers.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/distutils_helpers.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/distutils_helpers.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,263 +0,0 @@ -""" -This module contains various utilities for introspecting the distutils -module and the setup process. - -Some of these utilities require the -`astropy_helpers.setup_helpers.register_commands` function to be called first, -as it will affect introspection of setuptools command-line arguments. Other -utilities in this module do not have that restriction. -""" - -import os -import sys - -from distutils import ccompiler, log -from distutils.dist import Distribution -from distutils.errors import DistutilsError - -from .utils import silence - - -# This function, and any functions that call it, require the setup in -# `astropy_helpers.setup_helpers.register_commands` to be run first. -def get_dummy_distribution(): - """ - Returns a distutils Distribution object used to instrument the setup - environment before calling the actual setup() function. - """ - - from .setup_helpers import _module_state - - if _module_state['registered_commands'] is None: - raise RuntimeError( - 'astropy_helpers.setup_helpers.register_commands() must be ' - 'called before using ' - 'astropy_helpers.setup_helpers.get_dummy_distribution()') - - # Pre-parse the Distutils command-line options and config files to if - # the option is set. - dist = Distribution({'script_name': os.path.basename(sys.argv[0]), - 'script_args': sys.argv[1:]}) - dist.cmdclass.update(_module_state['registered_commands']) - - with silence(): - try: - dist.parse_config_files() - dist.parse_command_line() - except (DistutilsError, AttributeError, SystemExit): - # Let distutils handle DistutilsErrors itself AttributeErrors can - # get raise for ./setup.py --help SystemExit can be raised if a - # display option was used, for example - pass - - return dist - - -def get_main_package_directory(distribution): - """ - Given a Distribution object, return the main package directory. - """ - return min(distribution.packages, key=len) - -def get_distutils_option(option, commands): - """ Returns the value of the given distutils option. - - Parameters - ---------- - option : str - The name of the option - - commands : list of str - The list of commands on which this option is available - - Returns - ------- - val : str or None - the value of the given distutils option. If the option is not set, - returns None. - """ - - dist = get_dummy_distribution() - - for cmd in commands: - cmd_opts = dist.command_options.get(cmd) - if cmd_opts is not None and option in cmd_opts: - return cmd_opts[option][1] - else: - return None - - -def get_distutils_build_option(option): - """ Returns the value of the given distutils build option. - - Parameters - ---------- - option : str - The name of the option - - Returns - ------- - val : str or None - The value of the given distutils build option. If the option - is not set, returns None. - """ - return get_distutils_option(option, ['build', 'build_ext', 'build_clib']) - - -def get_distutils_install_option(option): - """ Returns the value of the given distutils install option. - - Parameters - ---------- - option : str - The name of the option - - Returns - ------- - val : str or None - The value of the given distutils build option. If the option - is not set, returns None. - """ - return get_distutils_option(option, ['install']) - - -def get_distutils_build_or_install_option(option): - """ Returns the value of the given distutils build or install option. - - Parameters - ---------- - option : str - The name of the option - - Returns - ------- - val : str or None - The value of the given distutils build or install option. If the - option is not set, returns None. - """ - return get_distutils_option(option, ['build', 'build_ext', 'build_clib', - 'install']) - - -def get_compiler_option(): - """ Determines the compiler that will be used to build extension modules. - - Returns - ------- - compiler : str - The compiler option specified for the build, build_ext, or build_clib - command; or the default compiler for the platform if none was - specified. - - """ - - compiler = get_distutils_build_option('compiler') - if compiler is None: - return ccompiler.get_default_compiler() - - return compiler - - -def add_command_option(command, name, doc, is_bool=False): - """ - Add a custom option to a setup command. - - Issues a warning if the option already exists on that command. - - Parameters - ---------- - command : str - The name of the command as given on the command line - - name : str - The name of the build option - - doc : str - A short description of the option, for the `--help` message - - is_bool : bool, optional - When `True`, the option is a boolean option and doesn't - require an associated value. - """ - - dist = get_dummy_distribution() - cmdcls = dist.get_command_class(command) - - if (hasattr(cmdcls, '_astropy_helpers_options') and - name in cmdcls._astropy_helpers_options): - return - - attr = name.replace('-', '_') - - if hasattr(cmdcls, attr): - raise RuntimeError( - '{0!r} already has a {1!r} class attribute, barring {2!r} from ' - 'being usable as a custom option name.'.format(cmdcls, attr, name)) - - for idx, cmd in enumerate(cmdcls.user_options): - if cmd[0] == name: - log.warn('Overriding existing {0!r} option ' - '{1!r}'.format(command, name)) - del cmdcls.user_options[idx] - if name in cmdcls.boolean_options: - cmdcls.boolean_options.remove(name) - break - - cmdcls.user_options.append((name, None, doc)) - - if is_bool: - cmdcls.boolean_options.append(name) - - # Distutils' command parsing requires that a command object have an - # attribute with the same name as the option (with '-' replaced with '_') - # in order for that option to be recognized as valid - setattr(cmdcls, attr, None) - - # This caches the options added through add_command_option so that if it is - # run multiple times in the same interpreter repeated adds are ignored - # (this way we can still raise a RuntimeError if a custom option overrides - # a built-in option) - if not hasattr(cmdcls, '_astropy_helpers_options'): - cmdcls._astropy_helpers_options = set([name]) - else: - cmdcls._astropy_helpers_options.add(name) - - -def get_distutils_display_options(): - """ Returns a set of all the distutils display options in their long and - short forms. These are the setup.py arguments such as --name or --version - which print the project's metadata and then exit. - - Returns - ------- - opts : set - The long and short form display option arguments, including the - or -- - """ - - short_display_opts = set('-' + o[1] for o in Distribution.display_options - if o[1]) - long_display_opts = set('--' + o[0] for o in Distribution.display_options) - - # Include -h and --help which are not explicitly listed in - # Distribution.display_options (as they are handled by optparse) - short_display_opts.add('-h') - long_display_opts.add('--help') - - # This isn't the greatest approach to hardcode these commands. - # However, there doesn't seem to be a good way to determine - # whether build *will be* run as part of the command at this - # phase. - display_commands = set([ - 'clean', 'register', 'setopt', 'saveopts', 'egg_info', - 'alias']) - - return short_display_opts.union(long_display_opts.union(display_commands)) - - -def is_distutils_display_option(): - """ Returns True if sys.argv contains any of the distutils display options - such as --version or --name. - """ - - display_options = get_distutils_display_options() - return bool(set(sys.argv[1:]).intersection(display_options)) diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/git_helpers.py ginga-3.1.0/astropy_helpers/astropy_helpers/git_helpers.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/git_helpers.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/git_helpers.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,195 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst - -""" -Utilities for retrieving revision information from a project's git repository. -""" - -# Do not remove the following comment; it is used by -# astropy_helpers.version_helpers to determine the beginning of the code in -# this module - -# BEGIN - -import locale -import os -import subprocess -import warnings - -__all__ = ['get_git_devstr'] - - -def _decode_stdio(stream): - try: - stdio_encoding = locale.getdefaultlocale()[1] or 'utf-8' - except ValueError: - stdio_encoding = 'utf-8' - - try: - text = stream.decode(stdio_encoding) - except UnicodeDecodeError: - # Final fallback - text = stream.decode('latin1') - - return text - - -def update_git_devstr(version, path=None): - """ - Updates the git revision string if and only if the path is being imported - directly from a git working copy. This ensures that the revision number in - the version string is accurate. - """ - - try: - # Quick way to determine if we're in git or not - returns '' if not - devstr = get_git_devstr(sha=True, show_warning=False, path=path) - except OSError: - return version - - if not devstr: - # Probably not in git so just pass silently - return version - - if 'dev' in version: # update to the current git revision - version_base = version.split('.dev', 1)[0] - devstr = get_git_devstr(sha=False, show_warning=False, path=path) - - return version_base + '.dev' + devstr - else: - # otherwise it's already the true/release version - return version - - -def get_git_devstr(sha=False, show_warning=True, path=None): - """ - Determines the number of revisions in this repository. - - Parameters - ---------- - sha : bool - If True, the full SHA1 hash will be returned. Otherwise, the total - count of commits in the repository will be used as a "revision - number". - - show_warning : bool - If True, issue a warning if git returns an error code, otherwise errors - pass silently. - - path : str or None - If a string, specifies the directory to look in to find the git - repository. If `None`, the current working directory is used, and must - be the root of the git repository. - If given a filename it uses the directory containing that file. - - Returns - ------- - devversion : str - Either a string with the revision number (if `sha` is False), the - SHA1 hash of the current commit (if `sha` is True), or an empty string - if git version info could not be identified. - - """ - - if path is None: - path = os.getcwd() - - if not os.path.isdir(path): - path = os.path.abspath(os.path.dirname(path)) - - if sha: - # Faster for getting just the hash of HEAD - cmd = ['rev-parse', 'HEAD'] - else: - cmd = ['rev-list', '--count', 'HEAD'] - - def run_git(cmd): - try: - p = subprocess.Popen(['git'] + cmd, cwd=path, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=subprocess.PIPE) - stdout, stderr = p.communicate() - except OSError as e: - if show_warning: - warnings.warn('Error running git: ' + str(e)) - return (None, b'', b'') - - if p.returncode == 128: - if show_warning: - warnings.warn('No git repository present at {0!r}! Using ' - 'default dev version.'.format(path)) - return (p.returncode, b'', b'') - if p.returncode == 129: - if show_warning: - warnings.warn('Your git looks old (does it support {0}?); ' - 'consider upgrading to v1.7.2 or ' - 'later.'.format(cmd[0])) - return (p.returncode, stdout, stderr) - elif p.returncode != 0: - if show_warning: - warnings.warn('Git failed while determining revision ' - 'count: {0}'.format(_decode_stdio(stderr))) - return (p.returncode, stdout, stderr) - - return p.returncode, stdout, stderr - - returncode, stdout, stderr = run_git(cmd) - - if not sha and returncode == 128: - # git returns 128 if the command is not run from within a git - # repository tree. In this case, a warning is produced above but we - # return the default dev version of '0'. - return '0' - elif not sha and returncode == 129: - # git returns 129 if a command option failed to parse; in - # particular this could happen in git versions older than 1.7.2 - # where the --count option is not supported - # Also use --abbrev-commit and --abbrev=0 to display the minimum - # number of characters needed per-commit (rather than the full hash) - cmd = ['rev-list', '--abbrev-commit', '--abbrev=0', 'HEAD'] - returncode, stdout, stderr = run_git(cmd) - # Fall back on the old method of getting all revisions and counting - # the lines - if returncode == 0: - return str(stdout.count(b'\n')) - else: - return '' - elif sha: - return _decode_stdio(stdout)[:40] - else: - return _decode_stdio(stdout).strip() - - -# This function is tested but it is only ever executed within a subprocess when -# creating a fake package, so it doesn't get picked up by coverage metrics. -def _get_repo_path(pathname, levels=None): # pragma: no cover - """ - Given a file or directory name, determine the root of the git repository - this path is under. If given, this won't look any higher than ``levels`` - (that is, if ``levels=0`` then the given path must be the root of the git - repository and is returned if so. - - Returns `None` if the given path could not be determined to belong to a git - repo. - """ - - if os.path.isfile(pathname): - current_dir = os.path.abspath(os.path.dirname(pathname)) - elif os.path.isdir(pathname): - current_dir = os.path.abspath(pathname) - else: - return None - - current_level = 0 - - while levels is None or current_level <= levels: - if os.path.exists(os.path.join(current_dir, '.git')): - return current_dir - - current_level += 1 - if current_dir == os.path.dirname(current_dir): - break - - current_dir = os.path.dirname(current_dir) - - return None diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/__init__.py ginga-3.1.0/astropy_helpers/astropy_helpers/__init__.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/__init__.py 2018-11-26 18:47:54.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/__init__.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,49 +0,0 @@ -try: - from .version import version as __version__ - from .version import githash as __githash__ -except ImportError: - __version__ = '' - __githash__ = '' - - -# If we've made it as far as importing astropy_helpers, we don't need -# ah_bootstrap in sys.modules anymore. Getting rid of it is actually necessary -# if the package we're installing has a setup_requires of another package that -# uses astropy_helpers (and possibly a different version at that) -# See https://github.com/astropy/astropy/issues/3541 -import sys -if 'ah_bootstrap' in sys.modules: - del sys.modules['ah_bootstrap'] - - -# Note, this is repeated from ah_bootstrap.py, but is here too in case this -# astropy-helpers was upgraded to from an older version that did not have this -# check in its ah_bootstrap. -# matplotlib can cause problems if it is imported from within a call of -# run_setup(), because in some circumstances it will try to write to the user's -# home directory, resulting in a SandboxViolation. See -# https://github.com/matplotlib/matplotlib/pull/4165 -# Making sure matplotlib, if it is available, is imported early in the setup -# process can mitigate this (note importing matplotlib.pyplot has the same -# issue) -try: - import matplotlib - matplotlib.use('Agg') - import matplotlib.pyplot -except: - # Ignore if this fails for *any* reason* - pass - - -import os -# Ensure that all module-level code in astropy or other packages know that -# we're in setup mode: -if ('__main__' in sys.modules and - hasattr(sys.modules['__main__'], '__file__')): - filename = os.path.basename(sys.modules['__main__'].__file__) - - if filename.rstrip('co') == 'setup.py': - import builtins - builtins._ASTROPY_SETUP_ = True - - del filename diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/openmp_helpers.py ginga-3.1.0/astropy_helpers/astropy_helpers/openmp_helpers.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/openmp_helpers.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/openmp_helpers.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,308 +0,0 @@ -# This module defines functions that can be used to check whether OpenMP is -# available and if so what flags to use. To use this, import the -# add_openmp_flags_if_available function in a setup_package.py file where you -# are defining your extensions: -# -# from astropy_helpers.openmp_helpers import add_openmp_flags_if_available -# -# then call it with a single extension as the only argument: -# -# add_openmp_flags_if_available(extension) -# -# this will add the OpenMP flags if available. - -from __future__ import absolute_import, print_function - -import os -import sys -import glob -import time -import datetime -import tempfile -import subprocess - -from distutils import log -from distutils.ccompiler import new_compiler -from distutils.sysconfig import customize_compiler, get_config_var -from distutils.errors import CompileError, LinkError - -from .distutils_helpers import get_compiler_option - -__all__ = ['add_openmp_flags_if_available'] - -try: - # Check if this has already been instantiated, only set the default once. - _ASTROPY_DISABLE_SETUP_WITH_OPENMP_ -except NameError: - import builtins - # It hasn't, so do so. - builtins._ASTROPY_DISABLE_SETUP_WITH_OPENMP_ = False - -CCODE = """ -#include -#include -int main(void) { - #pragma omp parallel - printf("nthreads=%d\\n", omp_get_num_threads()); - return 0; -} -""" - - -def _get_flag_value_from_var(flag, var, delim=' '): - """ - Extract flags from an environment variable. - - Parameters - ---------- - flag : str - The flag to extract, for example '-I' or '-L' - var : str - The environment variable to extract the flag from, e.g. CFLAGS or LDFLAGS. - delim : str, optional - The delimiter separating flags inside the environment variable - - Examples - -------- - Let's assume the LDFLAGS is set to '-L/usr/local/include -customflag'. This - function will then return the following: - - >>> _get_flag_value_from_var('-L', 'LDFLAGS') - '/usr/local/include' - - Notes - ----- - Environment variables are first checked in ``os.environ[var]``, then in - ``distutils.sysconfig.get_config_var(var)``. - - This function is not supported on Windows. - """ - - if sys.platform.startswith('win'): - return None - - # Simple input validation - if not var or not flag: - return None - flag_length = len(flag) - if not flag_length: - return None - - # Look for var in os.eviron then in get_config_var - if var in os.environ: - flags = os.environ[var] - else: - try: - flags = get_config_var(var) - except KeyError: - return None - - # Extract flag from {var:value} - if flags: - for item in flags.split(delim): - if item.startswith(flag): - return item[flag_length:] - - -def get_openmp_flags(): - """ - Utility for returning compiler and linker flags possibly needed for - OpenMP support. - - Returns - ------- - result : `{'compiler_flags':, 'linker_flags':}` - - Notes - ----- - The flags returned are not tested for validity, use - `check_openmp_support(openmp_flags=get_openmp_flags())` to do so. - """ - - compile_flags = [] - link_flags = [] - - if get_compiler_option() == 'msvc': - compile_flags.append('-openmp') - else: - - include_path = _get_flag_value_from_var('-I', 'CFLAGS') - if include_path: - compile_flags.append('-I' + include_path) - - lib_path = _get_flag_value_from_var('-L', 'LDFLAGS') - if lib_path: - link_flags.append('-L' + lib_path) - link_flags.append('-Wl,-rpath,' + lib_path) - - compile_flags.append('-fopenmp') - link_flags.append('-fopenmp') - - return {'compiler_flags': compile_flags, 'linker_flags': link_flags} - - -def check_openmp_support(openmp_flags=None): - """ - Check whether OpenMP test code can be compiled and run. - - Parameters - ---------- - openmp_flags : dict, optional - This should be a dictionary with keys ``compiler_flags`` and - ``linker_flags`` giving the compiliation and linking flags respectively. - These are passed as `extra_postargs` to `compile()` and - `link_executable()` respectively. If this is not set, the flags will - be automatically determined using environment variables. - - Returns - ------- - result : bool - `True` if the test passed, `False` otherwise. - """ - - ccompiler = new_compiler() - customize_compiler(ccompiler) - - if not openmp_flags: - # customize_compiler() extracts info from os.environ. If certain keys - # exist it uses these plus those from sysconfig.get_config_vars(). - # If the key is missing in os.environ it is not extracted from - # sysconfig.get_config_var(). E.g. 'LDFLAGS' get left out, preventing - # clang from finding libomp.dylib because -L is not passed to - # linker. Call get_openmp_flags() to get flags missed by - # customize_compiler(). - openmp_flags = get_openmp_flags() - - compile_flags = openmp_flags.get('compiler_flags') - link_flags = openmp_flags.get('linker_flags') - - # Pass -coverage flag to linker. - # https://github.com/astropy/astropy-helpers/pull/374 - if '-coverage' in compile_flags and '-coverage' not in link_flags: - link_flags.append('-coverage') - - tmp_dir = tempfile.mkdtemp() - start_dir = os.path.abspath('.') - - try: - os.chdir(tmp_dir) - - # Write test program - with open('test_openmp.c', 'w') as f: - f.write(CCODE) - - os.mkdir('objects') - - # Compile, test program - ccompiler.compile(['test_openmp.c'], output_dir='objects', - extra_postargs=compile_flags) - - # Link test program - objects = glob.glob(os.path.join('objects', '*' + ccompiler.obj_extension)) - ccompiler.link_executable(objects, 'test_openmp', - extra_postargs=link_flags) - - # Run test program - output = subprocess.check_output('./test_openmp') - output = output.decode(sys.stdout.encoding or 'utf-8').splitlines() - - if 'nthreads=' in output[0]: - nthreads = int(output[0].strip().split('=')[1]) - if len(output) == nthreads: - is_openmp_supported = True - else: - log.warn("Unexpected number of lines from output of test OpenMP " - "program (output was {0})".format(output)) - is_openmp_supported = False - else: - log.warn("Unexpected output from test OpenMP " - "program (output was {0})".format(output)) - is_openmp_supported = False - except (CompileError, LinkError, subprocess.CalledProcessError): - is_openmp_supported = False - - finally: - os.chdir(start_dir) - - return is_openmp_supported - - -def is_openmp_supported(): - """ - Determine whether the build compiler has OpenMP support. - """ - log_threshold = log.set_threshold(log.FATAL) - ret = check_openmp_support() - log.set_threshold(log_threshold) - return ret - - -def add_openmp_flags_if_available(extension): - """ - Add OpenMP compilation flags, if supported (if not a warning will be - printed to the console and no flags will be added.) - - Returns `True` if the flags were added, `False` otherwise. - """ - - if _ASTROPY_DISABLE_SETUP_WITH_OPENMP_: - log.info("OpenMP support has been explicitly disabled.") - return False - - openmp_flags = get_openmp_flags() - using_openmp = check_openmp_support(openmp_flags=openmp_flags) - - if using_openmp: - compile_flags = openmp_flags.get('compiler_flags') - link_flags = openmp_flags.get('linker_flags') - log.info("Compiling Cython/C/C++ extension with OpenMP support") - extension.extra_compile_args.extend(compile_flags) - extension.extra_link_args.extend(link_flags) - else: - log.warn("Cannot compile Cython/C/C++ extension with OpenMP, reverting " - "to non-parallel code") - - return using_openmp - - -_IS_OPENMP_ENABLED_SRC = """ -# Autogenerated by {packagetitle}'s setup.py on {timestamp!s} - -def is_openmp_enabled(): - \"\"\" - Determine whether this package was built with OpenMP support. - \"\"\" - return {return_bool} -"""[1:] - - -def generate_openmp_enabled_py(packagename, srcdir='.', disable_openmp=None): - """ - Generate ``package.openmp_enabled.is_openmp_enabled``, which can then be used - to determine, post build, whether the package was built with or without - OpenMP support. - """ - - if packagename.lower() == 'astropy': - packagetitle = 'Astropy' - else: - packagetitle = packagename - - epoch = int(os.environ.get('SOURCE_DATE_EPOCH', time.time())) - timestamp = datetime.datetime.utcfromtimestamp(epoch) - - if disable_openmp is not None: - import builtins - builtins._ASTROPY_DISABLE_SETUP_WITH_OPENMP_ = disable_openmp - if _ASTROPY_DISABLE_SETUP_WITH_OPENMP_: - log.info("OpenMP support has been explicitly disabled.") - openmp_support = False if _ASTROPY_DISABLE_SETUP_WITH_OPENMP_ else is_openmp_supported() - - src = _IS_OPENMP_ENABLED_SRC.format(packagetitle=packagetitle, - timestamp=timestamp, - return_bool=openmp_support) - - package_srcdir = os.path.join(srcdir, *packagename.split('.')) - is_openmp_enabled_py = os.path.join(package_srcdir, 'openmp_enabled.py') - with open(is_openmp_enabled_py, 'w') as f: - f.write(src) diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/setup_helpers.py ginga-3.1.0/astropy_helpers/astropy_helpers/setup_helpers.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/setup_helpers.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/setup_helpers.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,785 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst -""" -This module contains a number of utilities for use during -setup/build/packaging that are useful to astropy as a whole. -""" - -from __future__ import absolute_import - -import collections -import os -import re -import subprocess -import sys -import traceback -import warnings -from configparser import ConfigParser -import builtins - -from distutils import log -from distutils.errors import DistutilsOptionError, DistutilsModuleError -from distutils.core import Extension -from distutils.core import Command -from distutils.command.sdist import sdist as DistutilsSdist - -from setuptools import setup as setuptools_setup -from setuptools.config import read_configuration -from setuptools import find_packages as _find_packages - -from .distutils_helpers import (add_command_option, get_compiler_option, - get_dummy_distribution, get_distutils_build_option, - get_distutils_build_or_install_option) -from .version_helpers import get_pkg_version_module, generate_version_py -from .utils import (walk_skip_hidden, import_file, extends_doc, - resolve_name, AstropyDeprecationWarning) - -from .commands.build_ext import AstropyHelpersBuildExt -from .commands.test import AstropyTest - -# These imports are not used in this module, but are included for backwards -# compat with older versions of this module -from .utils import get_numpy_include_path, write_if_different # noqa - -__all__ = ['register_commands', 'get_package_info'] - -_module_state = {'registered_commands': None, - 'have_sphinx': False, - 'package_cache': None, - 'exclude_packages': set(), - 'excludes_too_late': False} - -try: - import sphinx # noqa - _module_state['have_sphinx'] = True -except ValueError as e: - # This can occur deep in the bowels of Sphinx's imports by way of docutils - # and an occurrence of this bug: http://bugs.python.org/issue18378 - # In this case sphinx is effectively unusable - if 'unknown locale' in e.args[0]: - log.warn( - "Possible misconfiguration of one of the environment variables " - "LC_ALL, LC_CTYPES, LANG, or LANGUAGE. For an example of how to " - "configure your system's language environment on OSX see " - "http://blog.remibergsma.com/2012/07/10/" - "setting-locales-correctly-on-mac-osx-terminal-application/") -except ImportError: - pass -except SyntaxError: - # occurs if markupsafe is recent version, which doesn't support Python 3.2 - pass - - -def setup(**kwargs): - """ - A wrapper around setuptools' setup() function that automatically sets up - custom commands, generates a version file, and customizes the setup process - via the ``setup_package.py`` files. - """ - - # DEPRECATED: store the package name in a built-in variable so it's easy - # to get from other parts of the setup infrastructure. We should phase this - # out in packages that use it - the cookiecutter template should now be - # able to put the right package name where needed. - conf = read_configuration('setup.cfg') - builtins._ASTROPY_PACKAGE_NAME_ = conf['metadata']['name'] - - # Create a dictionary with setup command overrides. Note that this gets - # information about the package (name and version) from the setup.cfg file. - cmdclass = register_commands() - - # Freeze build information in version.py. Note that this gets information - # about the package (name and version) from the setup.cfg file. - version = generate_version_py() - - # Get configuration information from all of the various subpackages. - # See the docstring for setup_helpers.update_package_files for more - # details. - package_info = get_package_info() - package_info['cmdclass'] = cmdclass - package_info['version'] = version - - # Override using any specified keyword arguments - package_info.update(kwargs) - - setuptools_setup(**package_info) - - -def adjust_compiler(package): - warnings.warn( - 'The adjust_compiler function in setup.py is ' - 'deprecated and can be removed from your setup.py.', - AstropyDeprecationWarning) - - -def get_debug_option(packagename): - """ Determines if the build is in debug mode. - - Returns - ------- - debug : bool - True if the current build was started with the debug option, False - otherwise. - - """ - - try: - current_debug = get_pkg_version_module(packagename, - fromlist=['debug'])[0] - except (ImportError, AttributeError): - current_debug = None - - # Only modify the debug flag if one of the build commands was explicitly - # run (i.e. not as a sub-command of something else) - dist = get_dummy_distribution() - if any(cmd in dist.commands for cmd in ['build', 'build_ext']): - debug = bool(get_distutils_build_option('debug')) - else: - debug = bool(current_debug) - - if current_debug is not None and current_debug != debug: - build_ext_cmd = dist.get_command_class('build_ext') - build_ext_cmd._force_rebuild = True - - return debug - - -def add_exclude_packages(excludes): - - if _module_state['excludes_too_late']: - raise RuntimeError( - "add_package_excludes must be called before all other setup helper " - "functions in order to properly handle excluded packages") - - _module_state['exclude_packages'].update(set(excludes)) - - -def register_commands(package=None, version=None, release=None, srcdir='.'): - """ - This function generates a dictionary containing customized commands that - can then be passed to the ``cmdclass`` argument in ``setup()``. - """ - - if package is not None: - warnings.warn('The package argument to generate_version_py has ' - 'been deprecated and will be removed in future. Specify ' - 'the package name in setup.cfg instead', AstropyDeprecationWarning) - - if version is not None: - warnings.warn('The version argument to generate_version_py has ' - 'been deprecated and will be removed in future. Specify ' - 'the version number in setup.cfg instead', AstropyDeprecationWarning) - - if release is not None: - warnings.warn('The release argument to generate_version_py has ' - 'been deprecated and will be removed in future. We now ' - 'use the presence of the "dev" string in the version to ' - 'determine whether this is a release', AstropyDeprecationWarning) - - # We use ConfigParser instead of read_configuration here because the latter - # only reads in keys recognized by setuptools, but we need to access - # package_name below. - conf = ConfigParser() - conf.read('setup.cfg') - - if conf.has_option('metadata', 'name'): - package = conf.get('metadata', 'name') - elif conf.has_option('metadata', 'package_name'): - # The package-template used package_name instead of name for a while - warnings.warn('Specifying the package name using the "package_name" ' - 'option in setup.cfg is deprecated - use the "name" ' - 'option instead.', AstropyDeprecationWarning) - package = conf.get('metadata', 'package_name') - elif package is not None: # deprecated - pass - else: - sys.stderr.write('ERROR: Could not read package name from setup.cfg\n') - sys.exit(1) - - if _module_state['registered_commands'] is not None: - return _module_state['registered_commands'] - - if _module_state['have_sphinx']: - try: - from .commands.build_sphinx import (AstropyBuildSphinx, - AstropyBuildDocs) - except ImportError: - AstropyBuildSphinx = AstropyBuildDocs = FakeBuildSphinx - else: - AstropyBuildSphinx = AstropyBuildDocs = FakeBuildSphinx - - _module_state['registered_commands'] = registered_commands = { - 'test': generate_test_command(package), - - # Use distutils' sdist because it respects package_data. - # setuptools/distributes sdist requires duplication of information in - # MANIFEST.in - 'sdist': DistutilsSdist, - - 'build_ext': AstropyHelpersBuildExt, - 'build_sphinx': AstropyBuildSphinx, - 'build_docs': AstropyBuildDocs - } - - # Need to override the __name__ here so that the commandline options are - # presented as being related to the "build" command, for example; normally - # this wouldn't be necessary since commands also have a command_name - # attribute, but there is a bug in distutils' help display code that it - # uses __name__ instead of command_name. Yay distutils! - for name, cls in registered_commands.items(): - cls.__name__ = name - - # Add a few custom options; more of these can be added by specific packages - # later - for option in [ - ('use-system-libraries', - "Use system libraries whenever possible", True)]: - add_command_option('build', *option) - add_command_option('install', *option) - - add_command_hooks(registered_commands, srcdir=srcdir) - - return registered_commands - - -def add_command_hooks(commands, srcdir='.'): - """ - Look through setup_package.py modules for functions with names like - ``pre__hook`` and ``post__hook`` where - ```` is the name of a ``setup.py`` command (e.g. build_ext). - - If either hook is present this adds a wrapped version of that command to - the passed in ``commands`` `dict`. ``commands`` may be pre-populated with - other custom distutils command classes that should be wrapped if there are - hooks for them (e.g. `AstropyBuildPy`). - """ - - hook_re = re.compile(r'^(pre|post)_(.+)_hook$') - - # Distutils commands have a method of the same name, but it is not a - # *classmethod* (which probably didn't exist when distutils was first - # written) - def get_command_name(cmdcls): - if hasattr(cmdcls, 'command_name'): - return cmdcls.command_name - else: - return cmdcls.__name__ - - packages = find_packages(srcdir) - dist = get_dummy_distribution() - - hooks = collections.defaultdict(dict) - - for setuppkg in iter_setup_packages(srcdir, packages): - for name, obj in vars(setuppkg).items(): - match = hook_re.match(name) - if not match: - continue - - hook_type = match.group(1) - cmd_name = match.group(2) - - if hook_type not in hooks[cmd_name]: - hooks[cmd_name][hook_type] = [] - - hooks[cmd_name][hook_type].append((setuppkg.__name__, obj)) - - for cmd_name, cmd_hooks in hooks.items(): - commands[cmd_name] = generate_hooked_command( - cmd_name, dist.get_command_class(cmd_name), cmd_hooks) - - -def generate_hooked_command(cmd_name, cmd_cls, hooks): - """ - Returns a generated subclass of ``cmd_cls`` that runs the pre- and - post-command hooks for that command before and after the ``cmd_cls.run`` - method. - """ - - def run(self, orig_run=cmd_cls.run): - self.run_command_hooks('pre_hooks') - orig_run(self) - self.run_command_hooks('post_hooks') - - return type(cmd_name, (cmd_cls, object), - {'run': run, 'run_command_hooks': run_command_hooks, - 'pre_hooks': hooks.get('pre', []), - 'post_hooks': hooks.get('post', [])}) - - -def run_command_hooks(cmd_obj, hook_kind): - """Run hooks registered for that command and phase. - - *cmd_obj* is a finalized command object; *hook_kind* is either - 'pre_hook' or 'post_hook'. - """ - - hooks = getattr(cmd_obj, hook_kind, None) - - if not hooks: - return - - for modname, hook in hooks: - if isinstance(hook, str): - try: - hook_obj = resolve_name(hook) - except ImportError as exc: - raise DistutilsModuleError( - 'cannot find hook {0}: {1}'.format(hook, exc)) - else: - hook_obj = hook - - if not callable(hook_obj): - raise DistutilsOptionError('hook {0!r} is not callable' % hook) - - log.info('running {0} from {1} for {2} command'.format( - hook_kind.rstrip('s'), modname, cmd_obj.get_command_name())) - - try: - hook_obj(cmd_obj) - except Exception: - log.error('{0} command hook {1} raised an exception: %s\n'.format( - hook_obj.__name__, cmd_obj.get_command_name())) - log.error(traceback.format_exc()) - sys.exit(1) - - -def generate_test_command(package_name): - """ - Creates a custom 'test' command for the given package which sets the - command's ``package_name`` class attribute to the name of the package being - tested. - """ - - return type(package_name.title() + 'Test', (AstropyTest,), - {'package_name': package_name}) - - -def update_package_files(srcdir, extensions, package_data, packagenames, - package_dirs): - """ - This function is deprecated and maintained for backward compatibility - with affiliated packages. Affiliated packages should update their - setup.py to use `get_package_info` instead. - """ - - info = get_package_info(srcdir) - extensions.extend(info['ext_modules']) - package_data.update(info['package_data']) - packagenames = list(set(packagenames + info['packages'])) - package_dirs.update(info['package_dir']) - - -def get_package_info(srcdir='.', exclude=()): - """ - Collates all of the information for building all subpackages - and returns a dictionary of keyword arguments that can - be passed directly to `distutils.setup`. - - The purpose of this function is to allow subpackages to update the - arguments to the package's ``setup()`` function in its setup.py - script, rather than having to specify all extensions/package data - directly in the ``setup.py``. See Astropy's own - ``setup.py`` for example usage and the Astropy development docs - for more details. - - This function obtains that information by iterating through all - packages in ``srcdir`` and locating a ``setup_package.py`` module. - This module can contain the following functions: - ``get_extensions()``, ``get_package_data()``, - ``get_build_options()``, and ``get_external_libraries()``. - - Each of those functions take no arguments. - - - ``get_extensions`` returns a list of - `distutils.extension.Extension` objects. - - - ``get_package_data()`` returns a dict formatted as required by - the ``package_data`` argument to ``setup()``. - - - ``get_build_options()`` returns a list of tuples describing the - extra build options to add. - - - ``get_external_libraries()`` returns - a list of libraries that can optionally be built using external - dependencies. - """ - ext_modules = [] - packages = [] - package_dir = {} - - # Read in existing package data, and add to it below - setup_cfg = os.path.join(srcdir, 'setup.cfg') - if os.path.exists(setup_cfg): - conf = read_configuration(setup_cfg) - if 'options' in conf and 'package_data' in conf['options']: - package_data = conf['options']['package_data'] - else: - package_data = {} - else: - package_data = {} - - if exclude: - warnings.warn( - "Use of the exclude parameter is no longer supported since it does " - "not work as expected. Use add_exclude_packages instead. Note that " - "it must be called prior to any other calls from setup helpers.", - AstropyDeprecationWarning) - - # Use the find_packages tool to locate all packages and modules - packages = find_packages(srcdir, exclude=exclude) - - # Update package_dir if the package lies in a subdirectory - if srcdir != '.': - package_dir[''] = srcdir - - # For each of the setup_package.py modules, extract any - # information that is needed to install them. The build options - # are extracted first, so that their values will be available in - # subsequent calls to `get_extensions`, etc. - for setuppkg in iter_setup_packages(srcdir, packages): - if hasattr(setuppkg, 'get_build_options'): - options = setuppkg.get_build_options() - for option in options: - add_command_option('build', *option) - if hasattr(setuppkg, 'get_external_libraries'): - libraries = setuppkg.get_external_libraries() - for library in libraries: - add_external_library(library) - - for setuppkg in iter_setup_packages(srcdir, packages): - # get_extensions must include any Cython extensions by their .pyx - # filename. - if hasattr(setuppkg, 'get_extensions'): - ext_modules.extend(setuppkg.get_extensions()) - if hasattr(setuppkg, 'get_package_data'): - package_data.update(setuppkg.get_package_data()) - - # Locate any .pyx files not already specified, and add their extensions in. - # The default include dirs include numpy to facilitate numerical work. - ext_modules.extend(get_cython_extensions(srcdir, packages, ext_modules, - ['numpy'])) - - # Now remove extensions that have the special name 'skip_cython', as they - # exist Only to indicate that the cython extensions shouldn't be built - for i, ext in reversed(list(enumerate(ext_modules))): - if ext.name == 'skip_cython': - del ext_modules[i] - - # On Microsoft compilers, we need to pass the '/MANIFEST' - # commandline argument. This was the default on MSVC 9.0, but is - # now required on MSVC 10.0, but it doesn't seem to hurt to add - # it unconditionally. - if get_compiler_option() == 'msvc': - for ext in ext_modules: - ext.extra_link_args.append('/MANIFEST') - - return { - 'ext_modules': ext_modules, - 'packages': packages, - 'package_dir': package_dir, - 'package_data': package_data, - } - - -def iter_setup_packages(srcdir, packages): - """ A generator that finds and imports all of the ``setup_package.py`` - modules in the source packages. - - Returns - ------- - modgen : generator - A generator that yields (modname, mod), where `mod` is the module and - `modname` is the module name for the ``setup_package.py`` modules. - - """ - - for packagename in packages: - package_parts = packagename.split('.') - package_path = os.path.join(srcdir, *package_parts) - setup_package = os.path.relpath( - os.path.join(package_path, 'setup_package.py')) - - if os.path.isfile(setup_package): - module = import_file(setup_package, - name=packagename + '.setup_package') - yield module - - -def iter_pyx_files(package_dir, package_name): - """ - A generator that yields Cython source files (ending in '.pyx') in the - source packages. - - Returns - ------- - pyxgen : generator - A generator that yields (extmod, fullfn) where `extmod` is the - full name of the module that the .pyx file would live in based - on the source directory structure, and `fullfn` is the path to - the .pyx file. - """ - for dirpath, dirnames, filenames in walk_skip_hidden(package_dir): - for fn in filenames: - if fn.endswith('.pyx'): - fullfn = os.path.relpath(os.path.join(dirpath, fn)) - # Package must match file name - extmod = '.'.join([package_name, fn[:-4]]) - yield (extmod, fullfn) - - break # Don't recurse into subdirectories - - -def get_cython_extensions(srcdir, packages, prevextensions=tuple(), - extincludedirs=None): - """ - Looks for Cython files and generates Extensions if needed. - - Parameters - ---------- - srcdir : str - Path to the root of the source directory to search. - prevextensions : list of `~distutils.core.Extension` objects - The extensions that are already defined. Any .pyx files already here - will be ignored. - extincludedirs : list of str or None - Directories to include as the `include_dirs` argument to the generated - `~distutils.core.Extension` objects. - - Returns - ------- - exts : list of `~distutils.core.Extension` objects - The new extensions that are needed to compile all .pyx files (does not - include any already in `prevextensions`). - """ - - # Vanilla setuptools and old versions of distribute include Cython files - # as .c files in the sources, not .pyx, so we cannot simply look for - # existing .pyx sources in the previous sources, but we should also check - # for .c files with the same remaining filename. So we look for .pyx and - # .c files, and we strip the extension. - prevsourcepaths = [] - ext_modules = [] - - for ext in prevextensions: - for s in ext.sources: - if s.endswith(('.pyx', '.c', '.cpp')): - sourcepath = os.path.realpath(os.path.splitext(s)[0]) - prevsourcepaths.append(sourcepath) - - for package_name in packages: - package_parts = package_name.split('.') - package_path = os.path.join(srcdir, *package_parts) - - for extmod, pyxfn in iter_pyx_files(package_path, package_name): - sourcepath = os.path.realpath(os.path.splitext(pyxfn)[0]) - if sourcepath not in prevsourcepaths: - ext_modules.append(Extension(extmod, [pyxfn], - include_dirs=extincludedirs)) - - return ext_modules - - -class DistutilsExtensionArgs(collections.defaultdict): - """ - A special dictionary whose default values are the empty list. - - This is useful for building up a set of arguments for - `distutils.Extension` without worrying whether the entry is - already present. - """ - def __init__(self, *args, **kwargs): - def default_factory(): - return [] - - super(DistutilsExtensionArgs, self).__init__( - default_factory, *args, **kwargs) - - def update(self, other): - for key, val in other.items(): - self[key].extend(val) - - -def pkg_config(packages, default_libraries, executable='pkg-config'): - """ - Uses pkg-config to update a set of distutils Extension arguments - to include the flags necessary to link against the given packages. - - If the pkg-config lookup fails, default_libraries is applied to - libraries. - - Parameters - ---------- - packages : list of str - A list of pkg-config packages to look up. - - default_libraries : list of str - A list of library names to use if the pkg-config lookup fails. - - Returns - ------- - config : dict - A dictionary containing keyword arguments to - `distutils.Extension`. These entries include: - - - ``include_dirs``: A list of include directories - - ``library_dirs``: A list of library directories - - ``libraries``: A list of libraries - - ``define_macros``: A list of macro defines - - ``undef_macros``: A list of macros to undefine - - ``extra_compile_args``: A list of extra arguments to pass to - the compiler - """ - - flag_map = {'-I': 'include_dirs', '-L': 'library_dirs', '-l': 'libraries', - '-D': 'define_macros', '-U': 'undef_macros'} - command = "{0} --libs --cflags {1}".format(executable, ' '.join(packages)), - - result = DistutilsExtensionArgs() - - try: - pipe = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE) - output = pipe.communicate()[0].strip() - except subprocess.CalledProcessError as e: - lines = [ - ("{0} failed. This may cause the build to fail below." - .format(executable)), - " command: {0}".format(e.cmd), - " returncode: {0}".format(e.returncode), - " output: {0}".format(e.output) - ] - log.warn('\n'.join(lines)) - result['libraries'].extend(default_libraries) - else: - if pipe.returncode != 0: - lines = [ - "pkg-config could not lookup up package(s) {0}.".format( - ", ".join(packages)), - "This may cause the build to fail below." - ] - log.warn('\n'.join(lines)) - result['libraries'].extend(default_libraries) - else: - for token in output.split(): - # It's not clear what encoding the output of - # pkg-config will come to us in. It will probably be - # some combination of pure ASCII (for the compiler - # flags) and the filesystem encoding (for any argument - # that includes directories or filenames), but this is - # just conjecture, as the pkg-config documentation - # doesn't seem to address it. - arg = token[:2].decode('ascii') - value = token[2:].decode(sys.getfilesystemencoding()) - if arg in flag_map: - if arg == '-D': - value = tuple(value.split('=', 1)) - result[flag_map[arg]].append(value) - else: - result['extra_compile_args'].append(value) - - return result - - -def add_external_library(library): - """ - Add a build option for selecting the internal or system copy of a library. - - Parameters - ---------- - library : str - The name of the library. If the library is `foo`, the build - option will be called `--use-system-foo`. - """ - - for command in ['build', 'build_ext', 'install']: - add_command_option(command, str('use-system-' + library), - 'Use the system {0} library'.format(library), - is_bool=True) - - -def use_system_library(library): - """ - Returns `True` if the build configuration indicates that the given - library should use the system copy of the library rather than the - internal one. - - For the given library `foo`, this will be `True` if - `--use-system-foo` or `--use-system-libraries` was provided at the - commandline or in `setup.cfg`. - - Parameters - ---------- - library : str - The name of the library - - Returns - ------- - use_system : bool - `True` if the build should use the system copy of the library. - """ - return ( - get_distutils_build_or_install_option('use_system_{0}'.format(library)) or - get_distutils_build_or_install_option('use_system_libraries')) - - -@extends_doc(_find_packages) -def find_packages(where='.', exclude=(), invalidate_cache=False): - """ - This version of ``find_packages`` caches previous results to speed up - subsequent calls. Use ``invalide_cache=True`` to ignore cached results - from previous ``find_packages`` calls, and repeat the package search. - """ - - if exclude: - warnings.warn( - "Use of the exclude parameter is no longer supported since it does " - "not work as expected. Use add_exclude_packages instead. Note that " - "it must be called prior to any other calls from setup helpers.", - AstropyDeprecationWarning) - - # Calling add_exclude_packages after this point will have no effect - _module_state['excludes_too_late'] = True - - if not invalidate_cache and _module_state['package_cache'] is not None: - return _module_state['package_cache'] - - packages = _find_packages( - where=where, exclude=list(_module_state['exclude_packages'])) - _module_state['package_cache'] = packages - - return packages - - -class FakeBuildSphinx(Command): - """ - A dummy build_sphinx command that is called if Sphinx is not - installed and displays a relevant error message - """ - - # user options inherited from sphinx.setup_command.BuildDoc - user_options = [ - ('fresh-env', 'E', ''), - ('all-files', 'a', ''), - ('source-dir=', 's', ''), - ('build-dir=', None, ''), - ('config-dir=', 'c', ''), - ('builder=', 'b', ''), - ('project=', None, ''), - ('version=', None, ''), - ('release=', None, ''), - ('today=', None, ''), - ('link-index', 'i', '')] - - # user options appended in astropy.setup_helpers.AstropyBuildSphinx - user_options.append(('warnings-returncode', 'w', '')) - user_options.append(('clean-docs', 'l', '')) - user_options.append(('no-intersphinx', 'n', '')) - user_options.append(('open-docs-in-browser', 'o', '')) - - def initialize_options(self): - try: - raise RuntimeError("Sphinx and its dependencies must be installed " - "for build_docs.") - except: - log.error('error: Sphinx and its dependencies must be installed ' - 'for build_docs.') - sys.exit(1) diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/sphinx/conf.py ginga-3.1.0/astropy_helpers/astropy_helpers/sphinx/conf.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/sphinx/conf.py 2018-12-18 21:35:07.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/sphinx/conf.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -import warnings - -from sphinx_astropy.conf import * - -warnings.warn("Note that astropy_helpers.sphinx.conf is deprecated - use sphinx_astropy.conf instead") diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/utils.py ginga-3.1.0/astropy_helpers/astropy_helpers/utils.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/utils.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/utils.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,312 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst -from __future__ import absolute_import, unicode_literals - -import contextlib -import functools -import imp -import os -import sys -import glob - -from importlib import machinery as import_machinery - - -# Note: The following Warning subclasses are simply copies of the Warnings in -# Astropy of the same names. -class AstropyWarning(Warning): - """ - The base warning class from which all Astropy warnings should inherit. - - Any warning inheriting from this class is handled by the Astropy logger. - """ - - -class AstropyDeprecationWarning(AstropyWarning): - """ - A warning class to indicate a deprecated feature. - """ - - -class AstropyPendingDeprecationWarning(PendingDeprecationWarning, - AstropyWarning): - """ - A warning class to indicate a soon-to-be deprecated feature. - """ - - -def _get_platlib_dir(cmd): - """ - Given a build command, return the name of the appropriate platform-specific - build subdirectory directory (e.g. build/lib.linux-x86_64-2.7) - """ - - plat_specifier = '.{0}-{1}'.format(cmd.plat_name, sys.version[0:3]) - return os.path.join(cmd.build_base, 'lib' + plat_specifier) - - -def get_numpy_include_path(): - """ - Gets the path to the numpy headers. - """ - # We need to go through this nonsense in case setuptools - # downloaded and installed Numpy for us as part of the build or - # install, since Numpy may still think it's in "setup mode", when - # in fact we're ready to use it to build astropy now. - - import builtins - if hasattr(builtins, '__NUMPY_SETUP__'): - del builtins.__NUMPY_SETUP__ - import imp - import numpy - imp.reload(numpy) - - try: - numpy_include = numpy.get_include() - except AttributeError: - numpy_include = numpy.get_numpy_include() - return numpy_include - - -class _DummyFile(object): - """A noop writeable object.""" - - errors = '' - - def write(self, s): - pass - - def flush(self): - pass - - -@contextlib.contextmanager -def silence(): - """A context manager that silences sys.stdout and sys.stderr.""" - - old_stdout = sys.stdout - old_stderr = sys.stderr - sys.stdout = _DummyFile() - sys.stderr = _DummyFile() - exception_occurred = False - try: - yield - except: - exception_occurred = True - # Go ahead and clean up so that exception handling can work normally - sys.stdout = old_stdout - sys.stderr = old_stderr - raise - - if not exception_occurred: - sys.stdout = old_stdout - sys.stderr = old_stderr - - -if sys.platform == 'win32': - import ctypes - - def _has_hidden_attribute(filepath): - """ - Returns True if the given filepath has the hidden attribute on - MS-Windows. Based on a post here: - http://stackoverflow.com/questions/284115/cross-platform-hidden-file-detection - """ - if isinstance(filepath, bytes): - filepath = filepath.decode(sys.getfilesystemencoding()) - try: - attrs = ctypes.windll.kernel32.GetFileAttributesW(filepath) - assert attrs != -1 - result = bool(attrs & 2) - except (AttributeError, AssertionError): - result = False - return result -else: - def _has_hidden_attribute(filepath): - return False - - -def is_path_hidden(filepath): - """ - Determines if a given file or directory is hidden. - - Parameters - ---------- - filepath : str - The path to a file or directory - - Returns - ------- - hidden : bool - Returns `True` if the file is hidden - """ - - name = os.path.basename(os.path.abspath(filepath)) - if isinstance(name, bytes): - is_dotted = name.startswith(b'.') - else: - is_dotted = name.startswith('.') - return is_dotted or _has_hidden_attribute(filepath) - - -def walk_skip_hidden(top, onerror=None, followlinks=False): - """ - A wrapper for `os.walk` that skips hidden files and directories. - - This function does not have the parameter `topdown` from - `os.walk`: the directories must always be recursed top-down when - using this function. - - See also - -------- - os.walk : For a description of the parameters - """ - - for root, dirs, files in os.walk( - top, topdown=True, onerror=onerror, - followlinks=followlinks): - # These lists must be updated in-place so os.walk will skip - # hidden directories - dirs[:] = [d for d in dirs if not is_path_hidden(d)] - files[:] = [f for f in files if not is_path_hidden(f)] - yield root, dirs, files - - -def write_if_different(filename, data): - """Write `data` to `filename`, if the content of the file is different. - - Parameters - ---------- - filename : str - The file name to be written to. - data : bytes - The data to be written to `filename`. - """ - - assert isinstance(data, bytes) - - if os.path.exists(filename): - with open(filename, 'rb') as fd: - original_data = fd.read() - else: - original_data = None - - if original_data != data: - with open(filename, 'wb') as fd: - fd.write(data) - - -def import_file(filename, name=None): - """ - Imports a module from a single file as if it doesn't belong to a - particular package. - - The returned module will have the optional ``name`` if given, or else - a name generated from the filename. - """ - # Specifying a traditional dot-separated fully qualified name here - # results in a number of "Parent module 'astropy' not found while - # handling absolute import" warnings. Using the same name, the - # namespaces of the modules get merged together. So, this - # generates an underscore-separated name which is more likely to - # be unique, and it doesn't really matter because the name isn't - # used directly here anyway. - mode = 'r' - - if name is None: - basename = os.path.splitext(filename)[0] - name = '_'.join(os.path.relpath(basename).split(os.sep)[1:]) - - if not os.path.exists(filename): - raise ImportError('Could not import file {0}'.format(filename)) - - if import_machinery: - loader = import_machinery.SourceFileLoader(name, filename) - mod = loader.load_module() - else: - with open(filename, mode) as fd: - mod = imp.load_module(name, fd, filename, ('.py', mode, 1)) - - return mod - - -def resolve_name(name): - """Resolve a name like ``module.object`` to an object and return it. - - Raise `ImportError` if the module or name is not found. - """ - - parts = name.split('.') - cursor = len(parts) - 1 - module_name = parts[:cursor] - attr_name = parts[-1] - - while cursor > 0: - try: - ret = __import__('.'.join(module_name), fromlist=[attr_name]) - break - except ImportError: - if cursor == 0: - raise - cursor -= 1 - module_name = parts[:cursor] - attr_name = parts[cursor] - ret = '' - - for part in parts[cursor:]: - try: - ret = getattr(ret, part) - except AttributeError: - raise ImportError(name) - - return ret - - -def extends_doc(extended_func): - """ - A function decorator for use when wrapping an existing function but adding - additional functionality. This copies the docstring from the original - function, and appends to it (along with a newline) the docstring of the - wrapper function. - - Examples - -------- - - >>> def foo(): - ... '''Hello.''' - ... - >>> @extends_doc(foo) - ... def bar(): - ... '''Goodbye.''' - ... - >>> print(bar.__doc__) - Hello. - - Goodbye. - - """ - - def decorator(func): - if not (extended_func.__doc__ is None or func.__doc__ is None): - func.__doc__ = '\n\n'.join([extended_func.__doc__.rstrip('\n'), - func.__doc__.lstrip('\n')]) - return func - - return decorator - - -def find_data_files(package, pattern): - """ - Include files matching ``pattern`` inside ``package``. - - Parameters - ---------- - package : str - The package inside which to look for data files - pattern : str - Pattern (glob-style) to match for the data files (e.g. ``*.dat``). - This supports the``**``recursive syntax. For example, ``**/*.fits`` - matches all files ending with ``.fits`` recursively. Only one - instance of ``**`` can be included in the pattern. - """ - - return glob.glob(os.path.join(package, pattern), recursive=True) diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/version_helpers.py ginga-3.1.0/astropy_helpers/astropy_helpers/version_helpers.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/version_helpers.py 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/version_helpers.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,367 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst - -""" -Utilities for generating the version string for Astropy (or an affiliated -package) and the version.py module, which contains version info for the -package. - -Within the generated astropy.version module, the `major`, `minor`, and `bugfix` -variables hold the respective parts of the version number (bugfix is '0' if -absent). The `release` variable is True if this is a release, and False if this -is a development version of astropy. For the actual version string, use:: - - from astropy.version import version - -or:: - - from astropy import __version__ - -""" - -from __future__ import division - -import datetime -import os -import pkgutil -import sys -import time -import warnings - -from distutils import log -from configparser import ConfigParser - -import pkg_resources - -from . import git_helpers -from .distutils_helpers import is_distutils_display_option -from .git_helpers import get_git_devstr -from .utils import AstropyDeprecationWarning, import_file - -__all__ = ['generate_version_py'] - - -def _version_split(version): - """ - Split a version string into major, minor, and bugfix numbers. If any of - those numbers are missing the default is zero. Any pre/post release - modifiers are ignored. - - Examples - ======== - >>> _version_split('1.2.3') - (1, 2, 3) - >>> _version_split('1.2') - (1, 2, 0) - >>> _version_split('1.2rc1') - (1, 2, 0) - >>> _version_split('1') - (1, 0, 0) - >>> _version_split('') - (0, 0, 0) - """ - - parsed_version = pkg_resources.parse_version(version) - - if hasattr(parsed_version, 'base_version'): - # New version parsing for setuptools >= 8.0 - if parsed_version.base_version: - parts = [int(part) - for part in parsed_version.base_version.split('.')] - else: - parts = [] - else: - parts = [] - for part in parsed_version: - if part.startswith('*'): - # Ignore any .dev, a, b, rc, etc. - break - parts.append(int(part)) - - if len(parts) < 3: - parts += [0] * (3 - len(parts)) - - # In principle a version could have more parts (like 1.2.3.4) but we only - # support .. - return tuple(parts[:3]) - - -# This is used by setup.py to create a new version.py - see that file for -# details. Note that the imports have to be absolute, since this is also used -# by affiliated packages. -_FROZEN_VERSION_PY_TEMPLATE = """ -# Autogenerated by {packagetitle}'s setup.py on {timestamp!s} UTC -from __future__ import unicode_literals -import datetime - -{header} - -major = {major} -minor = {minor} -bugfix = {bugfix} - -version_info = (major, minor, bugfix) - -release = {rel} -timestamp = {timestamp!r} -debug = {debug} - -astropy_helpers_version = "{ahver}" -"""[1:] - - -_FROZEN_VERSION_PY_WITH_GIT_HEADER = """ -{git_helpers} - - -_packagename = "{packagename}" -_last_generated_version = "{verstr}" -_last_githash = "{githash}" - -# Determine where the source code for this module -# lives. If __file__ is not a filesystem path then -# it is assumed not to live in a git repo at all. -if _get_repo_path(__file__, levels=len(_packagename.split('.'))): - version = update_git_devstr(_last_generated_version, path=__file__) - githash = get_git_devstr(sha=True, show_warning=False, - path=__file__) or _last_githash -else: - # The file does not appear to live in a git repo so don't bother - # invoking git - version = _last_generated_version - githash = _last_githash -"""[1:] - - -_FROZEN_VERSION_PY_STATIC_HEADER = """ -version = "{verstr}" -githash = "{githash}" -"""[1:] - - -def _get_version_py_str(packagename, version, githash, release, debug, - uses_git=True): - try: - from astropy_helpers import __version__ as ahver - except ImportError: - ahver = "unknown" - - epoch = int(os.environ.get('SOURCE_DATE_EPOCH', time.time())) - timestamp = datetime.datetime.utcfromtimestamp(epoch) - major, minor, bugfix = _version_split(version) - - if packagename.lower() == 'astropy': - packagetitle = 'Astropy' - else: - packagetitle = 'Astropy-affiliated package ' + packagename - - header = '' - - if uses_git: - header = _generate_git_header(packagename, version, githash) - elif not githash: - # _generate_git_header will already generate a new git has for us, but - # for creating a new version.py for a release (even if uses_git=False) - # we still need to get the githash to include in the version.py - # See https://github.com/astropy/astropy-helpers/issues/141 - githash = git_helpers.get_git_devstr(sha=True, show_warning=True) - - if not header: # If _generate_git_header fails it returns an empty string - header = _FROZEN_VERSION_PY_STATIC_HEADER.format(verstr=version, - githash=githash) - - return _FROZEN_VERSION_PY_TEMPLATE.format(packagetitle=packagetitle, - timestamp=timestamp, - header=header, - major=major, - minor=minor, - bugfix=bugfix, - ahver=ahver, - rel=release, debug=debug) - - -def _generate_git_header(packagename, version, githash): - """ - Generates a header to the version.py module that includes utilities for - probing the git repository for updates (to the current git hash, etc.) - These utilities should only be available in development versions, and not - in release builds. - - If this fails for any reason an empty string is returned. - """ - - loader = pkgutil.get_loader(git_helpers) - source = loader.get_source(git_helpers.__name__) or '' - source_lines = source.splitlines() - if not source_lines: - log.warn('Cannot get source code for astropy_helpers.git_helpers; ' - 'git support disabled.') - return '' - - idx = 0 - for idx, line in enumerate(source_lines): - if line.startswith('# BEGIN'): - break - git_helpers_py = '\n'.join(source_lines[idx + 1:]) - - verstr = version - - new_githash = git_helpers.get_git_devstr(sha=True, show_warning=False) - - if new_githash: - githash = new_githash - - return _FROZEN_VERSION_PY_WITH_GIT_HEADER.format( - git_helpers=git_helpers_py, packagename=packagename, - verstr=verstr, githash=githash) - - -def generate_version_py(packagename=None, version=None, release=None, debug=None, - uses_git=None, srcdir='.'): - """ - Generate a version.py file in the package with version information, and - update developer version strings. - - This function should normally be called without any arguments. In this case - the package name and version is read in from the ``setup.cfg`` file (from - the ``name`` or ``package_name`` entry and the ``version`` entry in the - ``[metadata]`` section). - - If the version is a developer version (of the form ``3.2.dev``), the - version string will automatically be expanded to include a sequential - number as a suffix (e.g. ``3.2.dev13312``), and the updated version string - will be returned by this function. - - Based on this updated version string, a ``version.py`` file will be - generated inside the package, containing the version string as well as more - detailed information (for example the major, minor, and bugfix version - numbers, a ``release`` flag indicating whether the current version is a - stable or developer version, and so on. - """ - - if packagename is not None: - warnings.warn('The packagename argument to generate_version_py has ' - 'been deprecated and will be removed in future. Specify ' - 'the package name in setup.cfg instead', AstropyDeprecationWarning) - - if version is not None: - warnings.warn('The version argument to generate_version_py has ' - 'been deprecated and will be removed in future. Specify ' - 'the version number in setup.cfg instead', AstropyDeprecationWarning) - - if release is not None: - warnings.warn('The release argument to generate_version_py has ' - 'been deprecated and will be removed in future. We now ' - 'use the presence of the "dev" string in the version to ' - 'determine whether this is a release', AstropyDeprecationWarning) - - # We use ConfigParser instead of read_configuration here because the latter - # only reads in keys recognized by setuptools, but we need to access - # package_name below. - conf = ConfigParser() - conf.read('setup.cfg') - - if conf.has_option('metadata', 'name'): - packagename = conf.get('metadata', 'name') - elif conf.has_option('metadata', 'package_name'): - # The package-template used package_name instead of name for a while - warnings.warn('Specifying the package name using the "package_name" ' - 'option in setup.cfg is deprecated - use the "name" ' - 'option instead.', AstropyDeprecationWarning) - packagename = conf.get('metadata', 'package_name') - elif packagename is not None: # deprecated - pass - else: - sys.stderr.write('ERROR: Could not read package name from setup.cfg\n') - sys.exit(1) - - if conf.has_option('metadata', 'version'): - version = conf.get('metadata', 'version') - add_git_devstr = True - elif version is not None: # deprecated - add_git_devstr = False - else: - sys.stderr.write('ERROR: Could not read package version from setup.cfg\n') - sys.exit(1) - - if release is None: - release = 'dev' not in version - - if not release and add_git_devstr: - version += get_git_devstr(False) - - if uses_git is None: - uses_git = not release - - # In some cases, packages have a - but this is a _ in the module. Since we - # are only interested in the module here, we replace - by _ - packagename = packagename.replace('-', '_') - - try: - version_module = get_pkg_version_module(packagename) - - try: - last_generated_version = version_module._last_generated_version - except AttributeError: - last_generated_version = version_module.version - - try: - last_githash = version_module._last_githash - except AttributeError: - last_githash = version_module.githash - - current_release = version_module.release - current_debug = version_module.debug - except ImportError: - version_module = None - last_generated_version = None - last_githash = None - current_release = None - current_debug = None - - if release is None: - # Keep whatever the current value is, if it exists - release = bool(current_release) - - if debug is None: - # Likewise, keep whatever the current value is, if it exists - debug = bool(current_debug) - - package_srcdir = os.path.join(srcdir, *packagename.split('.')) - version_py = os.path.join(package_srcdir, 'version.py') - - if (last_generated_version != version or current_release != release or - current_debug != debug): - if '-q' not in sys.argv and '--quiet' not in sys.argv: - log.set_threshold(log.INFO) - - if is_distutils_display_option(): - # Always silence unnecessary log messages when display options are - # being used - log.set_threshold(log.WARN) - - log.info('Freezing version number to {0}'.format(version_py)) - - with open(version_py, 'w') as f: - # This overwrites the actual version.py - f.write(_get_version_py_str(packagename, version, last_githash, - release, debug, uses_git=uses_git)) - - return version - - -def get_pkg_version_module(packagename, fromlist=None): - """Returns the package's .version module generated by - `astropy_helpers.version_helpers.generate_version_py`. Raises an - ImportError if the version module is not found. - - If ``fromlist`` is an iterable, return a tuple of the members of the - version module corresponding to the member names given in ``fromlist``. - Raises an `AttributeError` if any of these module members are not found. - """ - - version = import_file(os.path.join(packagename, 'version.py'), name='version') - - if fromlist: - return tuple(getattr(version, member) for member in fromlist) - else: - return version diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers/version.py ginga-3.1.0/astropy_helpers/astropy_helpers/version.py --- ginga-3.0.0/astropy_helpers/astropy_helpers/version.py 2016-07-29 04:58:02.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers/version.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,217 +0,0 @@ -# Autogenerated by Astropy-affiliated package astropy_helpers's setup.py on 2016-07-28 18:58:01.998874 -from __future__ import unicode_literals -import datetime - - -import locale -import os -import subprocess -import warnings - - -def _decode_stdio(stream): - try: - stdio_encoding = locale.getdefaultlocale()[1] or 'utf-8' - except ValueError: - stdio_encoding = 'utf-8' - - try: - text = stream.decode(stdio_encoding) - except UnicodeDecodeError: - # Final fallback - text = stream.decode('latin1') - - return text - - -def update_git_devstr(version, path=None): - """ - Updates the git revision string if and only if the path is being imported - directly from a git working copy. This ensures that the revision number in - the version string is accurate. - """ - - try: - # Quick way to determine if we're in git or not - returns '' if not - devstr = get_git_devstr(sha=True, show_warning=False, path=path) - except OSError: - return version - - if not devstr: - # Probably not in git so just pass silently - return version - - if 'dev' in version: # update to the current git revision - version_base = version.split('.dev', 1)[0] - devstr = get_git_devstr(sha=False, show_warning=False, path=path) - - return version_base + '.dev' + devstr - else: - #otherwise it's already the true/release version - return version - - -def get_git_devstr(sha=False, show_warning=True, path=None): - """ - Determines the number of revisions in this repository. - - Parameters - ---------- - sha : bool - If True, the full SHA1 hash will be returned. Otherwise, the total - count of commits in the repository will be used as a "revision - number". - - show_warning : bool - If True, issue a warning if git returns an error code, otherwise errors - pass silently. - - path : str or None - If a string, specifies the directory to look in to find the git - repository. If `None`, the current working directory is used, and must - be the root of the git repository. - If given a filename it uses the directory containing that file. - - Returns - ------- - devversion : str - Either a string with the revision number (if `sha` is False), the - SHA1 hash of the current commit (if `sha` is True), or an empty string - if git version info could not be identified. - - """ - - if path is None: - path = os.getcwd() - if not _get_repo_path(path, levels=0): - return '' - - if not os.path.isdir(path): - path = os.path.abspath(os.path.dirname(path)) - - if sha: - # Faster for getting just the hash of HEAD - cmd = ['rev-parse', 'HEAD'] - else: - cmd = ['rev-list', '--count', 'HEAD'] - - def run_git(cmd): - try: - p = subprocess.Popen(['git'] + cmd, cwd=path, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=subprocess.PIPE) - stdout, stderr = p.communicate() - except OSError as e: - if show_warning: - warnings.warn('Error running git: ' + str(e)) - return (None, b'', b'') - - if p.returncode == 128: - if show_warning: - warnings.warn('No git repository present at {0!r}! Using ' - 'default dev version.'.format(path)) - return (p.returncode, b'', b'') - if p.returncode == 129: - if show_warning: - warnings.warn('Your git looks old (does it support {0}?); ' - 'consider upgrading to v1.7.2 or ' - 'later.'.format(cmd[0])) - return (p.returncode, stdout, stderr) - elif p.returncode != 0: - if show_warning: - warnings.warn('Git failed while determining revision ' - 'count: {0}'.format(_decode_stdio(stderr))) - return (p.returncode, stdout, stderr) - - return p.returncode, stdout, stderr - - returncode, stdout, stderr = run_git(cmd) - - if not sha and returncode == 129: - # git returns 129 if a command option failed to parse; in - # particular this could happen in git versions older than 1.7.2 - # where the --count option is not supported - # Also use --abbrev-commit and --abbrev=0 to display the minimum - # number of characters needed per-commit (rather than the full hash) - cmd = ['rev-list', '--abbrev-commit', '--abbrev=0', 'HEAD'] - returncode, stdout, stderr = run_git(cmd) - # Fall back on the old method of getting all revisions and counting - # the lines - if returncode == 0: - return str(stdout.count(b'\n')) - else: - return '' - elif sha: - return _decode_stdio(stdout)[:40] - else: - return _decode_stdio(stdout).strip() - - -def _get_repo_path(pathname, levels=None): - """ - Given a file or directory name, determine the root of the git repository - this path is under. If given, this won't look any higher than ``levels`` - (that is, if ``levels=0`` then the given path must be the root of the git - repository and is returned if so. - - Returns `None` if the given path could not be determined to belong to a git - repo. - """ - - if os.path.isfile(pathname): - current_dir = os.path.abspath(os.path.dirname(pathname)) - elif os.path.isdir(pathname): - current_dir = os.path.abspath(pathname) - else: - return None - - current_level = 0 - - while levels is None or current_level <= levels: - if os.path.exists(os.path.join(current_dir, '.git')): - return current_dir - - current_level += 1 - if current_dir == os.path.dirname(current_dir): - break - - current_dir = os.path.dirname(current_dir) - - return None - -_packagename = "astropy_helpers" -_last_generated_version = "1.2.dev" -_last_githash = "111b1e5d1d05e2dd9a73d13862327ac6f854be8a" - -# Determine where the source code for this module -# lives. If __file__ is not a filesystem path then -# it is assumed not to live in a git repo at all. -if _get_repo_path(__file__, levels=len(_packagename.split('.'))): - version = update_git_devstr(_last_generated_version, path=__file__) - githash = get_git_devstr(sha=True, show_warning=False, - path=__file__) or _last_githash -else: - # The file does not appear to live in a git repo so don't bother - # invoking git - version = _last_generated_version - githash = _last_githash - - -major = 1 -minor = 2 -bugfix = 0 - -release = False -timestamp = datetime.datetime(2016, 7, 28, 18, 58, 1, 998874) -debug = False - -try: - from ._compiler import compiler -except ImportError: - compiler = "unknown" - -try: - from .cython_version import cython_version -except ImportError: - cython_version = "unknown" diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/dependency_links.txt ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/dependency_links.txt --- ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/dependency_links.txt 2016-07-29 04:58:02.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/dependency_links.txt 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ - diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/not-zip-safe ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/not-zip-safe --- ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/not-zip-safe 2016-07-29 04:58:02.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/not-zip-safe 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ - diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/PKG-INFO ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/PKG-INFO --- ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/PKG-INFO 2016-07-29 04:58:02.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/PKG-INFO 1970-01-01 00:00:00.000000000 +0000 @@ -1,55 +0,0 @@ -Metadata-Version: 1.1 -Name: astropy-helpers -Version: 1.2.dev591 -Summary: Utilities for building and installing Astropy, Astropy affiliated packages, and their respective documentation. -Home-page: http://astropy.org -Author: The Astropy Developers -Author-email: astropy.team@gmail.com -License: BSD -Download-URL: http://pypi.python.org/packages/source/a/astropy-helpers/astropy-helpers-1.2.dev591.tar.gz -Description: astropy-helpers - =============== - - This project provides a Python package, ``astropy_helpers``, which includes - many build, installation, and documentation-related tools used by the Astropy - project, but packaged separately for use by other projects that wish to - leverage this work. The motivation behind this package and details of its - implementation are in the accepted - `Astropy Proposal for Enhancement (APE) 4 `_. - - ``astropy_helpers`` includes a special "bootstrap" module called - ``ah_bootstrap.py`` which is intended to be used by a project's setup.py in - order to ensure that the ``astropy_helpers`` package is available for - build/installation. This is similar to the ``ez_setup.py`` module that is - shipped with some projects to bootstrap `setuptools - `_. - - As described in APE4, the version numbers for ``astropy_helpers`` follow the - corresponding major/minor version of the `astropy core package - `_, but with an independent sequence of micro (bugfix) - version numbers. Hence, the initial release is 0.4, in parallel with Astropy - v0.4, which will be the first version of Astropy to use ``astropy-helpers``. - - For examples of how to implement ``astropy-helpers`` in a project, - see the ``setup.py`` and ``setup.cfg`` files of the - `Affiliated package template `_. - - .. image:: https://travis-ci.org/astropy/astropy-helpers.png - :target: https://travis-ci.org/astropy/astropy-helpers - - .. image:: https://coveralls.io/repos/astropy/astropy-helpers/badge.png - :target: https://coveralls.io/r/astropy/astropy-helpers - -Platform: UNKNOWN -Classifier: Development Status :: 5 - Production/Stable -Classifier: Intended Audience :: Developers -Classifier: Framework :: Setuptools Plugin -Classifier: Framework :: Sphinx :: Extension -Classifier: Framework :: Sphinx :: Theme -Classifier: License :: OSI Approved :: BSD License -Classifier: Operating System :: OS Independent -Classifier: Programming Language :: Python -Classifier: Programming Language :: Python :: 3 -Classifier: Topic :: Software Development :: Build Tools -Classifier: Topic :: Software Development :: Libraries :: Python Modules -Classifier: Topic :: System :: Archiving :: Packaging diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/SOURCES.txt ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/SOURCES.txt --- ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/SOURCES.txt 2016-07-29 04:58:02.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/SOURCES.txt 1970-01-01 00:00:00.000000000 +0000 @@ -1,83 +0,0 @@ -CHANGES.rst -LICENSE.rst -MANIFEST.in -README.rst -ah_bootstrap.py -ez_setup.py -setup.cfg -setup.py -astropy_helpers/__init__.py -astropy_helpers/distutils_helpers.py -astropy_helpers/git_helpers.py -astropy_helpers/setup_helpers.py -astropy_helpers/test_helpers.py -astropy_helpers/utils.py -astropy_helpers/version.py -astropy_helpers/version_helpers.py -astropy_helpers.egg-info/PKG-INFO -astropy_helpers.egg-info/SOURCES.txt -astropy_helpers.egg-info/dependency_links.txt -astropy_helpers.egg-info/not-zip-safe -astropy_helpers.egg-info/top_level.txt -astropy_helpers/commands/__init__.py -astropy_helpers/commands/_dummy.py -astropy_helpers/commands/_test_compat.py -astropy_helpers/commands/build_ext.py -astropy_helpers/commands/build_py.py -astropy_helpers/commands/build_sphinx.py -astropy_helpers/commands/install.py -astropy_helpers/commands/install_lib.py -astropy_helpers/commands/register.py -astropy_helpers/commands/setup_package.py -astropy_helpers/commands/test.py -astropy_helpers/commands/src/compiler.c -astropy_helpers/compat/__init__.py -astropy_helpers/compat/subprocess.py -astropy_helpers/sphinx/__init__.py -astropy_helpers/sphinx/conf.py -astropy_helpers/sphinx/setup_package.py -astropy_helpers/sphinx/ext/__init__.py -astropy_helpers/sphinx/ext/astropyautosummary.py -astropy_helpers/sphinx/ext/autodoc_enhancements.py -astropy_helpers/sphinx/ext/automodapi.py -astropy_helpers/sphinx/ext/automodsumm.py -astropy_helpers/sphinx/ext/changelog_links.py -astropy_helpers/sphinx/ext/comment_eater.py -astropy_helpers/sphinx/ext/compiler_unparse.py -astropy_helpers/sphinx/ext/docscrape.py -astropy_helpers/sphinx/ext/docscrape_sphinx.py -astropy_helpers/sphinx/ext/doctest.py -astropy_helpers/sphinx/ext/edit_on_github.py -astropy_helpers/sphinx/ext/numpydoc.py -astropy_helpers/sphinx/ext/phantom_import.py -astropy_helpers/sphinx/ext/smart_resolver.py -astropy_helpers/sphinx/ext/tocdepthfix.py -astropy_helpers/sphinx/ext/traitsdoc.py -astropy_helpers/sphinx/ext/utils.py -astropy_helpers/sphinx/ext/viewcode.py -astropy_helpers/sphinx/ext/templates/autosummary_core/base.rst -astropy_helpers/sphinx/ext/templates/autosummary_core/class.rst -astropy_helpers/sphinx/ext/templates/autosummary_core/module.rst -astropy_helpers/sphinx/ext/tests/__init__.py -astropy_helpers/sphinx/ext/tests/test_autodoc_enhancements.py -astropy_helpers/sphinx/ext/tests/test_automodapi.py -astropy_helpers/sphinx/ext/tests/test_automodsumm.py -astropy_helpers/sphinx/ext/tests/test_docscrape.py -astropy_helpers/sphinx/ext/tests/test_utils.py -astropy_helpers/sphinx/local/python2_local_links.inv -astropy_helpers/sphinx/local/python3_local_links.inv -astropy_helpers/sphinx/themes/bootstrap-astropy/globaltoc.html -astropy_helpers/sphinx/themes/bootstrap-astropy/layout.html -astropy_helpers/sphinx/themes/bootstrap-astropy/localtoc.html -astropy_helpers/sphinx/themes/bootstrap-astropy/searchbox.html -astropy_helpers/sphinx/themes/bootstrap-astropy/theme.conf -astropy_helpers/sphinx/themes/bootstrap-astropy/static/astropy_linkout.svg -astropy_helpers/sphinx/themes/bootstrap-astropy/static/astropy_linkout_20.png -astropy_helpers/sphinx/themes/bootstrap-astropy/static/astropy_logo.ico -astropy_helpers/sphinx/themes/bootstrap-astropy/static/astropy_logo.svg -astropy_helpers/sphinx/themes/bootstrap-astropy/static/astropy_logo_32.png -astropy_helpers/sphinx/themes/bootstrap-astropy/static/bootstrap-astropy.css -astropy_helpers/sphinx/themes/bootstrap-astropy/static/copybutton.js -astropy_helpers/sphinx/themes/bootstrap-astropy/static/sidebar.js -licenses/LICENSE_COPYBUTTON.rst -licenses/LICENSE_NUMPYDOC.rst \ No newline at end of file diff -Nru ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/top_level.txt ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/top_level.txt --- ginga-3.0.0/astropy_helpers/astropy_helpers.egg-info/top_level.txt 2016-07-29 04:58:02.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/astropy_helpers.egg-info/top_level.txt 1970-01-01 00:00:00.000000000 +0000 @@ -1 +0,0 @@ -astropy_helpers diff -Nru ginga-3.0.0/astropy_helpers/CHANGES.rst ginga-3.1.0/astropy_helpers/CHANGES.rst --- ginga-3.0.0/astropy_helpers/CHANGES.rst 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/CHANGES.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,628 +0,0 @@ -astropy-helpers Changelog -************************* - - -3.2.1 (2019-06-13) ------------------- - -- Reverting issuing deprecation warning for the ``build_sphinx`` command. [#482] - -- Make sure that all data files get included in tar file releases. [#485] - - -3.2 (2019-05-29) ----------------- - -- Make sure that ``[options.package_data]`` in setup.cfg is taken into account - when collecting package data. [#453] - -- Simplified the code for the custom build_ext command. [#446] - -- Avoid importing the astropy package when trying to get the test command - when testing astropy itself. [#450] - -- Avoid importing whole package when trying to get version information. Note - that this has also introduced a small API change - ``cython_version`` and - ``compiler`` can no longer be imported from the ``package.version`` module - generated by astropy-helpers. Instead, you can import these from - ``package.cython_version`` and ``package.compiler_version`` respectively. [#442] - -- Make it possible to call ``generate_version_py`` and ``register_commands`` - without any arguments, which causes information to be read in from the - ``setup.cfg`` file. [#440] - -- Simplified setup.py and moved most of the configuration to setup.cfg. [#445] - -- Add a new ``astropy_helpers.setup_helpers.setup`` function that does all - the default boilerplate in typical ``setup.py`` files that use - astropy-helpers. [#443] - -- Remove ``deprecated``, ``deprecated_attribute``, and ``minversion`` from - ``astropy_helpers.utils``. [#447] - -- Updated minimum required version of setuptools to 30.3.0. [#440] - -- Remove functionality to adjust compilers if a broken compiler is detected. - This is not useful anymore as only a single compiler was previously patched - (now unlikely to be used) and this was only to fix a compilation issue in the - core astropy package. [#421] - -- ``sphinx-astropy`` is now a required dependency to build the docs, the - machinery to install it as eggs have been removed. [#474] - - -3.1.1 (2019-02-22) ------------------- - -- Moved documentation from README to Sphinx. [#444] - -- Fixed broken OpenMP detection when building with ``-coverage``. [#434] - - -3.1 (2018-12-04) ----------------- - -- Added extensive documentation about astropy-helpers to the README.rst file. [#416] - -- Fixed the compatibility of the build_docs command with Sphinx 1.8 and above. [#413] - -- Removing deprecated test_helpers.py file. [#369] - -- Removing ez_setup.py file and requiring setuptools 1.0 or later. [#384] - -- Remove all sphinx components from ``astropy-helpers``. These are now replaced - by the ``sphinx-astropy`` package in conjunction with the ``astropy-theme-sphinx``, - ``sphinx-automodapi``, and ``numpydoc`` packages. [#368] - -- openmp_helpers.py: Make add_openmp_flags_if_available() work for clang. - The necessary include, library, and runtime paths now get added to the C test code - used to determine if openmp works. - Autogenerator utility added ``openmp_enabled.is_openmp_enabled()`` - which can be called post build to determine state of OpenMP support. - [#382] - -- Add version_info tuple to autogenerated version.py. Allows for simple - version checking, i.e. version_info > (2,0,1). [#385] - - -3.0.2 (2018-06-01) ------------------- - -- Nothing changed. - - -3.0.1 (2018-02-22) ------------------- - -- Nothing changed. - - -3.0 (2018-02-09) ----------------- - -- Removing Python 2 support, including 2to3. Packages wishing to keep Python - 2 support should NOT update to this version. [#340] - -- Removing deprecated _test_compat making astropy a hard dependency for - packages wishing to use the astropy tests machinery. [#314] - -- Removing unused 'register' command since packages should be uploaded - with twine and get registered automatically. [#332] - - -2.0.10 (2019-05-29) -------------------- - -- Removed ``tocdepthfix`` sphinx extension that worked around a big in - Sphinx that has been long fixed. [#475] - -- Allow Python dev versions to pass the python version check. [#476] - -- Updated bundled version of sphinx-automodapi to v0.11. [#478] - - -2.0.9 (2019-02-22) ------------------- - -- Updated bundled version of sphinx-automodapi to v0.10. [#439] - -- Updated bundled sphinx extensions version to sphinx-astropy v1.1.1. [#454] - -- Include package name in error message for Python version in - ``ah_bootstrap.py``. [#441] - - -2.0.8 (2018-12-04) ------------------- - -- Fixed compatibility with Sphinx 1.8+. [#428] - -- Fixed error that occurs when installing a package in an environment where - ``numpy`` is not already installed. [#404] - -- Updated bundled version of sphinx-automodapi to v0.9. [#422] - -- Updated bundled version of numpydoc to v0.8.0. [#423] - - -2.0.7 (2018-06-01) ------------------- - -- Removing ez_setup.py file and requiring setuptools 1.0 or later. [#384] - - -2.0.6 (2018-02-24) ------------------- - -- Avoid deprecation warning due to ``exclude=`` keyword in ``setup.py``. [#379] - - -2.0.5 (2018-02-22) ------------------- - -- Fix segmentation faults that occurred when the astropy-helpers submodule - was first initialized in packages that also contained Cython code. [#375] - - -2.0.4 (2018-02-09) ------------------- - -- Support dotted package names as namespace packages in generate_version_py. - [#370] - -- Fix compatibility with setuptools 36.x and above. [#372] - -- Fix false negative in add_openmp_flags_if_available when measuring code - coverage with gcc. [#374] - - -2.0.3 (2018-01-20) ------------------- - -- Make sure that astropy-helpers 3.x.x is not downloaded on Python 2. [#362, #363] - -- The bundled version of sphinx-automodapi has been updated to v0.7. [#365] - -- Add --auto-use and --no-auto-use command-line flags to match the - ``auto_use`` configuration option, and add an alias - ``--use-system-astropy-helpers`` for ``--no-auto-use``. [#366] - - -2.0.2 (2017-10-13) ------------------- - -- Added new helper function add_openmp_flags_if_available that can add - OpenMP compilation flags to a C/Cython extension if needed. [#346] - -- Update numpydoc to v0.7. [#343] - -- The function ``get_git_devstr`` now returns ``'0'`` instead of ``None`` when - no git repository is present. This allows generation of development version - strings that are in a format that ``setuptools`` expects (e.g. "1.1.3.dev0" - instead of "1.1.3.dev"). [#330] - -- It is now possible to override generated timestamps to make builds - reproducible by setting the ``SOURCE_DATE_EPOCH`` environment variable [#341] - -- Mark Sphinx extensions as parallel-safe. [#344] - -- Switch to using mathjax instead of imgmath for local builds. [#342] - -- Deprecate ``exclude`` parameter of various functions in setup_helpers since - it could not work as intended. Add new function ``add_exclude_packages`` to - provide intended behavior. [#331] - -- Allow custom Sphinx doctest extension to recognize and process standard - doctest directives ``testsetup`` and ``doctest``. [#335] - - -2.0.1 (2017-07-28) ------------------- - -- Fix compatibility with Sphinx <1.5. [#326] - - -2.0 (2017-07-06) ----------------- - -- Add support for package that lies in a subdirectory. [#249] - -- Removing ``compat.subprocess``. [#298] - -- Python 3.3 is no longer supported. [#300] - -- The 'automodapi' Sphinx extension (and associated dependencies) has now - been moved to a standalone package which can be found at - https://github.com/astropy/sphinx-automodapi - this is now bundled in - astropy-helpers under astropy_helpers.extern.automodapi for - convenience. Version shipped with astropy-helpers is v0.6. - [#278, #303, #309, #323] - -- The ``numpydoc`` Sphinx extension has now been moved to - ``astropy_helpers.extern``. [#278] - -- Fix ``build_docs`` error catching, so it doesn't hide Sphinx errors. [#292] - -- Fix compatibility with Sphinx 1.6. [#318] - -- Updating ez_setup.py to the last version before it's removal. [#321] - - -1.3.1 (2017-03-18) ------------------- - -- Fixed the missing button to hide output in documentation code - blocks. [#287] - -- Fixed bug when ``build_docs`` when running with the clean (-l) option. [#289] - -- Add alternative location for various intersphinx inventories to fall back - to. [#293] - - -1.3 (2016-12-16) ----------------- - -- ``build_sphinx`` has been deprecated in favor of the ``build_docs`` command. - [#246] - -- Force the use of Cython's old ``build_ext`` command. A new ``build_ext`` - command was added in Cython 0.25, but it does not work with astropy-helpers - currently. [#261] - - -1.2 (2016-06-18) ----------------- - -- Added sphinx configuration value ``automodsumm_inherited_members``. - If ``True`` this will include members that are inherited from a base - class in the generated API docs. Defaults to ``False`` which matches - the previous behavior. [#215] - -- Fixed ``build_sphinx`` to recognize builds that succeeded but have output - *after* the "build succeeded." statement. This only applies when - ``--warnings-returncode`` is given (which is primarily relevant for Travis - documentation builds). [#223] - -- Fixed ``build_sphinx`` the sphinx extensions to not output a spurious warning - for sphinx versions > 1.4. [#229] - -- Add Python version dependent local sphinx inventories that contain - otherwise missing references. [#216] - -- ``astropy_helpers`` now require Sphinx 1.3 or later. [#226] - - -1.1.2 (2016-03-9) ------------------ - -- The CSS for the sphinx documentation was altered to prevent some text overflow - problems. [#217] - - -1.1.1 (2015-12-23) ------------------- - -- Fixed crash in build with ``AttributeError: cython_create_listing`` with - older versions of setuptools. [#209, #210] - - -1.1 (2015-12-10) ----------------- - -- The original ``AstropyTest`` class in ``astropy_helpers``, which implements - the ``setup.py test`` command, is deprecated in favor of moving the - implementation of that command closer to the actual Astropy test runner in - ``astropy.tests``. Now a dummy ``test`` command is provided solely for - informing users that they need ``astropy`` installed to run the tests - (however, the previous, now deprecated implementation is still provided and - continues to work with older versions of Astropy). See the related issue for - more details. [#184] - -- Added a useful new utility function to ``astropy_helpers.utils`` called - ``find_data_files``. This is similar to the ``find_packages`` function in - setuptools in that it can be used to search a package for data files - (matching a pattern) that can be passed to the ``package_data`` argument for - ``setup()``. See the docstring to ``astropy_helpers.utils.find_data_files`` - for more details. [#42] - -- The ``astropy_helpers`` module now sets the global ``_ASTROPY_SETUP_`` - flag upon import (from within a ``setup.py``) script, so it's not necessary - to have this in the ``setup.py`` script explicitly. If in doubt though, - there's no harm in setting it twice. Putting it in ``astropy_helpers`` - just ensures that any other imports that occur during build will have this - flag set. [#191] - -- It is now possible to use Cython as a ``setup_requires`` build requirement, - and still build Cython extensions even if Cython wasn't available at the - beginning of the build processes (that is, is automatically downloaded via - setuptools' processing of ``setup_requires``). [#185] - -- Moves the ``adjust_compiler`` check into the ``build_ext`` command itself, - so it's only used when actually building extension modules. This also - deprecates the stand-alone ``adjust_compiler`` function. [#76] - -- When running the ``build_sphinx`` / ``build_docs`` command with the ``-w`` - option, the output from Sphinx is streamed as it runs instead of silently - buffering until the doc build is complete. [#197] - -1.0.7 (unreleased) ------------------- - -- Fix missing import in ``astropy_helpers/utils.py``. [#196] - -1.0.6 (2015-12-04) ------------------- - -- Fixed bug where running ``./setup.py build_sphinx`` could return successfully - even when the build was not successful (and should have returned a non-zero - error code). [#199] - - -1.0.5 (2015-10-02) ------------------- - -- Fixed a regression in the ``./setup.py test`` command that was introduced in - v1.0.4. - - -1.0.4 (2015-10-02) ------------------- - -- Fixed issue with the sphinx documentation css where the line numbers for code - blocks were not aligned with the code. [#179, #180] - -- Fixed crash that could occur when trying to build Cython extension modules - when Cython isn't installed. Normally this still results in a failed build, - but was supposed to provide a useful error message rather than crash - outright (this was a regression introduced in v1.0.3). [#181] - -- Fixed a crash that could occur on Python 3 when a working C compiler isn't - found. [#182] - -- Quieted warnings about deprecated Numpy API in Cython extensions, when - building Cython extensions against Numpy >= 1.7. [#183, #186] - -- Improved support for py.test >= 2.7--running the ``./setup.py test`` command - now copies all doc pages into the temporary test directory as well, so that - all test files have a "common root directory". [#189, #190] - - -1.0.3 (2015-07-22) ------------------- - -- Added workaround for sphinx-doc/sphinx#1843, a but in Sphinx which - prevented descriptor classes with a custom metaclass from being documented - correctly. [#158] - -- Added an alias for the ``./setup.py build_sphinx`` command as - ``./setup.py build_docs`` which, to a new contributor, should hopefully be - less cryptic. [#161] - -- The fonts in graphviz diagrams now match the font of the HTML content. [#169] - -- When the documentation is built on readthedocs.org, MathJax will be - used for math rendering. When built elsewhere, the "pngmath" - extension is still used for math rendering. [#170] - -- Fix crash when importing astropy_helpers when running with ``python -OO`` - [#171] - -- The ``build`` and ``build_ext`` stages now correctly recognize the presence - of C++ files in Cython extensions (previously only vanilla C worked). [#173] - - -1.0.2 (2015-04-02) ------------------- - -- Various fixes enabling the astropy-helpers Sphinx build command and - Sphinx extensions to work with Sphinx 1.3. [#148] - -- More improvement to the ability to handle multiple versions of - astropy-helpers being imported in the same Python interpreter session - in the (somewhat rare) case of nested installs. [#147] - -- To better support high resolution displays, use SVG for the astropy - logo and linkout image, falling back to PNGs for browsers that - support it. [#150, #151] - -- Improve ``setup_helpers.get_compiler_version`` to work with more compilers, - and to return more info. This will help fix builds of Astropy on less - common compilers, like Sun C. [#153] - -1.0.1 (2015-03-04) ------------------- - -- Released in concert with v0.4.8 to address the same issues. - -0.4.8 (2015-03-04) ------------------- - -- Improved the ``ah_bootstrap`` script's ability to override existing - installations of astropy-helpers with new versions in the context of - installing multiple packages simultaneously within the same Python - interpreter (e.g. when one package has in its ``setup_requires`` another - package that uses a different version of astropy-helpers. [#144] - -- Added a workaround to an issue in matplotlib that can, in rare cases, lead - to a crash when installing packages that import matplotlib at build time. - [#144] - -1.0 (2015-02-17) ----------------- - -- Added new pre-/post-command hook points for ``setup.py`` commands. Now any - package can define code to run before and/or after any ``setup.py`` command - without having to manually subclass that command by adding - ``pre__hook`` and ``post__hook`` callables to - the package's ``setup_package.py`` module. See the PR for more details. - [#112] - -- The following objects in the ``astropy_helpers.setup_helpers`` module have - been relocated: - - - ``get_dummy_distribution``, ``get_distutils_*``, ``get_compiler_option``, - ``add_command_option``, ``is_distutils_display_option`` -> - ``astropy_helpers.distutils_helpers`` - - - ``should_build_with_cython``, ``generate_build_ext_command`` -> - ``astropy_helpers.commands.build_ext`` - - - ``AstropyBuildPy`` -> ``astropy_helpers.commands.build_py`` - - - ``AstropyBuildSphinx`` -> ``astropy_helpers.commands.build_sphinx`` - - - ``AstropyInstall`` -> ``astropy_helpers.commands.install`` - - - ``AstropyInstallLib`` -> ``astropy_helpers.commands.install_lib`` - - - ``AstropyRegister`` -> ``astropy_helpers.commands.register`` - - - ``get_pkg_version_module`` -> ``astropy_helpers.version_helpers`` - - - ``write_if_different``, ``import_file``, ``get_numpy_include_path`` -> - ``astropy_helpers.utils`` - - All of these are "soft" deprecations in the sense that they are still - importable from ``astropy_helpers.setup_helpers`` for now, and there is - no (easy) way to produce deprecation warnings when importing these objects - from ``setup_helpers`` rather than directly from the modules they are - defined in. But please consider updating any imports to these objects. - [#110] - -- Use of the ``astropy.sphinx.ext.astropyautosummary`` extension is deprecated - for use with Sphinx < 1.2. Instead it should suffice to remove this - extension for the ``extensions`` list in your ``conf.py`` and add the stock - ``sphinx.ext.autosummary`` instead. [#131] - - -0.4.7 (2015-02-17) ------------------- - -- Fixed incorrect/missing git hash being added to the generated ``version.py`` - when creating a release. [#141] - - -0.4.6 (2015-02-16) ------------------- - -- Fixed problems related to the automatically generated _compiler - module not being created properly. [#139] - - -0.4.5 (2015-02-11) ------------------- - -- Fixed an issue where ah_bootstrap.py could blow up when astropy_helper's - version number is 1.0. - -- Added a workaround for documentation of properties in the rare case - where the class's metaclass has a property of the same name. [#130] - -- Fixed an issue on Python 3 where importing a package using astropy-helper's - generated version.py module would crash when the current working directory - is an empty git repository. [#114, #137] - -- Fixed an issue where the "revision count" appended to .dev versions by - the generated version.py did not accurately reflect the revision count for - the package it belongs to, and could be invalid if the current working - directory is an unrelated git repository. [#107, #137] - -- Likewise, fixed a confusing warning message that could occur in the same - circumstances as the above issue. [#121, #137] - - -0.4.4 (2014-12-31) ------------------- - -- More improvements for building the documentation using Python 3.x. [#100] - -- Additional minor fixes to Python 3 support. [#115] - -- Updates to support new test features in Astropy [#92, #106] - - -0.4.3 (2014-10-22) ------------------- - -- The generated ``version.py`` file now preserves the git hash of installed - copies of the package as well as when building a source distribution. That - is, the git hash of the changeset that was installed/released is preserved. - [#87] - -- In smart resolver add resolution for class links when they exist in the - intersphinx inventory, but not the mapping of the current package - (e.g. when an affiliated package uses an astropy core class of which - "actual" and "documented" location differs) [#88] - -- Fixed a bug that could occur when running ``setup.py`` for the first time - in a repository that uses astropy-helpers as a submodule: - ``AttributeError: 'NoneType' object has no attribute 'mkdtemp'`` [#89] - -- Fixed a bug where optional arguments to the ``doctest-skip`` Sphinx - directive were sometimes being left in the generated documentation output. - [#90] - -- Improved support for building the documentation using Python 3.x. [#96] - -- Avoid error message if .git directory is not present. [#91] - - -0.4.2 (2014-08-09) ------------------- - -- Fixed some CSS issues in generated API docs. [#69] - -- Fixed the warning message that could be displayed when generating a - version number with some older versions of git. [#77] - -- Fixed automodsumm to work with new versions of Sphinx (>= 1.2.2). [#80] - - -0.4.1 (2014-08-08) ------------------- - -- Fixed git revision count on systems with git versions older than v1.7.2. - [#70] - -- Fixed display of warning text when running a git command fails (previously - the output of stderr was not being decoded properly). [#70] - -- The ``--offline`` flag to ``setup.py`` understood by ``ah_bootstrap.py`` - now also prevents git from going online to fetch submodule updates. [#67] - -- The Sphinx extension for converting issue numbers to links in the changelog - now supports working on arbitrary pages via a new ``conf.py`` setting: - ``changelog_links_docpattern``. By default it affects the ``changelog`` - and ``whatsnew`` pages in one's Sphinx docs. [#61] - -- Fixed crash that could result from users with missing/misconfigured - locale settings. [#58] - -- The font used for code examples in the docs is now the - system-defined ``monospace`` font, rather than ``Minaco``, which is - not available on all platforms. [#50] - - -0.4 (2014-07-15) ----------------- - -- Initial release of astropy-helpers. See `APE4 - `_ for - details of the motivation and design of this package. - -- The ``astropy_helpers`` package replaces the following modules in the - ``astropy`` package: - - - ``astropy.setup_helpers`` -> ``astropy_helpers.setup_helpers`` - - - ``astropy.version_helpers`` -> ``astropy_helpers.version_helpers`` - - - ``astropy.sphinx`` - > ``astropy_helpers.sphinx`` - - These modules should be considered deprecated in ``astropy``, and any new, - non-critical changes to those modules will be made in ``astropy_helpers`` - instead. Affiliated packages wishing to make use those modules (as in the - Astropy package-template) should use the versions from ``astropy_helpers`` - instead, and include the ``ah_bootstrap.py`` script in their project, for - bootstrapping the ``astropy_helpers`` package in their setup.py script. diff -Nru ginga-3.0.0/astropy_helpers/LICENSE.rst ginga-3.1.0/astropy_helpers/LICENSE.rst --- ginga-3.0.0/astropy_helpers/LICENSE.rst 2016-07-29 04:58:01.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/LICENSE.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,26 +0,0 @@ -Copyright (c) 2014, Astropy Developers - -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 Astropy Team nor the names 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 -Nru ginga-3.0.0/astropy_helpers/licenses/LICENSE_ASTROSCRAPPY.rst ginga-3.1.0/astropy_helpers/licenses/LICENSE_ASTROSCRAPPY.rst --- ginga-3.0.0/astropy_helpers/licenses/LICENSE_ASTROSCRAPPY.rst 2017-11-03 19:28:24.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/licenses/LICENSE_ASTROSCRAPPY.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,28 +0,0 @@ -# The OpenMP helpers include code heavily adapted from astroscrappy, released -# under the following license: -# -# Copyright (c) 2015, Curtis McCully -# 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 Astropy Team nor the names 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 -Nru ginga-3.0.0/astropy_helpers/README.rst ginga-3.1.0/astropy_helpers/README.rst --- ginga-3.0.0/astropy_helpers/README.rst 2019-07-31 00:02:09.000000000 +0000 +++ ginga-3.1.0/astropy_helpers/README.rst 1970-01-01 00:00:00.000000000 +0000 @@ -1,27 +0,0 @@ -astropy-helpers -=============== - -.. image:: https://travis-ci.org/astropy/astropy-helpers.svg - :target: https://travis-ci.org/astropy/astropy-helpers - -.. image:: https://ci.appveyor.com/api/projects/status/rt9161t9mhx02xp7/branch/master?svg=true - :target: https://ci.appveyor.com/project/Astropy/astropy-helpers - -.. image:: https://codecov.io/gh/astropy/astropy-helpers/branch/master/graph/badge.svg - :target: https://codecov.io/gh/astropy/astropy-helpers - -The **astropy-helpers** package includes many build, installation, and -documentation-related tools used by the Astropy project, but packaged separately -for use by other projects that wish to leverage this work. The motivation behind -this package and details of its implementation are in the accepted -`Astropy Proposal for Enhancement (APE) 4 `_. - -Astropy-helpers is not a traditional package in the sense that it is not -intended to be installed directly by users or developers. Instead, it is meant -to be accessed when the ``setup.py`` command is run - see the "Using -astropy-helpers in a package" section in the documentation for how -to do this. For a real-life example of how to implement astropy-helpers in a -project, see the ``setup.py`` and ``setup.cfg`` files of the -`Affiliated package template `_. - -For more information, see the documentation at http://astropy-helpers.readthedocs.io diff -Nru ginga-3.0.0/CITATION ginga-3.1.0/CITATION --- ginga-3.0.0/CITATION 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/CITATION 2019-12-06 23:16:41.000000000 +0000 @@ -17,6 +17,15 @@ Zenodo: https://zenodo.org/badge/latestdoi/4758330 +@inproceedings{Lim19A, + author = "P. L. Lim and E. Jeschke", + title = "stginga: Ginga Plugins for Data Analysis and Quality Assurance of HST and JWST Science Data", + booktitle = "Astronomical Data Analysis Software and Systems XXVIII", + editor = "P. J. Teuben and M. W. Pound and B. A. Thomas and E. M. Warner", + year = "2019", + publisher = "ASP", + note = "Vol. 523" } + @inproceedings{Jeschke13B, author = "E. Jeschke", title = "Ginga: an open-source astronomical image viewer and toolkit", diff -Nru ginga-3.0.0/CODE_OF_CONDUCT.md ginga-3.1.0/CODE_OF_CONDUCT.md --- ginga-3.0.0/CODE_OF_CONDUCT.md 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/CODE_OF_CONDUCT.md 2018-11-14 16:26:02.000000000 +0000 @@ -0,0 +1 @@ +Please read the [Astropy Project Code of Conduct](http://www.astropy.org/code_of_conduct.html). diff -Nru ginga-3.0.0/debian/changelog ginga-3.1.0/debian/changelog --- ginga-3.0.0/debian/changelog 2019-09-23 10:05:59.000000000 +0000 +++ ginga-3.1.0/debian/changelog 2020-07-24 09:43:29.000000000 +0000 @@ -1,3 +1,13 @@ +ginga (3.1.0-1) unstable; urgency=low + + * New upstream version 3.1.0. Rediff patches + * Push Standards-Version to 4.5.0. No changes needed + * Add Rules-Requires-Root:no to d/control + * Push dh-compat to 13 + * Update build dependencies. Remove unneeded builddep versions + + -- Ole Streicher Fri, 24 Jul 2020 11:43:29 +0200 + ginga (3.0.0-1) unstable; urgency=low * Add tests on salsa diff -Nru ginga-3.0.0/debian/clean ginga-3.1.0/debian/clean --- ginga-3.0.0/debian/clean 2016-11-17 11:26:16.000000000 +0000 +++ ginga-3.1.0/debian/clean 2020-07-24 09:43:29.000000000 +0000 @@ -1 +1,2 @@ *.1 +ginga/version.py diff -Nru ginga-3.0.0/debian/control ginga-3.1.0/debian/control --- ginga-3.0.0/debian/control 2019-09-23 10:05:53.000000000 +0000 +++ ginga-3.1.0/debian/control 2020-07-24 09:43:29.000000000 +0000 @@ -1,23 +1,24 @@ Source: ginga -Section: python -Priority: optional Maintainer: Debian Astronomy Team Uploaders: Ole Streicher -Build-Depends: debhelper-compat (= 12), +Section: python +Priority: optional +Build-Depends: debhelper-compat (= 13), dh-python, help2man, python3-all, python3-astropy, - python3-astropy-helpers, - python3-numpy (>= 1.7), + python3-numpy, python3-pytest, - python3-qtpy (>= 1.1), + python3-qtpy, python3-regions, - python3-setuptools -Standards-Version: 4.4.0 -Homepage: https://ejeschke.github.io/ginga -Vcs-Git: https://salsa.debian.org/debian-astro-team/ginga.git + python3-setuptools, + python3-setuptools-scm +Standards-Version: 4.5.0 Vcs-Browser: https://salsa.debian.org/debian-astro-team/ginga +Vcs-Git: https://salsa.debian.org/debian-astro-team/ginga.git +Homepage: https://ejeschke.github.io/ginga +Rules-Requires-Root: no Package: python3-ginga Architecture: all @@ -29,7 +30,7 @@ python3-magic, python3-matplotlib, python3-pil, - python3-pyqt5 | python3-qt4, + python3-pyqt5, python3-pyqt5.qtwebkit, python3-regions, python3-scipy @@ -57,13 +58,13 @@ star catalog access, cuts, star pick/fwhm, thumbnails, etc. Package: ginga -Section: science Architecture: all +Section: science Depends: python3, python3-astropy, python3-ginga (= ${binary:Version}), python3-pkg-resources, - python3-pyqt5 | python3-qt4, + python3-pyqt5, ${misc:Depends} Description: Astronomical image viewer Ginga is a toolkit designed for building viewers for scientific image diff -Nru ginga-3.0.0/debian/patches/Don-t-install-the-fonts-included-in-the-package.patch ginga-3.1.0/debian/patches/Don-t-install-the-fonts-included-in-the-package.patch --- ginga-3.0.0/debian/patches/Don-t-install-the-fonts-included-in-the-package.patch 2019-09-23 09:19:12.000000000 +0000 +++ ginga-3.1.0/debian/patches/Don-t-install-the-fonts-included-in-the-package.patch 2020-07-24 09:40:03.000000000 +0000 @@ -4,10 +4,9 @@ Use the package roboto-fonts-unhinted instead. --- - ginga/fonts/font_asst.py | 4 ++-- - ginga/fonts/setup_package.py | 2 +- - ginga/opengl/GlHelp.py | 4 ++-- - 3 files changed, 5 insertions(+), 5 deletions(-) + ginga/fonts/font_asst.py | 4 ++-- + ginga/opengl/GlHelp.py | 4 ++-- + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ginga/fonts/font_asst.py b/ginga/fonts/font_asst.py index ccc86e8..b9f228c 100644 @@ -24,22 +23,12 @@ +known_font = os.path.join(fontdir, 'roboto', 'hinted', 'Roboto-Regular.ttf') add_font(known_font, font_name='roboto') add_alias('fixed', 'roboto') -diff --git a/ginga/fonts/setup_package.py b/ginga/fonts/setup_package.py -index 9abe3d0..595d086 100644 ---- a/ginga/fonts/setup_package.py -+++ b/ginga/fonts/setup_package.py -@@ -2,4 +2,4 @@ - - - def get_package_data(): -- return {'ginga.fonts': ['*/*.ttf', '*/*.txt']} -+ return {} diff --git a/ginga/opengl/GlHelp.py b/ginga/opengl/GlHelp.py -index b693528..6cef536 100644 +index e2dc57a..41ca9de 100644 --- a/ginga/opengl/GlHelp.py +++ b/ginga/opengl/GlHelp.py -@@ -10,8 +10,8 @@ from ginga import colors - import ginga.fonts +@@ -13,8 +13,8 @@ import ginga.fonts + from ginga.canvas import transform # Set up known fonts -fontdir, xx = os.path.split(ginga.fonts.__file__) diff -Nru ginga-3.0.0/debian/patches/Install-the-scripts-only-for-the-Python-3-version.patch ginga-3.1.0/debian/patches/Install-the-scripts-only-for-the-Python-3-version.patch --- ginga-3.0.0/debian/patches/Install-the-scripts-only-for-the-Python-3-version.patch 2019-09-23 09:19:12.000000000 +0000 +++ ginga-3.1.0/debian/patches/Install-the-scripts-only-for-the-Python-3-version.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,32 +0,0 @@ -From: Ole Streicher -Date: Tue, 2 Aug 2016 09:06:36 +0200 -Subject: Install the scripts only for the Python 3 version - -However, they will not be installed in python3-ginga, but are taken away -by the ginga package. ---- - setup.py | 4 ++++ - 1 file changed, 4 insertions(+) - -diff --git a/setup.py b/setup.py -index 04e253e..d6eee5e 100644 ---- a/setup.py -+++ b/setup.py -@@ -57,6 +57,8 @@ generate_version_py() - scripts = [fname for fname in glob.glob(os.path.join('scripts', '*')) - if (os.path.basename(fname) != 'README.rst' and - os.path.basename(fname) != 'fits2pdf.py')] -+if sys.version_info[0] != 3: -+ scripts = [] - - # Get configuration information from all of the various subpackages. - # See the docstring for setup_helpers.update_package_files for more -@@ -76,6 +78,8 @@ entry_point_list = conf.items('entry_points') - for entry_point in entry_point_list: - entry_points['console_scripts'].append('{0} = {1}'.format(entry_point[0], - entry_point[1])) -+if sys.version_info[0] != 3: -+ entry_points = {} - - # Include all .c files, recursively, including those generated by - # Cython, since we can not do this in MANIFEST.in with a "dynamic" diff -Nru ginga-3.0.0/debian/patches/series ginga-3.1.0/debian/patches/series --- ginga-3.0.0/debian/patches/series 2019-09-23 09:19:12.000000000 +0000 +++ ginga-3.1.0/debian/patches/series 2020-07-24 09:40:03.000000000 +0000 @@ -1,3 +1 @@ -Install-the-scripts-only-for-the-Python-3-version.patch Don-t-install-the-fonts-included-in-the-package.patch -Use-astropy_helpers-provided-by-the-system.patch diff -Nru ginga-3.0.0/debian/patches/Use-astropy_helpers-provided-by-the-system.patch ginga-3.1.0/debian/patches/Use-astropy_helpers-provided-by-the-system.patch --- ginga-3.0.0/debian/patches/Use-astropy_helpers-provided-by-the-system.patch 2019-09-23 09:19:12.000000000 +0000 +++ ginga-3.1.0/debian/patches/Use-astropy_helpers-provided-by-the-system.patch 1970-01-01 00:00:00.000000000 +0000 @@ -1,21 +0,0 @@ -From: Ole Streicher -Date: Tue, 2 Aug 2016 09:06:37 +0200 -Subject: Use astropy_helpers provided by the system - ---- - setup.cfg | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/setup.cfg b/setup.cfg -index 4baafa5..b8a4c50 100644 ---- a/setup.cfg -+++ b/setup.cfg -@@ -13,7 +13,7 @@ norecursedirs = build doc/_build - addopts = -p no:warnings - - [ah_bootstrap] --auto_use = True -+auto_use = False - - [bdist_wheel] - universal = 1 diff -Nru ginga-3.0.0/doc/conf.py ginga-3.1.0/doc/conf.py --- ginga-3.0.0/doc/conf.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/doc/conf.py 2020-01-20 03:17:53.000000000 +0000 @@ -1,10 +1,6 @@ # -*- coding: utf-8 -*- # Licensed under a 3-clause BSD style license - see LICENSE.rst # -# Astropy documentation build configuration file. -# -# This file is execfile()d with the current directory set to its containing dir. -# # Note that not all possible configuration values are present in this file. # # All configuration values have a default. Some values are defined in @@ -20,26 +16,22 @@ # commented out with this explanation to make it clear why this should not be # done. If the sys.path entry above is added, when the astropy.sphinx.conf # import occurs, it will import the *source* version of astropy instead of the -# version installed (if invoked as "make html" or directly with sphinx), or the -# version in the build directory (if "python setup.py build_sphinx" is used). +# version installed (if invoked as "make html" or directly with sphinx). # Thus, any C-extensions that are needed to build the documentation will *not* # be accessible, and the documentation will not build correctly. import datetime import os import sys +from pkg_resources import get_distribution +# Load all of the global Astropy configuration try: - import astropy_helpers # noqa + from sphinx_astropy.conf.v1 import * # noqa except ImportError: - # Building from inside the docs/ directory? - if os.path.basename(os.getcwd()) == 'doc': - a_h_path = os.path.abspath(os.path.join('..', 'astropy_helpers')) - if os.path.isdir(a_h_path): - sys.path.insert(1, a_h_path) - -# Load all of the global Astropy configuration -from sphinx_astropy.conf.v1 import * # noqa + print('ERROR: the documentation requires the sphinx-astropy package ' + 'to be installed') + sys.exit(1) # Get configuration information from setup.cfg from configparser import ConfigParser @@ -79,14 +71,10 @@ # |version| and |release|, also used in various other places throughout the # built documents. -__import__(project) -package = sys.modules[project] - -# The short X.Y version. -version = package.__version__.split('-', 1)[0] # The full version, including alpha/beta/rc tags. -release = package.__version__ - +release = get_distribution(project).version +# The short X.Y version. +version = '.'.join(release.split('.')[:2]) # -- Options for HTML output --------------------------------------------------- @@ -155,19 +143,3 @@ # (source start file, name, description, authors, manual section). man_pages = [('index', project.lower(), project + u' Documentation', [author], 1)] - - -## -- Options for the edit_on_github extension ---------------------------------------- - -#if eval(setup_cfg.get('edit_on_github')): -# extensions += ['astropy_helpers.sphinx.ext.edit_on_github'] # noqa -# -# versionmod = __import__(project + '.version') -# edit_on_github_project = setup_cfg['github_project'] -# if versionmod.version.release: -# edit_on_github_branch = "v" + versionmod.version.version -# else: -# edit_on_github_branch = "master" -# -# edit_on_github_source_root = "" -# edit_on_github_doc_root = "doc" diff -Nru ginga-3.0.0/doc/dev_manual/developers.rst ginga-3.1.0/doc/dev_manual/developers.rst --- ginga-3.0.0/doc/dev_manual/developers.rst 2018-11-30 23:47:07.000000000 +0000 +++ ginga-3.1.0/doc/dev_manual/developers.rst 2020-07-08 20:09:29.000000000 +0000 @@ -413,7 +413,7 @@ self.layertag = 'ruler-canvas' self.ruletag = None - self.dc = fv.getDrawClasses() + self.dc = fv.get_draw_classes() canvas = self.dc.DrawingCanvas() canvas.enable_draw(True) canvas.enable_edit(True) @@ -439,7 +439,7 @@ vbox.set_border_width(4) vbox.set_spacing(2) - self.msgFont = self.fv.getFont("sansFont", 12) + self.msgFont = self.fv.get_font("sansFont", 12) tw = Widgets.TextArea(wrap=True, editable=False) tw.set_font(self.msgFont) self.tw = tw @@ -506,14 +506,14 @@ self.canvas.set_drawtype('ruler', color='cyan', units=units) if self.ruletag is not None: - obj = self.canvas.getObjectByTag(self.ruletag) + obj = self.canvas.get_object_by_tag(self.ruletag) if obj.kind == 'ruler': obj.units = units self.canvas.redraw(whence=3) return True def close(self): - chname = self.fv.get_channelName(self.fitsimage) + chname = self.fv.get_channel_name(self.fitsimage) self.fv.stop_local_plugin(chname, str(self)) return True @@ -533,11 +533,11 @@ self.resume() def pause(self): - self.canvas.ui_setActive(False) + self.canvas.ui_set_active(False) def resume(self): - self.canvas.ui_setActive(True) - self.fv.showStatus("Draw a ruler with the right mouse button") + self.canvas.ui_set_active(True) + self.fv.show_status("Draw a ruler with the right mouse button") def stop(self): # remove the canvas from the image @@ -546,8 +546,8 @@ p_canvas.delete_object_by_tag(self.layertag) except: pass - self.canvas.ui_setActive(False) - self.fv.showStatus("") + self.canvas.ui_set_active(False) + self.fv.show_status("") def redo(self): obj = self.canvas.get_object_by_tag(self.ruletag) diff -Nru ginga-3.0.0/doc/index.rst ginga-3.1.0/doc/index.rst --- ginga-3.0.0/doc/index.rst 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/doc/index.rst 2020-01-20 03:17:53.000000000 +0000 @@ -36,7 +36,7 @@ Copyright and License ===================== -Copyright (c) 2011-2019 Ginga Maintainers. All rights reserved. +Copyright (c) 2011-2020 Ginga Maintainers. All rights reserved. Ginga is distributed under an open-source BSD licence. Please see the file `LICENSE.txt` in the top-level directory for details. diff -Nru ginga-3.0.0/doc/install.rst ginga-3.1.0/doc/install.rst --- ginga-3.0.0/doc/install.rst 2019-08-03 02:20:19.000000000 +0000 +++ ginga-3.1.0/doc/install.rst 2020-07-08 20:09:29.000000000 +0000 @@ -20,18 +20,19 @@ ======== * python (v. 3.5 or higher) -* setuptools +* setuptools-scm * numpy (v. 1.13 or higher) * astropy Highly recommended, because some features will not be available without it: * scipy -* Pillow +* pillow * opencv-python (also distributed as opencv or python-opencv, depending on where you get it from) * piexif * beautifulsoup4 +* docutils (to display help for plugins) For opening `FITS `_ files you will need one of the following packages: @@ -83,6 +84,7 @@ * opencv-python (speeds up rotation, mosaicing and some transformations) * pyopencl (speeds up rotation, mosaicing and some transformations) +* pyopengl (for using OpenGL features) * numexpr (speeds up rotation a little) * filemagic (aids in identifying files when opening them) * Pillow (useful for various RGB file manipulations) @@ -115,7 +117,7 @@ Ginga can use GTK 3 (with ``gi``). (If you have an older version of ``pycairo`` package, you may need to install a newer version from -``pycairo``). +``pycairo``). Tk == @@ -178,7 +180,7 @@ #. Unpack, go into the top level directory, and run:: - python setup.py install + pip install -e . ============================== Platform Specific Instructions @@ -210,3 +212,28 @@ After installing Anaconda, open the Anaconda Prompt and follow instructions under :ref:`install_generic` via ``conda``. + +============= +Running tests +============= + +#. Install the following packages:: + + $ pip install -e .[test] + +#. Run the tests using `pytest`:: + + $ pytest + +====================== +Building documentation +====================== + +#. Install the following packages:: + + $ pip install -e .[docs] + +#. Build the documentation using `make`:: + + $ cd doc + $ make html diff -Nru ginga-3.0.0/doc/manual/customizing.rst ginga-3.1.0/doc/manual/customizing.rst --- ginga-3.0.0/doc/manual/customizing.rst 2018-08-07 21:29:17.000000000 +0000 +++ ginga-3.1.0/doc/manual/customizing.rst 2020-07-20 21:06:00.000000000 +0000 @@ -22,21 +22,61 @@ configuration files or customization of the default behavior. .. note:: The configuration area is determined first by examining the - environment variable ``GINGA_HOME``. If that is not set, then - ``$HOME/.ginga`` (Mac OS X, Linux) or + command line option ``--basedir``. If that is not set, then + the environment variable ``GINGA_HOME`` is checked. If that + is not set, then ``$HOME/.ginga`` (Mac OS X, Linux) or ``$HOMEDRIVE:$HOMEPATH\\.ginga`` (Windows) will be used. Examples of the types of configuration files with comments describing the -effects of the parameters can be found in ``.../ginga/examples/configs``. +effects of the parameters can be found in +``.../ginga/examples/configs``. + +The config files that end in ``.cfg`` use a stripped down Pythonic +format consisting of comments, blank lines and ``keyword = value`` pairs, +where values are specified using Python literal syntax. + +General Config Files +-------------------- +There is general top-level configuration file ``general.cfg`` in the +configuration area. You can find an example in the examples area +described above. + +.. _sec-bindings: + +Binding Config File +------------------- + +One configuration file that many users will be interested in is the one +controlling how keyboard and mouse/touch bindings are assigned. This is +handled by the configuration file ``bindings.cfg``. Several examples +are stored in ``.../ginga/examples/bindings``, including an example for +users familiar with the ds9 mouse controls, and an example for users +using a touchpad without a mouse (pinch zoom and scroll panning). +Simply copy the appropriate file to your Ginga settings area as +``bindings.cfg``. + + +Plugin Config Files +------------------- + Many of the plugins have their own configuration file, with preferences that are only changed via that file. You can copy an example configuration file to your Ginga settings area and change the settings to your liking. +Here is an example of a plugin configuration file for the ``Ruler`` +plugin: + +.. literalinclude:: ../../ginga/examples/configs/plugin_Ruler.cfg + :language: python + Usually it is sufficient to simply close the plugin and open it again to pick up any settings changes, but some changes may require a viewer restart to take effect. +Channel Config Files +-------------------- + Channels also use configuration files to store many different settings for the channel viewer windows. When a channel is created, the reference viewer looks to see if there is a configuration file for that @@ -49,198 +89,237 @@ ``.../ginga/examples/configs/channel_Image.cfg`` to your configuration area and edit it if you prefer. -============================================ -Saving the workspace layout between sessions -============================================ - -By default, Ginga will will write its window size, position and some layout -information to a "layout" file in the configuration directory when the -program is closed. Upon a subsequent startup Ginga will attempt to -restore the window to the saved configuration. If this behavior is not -desired you can add the option ``save_layout = False`` to your -``general.cfg`` file in the Ginga configuration directory. - -There is a sample ``general.cfg`` file in ``.../ginga/examples/configs``. - -Invoking the program with the ``--norestore`` option also prevents it from -reading the saved layout file. This may be needed in some cases when -the layout changes in an incompatible way between when the program was -last stopped and when it was started again. - -.. _sec-bindings: - -================== -Rebinding Controls -================== - -One configuration file that many users will be interested in is the one -controlling how keyboard and mouse/touch bindings are assigned. This is -handled by the configuration file ``bindings.cfg``. Several examples -are stored in ``.../ginga/examples/bindings``, including an example for -users familiar with the ds9 mouse controls, and an example for users -using a touchpad without a mouse (pinch zoom and scroll panning). -Simply copy the appropriate file to your Ginga settings area as -``bindings.cfg``. - .. _sec-workspaceconfig: -====================================================== -Customizing the Reference Viewer During Initialization -====================================================== - -The reference viewer can be customized during viewer initialization -using a module called ``ginga_config``, which can be anywhere in the -user's Python import path, including in the Ginga configuration folder -described above (e.g. ``$HOME/.ginga/ginga_config.py``). - -Specifically, this file will be imported and two methods will be run if -defined: ``pre_gui_config(ginga_shell)`` and -``post_gui_config(ginga_shell)``. -The parameter to each function is the main viewer shell. These functions -can be used to define a different viewer layout, add or remove plugins, -add menu entries, add custom image or star catalogs, etc. We will refer -back to these functions in the sections below. - -======================= -Workspace configuration -======================= +====================== +Customizing the Layout +====================== Ginga has a flexible table-driven layout scheme for dynamically creating -workspaces and mapping the available plugins to workspaces. By changing -a couple of tables via ``ginga_config.pre_gui_config()`` you can change -the way Ginga looks and presents its content. - -If you examine the module `ginga.rv.main` you will find a layout table -called ``default_layout``:: - - default_layout = ['seq', {}, - ['vbox', dict(name='top', width=1400, height=700), - dict(row=['hbox', dict(name='menu')], - stretch=0), - dict(row=['hpanel', dict(name='hpnl'), - ['ws', dict(name='left', wstype='tabs', - width=300, height=-1, group=2), - # (tabname, layout), ... - [("Info", ['vpanel', {}, - ['ws', dict(name='uleft', wstype='stack', - height=250, group=3)], - ['ws', dict(name='lleft', wstype='tabs', - height=330, group=3)], - ] - )]], - ['vbox', dict(name='main', width=600), - dict(row=['ws', dict(name='channels', wstype='tabs', - group=1, use_toolbar=True)], - stretch=1), - dict(row=['ws', dict(name='cbar', wstype='stack', - group=99)], stretch=0), - dict(row=['ws', dict(name='readout', wstype='stack', - group=99)], stretch=0), - dict(row=['ws', dict(name='operations', wstype='stack', - group=99)], stretch=0), - ], - ['ws', dict(name='right', wstype='tabs', - width=400, height=-1, group=2), - # (tabname, layout), ... - [("Dialogs", ['ws', dict(name='dialogs', wstype='tabs', - group=2) - ] - )] - ], - ], stretch=1), - dict(row=['ws', dict(name='toolbar', wstype='stack', - height=40, group=2)], - stretch=0), - dict(row=['hbox', dict(name='status')], stretch=0), - ]] - - -This rather arcane-looking table defines the precise layout of the -reference viewer shell, including how many workspaces it will have, their -characteristics, how they are organized, and their names. - -The key point in this section is that you can modify this table or -replace it entirely with one of your own design and set it in the -``pre_gui_config()`` method described above:: +workspaces and mapping the available plugins to workspaces. This layout +can be specified with a Python structure (`layout`) in the configuration +area. If there is no file initially, Ginga will use the built in +default layout. Ginga will will update its window size, position and +some layout information to the layout file when the program is closed, +creating a new custom layout. Upon a subsequent startup Ginga will +attempt to restore the window to the saved configuration. + +.. note:: The name of the layout file is set in the general + configuration file (``general.cfg``) as the value for + ``layout_file``. Set it to "layout.json" if you prefer to use + the JSON format or "layout" if you prefer to use the Python + format (the default). + +.. note:: If you don't want Ginga to remember your changes to the window + size or position, you can add the option ``save_layout = + False`` to your general configuration file. Ginga will still + read the layout from the file (if it exists--otherwise it will + use the default, built-in layout), but will not update it when + closing. + +.. note:: Invoking the program with the ``--norestore`` option + prevents it from reading the saved layout file, and forces use + of the internal default layout table. This may be needed in + some cases when the layout changes in an incompatible way + between when the program was last stopped and when it was + started again. - my_layout = [ - ... - ] +Format of the Layout Table +-------------------------- - def pre_gui_config(ginga_shell): - ... +The table consists of a list containing nested lists. Each list +represents a container or a non-container endpoint, and has the +following format: - ginga_shell.set_layout(my_layout) +.. code-block:: python -If done in the ``pre_gui_config()`` method (as shown) the new layout will -be the one that is used when the GUI is constructed. + [type config-dict optional-content0 ... optional-contentN] -Format of the Layout Table --------------------------- -The table consists of a nested list of sublists, tuples and/or dictionaries. -The first item in a sublist indicates the type of the container to be -constructed. The following types are available: +The first item in a list indicates the type of the container or object +to be constructed. The following types are available: * ``seq``: defines a sequence of top-level windows to be created - * ``hpanel``: a horizontal panel of containers, with handles to size them - * ``vpanel``: a vertical panel of containers, with handles to size them - * ``hbox``: a horizontal panel of containers of fixed size - * ``vbox``: a vertical panel of containers of fixed size - * ``ws``: a workspace that allows a plugin or a channel viewer to be loaded into it. A workspace can be configured in four ways: as a tabbed notebook (``wstype="tabs"``), as a stack (``wstype="stack"``), as an MDI (Multiple Document Interface, ``wstype="mdi"``) or a grid (``wstype="grid"``). -* ``widget``: a preconstructed widget passed in. - In every case the second item in the sublist is a dictionary that provides some optional parameters that modify the characteristics of the -container. If there is no need to override the default parameters the -dictionary can simply be empty. The optional third and following items -are specifications for nested content. +widget. If there is no need to override the default parameters the +dictionary can simply be empty. All types of containers honor the +following parameters in this ``dict``: + +* ``width``: can specify a desired width in pixels for the container. +* ``height``: can specify a desired height in pixels for the container. +* ``name``: specifies a mapping of a name to the created container + widget. The name is important especially for workspaces, as they may + be referred to as an output destination when registering plugins. -All types of containers honor the following parameters: +The optional third and following items in a list are specifications for +nested content. The format for nested content depends on the type of the +container: + +* ``seq``, ``hpanel`` and ``vpanel`` types expect the nested content items to + be lists, as described above. +* ``hbox`` and ``vbox`` content items can be lists (as described above) or + ``dict`` s. A ``vbox`` ``dict`` should have a ``row`` value and optionally a + ``stretch`` value; an ``hbox`` ``dict`` should have a ``col`` value and + optionally a ``stretch`` value. The ``row`` and ``col`` values should be + lists as described above. +* The ``ws`` (workspace) type takes one optional content item, which + should be a sublist containing 2-item lists (or tuples) with the format + ``(name, content)``, where ``content`` is a list as described above. The + ``name`` is used to identify each content item in the way appropriate + for the workspace type, whether that is a notebook tab, MDI window + titlebar, etc. + +Here is the standard layout (Python format), as an example: + +.. literalinclude:: ../../ginga/examples/layouts/standard/layout + :language: python + +In the above example, we define a top-level window consisting of a vbox +(named "top") with 4 layers: a hbox ("menu"), hpanel ("hpnl"), a +workspace ("toolbar") and another hbox ("status"). The main horizontal +panel ("hpnl") has three containers: a workspace ("left"), a vbox +("main") and a workspace ("right"). The "left" workspace is +pre-populated with an "Info" tab containing a vertical panel of two +workspaces: "uleft" and "lleft" (upper and lower left). The "right" +workspace is pre-populated with a "Dialogs" tab containing an empty +workspace. The "main" vbox is configured with four rows of workspaces: +"channels", "cbar", "readout" and "operations". + +.. note:: The workspace that has as a configuration option ``default: + True`` (in this example, "channels") will be used as the + default workspace where new channels should be created. -* width: can specify a desired width in pixels for the container. -* height: can specify a desired height in pixels for the container. +.. _sec-pluginconfig: -* name: specifies a mapping of a name to the created container - widget. The name is important especially for workspaces, as they may - be referred to as an output destination when registering plugins. +============================== +Customizing the set of plugins +============================== -.. note:: In the above example, we define a top-level window consisting - of a vbox (named "top") with 4 layers: a hbox ("menu"), hpanel - ("hpnl"), a workspace ("toolbar") and another hbox ("status"). - The main horizontal panel of three containers: a workspace - ("left") with a width of 300 pixels, a vbox ("main", 700 - pixels) and a workspace ("right", 400 pixels). - The "left" workspace is pre-populated - with an "Info" tab containing a vertical panel of two - workspaces: "uleft" and "lleft" with heights of 300 and 430 - pixels, respectively. The "right" workspace is pre-populated - with a "Dialogs" tab containing an empty workspace. - The "main" vbox is configured with three rows of workspaces: - "channels", "cbar" and "readout". - -Ginga uses some container names in special ways. -For example, Ginga looks for a "channels" workspace as the default -workspace for creating channels, and the "dialogs" workspace is where -most local plugins are instantiated (when activated), by default. -These two names should at least be defined somewhere in default_layout. - -================== -Auto-Start Plugins -================== +In the configuration directory, the presence of a file ``plugins.json`` +will override the built-in configuration of plugins. The file format is +a JSON array containing JSON objects, each of which configures a plugin. +Example: + +.. code-block:: + + [ + { + "__bunch__": true, + "module": "Info", + "tab": "Synopsis", + "workspace": "lleft", + "start": true, + "hidden": true, + "category": "System", + "menu": "Info [G]", + "ptype": "global" + }, + ... + ] + + +The keys for each object are defined as follows: + +* ``__bunch__``: should be present and set to ``true`` to force + deserialization to a `~ginga.misc.Bunch.Bunch`. +* ``module``: The name of the module in the ``$PYTHONPATH`` containing + the plugin. +* ``class``: if present, indicates the name of the class within the + module that denotes the plugin (if not present the class is assumed + to be named identically to the module). +* ``tab``: the name that the plugin should appear as when opened in a + workspace (usually as a tab, but it depends on the type of + workspace). Often the same name as the class, but can be different. + If not present, defaults to the class or module name of the plugin. +* ``workspace``: the name of the workspace defined in the layout file + (or default layout) where the plugin should be started (see section + below on workspace customization). +* ``start``: ``true`` if the module is of the global type and should + be started at program startup. Defaults to ``false``. +* ``hidden``: ``true`` if the plugin should be hidden from the + "Operation" and "Plugins" menus. Often paired with ``hidden`` being + ``true`` for plugins that are considered to be a necessary part of + continuous operation from program startup to shutdown. Defaults to + ``false``. +* ``category``: an arbitrary organizational name under which plugins + are organized in the ``Operation`` and ``Plugins`` menus. +* ``menu``: a name for how the plugin should appear under the category + in the menu structure. The convention is to append "[G]" if it is + a global plugin. +* ``ptype``: either "local" or "global", depending on whether the + plugin is a local or global one. +* ``optray``: to prevent a control icon from appearing in the + ``Operations`` plugin manager tray specify ``false``. The default for + non-hidden plugins is ``true`` and for hidden plugins ``false``. +* ``enabled``: ``false`` to disable the plugin from being loaded. + +See the standard set of plugins in +``.../ginga/examples/layouts/standard/plugins.json`` + + +Custom plugin directory +----------------------- + +If there is a ``plugins`` directory in the configuration area, it is added +to the ``PYTHONPATH`` for the purpose of loading plugins. You can put +plugin modules in this directory, and then add entries to the +``plugins.json`` file described above to add new, custom plugins. + +==================================================================== +Customizing the Reference Viewer (with Python) During Initialization +==================================================================== + +For the ultimate flexibility, the reference viewer can be customized +during viewer initialization using a Python module called ``ginga_config``, +which can be anywhere in the user's Python import path, including in the +Ginga configuration folder described above. + +Specifically, this file will be imported and two methods will be run if +defined: ``pre_gui_config(ginga_shell)`` and +``post_gui_config(ginga_shell)``. +The parameter to each function is the main viewer shell. These functions +can be used to define a different viewer layout, add or remove plugins, +add menu entries, add custom image or star catalogs, etc. We will refer +back to these functions in the sections below. + +Workspace configuration +----------------------- + +You can create a layout table (as described above in "Customizing the +Workspace") as a Python data structure, and then replace the default +layout table in the ``pre_gui_config()`` method described above:: + + my_layout = [ + ... + ] + + def pre_gui_config(ginga_shell): + ... + + ginga_shell.set_layout(my_layout) + +If done in the ``pre_gui_config()`` method (as shown) the new layout will +be the one that is used when the GUI is constructed. See the default +layout in ``~ginga.rv.main`` as an example. + +Start Plugins and Create Channels +--------------------------------- + +You can create channels and start plugins using the +``post_gui_config()`` method. -Not all plugins provided by Ginga are automatically started up by default. A plugin can be started automatically in ``post_gui_config()`` using the ``start_global_plugin()`` or ``start_local_plugin()`` methods, as appropriate:: @@ -259,7 +338,6 @@ ginga --plugins=MyLocalPlugin,Imexam --modules=MyGlobalPlugin -============== Adding Plugins ============== @@ -280,26 +358,6 @@ module called "DQCheck". When invoked from the Operations menu it would occupy a spot in the "dialogs" workspace (see layout discussion above). -Other keywords that can be used in a spec: - -* Global plugins use `ptype='global'`. - -* If a plugin should be hidden from the menus (e.g. it is started under - program control, not by the user), specify `hidden=True`. - -* If the plugin should be started when the program starts, specify - `start=True`. - -* To use a different name in the menu for starting the plugin, specify - `menu="Custom Name"`. - -* To use a different name in the tab that is showing the plugin GUI, - specify `tab="Tab Name"`. - -* To prevent a control icon from appearing in the Operations plugin - manager tray specify `optray=False`. - -================= Disabling Plugins ================= @@ -315,6 +373,10 @@ disable_plugins = "Zoom,Compose" +Finally, if you are using a custom "plugins.json" file as described +above, you can simply set the ``enabled`` attribute to ``False`` in the +JSON object for that plugin in the file. + Some plugins, like ``Operations``, when disabled, may result in inconvenient GUI experience. @@ -323,15 +385,21 @@ ============================== You can make a custom startup script to make the same reference viewer -configuration available without relying on the ``ginga_config`` module in -a personal settings area. To do this we make use of the `~ginga.rv.main` module:: +configuration available without relying on a custom set of startup files +or the ``ginga_config`` module. To do this we make use of the +`~ginga.rv.main` module: + +.. code-block:: python import sys + from argparse import ArgumentParser + from ginga.rv.main import ReferenceViewer - from optparse import OptionParser + # define your custom layout my_layout = [ ... ] + # define your custom plugin list plugins = [ ... ] if __name__ == "__main__": @@ -340,11 +408,9 @@ for spec in plugins: viewer.add_plugin(spec) - # Parse command line options with optparse module - usage = "usage: %prog [options] cmd [args]" - optprs = OptionParser(usage=usage) - viewer.add_default_options(optprs) - - (options, args) = optprs.parse_args(sys_argv[1:]) + argprs = ArgumentParser(description="Run my custom viewer.") + viewer.add_default_options(argprs) + (options, args) = argprs.parse_known_args(sys_argv[1:]) viewer.main(options, args) + Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/doc/manual/plugins_global/figures/zoom-plugin.png and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/doc/manual/plugins_global/figures/zoom-plugin.png differ diff -Nru ginga-3.0.0/doc/manual/plugins_global/zoom.rst ginga-3.1.0/doc/manual/plugins_global/zoom.rst --- ginga-3.0.0/doc/manual/plugins_global/zoom.rst 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/doc/manual/plugins_global/zoom.rst 2019-11-29 07:54:06.000000000 +0000 @@ -4,7 +4,7 @@ ==== .. image:: figures/zoom-plugin.png - :width: 250px + :width: 800px :align: center :alt: Zoom plugin diff -Nru ginga-3.0.0/doc/manual/plugins_local/collage.rst ginga-3.1.0/doc/manual/plugins_local/collage.rst --- ginga-3.0.0/doc/manual/plugins_local/collage.rst 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/doc/manual/plugins_local/collage.rst 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,13 @@ +.. _sec-plugins-collage: + +Collage +======= + +.. image:: figures/collage-plugin.png + :width: 800px + :align: center + :alt: Collage plugin + +.. automodapi:: ginga.rv.plugins.Collage + :no-heading: + :skip: Collage Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/doc/manual/plugins_local/figures/autocuts-prefs.png and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/doc/manual/plugins_local/figures/autocuts-prefs.png differ Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/doc/manual/plugins_local/figures/collage-plugin.png and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/doc/manual/plugins_local/figures/collage-plugin.png differ Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/doc/manual/plugins_local/figures/mosaic-plugin.png and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/doc/manual/plugins_local/figures/mosaic-plugin.png differ diff -Nru ginga-3.0.0/doc/manual/plugins_local/mosaic.rst ginga-3.1.0/doc/manual/plugins_local/mosaic.rst --- ginga-3.0.0/doc/manual/plugins_local/mosaic.rst 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/doc/manual/plugins_local/mosaic.rst 2020-07-08 20:09:29.000000000 +0000 @@ -3,6 +3,11 @@ Mosaic ====== +.. image:: figures/mosaic-plugin.png + :width: 800px + :align: center + :alt: Mosaic plugin + .. automodapi:: ginga.rv.plugins.Mosaic :no-heading: :skip: Mosaic diff -Nru ginga-3.0.0/doc/manual/plugins.rst ginga-3.1.0/doc/manual/plugins.rst --- ginga-3.0.0/doc/manual/plugins.rst 2019-07-31 01:35:09.000000000 +0000 +++ ginga-3.1.0/doc/manual/plugins.rst 2020-07-08 20:09:29.000000000 +0000 @@ -83,6 +83,7 @@ plugins_local/preferences plugins_local/catalogs plugins_local/mosaic + plugins_local/collage plugins_local/drawing plugins_local/fbrowser plugins_local/compose diff -Nru ginga-3.0.0/doc/WhatsNew.rst ginga-3.1.0/doc/WhatsNew.rst --- ginga-3.0.0/doc/WhatsNew.rst 2019-09-21 03:11:38.000000000 +0000 +++ ginga-3.1.0/doc/WhatsNew.rst 2020-07-20 22:22:49.000000000 +0000 @@ -2,6 +2,34 @@ What's New ++++++++++ +Ver 3.1.0 (2020-07-20) +====================== +- Zoom and Pan plugins refactored. Now shows graphical overlays. +- Improved performance of rendering when flipping, swapping axes or + rotating viewer. +- Fixed a bug where the display was not redrawn if an ICC profile was + changed +- Fixed bugs relating to drawing XRange, YRange and Rectangle objects on + rotated canvas +- Fixed a bug with fit image to window (zoom_fit) which was off by half + a pixel +- Fixed an issue where an error message appears in the log if the scale + is so small the image is invisible +- Fixed an issue where the readout under the cursor for value is + reported for an empty row to the left and column below of pixels +- Removed dependence on astropy-helpers submodule. +- Fixed an issue where limits were not reset correctly if image being + viewed is modified in place (and data array changes size) +- Fixed an issue with Mosaic plugin where images with a PC matrix were + not always oriented correctly +- New Collage plugin offers an efficient alternative way to view mosaics +- Fix for a bug where using Zoom and PixTable at the same time can cause + wrong results to be displayed in PixTable +- New ability to specify alternative Ginga home directories, with custom + layouts and plugin configurations (--basedir option) +- Fix for a bug that caused a crash when closing the Help window with + Qt/PySide backend + Ver 3.0.0 (2019-09-20) ====================== - Dropped Python 2 support. Ginga now requires Python 3.5 or later. diff -Nru ginga-3.0.0/ginga/aggw/AggHelp.py ginga-3.1.0/ginga/aggw/AggHelp.py --- ginga-3.0.0/ginga/aggw/AggHelp.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/aggw/AggHelp.py 2020-01-20 03:17:53.000000000 +0000 @@ -43,12 +43,8 @@ self.canvas = canvas def get_color(self, color): - if isinstance(color, str) or isinstance(color, type(u"")): - r, g, b = colors.lookup_color(color) - elif isinstance(color, tuple): - # color is assumed to be a 3-tuple of RGB values as floats - # between 0 and 1 - r, g, b = color + if color is not None: + r, g, b = colors.resolve_color(color) else: r, g, b = 1.0, 1.0, 1.0 diff -Nru ginga-3.0.0/ginga/aggw/CanvasRenderAgg.py ginga-3.1.0/ginga/aggw/CanvasRenderAgg.py --- ginga-3.0.0/ginga/aggw/CanvasRenderAgg.py 2019-09-09 18:09:55.000000000 +0000 +++ ginga-3.1.0/ginga/aggw/CanvasRenderAgg.py 2020-07-08 20:09:29.000000000 +0000 @@ -105,6 +105,10 @@ ##### DRAWING OPERATIONS ##### + def draw_image(self, cvs_img, cpoints, rgb_arr, whence, order='RGBA'): + # no-op for this renderer + pass + def draw_text(self, cx, cy, text, rot_deg=0.0): wd, ht = self.cr.text_extents(text, self.font) @@ -159,10 +163,10 @@ self.cr.canvas.path(path, self.pen, self.brush) -class CanvasRenderer(render.RendererBase): +class CanvasRenderer(render.StandardPixelRenderer): def __init__(self, viewer): - render.RendererBase.__init__(self, viewer) + render.StandardPixelRenderer.__init__(self, viewer) self.kind = 'agg' self.rgb_order = 'RGBA' @@ -174,11 +178,12 @@ given dimensions. """ width, height = dims[:2] - self.dims = (width, height) self.logger.debug("renderer reconfigured to %dx%d" % ( width, height)) # create agg surface the size of the window - self.surface = agg.Draw(self.rgb_order, self.dims, 'black') + self.surface = agg.Draw(self.rgb_order, (width, height), 'black') + + super(CanvasRenderer, self).resize(dims) def render_image(self, rgbobj, dst_x, dst_y): """Render the image represented by (rgbobj) at dst_x, dst_y @@ -222,4 +227,10 @@ cr.set_font_from_shape(shape) return cr.text_extents(shape.text) + def text_extents(self, text, font): + cr = RenderContext(self, self.viewer, self.surface) + cr.set_font(font.fontname, font.fontsize, color=font.color, + alpha=font.alpha) + return cr.text_extents(text) + #END diff -Nru ginga-3.0.0/ginga/aggw/ImageViewAgg.py ginga-3.1.0/ginga/aggw/ImageViewAgg.py --- ginga-3.0.0/ginga/aggw/ImageViewAgg.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/aggw/ImageViewAgg.py 2020-07-08 20:09:29.000000000 +0000 @@ -20,11 +20,11 @@ self.renderer = CanvasRenderer(self) - def update_image(self): + def update_widget(self): pass def configure_window(self, width, height): - self.configure_surface(width, height) + self.configure(width, height) class CanvasView(ImageViewAgg): diff -Nru ginga-3.0.0/ginga/aggw/ImageViewCanvasAgg.py ginga-3.1.0/ginga/aggw/ImageViewCanvasAgg.py --- ginga-3.0.0/ginga/aggw/ImageViewCanvasAgg.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/aggw/ImageViewCanvasAgg.py 2020-07-08 20:09:29.000000000 +0000 @@ -34,7 +34,7 @@ def reschedule_redraw(self, time_sec): pass - def update_image(self): + def update_widget(self): pass # METHODS THAT WERE IN IPG diff -Nru ginga-3.0.0/ginga/aggw/ImageViewCanvasTypesAgg.py ginga-3.1.0/ginga/aggw/ImageViewCanvasTypesAgg.py --- ginga-3.0.0/ginga/aggw/ImageViewCanvasTypesAgg.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/aggw/ImageViewCanvasTypesAgg.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# TODO: this line is for backward compatibility with files importing -# this module--to be removed -from ginga.canvas.types.all import * # noqa - -# END diff -Nru ginga-3.0.0/ginga/AstroImage.py ginga-3.1.0/ginga/AstroImage.py --- ginga-3.0.0/ginga/AstroImage.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/AstroImage.py 2020-07-08 20:09:29.000000000 +0000 @@ -13,7 +13,6 @@ from ginga.util import wcs, wcsmod from ginga.BaseImage import BaseImage, ImageError, Header -from ginga.misc import Bunch class AstroHeader(Header): @@ -91,6 +90,7 @@ # this is a handle to the full data array self._md_data = data + self.axisdim = data.shape # this will get reset in set_naxispath() if array is # multidimensional @@ -108,6 +108,14 @@ def load_hdu(self, hdu, fobj=None, naxispath=None, inherit_primary_header=None): + # this seems to be necessary now for some fits files... + try: + hdu.verify('fix') + + except Exception as e: + # Let's hope for the best! + self.logger.warning("Problem verifying fits HDU: {}".format(e)) + self.clear_metadata() # collect HDU header @@ -480,14 +488,7 @@ # <----- TODO: deprecate def info_xy(self, data_x, data_y, settings): - # Get the value under the data coordinates - try: - # We report the value across the pixel, even though the coords - # change halfway across the pixel - value = self.get_data_xy(int(data_x + 0.5), int(data_y + 0.5)) - - except Exception as e: - value = None + info = super(AstroImage, self).info_xy(data_x, data_y, settings) system = settings.get('wcs_coords', None) format = settings.get('wcs_display', 'sexagesimal') @@ -558,11 +559,8 @@ tb_str = "Traceback information unavailable." self.logger.error(tb_str) - info = Bunch.Bunch(itype='astro', data_x=data_x, data_y=data_y, - x=data_x, y=data_y, - ra_txt=ra_txt, dec_txt=dec_txt, - ra_lbl=ra_lbl, dec_lbl=dec_lbl, - value=value) + info.update(dict(itype='astro', ra_txt=ra_txt, dec_txt=dec_txt, + ra_lbl=ra_lbl, dec_lbl=dec_lbl)) return info # END diff -Nru ginga-3.0.0/ginga/_astropy_init.py ginga-3.1.0/ginga/_astropy_init.py --- ginga-3.0.0/ginga/_astropy_init.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/_astropy_init.py 2020-01-20 03:17:53.000000000 +0000 @@ -1,138 +1,40 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst +import os +from warnings import warn -__all__ = ['__version__', '__githash__', 'test'] - -# this indicates whether or not we are in the package's setup.py -try: - _ASTROPY_SETUP_ -except NameError: - import builtins - builtins._ASTROPY_SETUP_ = False +from astropy.config.configuration import ( + update_default_config, ConfigurationDefaultMissingError, + ConfigurationDefaultMissingWarning) +from astropy.tests.runner import TestRunner try: from .version import version as __version__ except ImportError: __version__ = '' -try: - from .version import githash as __githash__ -except ImportError: - __githash__ = '' - -# set up the test command -def _get_test_runner(): - import os - from astropy.tests.helper import TestRunner - return TestRunner(os.path.dirname(__file__)) - - -def test(package=None, test_path=None, args=None, plugins=None, - verbose=False, pastebin=None, remote_data=False, pep8=False, - pdb=False, coverage=False, open_files=False, **kwargs): - """ - Run the tests using `py.test `__. A proper set - of arguments is constructed and passed to `pytest.main`_. - - .. _py.test: http://pytest.org/latest/ - .. _pytest.main: http://pytest.org/latest/builtin.html#pytest.main - - Parameters - ---------- - package : str, optional - The name of a specific package to test, e.g. 'io.fits' or 'utils'. - If nothing is specified all default tests are run. - - test_path : str, optional - Specify location to test by path. May be a single file or - directory. Must be specified absolutely or relative to the - calling directory. - - args : str, optional - Additional arguments to be passed to pytest.main_ in the ``args`` - keyword argument. - - plugins : list, optional - Plugins to be passed to pytest.main_ in the ``plugins`` keyword - argument. - - verbose : bool, optional - Convenience option to turn on verbose output from py.test_. Passing - True is the same as specifying ``'-v'`` in ``args``. - - pastebin : {'failed','all',None}, optional - Convenience option for turning on py.test_ pastebin output. Set to - ``'failed'`` to upload info for failed tests, or ``'all'`` to upload - info for all tests. - - remote_data : bool, optional - Controls whether to run tests marked with @remote_data. These - tests use online data and are not run by default. Set to True to - run these tests. - - pep8 : bool, optional - Turn on PEP8 checking via the `pytest-pep8 plugin - `_ and disable normal - tests. Same as specifying ``'--pep8 -k pep8'`` in ``args``. - - pdb : bool, optional - Turn on PDB post-mortem analysis for failing tests. Same as - specifying ``'--pdb'`` in ``args``. - - coverage : bool, optional - Generate a test coverage report. The result will be placed in - the directory htmlcov. - - open_files : bool, optional - Fail when any tests leave files open. Off by default, because - this adds extra run time to the test suite. Requires the - `psutil `_ package. - - parallel : int, optional - When provided, run the tests in parallel on the specified - number of CPUs. If parallel is negative, it will use the all - the cores on the machine. Requires the - `pytest-xdist `_ plugin - installed. Only available when using Astropy 0.3 or later. - - kwargs - Any additional keywords passed into this function will be passed - on to the astropy test runner. This allows use of test-related - functionality implemented in later versions of astropy without - explicitly updating the package template. - - """ - test_runner = _get_test_runner() - return test_runner.run_tests( - package=package, test_path=test_path, args=args, - plugins=plugins, verbose=verbose, pastebin=pastebin, - remote_data=remote_data, pep8=pep8, pdb=pdb, - coverage=coverage, open_files=open_files, **kwargs) - - -if not _ASTROPY_SETUP_: # noqa - import os - from warnings import warn - from astropy.config.configuration import ( - update_default_config, ConfigurationDefaultMissingError, - ConfigurationDefaultMissingWarning) - - # add these here so we only need to cleanup the namespace at the end - config_dir = None - - if not os.environ.get('ASTROPY_SKIP_CONFIG_UPDATE', False): - config_dir = os.path.dirname(__file__) - config_template = os.path.join(config_dir, __package__ + ".cfg") - if os.path.isfile(config_template): +# Create the test function for self test +test = TestRunner.make_test_runner_in(os.path.dirname(__file__)) +test.__test__ = False + +__all__ = ['__version__', 'test'] + +# add these here so we only need to cleanup the namespace at the end +config_dir = None + +if not os.environ.get('ASTROPY_SKIP_CONFIG_UPDATE', False): + config_dir = os.path.dirname(__file__) + config_template = os.path.join(config_dir, __package__ + ".cfg") + if os.path.isfile(config_template): + try: + update_default_config( + __package__, config_dir, version=__version__) + except TypeError as orig_error: try: - update_default_config( - __package__, config_dir, version=__version__) - except TypeError as orig_error: - try: - update_default_config(__package__, config_dir) - except ConfigurationDefaultMissingError as e: - wmsg = (e.args[0] + " Cannot install default profile. If " - "you are importing from source, this is expected.") - warn(ConfigurationDefaultMissingWarning(wmsg)) - del e - except Exception: - raise orig_error + update_default_config(__package__, config_dir) + except ConfigurationDefaultMissingError as e: + wmsg = (e.args[0] + " Cannot install default profile. If " + "you are importing from source, this is expected.") + warn(ConfigurationDefaultMissingWarning(wmsg)) + del e + except Exception: + raise orig_error diff -Nru ginga-3.0.0/ginga/AutoCuts.py ginga-3.1.0/ginga/AutoCuts.py --- ginga-3.0.0/ginga/AutoCuts.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/AutoCuts.py 2019-11-30 21:27:55.000000000 +0000 @@ -5,7 +5,6 @@ # Please see the file LICENSE.txt for details. # import numpy as np -import threading from ginga.misc import Bunch #from ginga.misc.ParamSet import Param @@ -15,15 +14,10 @@ autocut_methods = ('minmax', 'median', 'histogram', 'stddev', 'zscale') try: import scipy.ndimage.filters - import scipy.optimize as optimize - #import scipy.misc except ImportError: have_scipy = False autocut_methods = ('minmax', 'histogram', 'stddev', 'zscale') -# Lock to work around a non-threadsafe bug in scipy -_lock = threading.RLock() - class Param(Bunch.Bunch): pass @@ -72,22 +66,19 @@ def cut_levels(self, data, loval, hival, vmin=0.0, vmax=255.0): loval, hival = float(loval), float(hival) + # ensure hival >= loval + hival = max(loval, hival) self.logger.debug("loval=%.2f hival=%.2f" % (loval, hival)) delta = hival - loval - if delta != 0.0: - data = data.clip(loval, hival) - f = ((data - loval) / delta) - else: - #f = (data - loval).clip(0.0, 1.0) - f = data - loval - f.clip(0.0, 1.0, out=f) - # threshold - f[np.nonzero(f)] = 1.0 - - # f = f.clip(0.0, 1.0) * vmax - # NOTE: optimization using in-place outputs for speed - f.clip(0.0, 1.0, out=f) - np.multiply(f, vmax, out=f) + if delta > 0.0: + f = (((data - loval) / delta) * vmax) + # NOTE: optimization using in-place outputs for speed + f.clip(0.0, vmax, out=f) + return f + + # hival == loval, so thresholding operation + f = (data - loval).clip(0.0, vmax) + f[f > 0.0] = vmax return f def __str__(self): @@ -447,210 +438,6 @@ return loval, hival -class ZScale2(AutoCutsBase): - - @classmethod - def get_params_metadata(cls): - return [ - Param(name='contrast', type=float, - default=0.25, allow_none=True, - description="Contrast"), - Param(name='num_points', type=int, - default=600, allow_none=True, - description="Number of points to sample"), - Param(name='num_per_row', type=int, - default=None, allow_none=True, - description="Number of points to sample"), - ] - - def __init__(self, logger, contrast=0.25, num_points=1000, - num_per_row=None): - super(ZScale2, self).__init__(logger) - - self.kind = 'zscale' - self.contrast = contrast - self.num_points = num_points - self.num_per_row = num_per_row - - def calc_cut_levels(self, image): - data = image.get_data() - - loval, hival = self.calc_zscale(data, contrast=self.contrast, - num_points=self.num_points, - num_per_row=self.num_per_row) - return loval, hival - - def calc_zscale(self, data, contrast=0.25, - num_points=1000, num_per_row=None): - """ - From the IRAF documentation: - - The zscale algorithm is designed to display the image values - near the median image value without the time consuming process of - computing a full image histogram. This is particularly useful for - astronomical images which generally have a very peaked histogram - corresponding to the background sky in direct imaging or the - continuum in a two dimensional spectrum. - - The sample of pixels, specified by values greater than zero in the - sample mask zmask or by an image section, is selected up to a - maximum of nsample pixels. If a bad pixel mask is specified by the - bpmask parameter then any pixels with mask values which are greater - than zero are not counted in the sample. Only the first pixels up - to the limit are selected where the order is by line beginning from - the first line. If no mask is specified then a grid of pixels with - even spacing along lines and columns that make up a number less - than or equal to the maximum sample size is used. - - If a contrast of zero is specified (or the zrange flag is used and - the image does not have a valid minimum/maximum value) then the - minimum and maximum of the sample is used for the intensity mapping - range. - - If the contrast is not zero the sample pixels are ranked in - brightness to form the function I(i), where i is the rank of the - pixel and I is its value. Generally the midpoint of this function - (the median) is very near the peak of the image histogram and there - is a well defined slope about the midpoint which is related to the - width of the histogram. At the ends of the I(i) function there are - a few very bright and dark pixels due to objects and defects in the - field. To determine the slope a linear function is fit with - iterative rejection; - - I(i) = intercept + slope * (i - midpoint) - - If more than half of the points are rejected then there is no well - defined slope and the full range of the sample defines z1 and z2. - Otherwise the endpoints of the linear function are used (provided - they are within the original range of the sample): - - z1 = I(midpoint) + (slope / contrast) * (1 - midpoint) - z2 = I(midpoint) + (slope / contrast) * (npoints - midpoint) - - As can be seen, the parameter contrast may be used to adjust the - contrast produced by this algorithm. - """ - - assert len(data.shape) >= 2, \ - AutoCutsError("input data should be 2D or greater") - ht, wd = data.shape[:2] - - assert (0.0 < contrast <= 1.0), \ - AutoCutsError("contrast (%.2f) not in range 0 < c <= 1" % ( - contrast)) - - # calculate num_points parameter, if omitted - total_points = np.size(data) - if num_points is None: - num_points = max(int(total_points * 0.0002), 600) - num_points = min(num_points, total_points) - - assert (0 < num_points <= total_points), \ - AutoCutsError("num_points not in range 0-%d" % (total_points)) - - # calculate num_per_row parameter, if omitted - if num_per_row is None: - num_per_row = max(int(0.015 * num_points), 1) - self.logger.debug("contrast=%.4f num_points=%d num_per_row=%d" % ( - contrast, num_points, num_per_row)) - - # sample the data - num_rows = num_points // num_per_row - xmax = wd - 1 - xskip = max(xmax // num_per_row, 1) - ymax = ht - 1 - yskip = max(ymax // num_rows, 1) - # evenly spaced sampling over rows and cols - ## xskip = int(max(1.0, np.sqrt(xmax * ymax / float(num_points)))) - ## yskip = xskip - - cutout = data[0:ymax:yskip, 0:xmax:xskip] - # flatten and trim off excess - cutout = cutout.flat[0:num_points] - - # actual number of points selected - num_pix = len(cutout) - assert num_pix <= num_points, \ - AutoCutsError("Actual number of points (%d) exceeds calculated " - "number (%d)" % (num_pix, num_points)) - - # sort the data by value - cutout = np.sort(cutout) - - # flat distribution? - data_min = np.nanmin(cutout) - data_max = np.nanmax(cutout) - if (data_min == data_max) or (contrast == 0.0): - return (data_min, data_max) - - # compute the midpoint and median - midpoint = (num_pix // 2) - if num_pix % 2 != 0: - median = cutout[midpoint] - else: - median = 0.5 * (cutout[midpoint - 1] + cutout[midpoint]) - self.logger.debug("num_pix=%d midpoint=%d median=%.4f" % ( - num_pix, midpoint, median)) - - ## # Remove outliers to aid fitting - ## threshold = np.std(cutout) * 2.5 - ## cutout = cutout[np.where(np.fabs(cutout - median) > threshold)] - ## num_pix = len(cutout) - - # zscale fitting function: - # I(x) = slope * (x - midpoint) + intercept - def fitting(x, slope, intercept): - y = slope * (x - midpoint) + intercept - return y - - # compute a least squares fit - X = np.arange(num_pix) - Y = cutout - sigma = np.array([1.0] * num_pix) - guess = np.array([0.0, 0.0]) - - # Curve fit - with _lock: - # NOTE: without this mutex, optimize.curvefit causes a fatal error - # sometimes--it appears not to be thread safe. - # The error is: - # "SystemError: null argument to internal routine" - # "Fatal Python error: GC object already tracked" - try: - p, cov = optimize.curve_fit(fitting, X, Y, guess, sigma) - - except Exception as e: - self.logger.debug("curve fitting failed: %s" % (str(e))) - cov = None - - if cov is None: - self.logger.debug("curve fitting failed") - return (float(data_min), float(data_max)) - - slope, intercept = p - ## num_chosen = 0 - self.logger.debug("intercept=%f slope=%f" % ( - intercept, slope)) - - ## if num_chosen < (num_pix // 2): - ## self.logger.debug("more than half pixels rejected--falling back to min/max of sample") - ## return (data_min, data_max) - - # finally, compute the range - falloff = slope / contrast - z1 = median - midpoint * falloff - z2 = median + (num_pix - midpoint) * falloff - - # final sanity check on cut levels - locut = max(z1, data_min) - hicut = min(z2, data_max) - if locut >= hicut: - locut = data_min - hicut = data_max - - return (float(locut), float(hicut)) - - # funky boolean converter _bool = lambda st: str(st).lower() == 'true' # noqa @@ -661,7 +448,6 @@ 'histogram': Histogram, 'median': MedianFilter, 'zscale': ZScale, - #'zscale2': ZScale2, } diff -Nru ginga-3.0.0/ginga/BaseImage.py ginga-3.1.0/ginga/BaseImage.py --- ginga-3.0.0/ginga/BaseImage.py 2019-08-29 00:23:59.000000000 +0000 +++ ginga-3.1.0/ginga/BaseImage.py 2020-07-08 20:09:29.000000000 +0000 @@ -63,7 +63,7 @@ raise KeyError(kwd) def get_list(self, *args): - return list(map(self.get, args)) + return [self.get(kwd) for kwd in args] def __getitem__(self, kwd): return self.metadata[kwd] @@ -87,12 +87,13 @@ name=name) if data_np is None: - data_np = np.zeros((1, 1)) + data_np = np.zeros((0, 0)) self._data = data_np self.order = '' self.name = name # For navigating multidimensional data + self.axisdim = [] self.naxispath = [] self.revnaxis = [] @@ -107,9 +108,9 @@ @property def width(self): - # NOTE: numpy stores data in column-major layout if self.ndim < 2: return 0 + # NOTE: numpy stores data in column-major layout return self.shape[1] @property @@ -170,7 +171,12 @@ ImageError("Indexes out of range: (x=%d, y=%d)" % ( x, y)) view = np.s_[y, x] - return self._slice(view) + res = self._slice(view) + if isinstance(res, np.ndarray) and self.get('ignore_alpha', False): + # <-- this image has a "hidden" alpha array + # NOTE: assumes that data is at index 0 + res = res[0] + return res def set_data(self, data_np, metadata=None, order=None, astype=None): """Use this method to SHARE (not copy) the incoming array. @@ -195,10 +201,11 @@ super(BaseImage, self).clear_all() # unreference data array - self._data = np.zeros((1, 1)) + self._data = np.zeros((0, 0)) def _slice(self, view): - view = tuple(view) + if not isinstance(view, tuple): + view = tuple(view) return self._get_data()[view] def get_slice(self, c): @@ -243,7 +250,7 @@ if depth == 1: self.order = 'M' elif depth == 2: - self.order = 'AM' + self.order = 'MA' elif depth == 3: self.order = 'RGB' elif depth == 4: @@ -298,16 +305,43 @@ other = self.__class__(data_np=data, metadata=metadata) return other - def cutout_data(self, x1, y1, x2, y2, xstep=1, ystep=1, astype=None): - """cut out data area based on coords. + def cutout_data(self, x1, y1, x2, y2, xstep=1, ystep=1, z=None, + astype=None): + """Cut out data area based on bounded coordinates. + + Parameters + ---------- + x1, y1 : int + Coordinates defining the minimum corner to be cut out + + x2, y2 : int + Coordinates *one greater* than the maximum corner + + xstep, ystep : int + Step values for skip intervals in the cutout region + + z : int + Value for a depth (slice) component for color images + + astype : + + Note that the coordinates for `x2`, `y2` are *outside* the + cutout region, similar to slicing parameters in Python. """ view = np.s_[y1:y2:ystep, x1:x2:xstep] - data = self._slice(view) + data_np = self._slice(view) + if z is not None and len(data_np.shape) > 2: + data_np = data_np[..., z] if astype: - data = data.astype(astype, copy=False) - return data + data_np = data_np.astype(astype, copy=False) + return data_np - def cutout_adjust(self, x1, y1, x2, y2, xstep=1, ystep=1, astype=None): + def cutout_adjust(self, x1, y1, x2, y2, xstep=1, ystep=1, z=0, astype=None): + """Like `cutout_data`, but adjusts coordinates `x1`, `y1`, `x2`, `y2` + to be inside the data area if they are not already. It tries to + preserve the width and height of the region, so e.g. (-2, -2, 5, 5) + could become (0, 0, 7, 7) + """ dx = x2 - x1 dy = y2 - y1 @@ -326,14 +360,13 @@ y1 = y2 - dy data = self.cutout_data(x1, y1, x2, y2, xstep=xstep, ystep=ystep, - astype=astype) + z=z, astype=astype) return (data, x1, y1, x2, y2) def cutout_radius(self, x, y, radius, xstep=1, ystep=1, astype=None): return self.cutout_adjust(x - radius, y - radius, x + radius + 1, y + radius + 1, - xstep=xstep, ystep=ystep, - astype=astype) + xstep=xstep, ystep=ystep, astype=astype) def cutout_cross(self, x, y, radius): """Cut two data subarrays that have a center at (x, y) and with @@ -408,82 +441,54 @@ def get_scaled_cutout_wdht(self, x1, y1, x2, y2, new_wd, new_ht, method='basic'): - """Extract a region of the image defined by corners (x1, y1) and - (x2, y2) and resample it to fit dimensions (new_wd, new_ht). - - `method` describes the method of interpolation used, where the - default "basic" is nearest neighbor. - """ - - if method in ('basic', 'view'): - shp = self.shape - - (view, (scale_x, scale_y)) = \ - trcalc.get_scaled_cutout_wdht_view(shp, x1, y1, x2, y2, - new_wd, new_ht) - newdata = self._slice(view) - - else: - data_np = self._get_data() - (newdata, (scale_x, scale_y)) = \ - trcalc.get_scaled_cutout_wdht(data_np, x1, y1, x2, y2, - new_wd, new_ht, - interpolation=method, - logger=self.logger) + # TO BE DEPRECATED + data_np = self._get_data() + (newdata, (scale_x, scale_y)) = \ + trcalc.get_scaled_cutout_wdht(data_np, x1, y1, x2, y2, + new_wd, new_ht, + interpolation=method, + logger=self.logger) res = Bunch.Bunch(data=newdata, scale_x=scale_x, scale_y=scale_y) return res def get_scaled_cutout_basic(self, x1, y1, x2, y2, scale_x, scale_y, method='basic'): - """Extract a region of the image defined by corners (x1, y1) and - (x2, y2) and scale it by scale factors (scale_x, scale_y). + # TO BE DEPRECATED + p1, p2 = (x1, y1), (x2, y2) + scales = (scale_x, scale_y) - `method` describes the method of interpolation used, where the - default "basic" is nearest neighbor. - """ - - new_wd = int(round(scale_x * (x2 - x1 + 1))) - new_ht = int(round(scale_y * (y2 - y1 + 1))) - - return self.get_scaled_cutout_wdht(x1, y1, x2, y2, new_wd, new_ht, - method=method) + return self.get_scaled_cutout2(p1, p2, scales, method=method, + logger=self.logger) def get_scaled_cutout(self, x1, y1, x2, y2, scale_x, scale_y, method='basic', logger=None): - if method in ('basic', 'view'): - return self.get_scaled_cutout_basic(x1, y1, x2, y2, - scale_x, scale_y, - method=method) + # TO BE DEPRECATED + p1, p2 = (x1, y1), (x2, y2) + scales = (scale_x, scale_y) - data = self._get_data() - newdata, (scale_x, scale_y) = trcalc.get_scaled_cutout_basic( - data, x1, y1, x2, y2, scale_x, scale_y, interpolation=method, - logger=logger) - - res = Bunch.Bunch(data=newdata, scale_x=scale_x, scale_y=scale_y) - return res + return self.get_scaled_cutout2(p1, p2, scales, method=method, + logger=logger) def get_scaled_cutout2(self, p1, p2, scales, method='basic', logger=None): + """Extract a region of the image defined by points `p1` and `p2` + and scale it by scale factors `scales`. - if method not in ('basic', 'view') and len(scales) == 2: - # for 2D images with alternate interpolation requirements - return self.get_scaled_cutout(p1[0], p1[1], p2[0], p2[1], - scales[0], scales[1], - method=method) - - shp = self.shape - - view, scales = trcalc.get_scaled_cutout_basic_view( - shp, p1, p2, scales) - - newdata = self._slice(view) + `method` describes the method of interpolation used, where the + default "basic" is nearest neighbor. + """ + if logger is None: + logger = self.logger - scale_x, scale_y = scales[:2] + data = self._get_data() + newdata, oscales = trcalc.get_scaled_cutout_basic2(data, p1, p2, scales, + interpolation=method, + logger=logger) + scale_x, scale_y = oscales[:2] res = Bunch.Bunch(data=newdata, scale_x=scale_x, scale_y=scale_y) if len(scales) > 2: - res.scale_z = scales[2] + res.scale_z = oscales[2] return res @@ -548,14 +553,22 @@ def info_xy(self, data_x, data_y, settings): # Get the value under the data coordinates try: - value = self.get_data_xy(int(data_x), int(data_y)) + # We report the value across the pixel, even though the coords + # change halfway across the pixel + _d_x, _d_y = (int(np.floor(data_x + 0.5)), + int(np.floor(data_y + 0.5))) + value = self.get_data_xy(_d_x, _d_y) except Exception as e: value = None info = Bunch.Bunch(itype='base', data_x=data_x, data_y=data_y, - x=data_x, y=data_y, - value=value) + x=data_x, y=data_y, value=value) + + wd, ht = self.get_size() + if 0 < data_x < wd and 0 < data_y < ht: + info.image_x, info.image_y = data_x, data_y + return info diff -Nru ginga-3.0.0/ginga/Bindings.py ginga-3.1.0/ginga/Bindings.py --- ginga-3.0.0/ginga/Bindings.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/Bindings.py 2020-07-08 20:09:29.000000000 +0000 @@ -13,6 +13,7 @@ from ginga import trcalc from ginga import cmap, imap from ginga.util.paths import icondir +from ginga.util import wcs class ImageViewBindings(object): @@ -827,10 +828,7 @@ def _cut_pct(self, viewer, pct, msg=True): msg = self.settings.get('msg_cuts', msg) - image = viewer.get_image() # noqa loval, hival = viewer.get_cut_levels() - ## minval, maxval = image.get_minmax() - ## spread = maxval - minval spread = hival - loval loval = loval + (pct * spread) hival = hival - (pct * spread) @@ -1000,6 +998,10 @@ def _rotate_xy(self, viewer, x, y, msg=True): msg = self.settings.get('msg_rotate', msg) ctr_x, ctr_y = viewer.get_center() + if None in (self._start_x, self._start_y): + # missed button down event, most likely, or we're getting this + # motion callback too early + return deg1 = math.degrees(math.atan2(ctr_y - self._start_y, self._start_x - ctr_x)) deg2 = math.degrees(math.atan2(ctr_y - y, x - ctr_x)) @@ -1022,7 +1024,7 @@ msg = self.settings.get('msg_orient', msg) image = viewer.get_image() - (x, y, xn, yn, xe, ye) = image.calc_compass_center() + (x, y, xn, yn, xe, ye) = wcs.calc_compass_center(image) degn = math.degrees(math.atan2(xn - x, yn - y)) self.logger.info("degn=%f xe=%f ye=%f" % ( degn, xe, ye)) @@ -1052,14 +1054,14 @@ image = viewer.get_image() if image is None: return - mddata = image.get_mddata() - if len(mddata.shape) < (axis + 1): - # image dimensions < 3D + _axis = len(image.axisdim) - axis + if _axis < 0: + # attempting to access a non-existant axis return + axis_lim = image.axisdim[_axis] naxispath = list(image.naxispath) - axis_lim = mddata.shape[axis] - m = axis - 2 + m = axis - 3 idx = naxispath[m] if direction == 'down': @@ -1848,26 +1850,28 @@ def ms_naxis(self, viewer, event, data_x, data_y, msg=True): + # which axis (in FITS NAXIS terminology) # TODO: be able to pick axis - axis = 2 + axis = 3 x, y = self.get_win_xy(viewer) image = viewer.get_image() if image is None: return - mddata = image.get_mddata() - if len(mddata.shape) < (axis + 1): - # image dimensions < 3D + + _axis = len(image.axisdim) - axis + if _axis < 0: + # attempting to access a non-existant axis return + axis_lim = image.axisdim[_axis] naxispath = list(image.naxispath) - axis_lim = mddata.shape[axis] - m = axis - 2 + m = axis - 3 if event.state in ('down', 'move'): win_wd, win_ht = viewer.get_window_size() - x_pct = x / float(win_wd) - idx = int(x_pct * axis_lim) + x_pct = min(max(0.0, x / float(win_wd)), 1.0) + idx = int(x_pct * axis_lim - 1) naxispath[m] = idx image.set_naxispath(naxispath) @@ -2026,7 +2030,7 @@ by scrolling. """ # TODO: be able to pick axis - axis = 2 + axis = 3 direction = self.get_direction(event.direction) return self._nav_naxis(viewer, axis, direction, msg=msg) @@ -2275,7 +2279,7 @@ return False # TODO: be able to pick axis - axis = 2 + axis = 3 direction = self.get_direction(event.direction) return self._nav_naxis(viewer, axis, direction, msg=msg) @@ -2294,14 +2298,20 @@ # this viewer doesn't have a camera return - delta = event.amount * 6 + zoom_accel = self.settings.get('scroll_zoom_acceleration', 6.0) + delta = event.amount * zoom_accel + direction = self.get_direction(event.direction) if direction == 'down': delta = - delta camera.track(delta) + camera.calc_gl_transform() - viewer.gl_update() + scales = camera.get_scale_2d() + # TODO: need to set scale in viewer settings, without triggering a + # scale operation on this viewer + viewer.update_widget() return True def ms_camera_orbit(self, viewer, event, data_x, data_y, msg=True): @@ -2315,10 +2325,12 @@ if event.state == 'move': camera.orbit(self._start_x, self._start_y, x, y) self._start_x, self._start_y = x, y + camera.calc_gl_transform() ## pos = tuple(camera.position.get()) ## mst = "Camera position: (%.4f, %.4f, %.4f)" % pos ## if msg: ## viewer.onscreen_message(mst, delay=0.5) + tup = camera.position.get() elif event.state == 'down': self._start_x, self._start_y = x, y @@ -2326,7 +2338,7 @@ ## else: ## viewer.onscreen_message(None) - viewer.gl_update() + viewer.update_widget() return True def ms_camera_pan_delta(self, viewer, event, data_x, data_y, msg=True): @@ -2341,6 +2353,7 @@ dx, dy = x - self._start_x, self._start_y - y camera.pan_delta(dx, dy) self._start_x, self._start_y = x, y + camera.calc_gl_transform() elif event.state == 'down': self._start_x, self._start_y = x, y @@ -2350,7 +2363,13 @@ ## else: ## viewer.onscreen_message(None) - viewer.gl_update() + # TODO: need to get the updated pan position and set it in + # viewer's settings without triggering a callback to the viewer + # itself + tup = camera.position.get() + data_x, data_y = viewer.tform['data_to_native'].from_(tup[:2]) + + viewer.update_widget() return True def kp_camera_reset(self, viewer, event, data_x, data_y): @@ -2360,8 +2379,9 @@ return False camera.reset() + camera.calc_gl_transform() viewer.onscreen_message("Reset camera", delay=0.5) - viewer.gl_update() + viewer.update_widget() return True def kp_camera_save(self, viewer, event, data_x, data_y): @@ -2382,7 +2402,7 @@ renderer = viewer.renderer renderer.mode3d = not renderer.mode3d - viewer.gl_update() + viewer.update_widget() return True @@ -2786,7 +2806,7 @@ event = KeyEvent(key=keyname, state='down', mode=self._kbdmode, modifiers=self._modifiers, viewer=viewer, data_x=last_x, data_y=last_y) - return viewer.make_ui_callback(cbname, event, last_x, last_y) + return viewer.make_ui_callback_viewer(viewer, cbname, event, last_x, last_y) def window_key_release(self, viewer, keyname): self.logger.debug("keyname=%s" % (keyname)) @@ -2827,7 +2847,7 @@ modifiers=self._modifiers, viewer=viewer, data_x=last_x, data_y=last_y) - return viewer.make_ui_callback(cbname, event, last_x, last_y) + return viewer.make_ui_callback_viewer(viewer, cbname, event, last_x, last_y) def window_button_press(self, viewer, btncode, data_x, data_y): self.logger.debug("x,y=%d,%d btncode=%s" % (data_x, data_y, @@ -2861,7 +2881,7 @@ event = PointEvent(button=button, state='down', mode=self._kbdmode, modifiers=self._modifiers, viewer=viewer, data_x=data_x, data_y=data_y) - return viewer.make_ui_callback(cbname, event, data_x, data_y) + return viewer.make_ui_callback_viewer(viewer, cbname, event, data_x, data_y) def window_motion(self, viewer, btncode, data_x, data_y): @@ -2892,7 +2912,7 @@ event = PointEvent(button=button, state='move', mode=self._kbdmode, modifiers=self._modifiers, viewer=viewer, data_x=data_x, data_y=data_y) - return viewer.make_ui_callback(cbname, event, data_x, data_y) + return viewer.make_ui_callback_viewer(viewer, cbname, event, data_x, data_y) def window_button_release(self, viewer, btncode, data_x, data_y): self.logger.debug("x,y=%d,%d button=%s" % (data_x, data_y, @@ -2925,7 +2945,7 @@ event = PointEvent(button=button, state='up', mode=self._kbdmode, modifiers=self._modifiers, viewer=viewer, data_x=data_x, data_y=data_y) - return viewer.make_ui_callback(cbname, event, data_x, data_y) + return viewer.make_ui_callback_viewer(viewer, cbname, event, data_x, data_y) def window_scroll(self, viewer, direction, amount, data_x, data_y): try: @@ -2948,7 +2968,7 @@ modifiers=self._modifiers, viewer=viewer, direction=direction, amount=amount, data_x=data_x, data_y=data_y) - return viewer.make_ui_callback(cbname, event) + return viewer.make_ui_callback_viewer(viewer, cbname, event) def window_pinch(self, viewer, state, rot_deg, scale): btncode = 0 @@ -2981,7 +3001,7 @@ self.logger.debug("making callback for %s (mode=%s)" % ( cbname, self._kbdmode)) - return viewer.make_ui_callback(cbname, event) + return viewer.make_ui_callback_viewer(viewer, cbname, event) def window_pan(self, viewer, state, delta_x, delta_y): btncode = 0 @@ -3014,7 +3034,7 @@ self.logger.debug("making callback for %s (mode=%s)" % ( cbname, self._kbdmode)) - return viewer.make_ui_callback(cbname, event) + return viewer.make_ui_callback_viewer(viewer, cbname, event) #END diff -Nru ginga-3.0.0/ginga/cairow/CanvasRenderCairo.py ginga-3.1.0/ginga/cairow/CanvasRenderCairo.py --- ginga-3.0.0/ginga/cairow/CanvasRenderCairo.py 2019-09-09 19:37:52.000000000 +0000 +++ ginga-3.1.0/ginga/cairow/CanvasRenderCairo.py 2020-07-08 20:09:29.000000000 +0000 @@ -29,12 +29,8 @@ self.fill_alpha = 1.0 def __get_color(self, color, alpha): - if isinstance(color, str) or isinstance(color, type(u"")): - r, g, b = colors.lookup_color(color) - elif isinstance(color, tuple): - # color is assumed to be a 3-tuple of RGB values as floats - # between 0 and 1 - r, g, b = color[:3] + if color is not None: + r, g, b = colors.resolve_color(color) else: r, g, b = 1.0, 1.0, 1.0 return (r, g, b, alpha) @@ -124,8 +120,22 @@ ht *= 1.2 return wd, ht + def setup_pen_brush(self, pen, brush): + if pen is not None: + self.set_line(pen.color, alpha=pen.alpha, linewidth=pen.linewidth, + style=pen.linestyle) + + if brush is None: + self.fill = False + else: + self.set_fill(brush.color, alpha=brush.alpha) + ##### DRAWING OPERATIONS ##### + def draw_image(self, cvs_img, cpoints, rgb_arr, whence, order='RGBA'): + # no-op for this renderer + pass + def draw_text(self, cx, cy, text, rot_deg=0.0): self.cr.save() self.cr.translate(cx, cy) @@ -190,10 +200,10 @@ self.cr.new_path() -class CanvasRenderer(render.RendererBase): +class CanvasRenderer(render.StandardPixelRenderer): def __init__(self, viewer): - render.RendererBase.__init__(self, viewer) + render.StandardPixelRenderer.__init__(self, viewer) self.kind = 'cairo' if sys.byteorder == 'little': @@ -208,7 +218,6 @@ given dimensions. """ width, height = dims[:2] - self.dims = (width, height) self.logger.debug("renderer reconfigured to %dx%d" % ( width, height)) @@ -224,6 +233,18 @@ width, height, stride) self.surface = surface + # fill surface with background color; + # this reduces unwanted garbage in the resizing window + cr = cairo.Context(self.surface) + + # fill surface with background color + cr.rectangle(0, 0, width, height) + r, g, b = self.viewer.get_bg() + cr.set_source_rgba(r, g, b) + cr.fill() + + super(CanvasRenderer, self).resize(dims) + def render_image(self, rgbobj, dst_x, dst_y): """Render the image represented by (rgbobj) at dst_x, dst_y in the pixel space. @@ -279,5 +300,10 @@ cr.set_font_from_shape(shape) return cr.text_extents(shape.text) + def text_extents(self, text, font): + cr = RenderContext(self, self.viewer, self.surface) + cr.set_font(font.fontname, font.fontsize, color=font.color, + alpha=font.alpha) + return cr.text_extents(text) # END diff -Nru ginga-3.0.0/ginga/cairow/ImageViewCairo.py ginga-3.1.0/ginga/cairow/ImageViewCairo.py --- ginga-3.0.0/ginga/cairow/ImageViewCairo.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/cairow/ImageViewCairo.py 2020-07-08 20:09:29.000000000 +0000 @@ -68,7 +68,7 @@ self.surface.write_to_png(ibuf) return ibuf - def update_image(self): + def update_widget(self): if not self.surface: return if not self.dst_surface: diff -Nru ginga-3.0.0/ginga/cairow/ImageViewCanvasTypesCairo.py ginga-3.1.0/ginga/cairow/ImageViewCanvasTypesCairo.py --- ginga-3.0.0/ginga/cairow/ImageViewCanvasTypesCairo.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/cairow/ImageViewCanvasTypesCairo.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# TODO: this line is for backward compatibility with files importing -# this module--to be removed -from ginga.canvas.types.all import * # noqa - -# END diff -Nru ginga-3.0.0/ginga/canvas/CanvasObject.py ginga-3.1.0/ginga/canvas/CanvasObject.py --- ginga-3.0.0/ginga/canvas/CanvasObject.py 2019-09-09 18:09:55.000000000 +0000 +++ ginga-3.1.0/ginga/canvas/CanvasObject.py 2020-07-08 20:09:29.000000000 +0000 @@ -157,12 +157,14 @@ # Draw edit control points in different colors than the others if isinstance(pt, EditPoint): cr.set_fill('black', alpha=alpha) - cr.draw_circle(cx, cy, radius + 2.0) + r = cr.renderer.calc_const_len(radius + 2.0) + cr.draw_circle(cx, cy, r) color = pt.edit_color cr.set_fill(color, alpha=alpha) - cr.draw_circle(cx, cy, radius) + r = cr.renderer.calc_const_len(radius) + cr.draw_circle(cx, cy, r) #cr.set_fill(self, None) i += 1 diff -Nru ginga-3.0.0/ginga/canvas/CompoundMixin.py ginga-3.1.0/ginga/canvas/CompoundMixin.py --- ginga-3.0.0/ginga/canvas/CompoundMixin.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/canvas/CompoundMixin.py 2020-07-08 20:09:29.000000000 +0000 @@ -33,6 +33,9 @@ self.opaque = False self._contains_reduce = np.logical_or + def __contains__(self, key): + return key in self.objects + def get_llur(self): """ Get lower-left and upper-right coordinates of the bounding box @@ -63,7 +66,10 @@ def get_items_at(self, pt): res = [] for obj in self.objects: - if obj.contains_pt(pt): + if obj.is_compound() and not obj.opaque: + # non-opaque compound object, list up compatible members + res.extend(obj.get_items_at(pt)) + elif obj.contains_pt(pt): #res.insert(0, obj) res.append(obj) return res @@ -234,12 +240,6 @@ get_center_pt = get_reference_pt - def reorder_layers(self): - self.objects.sort(key=lambda obj: getattr(obj, '_zorder', 0)) - for obj in self.objects: - if obj.is_compound(): - obj.reorder_layers() - def get_points(self): res = [] for obj in self.objects: diff -Nru ginga-3.0.0/ginga/canvas/DrawingMixin.py ginga-3.1.0/ginga/canvas/DrawingMixin.py --- ginga-3.0.0/ginga/canvas/DrawingMixin.py 2019-09-09 18:09:55.000000000 +0000 +++ ginga-3.1.0/ginga/canvas/DrawingMixin.py 2020-07-08 20:09:29.000000000 +0000 @@ -86,7 +86,7 @@ 'cursor-down', 'cursor-up', 'cursor-move', 'draw-scroll', 'keydown-poly_add', 'keydown-poly_del', 'keydown-edit_del', 'edit-event', - 'edit-select', 'drag-drop', 'cursor-changed'): + 'edit-select', 'drag-drop'): self.enable_callback(name) for name in ['key-down', 'key-up', 'btn-down', 'btn-move', 'btn-up', diff -Nru ginga-3.0.0/ginga/canvas/render.py ginga-3.1.0/ginga/canvas/render.py --- ginga-3.0.0/ginga/canvas/render.py 2018-09-11 02:59:35.000000000 +0000 +++ ginga-3.1.0/ginga/canvas/render.py 2020-07-20 21:06:00.000000000 +0000 @@ -5,6 +5,11 @@ # Please see the file LICENSE.txt for details. from io import BytesIO +import math +import time + +import numpy as np + try: import PIL.Image as PILimage @@ -13,8 +18,9 @@ except ImportError: have_PIL = False -from ginga import trcalc +from ginga import trcalc, RGBMap from ginga.fonts import font_asst +from ginga.util import rgb_cms class RenderError(Exception): @@ -31,7 +37,8 @@ self.renderkey = renderer.kind def scale_fontsize(self, fontsize): - return font_asst.scale_fontsize(self.renderkey, fontsize) + # TO BE EVENTUALLY DEPRECATED + return self.renderer.scale_fontsize(fontsize) class RendererBase(object): @@ -42,6 +49,14 @@ self.logger = viewer.get_logger() self.surface = None + def initialize(self): + #raise RenderError("subclass should override this method!") + pass + + def finalize(self): + #raise RenderError("subclass should override this method!") + pass + def get_surface_as_array(self, order=None): raise RenderError("subclass should override this method!") @@ -115,6 +130,727 @@ return arr + def scale(self, scales): + self.viewer.redraw(whence=0) + + def pan(self, pos): + self.viewer.redraw(whence=0) + + def rotate_2d(self, rot_deg): + self.viewer.redraw(whence=2.6) + + def transform_2d(self, state): + self.viewer.redraw(whence=2.5) + + def rgbmap_change(self, rgbmap): + self.viewer.redraw(whence=2) + + def levels_change(self, levels): + self.viewer.redraw(whence=1) + + def bg_change(self, bg): + self.viewer.redraw(whence=3) + + def fg_change(self, fg): + self.viewer.redraw(whence=3) + + def icc_profile_change(self): + self.viewer.redraw(whence=2.3) + + def interpolation_change(self, interp): + self.viewer.redraw(whence=0) + + def limits_change(self, limits): + self.viewer.redraw(whence=3) + + +class StandardPixelRenderer(RendererBase): + """Standard renderer for generating bitmap-based image that can be + copied to an RGB image type-widget or a canvas. + """ + + def __init__(self, viewer): + super(StandardPixelRenderer, self).__init__(viewer) + + # center (and reference) pixel in the screen image (in pixel coords) + self._ctr_x = 1 + self._ctr_y = 1 + + # data indexes at the reference pixel (in data coords) + self._org_x = 0 + self._org_y = 0 + self._org_z = 0 + + # offset from pan position (at center) in this array + self._org_xoff = 0 + self._org_yoff = 0 + + # actual scale factors produced from desired ones + self._org_scale_x = 1.0 + self._org_scale_y = 1.0 + self._org_scale_z = 1.0 + + # see _apply_transforms() and _apply_rotation() + self._xoff = 0 + self._yoff = 0 + + # offsets in the screen image for drawing (in screen coords) + self._dst_x = 0 + self._dst_y = 0 + + # last known dimensions of rendering window + self.dims = (0, 0) + + # order of RGBA channels that renderer needs to work in + self.rgb_order = 'RGBA' + + self.invalidate() + + def get_rgb_order(self): + return self.rgb_order + + def invalidate(self): + # handles to various intermediate arrays + self._rgbarr = None + self._rgbarr2 = None + self._rgbarr3 = None + self._rgbarr4 = None + self._rgbobj = None + + def create_bg_array(self, width, height, order): + # calculate dimensions of window RGB backing image + wd, ht = self._calc_bg_dimensions(width, height) + + # create backing image + depth = len(order) + rgbmap = self.viewer.get_rgbmap() + + # make backing image with the background color + r, g, b = self.viewer.get_bg() + rgba = trcalc.make_filled_array((ht, wd, depth), rgbmap.dtype, + order, r, g, b, 1.0) + + self._rgbarr = rgba + + #self.viewer.redraw(whence=0) + + def get_rgb_object(self, whence=0): + """Create and return RGB slices representing the data + that should be rendered at the current zoom level and pan settings. + + Parameters + ---------- + whence : {0, 1, 2, 3} + Optimization flag that reduces the time to create + the RGB object by only recalculating what is necessary: + + 0: New image, pan/scale has changed + 1: Cut levels or similar has changed + 2: Color mapping has changed + 2.3: ICC profile has changed + 2.5: Transforms have changed + 2.6: Rotation has changed + 3: Graphical overlays have changed + + Returns + ------- + rgbobj : `~ginga.RGBMap.RGBPlanes` + RGB object. + + """ + win_wd, win_ht = self.viewer.get_window_size() + order = self.get_rgb_order() + # NOTE: need to have an alpha channel in place to do overlay_image() + if 'A' not in order: + order = order + 'A' + + if whence <= 0.0: + # confirm and record pan and scale + pan_x, pan_y = self.viewer.get_pan(coord='data')[:2] + scale_x, scale_y = self.viewer.get_scale_xy() + self._confirm_pan_and_scale(scale_x, scale_y, + pan_x, pan_y, + win_wd, win_ht) + + if self._rgbarr is None: + self.create_bg_array(win_wd, win_ht, order) + + t1 = t2 = t3 = time.time() + + if (whence <= 2.0) or (self._rgbarr2 is None): + # Apply any RGB image overlays + self._rgbarr2 = np.copy(self._rgbarr) + p_canvas = self.viewer.get_private_canvas() + self._overlay_images(p_canvas, whence=whence) + + t2 = time.time() + + output_profile = self.viewer.t_.get('icc_output_profile', None) + if output_profile is None: + self._rgbarr3 = self._rgbarr2 + t3 = t2 + + elif (whence <= 2.3) or (self._rgbarr3 is None): + self._rgbarr3 = np.copy(self._rgbarr2) + + # convert to output ICC profile, if one is specified + working_profile = rgb_cms.working_profile + if (working_profile is not None) and (output_profile is not None): + self.convert_via_profile(self._rgbarr3, order, + working_profile, output_profile) + + t3 = time.time() + + if (whence <= 2.5) or (self._rgbarr4 is None): + data = np.copy(self._rgbarr3) + + # Apply any viewing transformations + self._rgbarr4 = self._apply_transforms(data) + + if (whence <= 2.6) or (self._rgbobj is None): + rotimg = np.copy(self._rgbarr4) + + # Apply any viewing rotations + rot_deg = self.viewer.get_rotation() + rotimg = self._apply_rotation(rotimg, rot_deg) + rotimg = np.ascontiguousarray(rotimg) + + self._rgbobj = RGBMap.RGBPlanes(rotimg, order) + + t4 = time.time() + self.logger.debug("times: t2=%.4f t3=%.4f t4=%.4f total=%.4f" % ( + t2 - t1, t3 - t2, t4 - t3, t4 - t1)) + + return self._rgbobj + + def render_whence(self, whence): + rgbobj = self.get_rgb_object(whence=whence) + self.render_image(rgbobj, self._dst_x, self._dst_y) + + def _calc_bg_dimensions(self, win_wd, win_ht): + """Calculate background image size necessary for rendering. + + This is an internal method, called during viewer window size + configuration. + + Parameters + ---------- + win_wd, win_ht : int + window dimensions in pixels + """ + # calc minimum size of pixel image we will generate + # necessary to fit the window in the desired size + + # Make a square from the scaled cutout, with room to rotate + slop = 20 + side = int(math.sqrt(win_wd**2 + win_ht**2) + slop) + wd = ht = side + + # Find center of new array + ncx, ncy = wd // 2, ht // 2 + self._org_xoff, self._org_yoff = ncx, ncy + + return (wd, ht) + + def _confirm_pan_and_scale(self, scale_x, scale_y, + pan_x, pan_y, win_wd, win_ht): + """Check and record the desired pan and scale factors. + + This is an internal method, called during viewer rendering. + + Parameters + ---------- + scale_x, scale_y : float + desired scale of viewer in each axis. + + pan_x, pan_y : float + pan position in data coordinates. + + win_wd, win_ht : int + window dimensions in pixels + """ + data_off = self.viewer.data_off + + # Sanity check on the scale + sx = float(win_wd) / scale_x + sy = float(win_ht) / scale_y + if (sx < 1.0) or (sy < 1.0): + #self.logger.warning("new scale would exceed max/min; scale unchanged") + raise RenderError("new scale would exceed pixel max; scale unchanged") + + # record location of pan position pixel + self._org_x, self._org_y = pan_x - data_off, pan_y - data_off + self._org_scale_x, self._org_scale_y = scale_x, scale_y + self._org_scale_z = (scale_x + scale_y) / 2.0 + + def _apply_transforms(self, data): + """Apply transformations to the given data. + These include flipping on axis and swapping X/Y axes. + + This is an internal method, called during viewer rendering. + + Parameters + ---------- + data : ndarray + Data to be transformed. + + Returns + ------- + data : ndarray + Transformed data. + + """ + ht, wd = data.shape[:2] + xoff, yoff = self._org_xoff, self._org_yoff + + # Do transforms as necessary + flip_x, flip_y, swap_xy = self.viewer.get_transforms() + + data = trcalc.transform(data, flip_x=flip_x, flip_y=flip_y, + swap_xy=swap_xy) + if flip_y: + yoff = ht - yoff + if flip_x: + xoff = wd - xoff + if swap_xy: + xoff, yoff = yoff, xoff + + self._xoff, self._yoff = xoff, yoff + + return data + + def _apply_rotation(self, data, rot_deg): + """Apply transformations to the given data. + These include rotation and invert Y. + + This is an internal method, called during viewer rendering. + + Parameters + ---------- + data : ndarray + Data to be rotated. + + rot_deg : float + Rotate the data by the given degrees. + + Returns + ------- + data : ndarray + Rotated data. + + """ + xoff, yoff = self._xoff, self._yoff + + # Rotate the image as necessary + if rot_deg != 0: + # This is the slowest part of the rendering-- + # install the OpenCv or Pillow packages to speed it up + data = np.ascontiguousarray(data) + pre_y, pre_x = data.shape[:2] + data = trcalc.rotate_clip(data, -rot_deg, out=data, + logger=self.logger) + + # apply other transforms + if self.viewer._invert_y: + # Flip Y for natural Y-axis inversion between FITS coords + # and screen coords + data = np.flipud(data) + + # dimensions may have changed in transformations + ht, wd = data.shape[:2] + + ctr_x, ctr_y = self._ctr_x, self._ctr_y + dst_x, dst_y = ctr_x - xoff, ctr_y - (ht - yoff) + self._dst_x, self._dst_y = dst_x, dst_y + self.logger.debug("ctr=%d,%d off=%d,%d dst=%d,%d cutout=%dx%d" % ( + ctr_x, ctr_y, xoff, yoff, dst_x, dst_y, wd, ht)) + + win_wd, win_ht = self.viewer.get_window_size() + self.logger.debug("win=%d,%d coverage=%d,%d" % ( + win_wd, win_ht, dst_x + wd, dst_y + ht)) + + return data + + def _overlay_images(self, canvas, whence=0.0): + """Overlay data from any canvas image objects. + + Parameters + ---------- + canvas : `~ginga.canvas.types.layer.DrawingCanvas` + Canvas containing possible images to overlay. + + data : ndarray + Output array on which to overlay image data. + + whence + See :meth:`get_rgb_object`. + + """ + #if not canvas.is_compound(): + if not hasattr(canvas, 'objects'): + return + + for obj in canvas.get_objects(): + if hasattr(obj, 'prepare_image'): + obj.prepare_image(self.viewer, whence) + elif obj.is_compound() and (obj != canvas): + self._overlay_images(obj, whence=whence) + + def _common_draw(self, cvs_img, cache, whence): + # internal common drawing phase for all images + image = cvs_img.image + if image is None: + return + dstarr = self._rgbarr2 + + if (whence <= 0.0) or (cache.cutout is None) or (not cvs_img.optimize): + # get extent of our data coverage in the window + # TODO: get rid of padding by fixing get_draw_rect() which + # doesn't quite get the coverage right at high magnifications + pad = 1.0 + pts = np.asarray(self.viewer.get_draw_rect()).T + xmin = int(np.min(pts[0])) - pad + ymin = int(np.min(pts[1])) - pad + xmax = int(np.ceil(np.max(pts[0]))) + pad + ymax = int(np.ceil(np.max(pts[1]))) + pad + + # get destination location in data_coords + dst_x, dst_y = cvs_img.crdmap.to_data((cvs_img.x, cvs_img.y)) + + a1, b1, a2, b2 = 0, 0, cvs_img.image.width - 1, cvs_img.image.height - 1 + + # calculate the cutout that we can make and scale to merge + # onto the final image--by only cutting out what is necessary + # this speeds scaling greatly at zoomed in sizes + ((dst_x, dst_y), (a1, b1), (a2, b2)) = \ + trcalc.calc_image_merge_clip((xmin, ymin), (xmax, ymax), + (dst_x, dst_y), + (a1, b1), (a2, b2)) + + # is image completely off the screen? + if (a2 - a1 <= 0) or (b2 - b1 <= 0): + # no overlay needed + cache.cutout = None + return + + # cutout and scale the piece appropriately by the viewer scale + scale_x, scale_y = self.viewer.get_scale_xy() + # scale additionally by our scale + _scale_x, _scale_y = (scale_x * cvs_img.scale_x, + scale_y * cvs_img.scale_y) + + interp = cvs_img.interpolation + if interp is None: + t_ = self.viewer.get_settings() + interp = t_.get('interpolation', 'basic') + + # previous choice might not be available if preferences + # were saved when opencv was being used (and not used now); + # if so, silently default to "basic" + if interp not in trcalc.interpolation_methods: + interp = 'basic' + res = image.get_scaled_cutout2((a1, b1), (a2, b2), + (_scale_x, _scale_y), + method=interp) + data = res.data + + if cvs_img.flipy: + data = np.flipud(data) + cache.cutout = data + + # calculate our offset from the pan position + pan_x, pan_y = self.viewer.get_pan() + pan_off = self.viewer.data_off + pan_x, pan_y = pan_x + pan_off, pan_y + pan_off + off_x, off_y = dst_x - pan_x, dst_y - pan_y + # scale offset + off_x *= scale_x + off_y *= scale_y + + # dst position in the pre-transformed array should be calculated + # from the center of the array plus offsets + ht, wd, dp = dstarr.shape + cvs_x = int(np.round(wd / 2.0 + off_x)) + cvs_y = int(np.round(ht / 2.0 + off_y)) + cache.cvs_pos = (cvs_x, cvs_y) + + def _prepare_image(self, cvs_img, cache, whence): + if whence > 2.3 and cache.rgbarr is not None: + return + dstarr = self._rgbarr2 + + t1 = t2 = time.time() + + self._common_draw(cvs_img, cache, whence) + + if cache.cutout is None: + return + + cache.rgbarr = cache.cutout + + t2 = time.time() + # should this be self.get_rgb_order() ? + dst_order = self.viewer.get_rgb_order() + image_order = cvs_img.image.get_order() + + # composite the image into the destination array at the + # calculated position + trcalc.overlay_image(dstarr, cache.cvs_pos, cache.rgbarr, + dst_order=dst_order, src_order=image_order, + alpha=cvs_img.alpha, fill=True, flipy=False) + + cache.drawn = True + t3 = time.time() + self.logger.debug("draw: t2=%.4f t3=%.4f total=%.4f" % ( + t2 - t1, t3 - t2, t3 - t1)) + + def _prepare_norm_image(self, cvs_img, cache, whence): + if whence > 2.3 and cache.rgbarr is not None: + return + dstarr = self._rgbarr2 + + t1 = t2 = t3 = t4 = time.time() + + self._common_draw(cvs_img, cache, whence) + + if cache.cutout is None: + return + + t2 = time.time() + if cvs_img.rgbmap is not None: + rgbmap = cvs_img.rgbmap + else: + rgbmap = self.viewer.get_rgbmap() + + image_order = cvs_img.image.get_order() + + if (whence <= 0.0) or (not cvs_img.optimize): + # if image has an alpha channel, then strip it off and save + # it until it is recombined later with the colorized output + # this saves us having to deal with an alpha band in the + # cuts leveling and RGB mapping routines + img_arr = cache.cutout + if 'A' not in image_order: + cache.alpha = None + else: + # normalize alpha array to the final output range + mn, mx = trcalc.get_minmax_dtype(img_arr.dtype) + a_idx = image_order.index('A') + cache.alpha = (img_arr[..., a_idx] / mx * + rgbmap.maxc).astype(rgbmap.dtype) + cache.cutout = img_arr[..., 0:a_idx] + + if (whence <= 1.0) or (cache.prergb is None) or (not cvs_img.optimize): + # apply visual changes prior to color mapping (cut levels, etc) + vmax = rgbmap.get_hash_size() - 1 + newdata = self._apply_visuals(cvs_img, cache.cutout, 0, vmax) + + # result becomes an index array fed to the RGB mapper + if not np.issubdtype(newdata.dtype, np.dtype('uint')): + newdata = newdata.astype(np.uint) + idx = newdata + + self.logger.debug("shape of index is %s" % (str(idx.shape))) + cache.prergb = idx + + t3 = time.time() + dst_order = self.get_rgb_order() + + if (whence <= 2.0) or (cache.rgbarr is None) or (not cvs_img.optimize): + # get RGB mapped array + rgbobj = rgbmap.get_rgbarray(cache.prergb, order=dst_order, + image_order=image_order) + cache.rgbarr = rgbobj.get_array(dst_order) + + if cache.alpha is not None and 'A' in dst_order: + a_idx = dst_order.index('A') + cache.rgbarr[..., a_idx] = cache.alpha + + t4 = time.time() + + # composite the image into the destination array at the + # calculated position + trcalc.overlay_image(dstarr, cache.cvs_pos, cache.rgbarr, + dst_order=dst_order, src_order=dst_order, + alpha=cvs_img.alpha, fill=True, flipy=False) + + cache.drawn = True + t5 = time.time() + self.logger.debug("draw: t2=%.4f t3=%.4f t4=%.4f t5=%.4f total=%.4f" % ( + t2 - t1, t3 - t2, t4 - t3, t5 - t4, t5 - t1)) + + def _apply_visuals(self, cvs_img, data, vmin, vmax): + if cvs_img.autocuts is not None: + autocuts = cvs_img.autocuts + else: + autocuts = self.viewer.autocuts + + # Apply cut levels + if cvs_img.cuts is not None: + loval, hival = cvs_img.cuts + else: + loval, hival = self.viewer.t_['cuts'] + newdata = autocuts.cut_levels(data, loval, hival, + vmin=vmin, vmax=vmax) + return newdata + + def prepare_image(self, cvs_img, cache, whence): + if cvs_img.kind == 'image': + self._prepare_image(cvs_img, cache, whence) + elif cvs_img.kind == 'normimage': + self._prepare_norm_image(cvs_img, cache, whence) + else: + raise RenderError("I don't know how to render canvas type '{}'".format(cvs_img.kind)) + + def convert_via_profile(self, data_np, order, inprof_name, outprof_name): + """Convert the given RGB data from the working ICC profile + to the output profile in-place. + + Parameters + ---------- + data_np : ndarray + RGB image data to be displayed. + + order : str + Order of channels in the data (e.g. "BGRA"). + + inprof_name, outprof_name : str + ICC profile names (see :func:`ginga.util.rgb_cms.get_profiles`). + + """ + t_ = self.viewer.get_settings() + # get rest of necessary conversion parameters + to_intent = t_.get('icc_output_intent', 'perceptual') + proofprof_name = t_.get('icc_proof_profile', None) + proof_intent = t_.get('icc_proof_intent', 'perceptual') + use_black_pt = t_.get('icc_black_point_compensation', False) + + try: + rgbobj = RGBMap.RGBPlanes(data_np, order) + arr_np = rgbobj.get_array('RGB') + + arr = rgb_cms.convert_profile_fromto(arr_np, inprof_name, outprof_name, + to_intent=to_intent, + proof_name=proofprof_name, + proof_intent=proof_intent, + use_black_pt=use_black_pt, + logger=self.logger) + ri, gi, bi = rgbobj.get_order_indexes('RGB') + + out = data_np + out[..., ri] = arr[..., 0] + out[..., gi] = arr[..., 1] + out[..., bi] = arr[..., 2] + + self.logger.debug("Converted from '%s' to '%s' profile" % ( + inprof_name, outprof_name)) + + except Exception as e: + self.logger.warning("Error converting output from working profile: %s" % (str(e))) + # TODO: maybe should have a traceback here + self.logger.info("Output left unprofiled") + + def getwin_array(self, order='RGBA', alpha=1.0, dtype=None): + """Get Numpy data array for display window. + + Parameters + ---------- + order : str + The desired order of RGB color layers. + + alpha : float + Opacity. + + dtype : numpy dtype + Numpy data type desired; defaults to rgb mapper setting. + + Returns + ------- + outarr : ndarray + Numpy data array for display window. + + """ + dst_order = order.upper() + src_order = self.get_rgb_order() + # NOTE: need to have an alpha channel in place to do overlay_image() + if 'A' not in src_order: + src_order = src_order + 'A' + + if dtype is None: + rgbmap = self.viewer.get_rgbmap() + dtype = rgbmap.dtype + + # Prepare data array for rendering + data = self._rgbobj.get_array(src_order, dtype=dtype) + + # NOTE [A] + height, width, depth = data.shape + + win_wd, win_ht = self.viewer.get_window_size() + + # create RGBA image array with the background color for output + r, g, b = self.viewer.get_bg() + outarr = trcalc.make_filled_array((win_ht, win_wd, depth), + dtype, src_order, r, g, b, alpha) + + # overlay our data + trcalc.overlay_image(outarr, (self._dst_x, self._dst_y), + data, dst_order=src_order, src_order=src_order, + flipy=False, fill=False, copy=False) + + outarr = self.reorder(dst_order, outarr, src_order=src_order) + return outarr + + def render_image(self, rgbobj, win_x, win_y): + """Render image. + This must be implemented by subclasses. + + Parameters + ---------- + rgbobj : `~ginga.RGBMap.RGBPlanes` + RGB object. + + win_x, win_y : float + Offsets in screen coordinates. + + """ + self.logger.warning("Subclass should override this abstract method!") + + def get_scale(self): + return (self._org_scale_x, self._org_scale_y, self._org_scale_z) + + def get_origin(self): + return (self._org_x, self._org_y, self._org_z) + + def get_window_center(self): + return (self._ctr_x, self._ctr_y) + + def get_window_size(self): + return self.dims[:2] + + def _resize(self, dims): + self.dims = dims + width, height = dims[:2] + + self._ctr_x = width // 2 + self._ctr_y = height // 2 + + def resize(self, dims): + self._resize(dims) + + self.invalidate() + self.viewer.redraw(whence=0) + + def get_center(self): + return (self._ctr_x, self._ctr_y) + + def calc_const_len(self, clen): + # For standard pixel renderer, pixel size is constant + return clen + + def scale_fontsize(self, fontsize): + return font_asst.scale_fontsize(self.kind, fontsize) + def get_render_class(rtype): @@ -143,4 +879,8 @@ from ginga.qtw import CanvasRenderQt return CanvasRenderQt.CanvasRenderer + if rtype == 'vqt': + from ginga.qtw import CanvasRenderQt + return CanvasRenderQt.VectorCanvasRenderer + raise ValueError("Don't know about '%s' renderer type" % (rtype)) diff -Nru ginga-3.0.0/ginga/canvas/transform.py ginga-3.1.0/ginga/canvas/transform.py --- ginga-3.0.0/ginga/canvas/transform.py 2018-11-30 23:47:07.000000000 +0000 +++ ginga-3.1.0/ginga/canvas/transform.py 2020-07-08 20:09:29.000000000 +0000 @@ -12,7 +12,7 @@ __all__ = ['TransformError', 'BaseTransform', 'ComposedTransform', 'InvertedTransform', 'PassThruTransform', 'WindowNativeTransform', 'CartesianWindowTransform', - 'CartesianNativeTransform', + 'CartesianNativeTransform', 'AsIntegerTransform', 'RotationTransform', 'ScaleTransform', 'DataCartesianTransform', 'OffsetDataTransform', 'WCSDataTransform', 'get_catalog' @@ -37,23 +37,8 @@ def __add__(self, trans): return ComposedTransform(self, trans) - @classmethod - def inverted_class(cls): - class _inverted(cls): - - def __init__(self, *args, **kwargs): - cls.__init__(self, *args, **kwargs) - - def to_(self, pts, **kwargs): - return cls.from_(self, pts, **kwargs) - - def from_(self, pts, **kwargs): - return cls.to_(self, pts, **kwargs) - - return _inverted - def invert(self): - return self.inverted_class()(self) + return InvertedTransform(self) class ComposedTransform(BaseTransform): @@ -273,7 +258,6 @@ win_pts = np.add(np.multiply(off_pts, mpy_pt), ctr_pt) # round to pixel units, if asked - # round to pixel units, if asked if self.as_int: win_pts = np.rint(win_pts).astype(np.int, copy=False) @@ -305,6 +289,87 @@ return off_pts +class AsIntegerTransform(BaseTransform): + """ + A transform from floating point coordinates to integer coordinates. + """ + + def __init__(self, viewer): + super(AsIntegerTransform, self).__init__() + self.viewer = viewer + + def to_(self, flt_pts): + int_pts = np.asarray(flt_pts, dtype=np.int) + return int_pts + + def from_(self, int_pts): + """Reverse of :meth:`to_`.""" + flt_pts = np.asarray(int_pts, dtype=np.float) + return flt_pts + + +class FlipSwapTransform(BaseTransform): + """ + A transform in cartesian coordinates based on the flip/swap setting + of a viewer. + """ + + def __init__(self, viewer): + super(FlipSwapTransform, self).__init__() + self.viewer = viewer + + def to_(self, off_pts): + off_pts = np.asarray(off_pts, dtype=np.float) + has_z = (off_pts.shape[-1] > 2) + + t_ = self.viewer.t_ + + # flip + flip_pt = [1.0, 1.0] + if t_['flip_x']: + flip_pt[0] = -1.0 + if t_['flip_y']: + flip_pt[1] = -1.0 + if has_z: + # no flip_z at the moment + flip_pt.append(1.0) + + off_pts = np.multiply(off_pts, flip_pt) + + # swap + if t_['swap_xy']: + p = list(off_pts.T) + off_pts = np.asarray([p[1], p[0]] + list(p[2:])).T + + return off_pts + + def from_(self, off_pts): + """Reverse of :meth:`to_`.""" + off_pts = np.asarray(off_pts, dtype=np.float) + has_z = (off_pts.shape[-1] > 2) + + t_ = self.viewer.t_ + + # swap + if t_['swap_xy']: + p = list(off_pts.T) + off_pts = np.asarray([p[1], p[0]] + list(p[2:])).T + + # flip + flip_pt = [1.0, 1.0] + if t_['flip_x']: + flip_pt[0] = -1.0 + if t_['flip_y']: + flip_pt[1] = -1.0 + if has_z: + # no flip_z at the moment + flip_pt.append(1.0) + + off_pts = np.multiply(off_pts, flip_pt) + + return off_pts + + class RotationTransform(BaseTransform): """ A transform in cartesian coordinates based on the flip/swap setting and @@ -321,6 +386,50 @@ t_ = self.viewer.t_ + # rotate + if t_['rot_deg'] != 0: + thetas = [t_['rot_deg']] + offset = [0.0, 0.0] + if has_z: + offset.append(0.0) + off_pts = trcalc.rotate_coord(off_pts, thetas, offset) + + return off_pts + + def from_(self, off_pts): + """Reverse of :meth:`to_`.""" + off_pts = np.asarray(off_pts, dtype=np.float) + has_z = (off_pts.shape[-1] > 2) + + t_ = self.viewer.t_ + + # rotate + if t_['rot_deg'] != 0: + thetas = [- t_['rot_deg']] + offset = [0.0, 0.0] + if has_z: + offset.append(0.0) + off_pts = trcalc.rotate_coord(off_pts, thetas, offset) + + return off_pts + + +class RotationFlipTransform(BaseTransform): + """ + A transform in cartesian coordinates based on the flip/swap setting and + rotation setting of a viewer. + """ + + def __init__(self, viewer): + super(RotationFlipTransform, self).__init__() + self.viewer = viewer + + def to_(self, off_pts): + off_pts = np.asarray(off_pts, dtype=np.float) + has_z = (off_pts.shape[-1] > 2) + + t_ = self.viewer.t_ + # flip flip_pt = [1.0, 1.0] if t_['flip_x']: @@ -398,9 +507,10 @@ has_z = (off_pts.shape[-1] > 2) # scale according to current settings - scale_pt = [self.viewer._org_scale_x, self.viewer._org_scale_y] + sc = self.viewer.renderer.get_scale() + scale_pt = [sc[0], sc[1]] if has_z: - scale_pt.append(self.viewer._org_scale_z) + scale_pt.append(sc[2]) off_pts = np.multiply(off_pts, scale_pt) return off_pts @@ -409,10 +519,10 @@ off_pts = np.asarray(off_pts, dtype=np.float) has_z = (off_pts.shape[-1] > 2) - scale_pt = [1.0 / self.viewer._org_scale_x, - 1.0 / self.viewer._org_scale_y] + sc = self.viewer.renderer.get_scale() + scale_pt = [1.0 / sc[0], 1.0 / sc[1]] if has_z: - scale_pt.append(1.0 / self.viewer._org_scale_z) + scale_pt.append(1.0 / sc[2]) # Reverse scaling off_pts = np.multiply(off_pts, scale_pt) @@ -443,9 +553,10 @@ data_pts = data_pts - self.viewer.data_off # subtract data indexes at center reference pixel - ref_pt = [self.viewer._org_x, self.viewer._org_y] + origin = self.viewer.renderer.get_origin() + ref_pt = [origin[0], origin[1]] if has_z: - ref_pt.append(self.viewer._org_z) + ref_pt.append(origin[2]) off_pts = np.subtract(data_pts, ref_pt) return off_pts @@ -456,9 +567,10 @@ # Add data index at center to offset # subtract data indexes at center reference pixel - ref_pt = [self.viewer._org_x, self.viewer._org_y] + origin = self.viewer.renderer.get_origin() + ref_pt = [origin[0], origin[1]] if has_z: - ref_pt.append(self.viewer._org_z) + ref_pt.append(origin[2]) data_pts = np.add(off_pts, ref_pt) diff -Nru ginga-3.0.0/ginga/canvas/types/astro.py ginga-3.1.0/ginga/canvas/types/astro.py --- ginga-3.0.0/ginga/canvas/types/astro.py 2019-09-21 03:03:18.000000000 +0000 +++ ginga-3.1.0/ginga/canvas/types/astro.py 2020-07-20 21:06:00.000000000 +0000 @@ -9,7 +9,6 @@ import math import numpy as np -from ginga.AstroImage import AstroImage from ginga.canvas.CanvasObject import (CanvasObjectBase, _bool, _color, Point, MovePoint, ScalePoint, register_canvas_types, get_canvas_type, @@ -27,7 +26,7 @@ 'Annulus2R', 'WCSAxes'] -class Ruler(TwoPointMixin, CanvasObjectBase): +class RulerP(TwoPointMixin, CanvasObjectBase): """ Draws a WCS ruler (like a right triangle) on a DrawingCanvas. Parameters are: @@ -86,14 +85,14 @@ @classmethod def idraw(cls, canvas, cxt): - return cls(cxt.start_x, cxt.start_y, cxt.x, cxt.y, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), (cxt.x, cxt.y), **cxt.drawparams) - def __init__(self, x1, y1, x2, y2, color='green', color2='skyblue', + def __init__(self, pt1, pt2, color='green', color2='skyblue', alpha=1.0, linewidth=1, linestyle='solid', showcap=True, showplumb=True, showends=False, units='arcmin', font='Sans Serif', fontsize=None, **kwdargs): self.kind = 'ruler' - points = np.asarray([(x1, y1), (x2, y2)], dtype=np.float) + points = np.asarray([pt1, pt2], dtype=np.float) CanvasObjectBase.__init__(self, color=color, color2=color2, alpha=alpha, units=units, showplumb=showplumb, showends=showends, @@ -247,7 +246,25 @@ self.draw_caps(cr, self.cap, ((cx2, cy1), )) -class Compass(OnePointOneRadiusMixin, CanvasObjectBase): +class Ruler(RulerP): + + @classmethod + def idraw(cls, canvas, cxt): + return cls(cxt.start_x, cxt.start_y, cxt.x, cxt.y, **cxt.drawparams) + + def __init__(self, x1, y1, x2, y2, color='green', color2='skyblue', + alpha=1.0, linewidth=1, linestyle='solid', + showcap=True, showplumb=True, showends=False, units='arcmin', + font='Sans Serif', fontsize=None, **kwdargs): + RulerP.__init__(self, (x1, y1), (x2, y2), color=color, color2=color2, + alpha=alpha, units=units, + showplumb=showplumb, showends=showends, + linewidth=linewidth, showcap=showcap, + linestyle=linestyle, font=font, fontsize=fontsize, + **kwdargs) + + +class CompassP(OnePointOneRadiusMixin, CanvasObjectBase): """ Draws a WCS compass on a DrawingCanvas. Parameters are: @@ -298,13 +315,13 @@ def idraw(cls, canvas, cxt): radius = np.sqrt(abs(cxt.start_x - cxt.x) ** 2 + abs(cxt.start_y - cxt.y) ** 2) - return cls(cxt.start_x, cxt.start_y, radius, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), radius, **cxt.drawparams) - def __init__(self, x, y, radius, ctype='wcs', color='skyblue', + def __init__(self, pt, radius, ctype='wcs', color='skyblue', linewidth=1, fontsize=None, font='Sans Serif', alpha=1.0, linestyle='solid', showcap=True, **kwdargs): self.kind = 'compass' - points = np.asarray([(x, y)], dtype=np.float) + points = np.asarray([pt], dtype=np.float) CanvasObjectBase.__init__(self, ctype=ctype, color=color, alpha=alpha, linewidth=linewidth, showcap=showcap, linestyle=linestyle, @@ -440,7 +457,24 @@ return (xd, yd) -class Crosshair(OnePointMixin, CanvasObjectBase): +class Compass(CompassP): + + @classmethod + def idraw(cls, canvas, cxt): + radius = np.sqrt(abs(cxt.start_x - cxt.x) ** 2 + + abs(cxt.start_y - cxt.y) ** 2) + return cls(cxt.start_x, cxt.start_y, radius, **cxt.drawparams) + + def __init__(self, x, y, radius, ctype='wcs', color='skyblue', + linewidth=1, fontsize=None, font='Sans Serif', + alpha=1.0, linestyle='solid', showcap=True, **kwdargs): + CompassP.__init__(self, (x, y), radius, ctype=ctype, color=color, + alpha=alpha, linewidth=linewidth, showcap=showcap, + linestyle=linestyle, font=font, fontsize=fontsize, + **kwdargs) + + +class CrosshairP(OnePointMixin, CanvasObjectBase): """ Draws a crosshair on a DrawingCanvas. Parameters are: @@ -490,15 +524,15 @@ @classmethod def idraw(cls, canvas, cxt): - return cls(cxt.x, cxt.y, **cxt.drawparams) + return cls((cxt.x, cxt.y), **cxt.drawparams) - def __init__(self, x, y, color='green', + def __init__(self, pt, color='green', linewidth=1, alpha=1.0, linestyle='solid', text=None, textcolor='yellow', fontsize=10.0, font='Sans Serif', fontscale=True, format='xy', **kwdargs): self.kind = 'crosshair' - points = np.asarray([(x, y)], dtype=np.float) + points = np.asarray([pt], dtype=np.float) CanvasObjectBase.__init__(self, color=color, alpha=alpha, linewidth=linewidth, linestyle=linestyle, text=text, textcolor=textcolor, @@ -529,9 +563,7 @@ text = "X:%f, Y:%f" % (self.x, self.y) else: - image = viewer.get_image() - if image is None: - return + image = viewer.get_vip() # NOTE: x, y are assumed to be in data coordinates info = image.info_xy(self.x, self.y, viewer.get_settings()) if self.format == 'coords': @@ -541,7 +573,9 @@ text = "%s:%s, %s:%s" % (info.ra_lbl, info.ra_txt, info.dec_lbl, info.dec_txt) else: - if np.isscalar(info.value) or len(info.value) <= 1: + if info.value is None: + text = "V: None" + elif np.isscalar(info.value) or len(info.value) <= 1: text = "V: %f" % (info.value) else: values = ', '.join(["%d" % info.value[i] @@ -564,6 +598,25 @@ cr.draw_text(cx + 10, cy + 4 + txtht, text) +class Crosshair(CrosshairP): + + @classmethod + def idraw(cls, canvas, cxt): + return cls(cxt.x, cxt.y, **cxt.drawparams) + + def __init__(self, x, y, color='green', + linewidth=1, alpha=1.0, linestyle='solid', + text=None, textcolor='yellow', + fontsize=10.0, font='Sans Serif', fontscale=True, + format='xy', **kwdargs): + CrosshairP.__init__(self, (x, y), color=color, alpha=alpha, + linewidth=linewidth, linestyle=linestyle, + text=text, textcolor=textcolor, + fontsize=fontsize, font=font, + fontscale=fontscale, + format=format, **kwdargs) + + class AnnulusMixin(object): def contains_pt(self, pt): @@ -588,7 +641,7 @@ return obj2.select_contains_pt(viewer, pt) -class Annulus(AnnulusMixin, OnePointOneRadiusMixin, CompoundObject): +class AnnulusP(AnnulusMixin, OnePointOneRadiusMixin, CompoundObject): """ Special compound object to handle annulus shape that consists of two objects with the same centroid. @@ -637,14 +690,13 @@ def idraw(cls, canvas, cxt): radius = np.sqrt(abs(cxt.start_x - cxt.x)**2 + abs(cxt.start_y - cxt.y)**2) - return cls(cxt.start_x, cxt.start_y, radius, + return cls((cxt.start_x, cxt.start_y), radius, **cxt.drawparams) - def __init__(self, x, y, radius, width=None, + def __init__(self, pt, radius, width=None, atype='circle', color='yellow', linewidth=1, linestyle='solid', alpha=1.0, **kwdargs): - if width is None: # default width is 15% of radius width = 0.15 * radius @@ -656,19 +708,19 @@ coord = kwdargs.get('coord', None) klass = get_canvas_type(atype) - obj1 = klass(x, y, radius, color=color, + obj1 = klass(pt[0], pt[1], radius, color=color, linewidth=linewidth, linestyle=linestyle, alpha=alpha, coord=coord) obj1.editable = False - obj2 = klass(x, y, oradius, color=color, + obj2 = klass(pt[0], pt[1], oradius, color=color, linewidth=linewidth, linestyle=linestyle, alpha=alpha, coord=coord) obj2.editable = False - points = np.asarray([(x, y)], dtype=np.float) + points = np.asarray([pt], dtype=np.float) CompoundObject.__init__(self, obj1, obj2, points=points, radius=radius, @@ -738,12 +790,30 @@ self.objects[1].__dict__.update(d) def move_to_pt(self, dst_pt): - super(Annulus, self).move_to_pt(dst_pt) + super(AnnulusP, self).move_to_pt(dst_pt) self.set_data_points([dst_pt]) -class Annulus2R(AnnulusMixin, OnePointTwoRadiusMixin, CompoundObject): +class Annulus(AnnulusP): + + @classmethod + def idraw(cls, canvas, cxt): + radius = np.sqrt(abs(cxt.start_x - cxt.x)**2 + + abs(cxt.start_y - cxt.y)**2) + return cls(cxt.start_x, cxt.start_y, radius, + **cxt.drawparams) + + def __init__(self, x, y, radius, width=None, + atype='circle', color='yellow', + linewidth=1, linestyle='solid', alpha=1.0, + **kwdargs): + AnnulusP.__init__(self, (x, y), radius, width=width, atype=atype, + color=color, linewidth=linewidth, + linestyle=linestyle, alpha=alpha, **kwdargs) + + +class Annulus2RP(AnnulusMixin, OnePointTwoRadiusMixin, CompoundObject): """ Special compound object to handle annulus shape that consists of two objects, (one center point, plus two radii). @@ -800,13 +870,14 @@ @classmethod def idraw(cls, canvas, cxt): xradius, yradius = abs(cxt.start_x - cxt.x), abs(cxt.start_y - cxt.y) - return cls(cxt.start_x, cxt.start_y, xradius, yradius, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), (xradius, yradius), + **cxt.drawparams) - def __init__(self, x, y, xradius, yradius, xwidth=None, + def __init__(self, pt, radii, xwidth=None, ywidth=None, atype='ellipse', color='yellow', linewidth=1, linestyle='solid', alpha=1.0, rot_deg=0.0, **kwdargs): - + xradius, yradius = radii if xwidth is None: # default X width is 15% of X radius xwidth = 0.15 * xradius @@ -822,19 +893,19 @@ coord = kwdargs.get('coord', None) klass = get_canvas_type(atype) - obj1 = klass(x, y, xradius, yradius, color=color, + obj1 = klass(pt[0], pt[1], radii[0], radii[1], color=color, linewidth=linewidth, linestyle=linestyle, alpha=alpha, coord=coord, rot_deg=rot_deg) obj1.editable = False - obj2 = klass(x, y, oxradius, oyradius, color=color, + obj2 = klass(pt[0], pt[1], oxradius, oyradius, color=color, linewidth=linewidth, linestyle=linestyle, alpha=alpha, coord=coord, rot_deg=rot_deg) obj2.editable = False - points = np.asarray([(x, y)], dtype=np.float) + points = np.asarray([pt], dtype=np.float) CompoundObject.__init__(self, obj1, obj2, points=points, xradius=xradius, yradius=yradius, @@ -943,11 +1014,30 @@ self.objects[1].__dict__.update(d) def move_to_pt(self, dst_pt): - super(Annulus2R, self).move_to_pt(dst_pt) + super(Annulus2RP, self).move_to_pt(dst_pt) self.set_data_points([dst_pt]) +class Annulus2R(Annulus2RP): + + @classmethod + def idraw(cls, canvas, cxt): + xradius, yradius = abs(cxt.start_x - cxt.x), abs(cxt.start_y - cxt.y) + return cls(cxt.start_x, cxt.start_y, xradius, yradius, + **cxt.drawparams) + + def __init__(self, x, y, xradius, yradius, xwidth=None, + ywidth=None, atype='ellipse', color='yellow', + linewidth=1, linestyle='solid', alpha=1.0, + rot_deg=0.0, **kwdargs): + Annulus2RP.__init__(self, (x, y), (xradius, yradius), + xwidth=xwidth, ywidth=ywidth, atype=atype, + color=color, linewidth=linewidth, + linestyle=linestyle, alpha=alpha, + rot_deg=rot_deg, **kwdargs) + + class WCSAxes(CompoundObject): """ Special compound object to draw WCS axes. @@ -990,7 +1080,8 @@ # for keeping track of changes to image and orientation self._cur_rot = None self._cur_swap = None - self._cur_image = None + self._cur_limits = ((0.0, 0.0), (0.0, 0.0)) + self._cur_images = set([]) CompoundObject.__init__(self, color=color, alpha=alpha, @@ -1002,27 +1093,30 @@ self.opaque = True self.kind = 'wcsaxes' - def _calc_axes(self, viewer, image, rot_deg, swapxy): - self._cur_image = image + def _calc_axes(self, viewer, images, rot_deg, swapxy, limits): + self.logger.debug("recalculating axes...") + self._cur_images = images self._cur_rot = rot_deg self._cur_swap = swapxy + self._cur_limits = limits - if not isinstance(image, AstroImage) or not image.has_valid_wcs(): + image = viewer.get_image() + if image is None or not image.has_valid_wcs(): self.logger.debug( - 'WCSAxes can only be displayed for AstroImage with valid WCS') + 'WCSAxes can only be displayed for image with valid WCS') return [] - min_imsize = min(image.width, image.height) + x1, y1 = limits[0][:2] + x2, y2 = limits[1][:2] + min_imsize = min(x2 - x1, y2 - y1) if min_imsize <= 0: self.logger.debug('Cannot draw WCSAxes on image with 0 dim') return [] # Approximate bounding box in RA/DEC space - xmax = image.width - 1 - ymax = image.height - 1 try: radec = image.wcs.datapt_to_system( - [[0, 0], [0, ymax], [xmax, 0], [xmax, ymax]], + [(x1, y1), (x1, y2), (x2, y1), (x2, y2)], naxispath=image.naxispath) except Exception as e: self.logger.warning('WCSAxes failed: {}'.format(str(e))) @@ -1067,9 +1161,10 @@ self.logger.warning('WCSAxes failed: {}'.format(str(e))) return [] + (x1, y1), (x2, y2) = viewer.get_limits() # Don't draw outside image area - mask = ((pts[:, 0] >= 0) & (pts[:, 0] < image.width) & - (pts[:, 1] >= 0) & (pts[:, 1] < image.height)) + mask = ((pts[:, 0] >= x1) & (pts[:, 0] <= x2) & + (pts[:, 1] >= y1) & (pts[:, 1] <= y2)) pts = pts[mask] if len(pts) == 0: @@ -1160,11 +1255,12 @@ def draw(self, viewer): # see if we need to recalculate our grid - image = viewer.get_image() - update = False - if self._cur_image != image: - # new image loaded - update = True + canvas = viewer.get_canvas() + vip = viewer.get_vip() + cvs_imgs = vip.get_images([], canvas) + images = set([cvs_img.get_image() for cvs_img in cvs_imgs]) + diff = images.difference(self._cur_images) + update = len(diff) > 0 cur_swap = viewer.get_transforms()[2] if cur_swap != self._cur_swap: @@ -1179,6 +1275,11 @@ # and update all the text objects in self.objects update = True + cur_limits = viewer.get_limits() + if not np.all(np.isclose(cur_limits, self._cur_limits)): + # limits have changed + update = True + if len(self.objects) == 0: # initial time update = True @@ -1187,7 +1288,8 @@ # only expensive recalculation of grid if needed self.ra_angle = None self.dec_angle = None - self.objects = self._calc_axes(viewer, image, cur_rot, cur_swap) + self.objects = self._calc_axes(viewer, images, cur_rot, cur_swap, + cur_limits) super(WCSAxes, self).draw(viewer) diff -Nru ginga-3.0.0/ginga/canvas/types/basic.py ginga-3.1.0/ginga/canvas/types/basic.py --- ginga-3.0.0/ginga/canvas/types/basic.py 2019-09-09 18:09:55.000000000 +0000 +++ ginga-3.1.0/ginga/canvas/types/basic.py 2020-07-08 20:09:29.000000000 +0000 @@ -23,8 +23,9 @@ # # ==== BASIC CLASSES FOR GRAPHICS OBJECTS ==== # -class Text(OnePointMixin, CanvasObjectBase): +class TextP(OnePointMixin, CanvasObjectBase): """Draws text on a DrawingCanvas. + Parameters are: x, y: 0-based coordinates in the data space text: the text to draw @@ -73,22 +74,22 @@ @classmethod def idraw(cls, canvas, cxt): - return cls(cxt.start_x, cxt.start_y, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), **cxt.drawparams) - def __init__(self, x, y, text='EDIT ME', + def __init__(self, pt, text='EDIT ME', font='Sans Serif', fontsize=None, fontscale=False, fontsize_min=6.0, fontsize_max=None, color='yellow', alpha=1.0, rot_deg=0.0, showcap=False, **kwdargs): self.kind = 'text' - points = np.asarray([(x, y)], dtype=np.float) - super(Text, self).__init__(points=points, color=color, alpha=alpha, - font=font, fontsize=fontsize, - fontscale=fontscale, - fontsize_min=fontsize_min, - fontsize_max=fontsize_max, - text=text, rot_deg=rot_deg, - showcap=showcap, **kwdargs) + points = np.asarray([pt], dtype=np.float) + super(TextP, self).__init__(points=points, color=color, alpha=alpha, + font=font, fontsize=fontsize, + fontscale=fontscale, + fontsize_min=fontsize_min, + fontsize_max=fontsize_max, + text=text, rot_deg=rot_deg, + showcap=showcap, **kwdargs) OnePointMixin.__init__(self) def set_edit_point(self, i, pt, detail): @@ -137,12 +138,12 @@ def _get_unrotated_text_llur(self, viewer): # convert coordinate to data point and then pixel pt x1, y1 = self.get_data_points()[0] - cx1, cy1 = viewer.get_canvas_xy(x1, y1) + cx1, cy1 = viewer.tform['data_to_native'].to_((x1, y1)) # width and height of text define bbox wd_px, ht_px = viewer.renderer.get_dimensions(self) cx2, cy2 = cx1 + wd_px, cy1 - ht_px # convert back to data points and construct bbox - x2, y2 = viewer.get_data_xy(cx2, cy2) + x2, y2 = viewer.tform['data_to_native'].from_((cx2, cy2)) x1, y1, x2, y2 = self.swapxy(x1, y1, x2, y2) return (x1, y1, x2, y2) @@ -161,6 +162,23 @@ cr.draw_text(cx, cy, self.text, rot_deg=self.rot_deg) +class Text(TextP): + + @classmethod + def idraw(cls, canvas, cxt): + return cls(cxt.start_x, cxt.start_y, **cxt.drawparams) + + def __init__(self, x, y, text='EDIT ME', + font='Sans Serif', fontsize=None, fontscale=False, + fontsize_min=6.0, fontsize_max=None, + color='yellow', alpha=1.0, rot_deg=0.0, + showcap=False, **kwdargs): + TextP.__init__(self, (x, y), text=text, color=color, alpha=alpha, + font=font, fontsize=fontsize, fontscale=fontscale, + fontsize_min=fontsize_min, fontsize_max=fontsize_max, + rot_deg=rot_deg, showcap=showcap, **kwdargs) + + class Polygon(PolygonMixin, CanvasObjectBase): """Draws a polygon on a DrawingCanvas. Parameters are: @@ -432,7 +450,7 @@ self.draw_caps(cr, self.cap, cpoints) -class Box(OnePointTwoRadiusMixin, CanvasObjectBase): +class BoxP(OnePointTwoRadiusMixin, CanvasObjectBase): """Draws a box on a DrawingCanvas. Parameters are: x, y: 0-based coordinates of the center in the data space @@ -488,13 +506,15 @@ @classmethod def idraw(cls, canvas, cxt): xradius, yradius = abs(cxt.start_x - cxt.x), abs(cxt.start_y - cxt.y) - return cls(cxt.start_x, cxt.start_y, xradius, yradius, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), (xradius, yradius), + **cxt.drawparams) - def __init__(self, x, y, xradius, yradius, color='red', + def __init__(self, pt, radii, color='red', linewidth=1, linestyle='solid', showcap=False, fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, rot_deg=0.0, **kwdargs): - points = np.asarray([(x, y)], dtype=np.float) + xradius, yradius = radii[:2] + points = np.asarray([pt], dtype=np.float) CanvasObjectBase.__init__(self, points=points, color=color, linewidth=linewidth, showcap=showcap, linestyle=linestyle, @@ -546,7 +566,28 @@ self.draw_caps(cr, self.cap, cpoints) -class SquareBox(OnePointOneRadiusMixin, CanvasObjectBase): +class Box(BoxP): + + @classmethod + def idraw(cls, canvas, cxt): + xradius, yradius = abs(cxt.start_x - cxt.x), abs(cxt.start_y - cxt.y) + return cls(cxt.start_x, cxt.start_y, xradius, yradius, + **cxt.drawparams) + + def __init__(self, x, y, xradius, yradius, color='red', + linewidth=1, linestyle='solid', showcap=False, + fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, + rot_deg=0.0, **kwdargs): + BoxP.__init__(self, (x, y), (xradius, yradius), color=color, + linewidth=linewidth, showcap=showcap, + linestyle=linestyle, + fill=fill, fillcolor=fillcolor, + alpha=alpha, fillalpha=fillalpha, + rot_deg=rot_deg, + **kwdargs) + + +class SquareBoxP(OnePointOneRadiusMixin, CanvasObjectBase): """Draws a square box on a DrawingCanvas. Parameters are: x, y: 0-based coordinates of the center in the data space @@ -601,13 +642,13 @@ len_x = cxt.start_x - cxt.x len_y = cxt.start_y - cxt.y radius = max(abs(len_x), abs(len_y)) - return cls(cxt.start_x, cxt.start_y, radius, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), radius, **cxt.drawparams) - def __init__(self, x, y, radius, color='red', + def __init__(self, pt, radius, color='red', linewidth=1, linestyle='solid', showcap=False, fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, rot_deg=0.0, **kwdargs): - points = np.asarray([(x, y)], dtype=np.float) + points = np.asarray([pt], dtype=np.float) CanvasObjectBase.__init__(self, points=points, color=color, linewidth=linewidth, showcap=showcap, linestyle=linestyle, @@ -685,7 +726,28 @@ self.draw_caps(cr, self.cap, cpoints) -class Ellipse(OnePointTwoRadiusMixin, CanvasObjectBase): +class SquareBox(SquareBoxP): + + @classmethod + def idraw(cls, canvas, cxt): + len_x = cxt.start_x - cxt.x + len_y = cxt.start_y - cxt.y + radius = max(abs(len_x), abs(len_y)) + return cls(cxt.start_x, cxt.start_y, radius, **cxt.drawparams) + + def __init__(self, x, y, radius, color='red', + linewidth=1, linestyle='solid', showcap=False, + fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, + rot_deg=0.0, **kwdargs): + SquareBoxP.__init__(self, (x, y), radius, color=color, + linewidth=linewidth, showcap=showcap, + linestyle=linestyle, + fill=fill, fillcolor=fillcolor, + alpha=alpha, fillalpha=fillalpha, + rot_deg=rot_deg, **kwdargs) + + +class EllipseP(OnePointTwoRadiusMixin, CanvasObjectBase): """Draws an ellipse on a DrawingCanvas. Parameters are: x, y: 0-based coordinates of the center in the data space @@ -741,13 +803,15 @@ @classmethod def idraw(cls, canvas, cxt): xradius, yradius = abs(cxt.start_x - cxt.x), abs(cxt.start_y - cxt.y) - return cls(cxt.start_x, cxt.start_y, xradius, yradius, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), (xradius, yradius), + **cxt.drawparams) - def __init__(self, x, y, xradius, yradius, color='yellow', + def __init__(self, pt, radii, color='yellow', linewidth=1, linestyle='solid', showcap=False, fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, rot_deg=0.0, **kwdargs): - points = np.asarray([(x, y)], dtype=np.float) + xradius, yradius = radii + points = np.asarray([pt], dtype=np.float) CanvasObjectBase.__init__(self, points=points, color=color, linewidth=linewidth, showcap=showcap, linestyle=linestyle, @@ -865,7 +929,27 @@ self.draw_caps(cr, self.cap, cpoints) -class Triangle(OnePointTwoRadiusMixin, CanvasObjectBase): +class Ellipse(EllipseP): + + @classmethod + def idraw(cls, canvas, cxt): + xradius, yradius = abs(cxt.start_x - cxt.x), abs(cxt.start_y - cxt.y) + return cls(cxt.start_x, cxt.start_y, xradius, yradius, + **cxt.drawparams) + + def __init__(self, x, y, xradius, yradius, color='yellow', + linewidth=1, linestyle='solid', showcap=False, + fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, + rot_deg=0.0, **kwdargs): + EllipseP.__init__(self, (x, y), (xradius, yradius), color=color, + linewidth=linewidth, showcap=showcap, + linestyle=linestyle, + fill=fill, fillcolor=fillcolor, + alpha=alpha, fillalpha=fillalpha, + rot_deg=rot_deg, **kwdargs) + + +class TriangleP(OnePointTwoRadiusMixin, CanvasObjectBase): """Draws a triangle on a DrawingCanvas. Parameters are: x, y: 0-based coordinates of the center in the data space @@ -921,14 +1005,16 @@ @classmethod def idraw(cls, canvas, cxt): xradius, yradius = abs(cxt.start_x - cxt.x), abs(cxt.start_y - cxt.y) - return cls(cxt.start_x, cxt.start_y, xradius, yradius, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), (xradius, yradius), + **cxt.drawparams) - def __init__(self, x, y, xradius, yradius, color='pink', + def __init__(self, pt, radii, color='pink', linewidth=1, linestyle='solid', showcap=False, fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, rot_deg=0.0, **kwdargs): self.kind = 'triangle' - points = np.asarray([(x, y)], dtype=np.float) + xradius, yradius = radii[:2] + points = np.asarray([pt], dtype=np.float) CanvasObjectBase.__init__(self, points=points, color=color, linewidth=linewidth, showcap=showcap, linestyle=linestyle, alpha=alpha, @@ -995,7 +1081,27 @@ self.draw_caps(cr, self.cap, cpoints) -class Circle(OnePointOneRadiusMixin, CanvasObjectBase): +class Triangle(TriangleP): + + @classmethod + def idraw(cls, canvas, cxt): + xradius, yradius = abs(cxt.start_x - cxt.x), abs(cxt.start_y - cxt.y) + return cls(cxt.start_x, cxt.start_y, xradius, yradius, + **cxt.drawparams) + + def __init__(self, x, y, xradius, yradius, color='pink', + linewidth=1, linestyle='solid', showcap=False, + fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, + rot_deg=0.0, **kwdargs): + TriangleP.__init__(self, (x, y), (xradius, yradius), color=color, + linewidth=linewidth, showcap=showcap, + linestyle=linestyle, alpha=alpha, + fill=fill, fillcolor=fillcolor, + fillalpha=fillalpha, + rot_deg=rot_deg, **kwdargs) + + +class CircleP(OnePointOneRadiusMixin, CanvasObjectBase): """Draws a circle on a DrawingCanvas. Parameters are: x, y: 0-based coordinates of the center in the data space @@ -1046,13 +1152,13 @@ def idraw(cls, canvas, cxt): radius = np.sqrt(abs(cxt.start_x - cxt.x) ** 2 + abs(cxt.start_y - cxt.y) ** 2) - return cls(cxt.start_x, cxt.start_y, radius, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), radius, **cxt.drawparams) - def __init__(self, x, y, radius, color='yellow', + def __init__(self, pt, radius, color='yellow', linewidth=1, linestyle='solid', showcap=False, fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, **kwdargs): - points = np.asarray([(x, y)], dtype=np.float) + points = np.asarray([pt], dtype=np.float) CanvasObjectBase.__init__(self, points=points, color=color, linewidth=linewidth, showcap=showcap, linestyle=linestyle, @@ -1118,7 +1224,27 @@ self.draw_caps(cr, self.cap, ((cx, cy), )) -class Point(OnePointOneRadiusMixin, CanvasObjectBase): +class Circle(CircleP): + + @classmethod + def idraw(cls, canvas, cxt): + radius = np.sqrt(abs(cxt.start_x - cxt.x) ** 2 + + abs(cxt.start_y - cxt.y) ** 2) + return cls(cxt.start_x, cxt.start_y, radius, **cxt.drawparams) + + def __init__(self, x, y, radius, color='yellow', + linewidth=1, linestyle='solid', showcap=False, + fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, + **kwdargs): + CircleP.__init__(self, (x, y), radius, color=color, + linewidth=linewidth, showcap=showcap, + linestyle=linestyle, + fill=fill, fillcolor=fillcolor, + alpha=alpha, fillalpha=fillalpha, + **kwdargs) + + +class PointP(OnePointOneRadiusMixin, CanvasObjectBase): """Draws a point on a DrawingCanvas. Parameters are: x, y: 0-based coordinates of the center in the data space @@ -1165,13 +1291,13 @@ def idraw(cls, canvas, cxt): radius = max(abs(cxt.start_x - cxt.x), abs(cxt.start_y - cxt.y)) - return cls(cxt.start_x, cxt.start_y, radius, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), radius, **cxt.drawparams) - def __init__(self, x, y, radius, style='cross', color='yellow', + def __init__(self, pt, radius, style='cross', color='yellow', linewidth=1, linestyle='solid', alpha=1.0, showcap=False, **kwdargs): self.kind = 'point' - points = np.asarray([(x, y)], dtype=np.float) + points = np.asarray([pt], dtype=np.float) CanvasObjectBase.__init__(self, points=points, color=color, linewidth=linewidth, alpha=alpha, linestyle=linestyle, radius=radius, @@ -1225,13 +1351,13 @@ cr.draw_polygon(cpts) elif self.style == 'diamond': - cpts = [(cx, cy1), ((cx + cx2) / 2.0, cy), - (cx, cy2), ((cx1 + cx) / 2.0, cy)] + cpts = [(cx, cy1), ((cx + cx2) * 0.5, cy), + (cx, cy2), ((cx1 + cx) * 0.5, cy)] cr.draw_polygon(cpts) elif self.style == 'hexagon': - cpts = [(cx1, cy), ((cx1 + cx) / 2.0, cy2), ((cx + cx2) / 2.0, cy2), - (cx2, cy), ((cx + cx2) / 2.0, cy1), ((cx1 + cx) / 2.0, cy1)] + cpts = [(cx1, cy), ((cx1 + cx) * 0.5, cy2), ((cx + cx2) * 0.5, cy2), + (cx2, cy), ((cx + cx2) * 0.5, cy1), ((cx1 + cx) * 0.5, cy1)] cr.draw_polygon(cpts) elif self.style == 'downtriangle': @@ -1249,7 +1375,24 @@ self.draw_caps(cr, self.cap, ((cx, cy), )) -class Rectangle(TwoPointMixin, CanvasObjectBase): +class Point(PointP): + + @classmethod + def idraw(cls, canvas, cxt): + radius = max(abs(cxt.start_x - cxt.x), + abs(cxt.start_y - cxt.y)) + return cls(cxt.start_x, cxt.start_y, radius, **cxt.drawparams) + + def __init__(self, x, y, radius, style='cross', color='yellow', + linewidth=1, linestyle='solid', alpha=1.0, showcap=False, + **kwdargs): + PointP.__init__(self, (x, y), radius, color=color, + linewidth=linewidth, alpha=alpha, + linestyle=linestyle, showcap=showcap, style=style, + **kwdargs) + + +class RectangleP(TwoPointMixin, CanvasObjectBase): """Draws a rectangle on a DrawingCanvas. Parameters are: x1, y1: 0-based coordinates of one corner in the data space @@ -1304,15 +1447,15 @@ @classmethod def idraw(cls, canvas, cxt): - return cls(cxt.start_x, cxt.start_y, cxt.x, cxt.y, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), (cxt.x, cxt.y), **cxt.drawparams) - def __init__(self, x1, y1, x2, y2, color='red', + def __init__(self, pt1, pt2, color='red', linewidth=1, linestyle='solid', showcap=False, fill=False, fillcolor=None, alpha=1.0, drawdims=False, font='Sans Serif', fillalpha=1.0, **kwdargs): self.kind = 'rectangle' - points = np.asarray([(x1, y1), (x2, y2)], dtype=np.float) + points = np.asarray([pt1, pt2], dtype=np.float) CanvasObjectBase.__init__(self, points=points, color=color, linewidth=linewidth, showcap=showcap, @@ -1352,24 +1495,41 @@ fontsize = self.scale_font(viewer) cr.set_font(self.font, fontsize, color=self.color) - cx1, cy1 = cpoints[0] - cx2, cy2 = cpoints[2] - # draw label on X dimension - cx = cx1 + (cx2 - cx1) // 2 - cy = cy2 + -4 + pt = ((self.x1 + self.x2) * 0.5, self.y2) + cx, cy = self.get_cpoints(viewer, points=[pt])[0] cr.draw_text(cx, cy, "%f" % abs(self.x2 - self.x1)) # draw label on Y dimension - cy = cy1 + (cy2 - cy1) // 2 - cx = cx2 + 4 + pt = (self.x2, (self.y1 + self.y2) * 0.5) + cx, cy = self.get_cpoints(viewer, points=[pt])[0] cr.draw_text(cx, cy, "%f" % abs(self.y2 - self.y1)) if self.showcap: self.draw_caps(cr, self.cap, cpoints) -class Square(Rectangle): +class Rectangle(RectangleP): + + @classmethod + def idraw(cls, canvas, cxt): + return cls(cxt.start_x, cxt.start_y, cxt.x, cxt.y, **cxt.drawparams) + + def __init__(self, x1, y1, x2, y2, color='red', + linewidth=1, linestyle='solid', showcap=False, + fill=False, fillcolor=None, alpha=1.0, + drawdims=False, font='Sans Serif', fillalpha=1.0, + **kwdargs): + RectangleP.__init__(self, (x1, y1), (x2, y2), color=color, + linewidth=linewidth, showcap=showcap, + linestyle=linestyle, + fill=fill, fillcolor=fillcolor, + alpha=alpha, fillalpha=fillalpha, + drawdims=drawdims, font=font, + **kwdargs) + + +class Square(RectangleP): @classmethod def idraw(cls, canvas, cxt): @@ -1378,12 +1538,12 @@ length = max(abs(len_x), abs(len_y)) len_x = np.sign(len_x) * length len_y = np.sign(len_y) * length - return cls(cxt.start_x, cxt.start_y, - cxt.start_x - len_x, cxt.start_y - len_y, + return cls((cxt.start_x, cxt.start_y), + (cxt.start_x - len_x, cxt.start_y - len_y), **cxt.drawparams) -class Line(TwoPointMixin, CanvasObjectBase): +class LineP(TwoPointMixin, CanvasObjectBase): """Draws a line on a DrawingCanvas. Parameters are: x1, y1: 0-based coordinates of one end in the data space @@ -1427,13 +1587,13 @@ @classmethod def idraw(cls, canvas, cxt): - return cls(cxt.start_x, cxt.start_y, cxt.x, cxt.y, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), (cxt.x, cxt.y), **cxt.drawparams) - def __init__(self, x1, y1, x2, y2, color='red', + def __init__(self, pt1, pt2, color='red', linewidth=1, linestyle='solid', alpha=1.0, arrow=None, showcap=False, **kwdargs): self.kind = 'line' - points = np.asarray([(x1, y1), (x2, y2)], dtype=np.float) + points = np.asarray([pt1, pt2], dtype=np.float) CanvasObjectBase.__init__(self, points=points, color=color, alpha=alpha, linewidth=linewidth, showcap=showcap, linestyle=linestyle, arrow=arrow, @@ -1483,7 +1643,22 @@ self.draw_caps(cr, self.cap, caps) -class RightTriangle(TwoPointMixin, CanvasObjectBase): +class Line(LineP): + + @classmethod + def idraw(cls, canvas, cxt): + return cls(cxt.start_x, cxt.start_y, cxt.x, cxt.y, **cxt.drawparams) + + def __init__(self, x1, y1, x2, y2, color='red', + linewidth=1, linestyle='solid', alpha=1.0, + arrow=None, showcap=False, **kwdargs): + LineP.__init__(self, (x1, y1), (x2, y2), color=color, alpha=alpha, + linewidth=linewidth, showcap=showcap, + linestyle=linestyle, arrow=arrow, + **kwdargs) + + +class RightTriangleP(TwoPointMixin, CanvasObjectBase): """Draws a right triangle on a DrawingCanvas. Parameters are: x1, y1: 0-based coordinates of one end of the diagonal in the data space @@ -1533,14 +1708,14 @@ @classmethod def idraw(cls, canvas, cxt): - return cls(cxt.start_x, cxt.start_y, cxt.x, cxt.y, **cxt.drawparams) + return cls((cxt.start_x, cxt.start_y), (cxt.x, cxt.y), **cxt.drawparams) - def __init__(self, x1, y1, x2, y2, color='pink', + def __init__(self, pt1, pt2, color='pink', linewidth=1, linestyle='solid', showcap=False, fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, **kwdargs): self.kind = 'righttriangle' - points = np.asarray([(x1, y1), (x2, y2)], dtype=np.float) + points = np.asarray([pt1, pt2], dtype=np.float) CanvasObjectBase.__init__(self, points=points, color=color, alpha=alpha, linewidth=linewidth, showcap=showcap, linestyle=linestyle, @@ -1586,7 +1761,24 @@ self.draw_caps(cr, self.cap, cpoints) -class XRange(Rectangle): +class RightTriangle(RightTriangleP): + + @classmethod + def idraw(cls, canvas, cxt): + return cls(cxt.start_x, cxt.start_y, cxt.x, cxt.y, **cxt.drawparams) + + def __init__(self, x1, y1, x2, y2, color='pink', + linewidth=1, linestyle='solid', showcap=False, + fill=False, fillcolor=None, alpha=1.0, fillalpha=1.0, + **kwdargs): + RightTriangleP.__init__(self, (x1, y1), (x2, y2), color=color, + alpha=alpha, linewidth=linewidth, + showcap=showcap, linestyle=linestyle, + fill=fill, fillcolor=fillcolor, + fillalpha=fillalpha, **kwdargs) + + +class XRange(RectangleP): """Draws an xrange on a DrawingCanvas. Parameters are: x1: start X coordinate in the data space @@ -1638,13 +1830,13 @@ fillcolor='aquamarine', alpha=1.0, drawdims=False, font='Sans Serif', fillalpha=0.5, **kwdargs): - Rectangle.__init__(self, x1, 0, x2, 0, color=color, - linewidth=linewidth, - linestyle=linestyle, - fill=True, fillcolor=fillcolor, - alpha=alpha, fillalpha=fillalpha, - drawdims=drawdims, font=font, - **kwdargs) + RectangleP.__init__(self, (x1, 0), (x2, 0), color=color, + linewidth=linewidth, + linestyle=linestyle, + fill=True, fillcolor=fillcolor, + alpha=alpha, fillalpha=fillalpha, + drawdims=drawdims, font=font, + **kwdargs) self.kind = 'xrange' def contains_pts(self, pts): @@ -1655,20 +1847,20 @@ return contains def get_edit_points(self, viewer): - win_wd, win_ht = viewer.get_window_size() - crdmap = viewer.get_coordmap('window') - dx, dy = crdmap.to_data((0, win_ht // 2)) + tup = viewer.get_datarect() + dy = (tup[1] + tup[3]) * 0.5 pt = self.get_data_points(points=self.get_points()) - return [MovePoint((pt[0][0] + pt[2][0]) / 2.0, dy), + return [MovePoint((pt[0][0] + pt[2][0]) * 0.5, dy), EditPoint(pt[0][0], dy), EditPoint(pt[2][0], dy)] def draw(self, viewer): cr = viewer.renderer.setup_cr(self) - win_wd, win_ht = viewer.get_window_size() - cp = self.get_cpoints(viewer) - cpoints = ((cp[0][0], 0), (cp[1][0], 0), (cp[2][0], win_ht), (cp[3][0], win_ht)) + tup = viewer.get_datarect() + pts = [(self.x1, tup[1]), (self.x2, tup[1]), + (self.x2, tup[3]), (self.x1, tup[3])] + cpoints = self.get_cpoints(viewer, points=pts) cr.draw_polygon(cpoints) @@ -1676,16 +1868,12 @@ fontsize = self.scale_font(viewer) cr.set_font(self.font, fontsize, color=self.color) - cx1, cy1 = cpoints[0] - cx2, cy2 = cpoints[2] - - # draw label on X dimension - cx = cx1 + (cx2 - cx1) // 2 - cy = (cy1 + cy2) // 2 + pt = ((self.x1 + self.x2) * 0.5, (tup[1] + tup[3]) * 0.5) + cx, cy = self.get_cpoints(viewer, points=[pt])[0] cr.draw_text(cx, cy, "%f:%f" % (self.x1, self.x2)) -class YRange(Rectangle): +class YRange(RectangleP): """Draws a yrange on a DrawingCanvas. Parameters are: y1: start Y coordinate in the data space @@ -1737,13 +1925,13 @@ fill=True, fillcolor='aquamarine', alpha=1.0, drawdims=False, font='Sans Serif', fillalpha=0.5, **kwdargs): - Rectangle.__init__(self, 0, y1, 0, y2, - color=color, linewidth=linewidth, - linestyle=linestyle, - fill=True, fillcolor=fillcolor, - alpha=alpha, fillalpha=fillalpha, - drawdims=drawdims, font=font, - **kwdargs) + RectangleP.__init__(self, (0, y1), (0, y2), + color=color, linewidth=linewidth, + linestyle=linestyle, + fill=True, fillcolor=fillcolor, + alpha=alpha, fillalpha=fillalpha, + drawdims=drawdims, font=font, + **kwdargs) self.kind = 'yrange' def contains_pts(self, pts): @@ -1754,20 +1942,20 @@ return contains def get_edit_points(self, viewer): - win_wd, win_ht = viewer.get_window_size() - crdmap = viewer.get_coordmap('window') - dx, dy = crdmap.to_data((win_wd // 2, 0)) + tup = viewer.get_datarect() + dx = (tup[0] + tup[2]) * 0.5 pt = self.get_data_points(points=self.get_points()) - return [MovePoint(dx, (pt[0][1] + pt[2][1]) / 2.0), + return [MovePoint(dx, (pt[0][1] + pt[2][1]) * 0.5), EditPoint(dx, pt[0][1]), EditPoint(dx, pt[2][1])] def draw(self, viewer): cr = viewer.renderer.setup_cr(self) - win_wd, win_ht = viewer.get_window_size() - cp = self.get_cpoints(viewer) - cpoints = ((0, cp[0][1]), (win_wd, cp[1][1]), (win_wd, cp[2][1]), (0, cp[3][1])) + tup = viewer.get_datarect() + pts = [(tup[0], self.y1), (tup[2], self.y1), + (tup[2], self.y2), (tup[0], self.y2)] + cpoints = self.get_cpoints(viewer, points=pts) cr.draw_polygon(cpoints) @@ -1775,12 +1963,8 @@ fontsize = self.scale_font(viewer) cr.set_font(self.font, fontsize, color=self.color) - cx1, cy1 = cpoints[0] - cx2, cy2 = cpoints[2] - - # draw label on Y dimension - cy = cy1 + (cy2 - cy1) // 2 - cx = (cx1 + cx2) // 2 + pt = ((tup[0] + tup[2]) * 0.5, (self.y1 + self.y2) * 0.5) + cx, cy = self.get_cpoints(viewer, points=[pt])[0] cr.draw_text(cx, cy, "%f:%f" % (self.y1, self.y2)) diff -Nru ginga-3.0.0/ginga/canvas/types/image.py ginga-3.1.0/ginga/canvas/types/image.py --- ginga-3.0.0/ginga/canvas/types/image.py 2019-09-09 18:09:55.000000000 +0000 +++ ginga-3.1.0/ginga/canvas/types/image.py 2020-07-08 20:09:29.000000000 +0000 @@ -4,6 +4,7 @@ # This is open-source software licensed under a BSD license. # Please see the file LICENSE.txt for details. # +import uuid import numpy as np from ginga.canvas.CanvasObject import (CanvasObjectBase, _bool, _color, @@ -12,12 +13,11 @@ colors_plus_none, coord_names) from ginga.misc.ParamSet import Param from ginga.misc import Bunch -from ginga import trcalc from .mixins import OnePointMixin -class Image(OnePointMixin, CanvasObjectBase): +class ImageP(OnePointMixin, CanvasObjectBase): """Draws an image on a ImageViewCanvas. Parameters are: x, y: 0-based coordinates of one corner in the data space @@ -40,7 +40,7 @@ description="Scaling factor for X dimension of object"), Param(name='scale_y', type=float, default=1.0, description="Scaling factor for Y dimension of object"), - Param(name='interpolation', type=str, default='basic', + Param(name='interpolation', type=str, default=None, description="Interpolation method for scaling pixels"), Param(name='linewidth', type=int, default=0, min=0, max=20, widget='spinbutton', incr=1, @@ -65,13 +65,13 @@ description="Optimize rendering for this object"), ] - def __init__(self, x, y, image, alpha=1.0, scale_x=1.0, scale_y=1.0, - interpolation='basic', + def __init__(self, pt, image, alpha=1.0, scale_x=1.0, scale_y=1.0, + interpolation=None, linewidth=0, linestyle='solid', color='lightgreen', showcap=False, flipy=False, optimize=True, **kwdargs): self.kind = 'image' - points = np.asarray([(x, y)], dtype=np.float) + points = np.asarray([pt], dtype=np.float) CanvasObjectBase.__init__(self, points=points, image=image, alpha=alpha, scale_x=scale_x, scale_y=scale_y, interpolation=interpolation, @@ -84,21 +84,14 @@ # The cache holds intermediate step results by viewer. # Depending on value of `whence` they may not need to be recomputed. self._cache = {} - self._zorder = 0 + self.image_id = str(uuid.uuid4()) # images are not editable by default self.editable = False + # is this image "data" or something else + self.is_data = False self.enable_callback('image-set') - def get_zorder(self): - return self._zorder - - def set_zorder(self, zorder): - self._zorder = zorder - for viewer in self._cache: - viewer.reorder_layers() - viewer.redraw(whence=2) - def in_cache(self, viewer): return viewer in self._cache @@ -120,15 +113,19 @@ Note that actual insertion of the image into the output is handled in `draw_image()` """ + if self.image is None: + return + whence = viewer._whence cache = self.get_cache(viewer) if not cache.drawn: - cache.drawn = True - viewer.redraw(whence=2) + viewer.prepare_image(self, cache, whence) cpoints = self.get_cpoints(viewer) cr = viewer.renderer.setup_cr(self) + cr.draw_image(self, cpoints, cache.rgbarr, whence) + # draw optional border if self.linewidth > 0: cr.draw_polygon(cpoints) @@ -136,90 +133,12 @@ if self.showcap: self.draw_caps(cr, self.cap, cpoints) - def draw_image(self, viewer, dstarr, whence=0.0): - if self.image is None: - return - + def prepare_image(self, viewer, whence): cache = self.get_cache(viewer) - - dst_order = viewer.get_rgb_order() - image_order = self.image.get_order() - - if (whence <= 0.0) or (cache.cutout is None) or (not self.optimize): - # get extent of our data coverage in the window - pts = np.asarray(viewer.get_pan_rect()).T - xmin = int(np.min(pts[0])) - ymin = int(np.min(pts[1])) - xmax = int(np.ceil(np.max(pts[0]))) - ymax = int(np.ceil(np.max(pts[1]))) - - # get destination location in data_coords - dst_x, dst_y = self.crdmap.to_data((self.x, self.y)) - - a1, b1, a2, b2 = 0, 0, self.image.width - 1, self.image.height - 1 - - # calculate the cutout that we can make and scale to merge - # onto the final image--by only cutting out what is necessary - # this speeds scaling greatly at zoomed in sizes - ((dst_x, dst_y), (a1, b1), (a2, b2)) = \ - trcalc.calc_image_merge_clip((xmin, ymin), (xmax, ymax), - (dst_x, dst_y), - (a1, b1), (a2, b2)) - - # is image completely off the screen? - if (a2 - a1 <= 0) or (b2 - b1 <= 0): - # no overlay needed - return - - # cutout and scale the piece appropriately by the viewer scale - scale_x, scale_y = viewer.get_scale_xy() - # scale additionally by our scale - _scale_x, _scale_y = scale_x * self.scale_x, scale_y * self.scale_y - - res = self.image.get_scaled_cutout2((a1, b1), (a2, b2), - (_scale_x, _scale_y), - method=self.interpolation) - - # don't ask for an alpha channel from overlaid image if it - # doesn't have one - ## if ('A' in dst_order) and not ('A' in image_order): - ## dst_order = dst_order.replace('A', '') - - ## if dst_order != image_order: - ## # reorder result to match desired rgb_order by backend - ## cache.cutout = trcalc.reorder_image(dst_order, res.data, - ## image_order) - ## else: - ## cache.cutout = res.data - data = res.data - if self.flipy: - data = np.flipud(data) - cache.cutout = data - - # calculate our offset from the pan position - pan_x, pan_y = viewer.get_pan() - pan_off = viewer.data_off - pan_x, pan_y = pan_x + pan_off, pan_y + pan_off - off_x, off_y = dst_x - pan_x, dst_y - pan_y - # scale offset - off_x *= scale_x - off_y *= scale_y - - # dst position in the pre-transformed array should be calculated - # from the center of the array plus offsets - ht, wd, dp = dstarr.shape - cvs_x = int(np.round(wd / 2.0 + off_x)) - cvs_y = int(np.round(ht / 2.0 + off_y)) - cache.cvs_pos = (cvs_x, cvs_y) - - # composite the image into the destination array at the - # calculated position - trcalc.overlay_image(dstarr, cache.cvs_pos, cache.cutout, - dst_order=dst_order, src_order=image_order, - alpha=self.alpha, fill=True, flipy=False) + viewer.prepare_image(self, cache, whence) def _reset_cache(self, cache): - cache.setvals(cutout=None, drawn=False, cvs_pos=(0, 0)) + cache.setvals(cutout=None, rgbarr=None, drawn=False, cvs_pos=(0, 0)) return cache def reset_optimize(self): @@ -236,12 +155,15 @@ self.make_callback('image-set', image) def get_scaled_wdht(self): - width = int(self.image.width * self.scale_x) - height = int(self.image.height * self.scale_y) + width = self.image.width * self.scale_x + height = self.image.height * self.scale_y return (width, height) def get_coords(self): x1, y1 = self.crdmap.to_data((self.x, self.y)) + # TODO: this should be viewer.data_off instead of hard-coded, + # but we don't have a handle to the viewer here. + x1, y1 = x1 - 0.5, y1 - 0.5 wd, ht = self.get_scaled_wdht() x2, y2 = x1 + wd, y1 + ht return (x1, y1, x2, y2) @@ -250,20 +172,21 @@ return self.get_coords() def get_center_pt(self): - wd, ht = self.get_scaled_wdht() x1, y1, x2, y2 = self.get_coords() - return ((x1 + x2) / 2.0, (y1 + y2) / 2.0) + return ((x1 + x2) * 0.5, (y1 + y2) * 0.5) def get_points(self): x1, y1, x2, y2 = self.get_coords() return [(x1, y1), (x2, y1), (x2, y2), (x1, y2)] - def contains_pt(self, pt): - data_x, data_y = pt[:2] + def contains_pts(self, pts): + x_arr, y_arr = np.asarray(pts).T x1, y1, x2, y2 = self.get_coords() - if ((x1 <= data_x < x2) and (y1 <= data_y < y2)): - return True - return False + + contains = np.logical_and( + np.logical_and(x1 <= x_arr, x_arr <= x2), + np.logical_and(y1 <= y_arr, y_arr <= y2)) + return contains def rotate(self, theta, xoff=0, yoff=0): raise ValueError("Images cannot be rotated") @@ -277,10 +200,10 @@ if i == 0: self.move_to_pt(pt) elif i == 1: - scale_x, scale_y = self.calc_dual_scale_from_pt(pt, detail) + scale_x = self.calc_scale_from_pt(pt, detail) self.scale_x = detail.scale_x * scale_x elif i == 2: - scale_x, scale_y = self.calc_dual_scale_from_pt(pt, detail) + scale_y = self.calc_scale_from_pt(pt, detail) self.scale_y = detail.scale_y * scale_y elif i == 3: scale_x, scale_y = self.calc_dual_scale_from_pt(pt, detail) @@ -290,13 +213,14 @@ raise ValueError("No point corresponding to index %d" % (i)) self.reset_optimize() + detail.viewer.redraw(whence=0) def get_edit_points(self, viewer): x1, y1, x2, y2 = self.get_coords() return [MovePoint(*self.get_center_pt()), # location - Point(x2, (y1 + y2) / 2.), # width scale - Point((x1 + x2) / 2., y2), # height scale - Point(x2, y2), # both scale + Point(x2, (y1 + y2) * 0.5), # width scale + Point((x1 + x2) * 0.5, y2), # height scale + Point(x2, y2), # both scale ] def scale_by_factors(self, factors): @@ -315,7 +239,23 @@ self.reset_optimize() -class NormImage(Image): +class Image(ImageP): + + def __init__(self, x, y, image, alpha=1.0, scale_x=1.0, scale_y=1.0, + interpolation=None, + linewidth=0, linestyle='solid', color='lightgreen', + showcap=False, flipy=False, optimize=True, + **kwdargs): + ImageP.__init__(self, (x, y), image, alpha=alpha, + scale_x=scale_x, scale_y=scale_y, + interpolation=interpolation, + linewidth=linewidth, linestyle=linestyle, + color=color, showcap=showcap, + flipy=flipy, optimize=optimize, + **kwdargs) + + +class NormImageP(ImageP): """Draws an image on a ImageViewCanvas. Parameters are: @@ -339,7 +279,7 @@ description="Scaling factor for X dimension of object"), Param(name='scale_y', type=float, default=1.0, description="Scaling factor for Y dimension of object"), - Param(name='interpolation', type=str, default='basic', + Param(name='interpolation', type=str, default=None, description="Interpolation method for scaling pixels"), Param(name='linewidth', type=int, default=0, min=0, max=20, widget='spinbutton', incr=1, @@ -362,152 +302,55 @@ Param(name='optimize', type=_bool, default=True, valid=[False, True], description="Optimize rendering for this object"), + ## Param(name='cuts', type=tuple, default=None, + ## description="Tuple of (lo, hi) cut levels for image"), ## Param(name='rgbmap', type=?, ## description="RGB mapper for the image"), ## Param(name='autocuts', type=?, ## description="Cuts manager for the image"), ] - def __init__(self, x, y, image, alpha=1.0, scale_x=1.0, scale_y=1.0, - interpolation='basic', linewidth=0, linestyle='solid', + def __init__(self, pt, image, alpha=1.0, scale_x=1.0, scale_y=1.0, + interpolation=None, cuts=None, linewidth=0, linestyle='solid', color='lightgreen', showcap=False, optimize=True, rgbmap=None, autocuts=None, **kwdargs): + super(NormImageP, self).__init__(pt, image, alpha=alpha, + scale_x=scale_x, scale_y=scale_y, + interpolation=interpolation, + linewidth=linewidth, linestyle=linestyle, + color=color, + showcap=showcap, optimize=optimize, + **kwdargs) self.kind = 'normimage' - super(NormImage, self).__init__(x, y, image=image, alpha=alpha, - scale_x=scale_x, scale_y=scale_y, - interpolation=interpolation, - linewidth=linewidth, linestyle=linestyle, - color=color, - showcap=showcap, optimize=optimize, - **kwdargs) self.rgbmap = rgbmap + self.cuts = cuts self.autocuts = autocuts - def draw_image(self, viewer, dstarr, whence=0.0): - if self.image is None: - return - - cache = self.get_cache(viewer) - - if (whence <= 0.0) or (cache.cutout is None) or (not self.optimize): - # get extent of our data coverage in the window - pts = np.asarray(viewer.get_pan_rect()).T - xmin = int(np.min(pts[0])) - ymin = int(np.min(pts[1])) - xmax = int(np.ceil(np.max(pts[0]))) - ymax = int(np.ceil(np.max(pts[1]))) - - # destination location in data_coords - dst_x, dst_y = self.crdmap.to_data((self.x, self.y)) - - a1, b1, a2, b2 = 0, 0, self.image.width - 1, self.image.height - 1 - - # calculate the cutout that we can make and scale to merge - # onto the final image--by only cutting out what is necessary - # this speeds scaling greatly at zoomed in sizes - ((dst_x, dst_y), (a1, b1), (a2, b2)) = \ - trcalc.calc_image_merge_clip((xmin, ymin), (xmax, ymax), - (dst_x, dst_y), - (a1, b1), (a2, b2)) - - # is image completely off the screen? - if (a2 - a1 <= 0) or (b2 - b1 <= 0): - # no overlay needed - return - - # cutout and scale the piece appropriately by viewer scale - scale_x, scale_y = viewer.get_scale_xy() - # scale additionally by our scale - _scale_x, _scale_y = scale_x * self.scale_x, scale_y * self.scale_y - - res = self.image.get_scaled_cutout2((a1, b1), (a2, b2), - (_scale_x, _scale_y), - method=self.interpolation) - cache.cutout = res.data - - # calculate our offset from the pan position - pan_x, pan_y = viewer.get_pan() - pan_off = viewer.data_off - pan_x, pan_y = pan_x + pan_off, pan_y + pan_off - off_x, off_y = dst_x - pan_x, dst_y - pan_y - # scale offset - off_x *= scale_x - off_y *= scale_y - - # dst position in the pre-transformed array should be calculated - # from the center of the array plus offsets - ht, wd, dp = dstarr.shape - cvs_x = int(np.round(wd / 2.0 + off_x)) - cvs_y = int(np.round(ht / 2.0 + off_y)) - cache.cvs_pos = (cvs_x, cvs_y) - - if self.rgbmap is not None: - rgbmap = self.rgbmap - else: - rgbmap = viewer.get_rgbmap() - - if (whence <= 1.0) or (cache.prergb is None) or (not self.optimize): - # apply visual changes prior to color mapping (cut levels, etc) - vmax = rgbmap.get_hash_size() - 1 - newdata = self.apply_visuals(viewer, cache.cutout, 0, vmax) - - # result becomes an index array fed to the RGB mapper - if not np.issubdtype(newdata.dtype, np.dtype('uint')): - newdata = newdata.astype(np.uint) - idx = newdata - - self.logger.debug("shape of index is %s" % (str(idx.shape))) - cache.prergb = idx - - dst_order = viewer.get_rgb_order() - image_order = self.image.get_order() - get_order = dst_order - # note: is this still needed? I think overlay_image will handle - # a mismatch of alpha channel now - if ('A' in dst_order) and not ('A' in image_order): - get_order = dst_order.replace('A', '') - - if (whence <= 2.5) or (cache.rgbarr is None) or (not self.optimize): - # get RGB mapped array - rgbobj = rgbmap.get_rgbarray(cache.prergb, order=dst_order, - image_order=image_order) - cache.rgbarr = rgbobj.get_array(get_order) - - # composite the image into the destination array at the - # calculated position - trcalc.overlay_image(dstarr, cache.cvs_pos, cache.rgbarr, - dst_order=dst_order, src_order=get_order, - alpha=self.alpha, fill=True, flipy=False) - - def apply_visuals(self, viewer, data, vmin, vmax): - if self.autocuts is not None: - autocuts = self.autocuts - else: - autocuts = viewer.autocuts - - # Apply cut levels - loval, hival = viewer.t_['cuts'] - newdata = autocuts.cut_levels(data, loval, hival, - vmin=vmin, vmax=vmax) - return newdata - def _reset_cache(self, cache): - cache.setvals(cutout=None, prergb=None, rgbarr=None, + cache.setvals(cutout=None, alpha=None, prergb=None, rgbarr=None, drawn=False, cvs_pos=(0, 0)) return cache - def set_image(self, image): - self.image = image - self.reset_optimize() - - self.make_callback('image-set', image) - def scale_by(self, scale_x, scale_y): self.scale_x *= scale_x self.scale_y *= scale_y self.reset_optimize() +class NormImage(NormImageP): + + def __init__(self, x, y, image, alpha=1.0, scale_x=1.0, scale_y=1.0, + interpolation=None, cuts=None, linewidth=0, linestyle='solid', + color='lightgreen', showcap=False, + optimize=True, rgbmap=None, autocuts=None, **kwdargs): + NormImageP.__init__(self, (x, y), image, alpha=alpha, + scale_x=scale_x, scale_y=scale_y, + interpolation=interpolation, + linewidth=linewidth, linestyle=linestyle, + color=color, showcap=showcap, optimize=optimize, + **kwdargs) + + # register our types register_canvas_types(dict(image=Image, normimage=NormImage)) diff -Nru ginga-3.0.0/ginga/canvas/types/utils.py ginga-3.1.0/ginga/canvas/types/utils.py --- ginga-3.0.0/ginga/canvas/types/utils.py 2019-09-09 18:09:55.000000000 +0000 +++ ginga-3.1.0/ginga/canvas/types/utils.py 2020-01-20 03:17:53.000000000 +0000 @@ -7,7 +7,7 @@ from ginga.canvas.CanvasObject import (CanvasObjectBase, _bool, _color, register_canvas_types, colors_plus_none, coord_names) -from .basic import Rectangle +from .basic import RectangleP from ginga.misc.ParamSet import Param @@ -197,7 +197,7 @@ cr.draw_polygon(tr.to_(cpoints)) -class DrawableColorBar(Rectangle): +class DrawableColorBarP(RectangleP): @classmethod def get_params_metadata(cls): @@ -243,16 +243,16 @@ description="Opacity of fill"), ] - def __init__(self, x1, y1, x2, y2, showrange=True, + def __init__(self, p1, p2, showrange=True, font='Sans Serif', fontsize=8, color='black', bgcolor='white', linewidth=1, linestyle='solid', alpha=1.0, fillalpha=1.0, rgbmap=None, optimize=True, **kwdargs): - Rectangle.__init__(self, x1, y1, x2, y2, - font=font, fontsize=fontsize, - color=color, bgcolor=bgcolor, linewidth=linewidth, - linestyle=linestyle, alpha=alpha, - fillalpha=fillalpha, **kwdargs) + RectangleP.__init__(self, p1, p2, + font=font, fontsize=fontsize, + color=color, bgcolor=bgcolor, linewidth=linewidth, + linestyle=linestyle, alpha=alpha, + fillalpha=fillalpha, **kwdargs) self.showrange = showrange self.rgbmap = rgbmap self.kind = 'drawablecolorbar' @@ -385,6 +385,26 @@ cr.draw_polygon(tr.to_(cpoints)) +class DrawableColorBar(DrawableColorBarP): + + @classmethod + def idraw(cls, canvas, cxt): + return cls(cxt.start_x, cxt.start_y, cxt.x, cxt.y, **cxt.drawparams) + + def __init__(self, x1, y1, x2, y2, showrange=True, + font='Sans Serif', fontsize=8, + color='black', bgcolor='white', + linewidth=1, linestyle='solid', alpha=1.0, + fillalpha=1.0, rgbmap=None, optimize=True, **kwdargs): + DrawableColorBarP.__init__(self, (x1, y1), (x2, y2), + showrange=showrange, font=font, + fontsize=fontsize, + color=color, bgcolor=bgcolor, + linewidth=linewidth, linestyle=linestyle, + alpha=alpha, fillalpha=fillalpha, + rgbmap=rgbmap, optimize=optimize, **kwdargs) + + class ModeIndicator(CanvasObjectBase): """ Shows a mode indicator. diff -Nru ginga-3.0.0/ginga/cmap.py ginga-3.1.0/ginga/cmap.py --- ginga-3.0.0/ginga/cmap.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/cmap.py 2020-07-08 20:09:29.000000000 +0000 @@ -531,7 +531,7 @@ (0.99216, 0.99216, 1.00000), (0.99608, 0.99608, 1.00000), (1.00000, 1.00000, 1.00000), - ) +) cmap_rainbow4 = ( (0.00000, 0.00000, 0.01176), @@ -1049,7 +1049,7 @@ (0.99608, 0.98431, 0.00000), (1.00000, 0.99216, 0.00000), (1.00000, 1.00000, 0.00000), - ) +) cmap_idl5 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -1308,7 +1308,7 @@ (1.00000, 1.00000, 0.97255), (1.00000, 1.00000, 0.98431), (1.00000, 1.00000, 1.00000), - ) +) cmap_idl6 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -1567,7 +1567,7 @@ (0.00000, 0.00000, 0.03529), (0.00000, 0.00000, 0.01961), (0.00000, 0.00000, 0.00000), - ) +) cmap_smooth1 = ( (0.30980, 0.29020, 0.22353), # noqa @@ -1826,7 +1826,7 @@ (0.80392, 0.74118, 0.69020), (0.83529, 0.78039, 0.73333), (0.87059, 0.82353, 0.78431), - ) +) cmap_smooth = ( (0.00000, 0.00000, 1.00000), # noqa @@ -2085,7 +2085,7 @@ (0.00000, 0.00000, 0.00000), (0.00000, 0.00000, 0.00000), (0.00000, 0.00000, 0.00000), - ) +) cmap_isophot = ( (0.00000, 0.00000, 0.00000), # noqa @@ -2344,7 +2344,7 @@ (1.00000, 0.91373, 1.00000), (1.00000, 0.95686, 1.00000), (1.00000, 1.00000, 1.00000), - ) +) cmap_smooth2 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -2603,7 +2603,7 @@ (1.00000, 1.00000, 0.80000), (1.00000, 1.00000, 0.86667), (1.00000, 1.00000, 1.00000), - ) +) cmap_heat = ( (0.00000, 0.00000, 0.00000), # noqa @@ -2862,7 +2862,7 @@ (1.00000, 0.99216, 1.00000), (1.00000, 0.99608, 1.00000), (1.00000, 1.00000, 1.00000), - ) +) cmap_smooth3 = ( (0.00000, 0.00000, 0.00784), # noqa @@ -3121,7 +3121,7 @@ (0.96674, 1.00000, 0.96343), (0.75749, 1.00000, 1.00000), (0.00000, 1.00000, 1.00000), - ) +) cmap_rainbow = ( (0.00000, 0.00000, 0.16471), # noqa @@ -3380,7 +3380,7 @@ (0.97647, 0.85882, 0.85882), (0.98824, 0.92941, 0.92941), (1.00000, 1.00000, 1.00000), - ) +) cmap_manycol = ( (0.34902, 0.34902, 0.34902), # noqa @@ -3639,7 +3639,7 @@ (0.00000, 0.88235, 0.00000), (0.72549, 0.00000, 0.72549), (0.72549, 0.00000, 0.72549), - ) +) cmap_gray = ( (0.00000, 0.00000, 0.00000), # noqa @@ -3898,7 +3898,7 @@ (0.99216, 0.99216, 0.99216), (0.99608, 0.99608, 0.99608), (1.00000, 1.00000, 1.00000), - ) +) cmap_grayclip = ( # Like gray, but shows clipping of top and bottom 5% @@ -4417,7 +4417,7 @@ (0.99608, 0.97255, 0.00392), (1.00000, 0.98039, 0.00000), (1.00000, 0.99216, 0.00000), - ) +) cmap_light = ( (0.00000, 0.00392, 0.00000), # noqa @@ -4676,7 +4676,7 @@ (1.00000, 0.99608, 1.00000), (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), - ) +) cmap_random1 = ( (0.00000, 0.00000, 0.16471), # noqa @@ -4935,7 +4935,7 @@ (0.78824, 0.00000, 0.00000), (0.78824, 0.00000, 0.00000), (0.78824, 0.00000, 0.00000), - ) +) cmap_random2 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -5194,7 +5194,7 @@ (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), - ) +) cmap_random3 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -5453,7 +5453,7 @@ (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), - ) +) cmap_random4 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -5712,7 +5712,7 @@ (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), - ) +) cmap_random5 = ( (0.00000, 0.00000, 1.00000), # noqa @@ -5971,7 +5971,7 @@ (0.98824, 0.71373, 0.00000), (0.99216, 0.85098, 0.00000), (1.00000, 1.00000, 0.00000), - ) +) cmap_random6 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -6230,7 +6230,7 @@ (0.98824, 0.70588, 0.98824), (0.98824, 0.84706, 0.98824), (0.98824, 0.98824, 0.98824), - ) +) cmap_color = ( (0.00000, 0.00000, 0.00000), # noqa @@ -6489,7 +6489,7 @@ (0.74902, 0.00000, 0.30980), (0.74902, 0.00000, 0.30980), (0.74902, 0.00000, 0.30980), - ) +) cmap_standard = ( (0.00392, 0.00392, 0.33333), @@ -7007,7 +7007,7 @@ (0.96899, 0.96899, 0.99216), (0.98441, 0.98441, 0.99608), (1.00000, 1.00000, 1.00000), - ) +) cmap_green = ( (0.00000, 0.00000, 0.00000), # noqa @@ -7266,7 +7266,7 @@ (0.00000, 0.99216, 0.00000), (0.00000, 0.99608, 0.00392), (0.00000, 1.00000, 0.00784), - ) +) cmap_staircase = ( (0.00392, 0.00392, 0.31373), # noqa @@ -7525,7 +7525,7 @@ (0.98431, 0.80392, 0.80392), (0.99216, 0.80000, 0.80000), (1.00000, 1.00000, 1.00000), - ) +) cmap_random = ( (0.00000, 0.00000, 0.00000), # noqa @@ -7784,7 +7784,7 @@ (0.00000, 0.00000, 0.00000), (0.00000, 0.00000, 0.00000), (0.00000, 0.00000, 0.00000), - ) +) cmap_blue = ( (0.00000, 0.00000, 0.00000), # noqa @@ -8043,7 +8043,7 @@ (0.00000, 0.01176, 0.99216), (0.00000, 0.01569, 0.99608), (0.00000, 0.00392, 1.00000), - ) +) cmap_red = ( (0.00000, 0.00000, 0.00000), # noqa @@ -8302,7 +8302,7 @@ (0.99216, 0.00000, 0.00000), (0.99608, 0.00000, 0.00392), (1.00000, 0.00000, 0.00784), - ) +) cmap_aips0 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -8561,7 +8561,7 @@ (1.00000, 0.00000, 0.00000), (1.00000, 0.00000, 0.00000), (1.00000, 0.00000, 0.00000), - ) +) cmap_stairs8 = ( (0.76471, 0.00000, 1.00000), # noqa @@ -8820,7 +8820,7 @@ (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), - ) +) cmap_idl11 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -9079,7 +9079,7 @@ (1.00000, 0.00000, 0.01961), (1.00000, 0.00000, 0.00000), (1.00000, 0.00000, 0.00000), - ) +) cmap_stairs9 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -9338,7 +9338,7 @@ (0.00000, 0.00000, 1.00000), (0.00000, 0.00000, 1.00000), (0.00000, 0.00000, 1.00000), - ) +) cmap_backgr = ( (0.00000, 0.00000, 0.00000), # noqa @@ -9597,7 +9597,7 @@ (1.00000, 0.03174, 0.00000), (1.00000, 0.01587, 0.00000), (1.00000, 0.00000, 0.00000), - ) +) cmap_idl12 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -9856,7 +9856,7 @@ (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), (1.00000, 1.00000, 1.00000), - ) +) cmap_rainbow1 = ( (0.00000, 0.00000, 0.16471), @@ -10374,7 +10374,7 @@ (1.00000, 0.94902, 0.93725), (1.00000, 0.97255, 0.96863), (1.00000, 1.00000, 1.00000), - ) +) cmap_rainbow2 = ( (0.00000, 0.00000, 0.00000), @@ -10892,7 +10892,7 @@ (1.00000, 0.99608, 0.98824), (1.00000, 1.00000, 0.99608), (1.00000, 1.00000, 1.00000), - ) +) cmap_idl15 = ( (0.00000, 0.00000, 0.00000), # noqa @@ -11928,7 +11928,7 @@ (1.0, 1.0, 0.94509803921568625), (1.0, 1.0, 0.96470588235294119), (1.0, 1.0, 0.97647058823529409), - ) +) cmap_ds9_bb = ( (0.0, 0.0, 0.0), # noqa @@ -12187,7 +12187,7 @@ (1.0, 1.0, 0.97254901960784312), (1.0, 1.0, 0.98039215686274506), (1.0, 1.0, 0.9882352941176471), - ) +) cmap_ds9_cool = ( (0.0, 0.0, 0.0), @@ -12705,7 +12705,7 @@ (0.99215686274509807, 0.99215686274509807, 0.98431372549019602), (0.99215686274509807, 0.99607843137254903, 0.9882352941176471), (0.99607843137254903, 0.99607843137254903, 0.99215686274509807), - ) +) cmap_ds9_i8 = ( (0.0, 0.0, 0.0), # noqa @@ -12964,7 +12964,7 @@ (1.0, 1.0, 1.0), (1.0, 1.0, 1.0), (1.0, 1.0, 1.0), - ) +) # # the "John Tonry" colormap used by ZView @@ -13226,7 +13226,7 @@ (1.0, 1.0, 1.0), (1.0, 1.0, 1.0), (1.0, 1.0, 1.0), - ) +) # to be eventually deprecated @@ -13242,6 +13242,7 @@ class ColorMap(object): """Class to handle color maps.""" + def __init__(self, name, clst): self.name = name self.clst = clst diff -Nru ginga-3.0.0/ginga/ColorDist.py ginga-3.1.0/ginga/ColorDist.py --- ginga-3.0.0/ginga/ColorDist.py 2019-08-24 00:57:36.000000000 +0000 +++ ginga-3.1.0/ginga/ColorDist.py 2020-01-20 03:17:53.000000000 +0000 @@ -10,7 +10,6 @@ http://ds9.si.edu/doc/ref/how.html """ -import math import numpy as np @@ -54,10 +53,30 @@ ColorDistError("Computed hash table size (%d) != specified size " "(%d)" % (hashlen, self.hashsize)) + self.hash.clip(0, self.colorlen - 1) + def calc_hash(self): + """Create the hash table that implements the distribution function. + """ raise ColorDistError("Subclass needs to override this method") def get_dist_pct(self, pct): + """Calculate a domain value based on a percentage into the range. + + Given a value between 0 and 1, calculate the value in the domain + that corresponds to this percentage of the distribution range. + This function is primarily used to build color bars for display. + + Parameters + ---------- + pct : float + A floating point value between 0 and 1 + + Returns + ------- + val : float + A value in the domain of the color distribution function + """ raise ColorDistError("Subclass needs to override this method") @@ -79,7 +98,8 @@ self.check_hash() def get_dist_pct(self, pct): - val = min(max(float(pct), 0.0), 1.0) + pct = np.asarray(pct, dtype=np.float) + val = np.clip(pct, 0.0, 1.0) return val def __str__(self): @@ -107,8 +127,9 @@ self.check_hash() def get_dist_pct(self, pct): - val_inv = (math.exp(pct * math.log(self.exp)) - 1) / self.exp - val = min(max(float(val_inv), 0.0), 1.0) + pct = np.asarray(pct, dtype=np.float) + val_inv = (np.exp(pct * np.log(self.exp)) - 1) / self.exp + val = np.clip(val_inv, 0.0, 1.0) return val def __str__(self): @@ -136,8 +157,9 @@ self.check_hash() def get_dist_pct(self, pct): - val_inv = math.log(self.exp * pct + 1, self.exp) - val = min(max(float(val_inv), 0.0), 1.0) + pct = np.asarray(pct, dtype=np.float) + val_inv = np.log(self.exp * pct + 1) / np.log(self.exp) + val = np.clip(val_inv, 0.0, 1.0) return val def __str__(self): @@ -164,8 +186,9 @@ self.check_hash() def get_dist_pct(self, pct): + pct = np.asarray(pct, dtype=np.float) val_inv = pct ** 2.0 - val = min(max(float(val_inv), 0.0), 1.0) + val = np.clip(val_inv, 0.0, 1.0) return val def __str__(self): @@ -191,8 +214,9 @@ self.check_hash() def get_dist_pct(self, pct): - val_inv = math.sqrt(pct) - val = min(max(float(val_inv), 0.0), 1.0) + pct = np.asarray(pct, dtype=np.float) + val_inv = np.sqrt(pct) + val = np.clip(val_inv, 0.0, 1.0) return val def __str__(self): @@ -222,9 +246,10 @@ self.check_hash() def get_dist_pct(self, pct): + pct = np.asarray(pct, dtype=np.float) # calculate inverse of dist fn - val_inv = math.sinh(self.nonlinearity * pct) / self.factor - val = min(max(float(val_inv), 0.0), 1.0) + val_inv = np.sinh(self.nonlinearity * pct) / self.factor + val = np.clip(val_inv, 0.0, 1.0) return val def __str__(self): @@ -254,9 +279,10 @@ self.check_hash() def get_dist_pct(self, pct): + pct = np.asarray(pct, dtype=np.float) # calculate inverse of dist fn - val_inv = math.asinh(self.nonlinearity * pct) / self.factor - val = min(max(float(val_inv), 0.0), 1.0) + val_inv = np.arcsinh(self.nonlinearity * pct) / self.factor + val = np.clip(val_inv, 0.0, 1.0) return val def __str__(self): @@ -302,7 +328,9 @@ return arr def get_dist_pct(self, pct): + pct = np.asarray(pct, dtype=np.float) # TODO: this is wrong but we need a way to invert the hash + val = np.clip(pct, 0.0, 1.0) return pct def __str__(self): diff -Nru ginga-3.0.0/ginga/colors.py ginga-3.1.0/ginga/colors.py --- ginga-3.0.0/ginga/colors.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/colors.py 2020-07-08 20:09:29.000000000 +0000 @@ -6,6 +6,7 @@ # import re +import collections color_dict = { 'aliceblue': (0.9411764705882353, 0.9725490196078431, 1.0), # noqa @@ -742,7 +743,7 @@ 'yellow3': (0.803921568627451, 0.803921568627451, 0.0), 'yellow4': (0.5450980392156862, 0.5450980392156862, 0.0), 'yellowgreen': (0.6039215686274509, 0.803921568627451, 0.19607843137254902), - } +} color_list = [] @@ -777,8 +778,20 @@ raise ValueError("format needs to be 'tuple' or 'hash'") +def resolve_color(color): + if isinstance(color, str): + r, g, b = lookup_color(color) + elif isinstance(color, collections.Sequence): + r, g, b = color[:3] + _validate_color_tuple((r, g, b)) + else: + raise ValueError("color parameter must be a str or sequence") + + return (r, g, b) + + def _validate_color_tuple(tup): - if not isinstance(tup, (tuple, list)): + if isinstance(tup, str) or not isinstance(tup, collections.Sequence): raise TypeError("the color element must be a tuple or list") if len(tup) != 3: diff -Nru ginga-3.0.0/ginga/conftest.py ginga-3.1.0/ginga/conftest.py --- ginga-3.0.0/ginga/conftest.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/conftest.py 2020-04-10 20:19:26.000000000 +0000 @@ -1,36 +1,26 @@ -# This contains imports plugins that configure pytest for Ginga tests. -# By importing them here in conftest.py they are discoverable by pytest -# no matter how it is invoked within the source tree. - -from astropy.tests.helper import enable_deprecations_as_exceptions -from astropy.tests.plugins.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS +import os -## Uncomment the following line to treat all DeprecationWarnings as -## exceptions -enable_deprecations_as_exceptions() +try: + from pytest_astropy_header.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS +except ImportError: + TESTED_VERSIONS = {} + PYTEST_HEADER_MODULES = {} -## Uncomment and customize the following lines to add/remove entries from -## the list of packages for which version numbers are displayed when running -## the tests. Making it pass for KeyError is essential in some cases when -## the package uses other Astropy affiliated packages. try: - PYTEST_HEADER_MODULES['Astropy'] = 'astropy' - PYTEST_HEADER_MODULES['scikit-image'] = 'skimage' - del PYTEST_HEADER_MODULES['h5py'] -except KeyError: - pass + from .version import version +except ImportError: + version = 'unknown' -## Uncomment the following lines to display the version number of the -## package rather than the version number of Astropy in the top line when -## running the tests. -import os +# Uncomment the following line to treat all DeprecationWarnings as +# exceptions +from astropy.tests.helper import enable_deprecations_as_exceptions +enable_deprecations_as_exceptions() -## This is to figure out the affiliated package version, rather than -## using Astropy's -from . import version +# Uncomment and customize the following lines to add/remove entries +# from the list of packages for which version numbers are displayed +# when running the tests. +PYTEST_HEADER_MODULES['Astropy'] = 'astropy' +PYTEST_HEADER_MODULES['scikit-image'] = 'skimage' -try: - packagename = os.path.basename(os.path.dirname(__file__)) - TESTED_VERSIONS[packagename] = version.version -except KeyError: - pass +packagename = os.path.basename(os.path.dirname(__file__)) +TESTED_VERSIONS[packagename] = version diff -Nru ginga-3.0.0/ginga/cvw/CanvasRenderCv.py ginga-3.1.0/ginga/cvw/CanvasRenderCv.py --- ginga-3.0.0/ginga/cvw/CanvasRenderCv.py 2019-09-09 18:09:55.000000000 +0000 +++ ginga-3.1.0/ginga/cvw/CanvasRenderCv.py 2020-07-08 20:09:29.000000000 +0000 @@ -85,8 +85,22 @@ def text_extents(self, text): return self.cr.text_extents(text, self.font) + def setup_pen_brush(self, pen, brush): + if pen is not None: + self.set_line(pen.color, alpha=pen.alpha, linewidth=pen.linewidth, + style=pen.linestyle) + + if brush is None: + self.brush = None + else: + self.set_fill(brush.color, alpha=brush.alpha) + ##### DRAWING OPERATIONS ##### + def draw_image(self, cvs_img, cpoints, rgb_arr, whence, order='RGBA'): + # no-op for this renderer + pass + def draw_text(self, cx, cy, text, rot_deg=0.0): self.cr.text((cx, cy), text, self.font) @@ -105,10 +119,10 @@ self.cr.path(cpoints, self.pen) -class CanvasRenderer(render.RendererBase): +class CanvasRenderer(render.StandardPixelRenderer): def __init__(self, viewer): - render.RendererBase.__init__(self, viewer) + render.StandardPixelRenderer.__init__(self, viewer) self.kind = 'opencv' # According to OpenCV documentation: @@ -116,6 +130,8 @@ # you can use any channel ordering. The drawing functions process # each channel independently and do not depend on the channel # order or even on the used color space." + # NOTE: OpenCv does not seem to be happy using anti-aliasing on + # transparent arrays self.rgb_order = 'RGB' self.surface = None self.dims = () @@ -125,7 +141,6 @@ given dimensions. """ width, height = dims[:2] - self.dims = (width, height) self.logger.debug("renderer reconfigured to %dx%d" % ( width, height)) @@ -134,6 +149,8 @@ depth = len(self.rgb_order) self.surface = np.zeros((height, width, depth), dtype=np.uint8) + super(CanvasRenderer, self).resize(dims) + def render_image(self, rgbobj, dst_x, dst_y): """Render the image represented by (rgbobj) at dst_x, dst_y in the pixel space. @@ -143,9 +160,9 @@ self.logger.debug("redraw surface") # get window contents as an array and store it into the CV surface - rgb_arr = self.viewer.getwin_array(order=self.rgb_order, dtype=np.uint8) + rgb_arr = self.getwin_array(order='RGBA', dtype=np.uint8) # TODO: is there a faster way to copy this array in? - self.surface[:, :, :] = rgb_arr + self.surface[:, :, :] = rgb_arr[:, :, 0:3] def get_surface_as_array(self, order=None): if self.surface is None: @@ -164,4 +181,10 @@ cr.set_font_from_shape(shape) return cr.text_extents(shape.text) + def text_extents(self, text, font): + cr = RenderContext(self, self.viewer, self.surface) + cr.set_font(font.fontname, font.fontsize, color=font.color, + alpha=font.alpha) + return cr.text_extents(text) + #END diff -Nru ginga-3.0.0/ginga/cvw/CvHelp.py ginga-3.1.0/ginga/cvw/CvHelp.py --- ginga-3.0.0/ginga/cvw/CvHelp.py 2018-08-07 21:29:17.000000000 +0000 +++ ginga-3.1.0/ginga/cvw/CvHelp.py 2020-07-08 20:09:29.000000000 +0000 @@ -74,12 +74,8 @@ self.canvas = canvas def get_color(self, color, alpha=1.0): - if isinstance(color, str) or isinstance(color, type(u"")): - r, g, b = colors.lookup_color(color) - elif isinstance(color, tuple): - # color is assumed to be a 3-tuple of RGB values as floats - # between 0 and 1 - r, g, b = color + if color is not None: + r, g, b = colors.resolve_color(color) else: r, g, b = 1.0, 1.0, 1.0 @@ -112,6 +108,13 @@ wd, ht = retval return wd, ht + def image(self, pt, rgb_arr): + # TODO: is there a faster way to copy this array in? + cx, cy = pt[:2] + daht, dawd, depth = rgb_arr.shape + + self.canvas[cy:cy + daht, cx:cx + dawd, :] = rgb_arr + def text(self, pt, text, font): x, y = pt font.font.putText(self.canvas, text, (x, y), font.scale, diff -Nru ginga-3.0.0/ginga/cvw/ImageViewCanvasCv.py ginga-3.1.0/ginga/cvw/ImageViewCanvasCv.py --- ginga-3.0.0/ginga/cvw/ImageViewCanvasCv.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/cvw/ImageViewCanvasCv.py 2020-07-08 20:09:29.000000000 +0000 @@ -34,7 +34,7 @@ def reschedule_redraw(self, time_sec): pass - def update_image(self): + def update_widget(self): pass # METHODS THAT WERE IN IPG diff -Nru ginga-3.0.0/ginga/cvw/ImageViewCanvasTypesCv.py ginga-3.1.0/ginga/cvw/ImageViewCanvasTypesCv.py --- ginga-3.0.0/ginga/cvw/ImageViewCanvasTypesCv.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/cvw/ImageViewCanvasTypesCv.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# TODO: this line is for backward compatibility with files importing -# this module--to be removed -from ginga.canvas.types.all import * # noqa - -# END diff -Nru ginga-3.0.0/ginga/cvw/ImageViewCv.py ginga-3.1.0/ginga/cvw/ImageViewCv.py --- ginga-3.0.0/ginga/cvw/ImageViewCv.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/cvw/ImageViewCv.py 2020-07-08 20:09:29.000000000 +0000 @@ -30,12 +30,12 @@ self.renderer = CanvasRenderer(self) - def update_image(self): + def update_widget(self): # no widget to update pass def configure_window(self, width, height): - self.configure_surface(width, height) + self.configure(width, height) class CanvasView(ImageViewCv): diff -Nru ginga-3.0.0/ginga/doc/setup_package.py ginga-3.1.0/ginga/doc/setup_package.py --- ginga-3.0.0/ginga/doc/setup_package.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/doc/setup_package.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst - - -def get_package_data(): - return {'ginga.doc': ['*.html']} diff -Nru ginga-3.0.0/ginga/examples/bindings/bindings.cfg.ds9 ginga-3.1.0/ginga/examples/bindings/bindings.cfg.ds9 --- ginga-3.0.0/ginga/examples/bindings/bindings.cfg.ds9 2018-09-11 02:59:35.000000000 +0000 +++ ginga-3.1.0/ginga/examples/bindings/bindings.cfg.ds9 2020-07-20 21:06:00.000000000 +0000 @@ -21,6 +21,8 @@ btn_left = 0x1 btn_middle= 0x2 btn_right = 0x4 +btn_back = 0x8 +btn_forward = 0x10 # Set up our standard modifiers. # These should not contain "normal" keys--they should be valid modifier @@ -92,6 +94,8 @@ kp_cut_255 = ['cuts+A'] kp_cut_minmax = ['cuts+S'] kp_cut_auto = ['a', 'cuts+a'] +kp_autocuts_alg_prev = ['cuts+up', 'cuts+b'] +kp_autocuts_alg_next = ['cuts+down', 'cuts+n'] kp_autocuts_toggle = [':', 'cuts+:'] kp_autocuts_override = [';', 'cuts+;'] kp_autocenter_toggle = ['?', 'pan+?'] @@ -110,8 +114,9 @@ kp_flip_y = [']', '}', 'rotate+]', 'rotate+}'] kp_swap_xy = ['backslash', '|', 'rotate+backslash', 'rotate+|'] kp_rotate_reset = ['R', 'rotate+R'] -kp_rotate_inc90 = [',', 'rotate+.'] -kp_rotate_dec90 = ['.', 'rotate+,'] +kp_save_profile = ['S'] +kp_rotate_inc90 = ['.', 'rotate+.'] +kp_rotate_dec90 = [',', 'rotate+,'] kp_orient_lh = ['o', 'rotate+o'] kp_orient_rh = ['O', 'rotate+O'] kp_poly_add = ['v', 'draw+v'] @@ -198,6 +203,7 @@ pa_pan = ['pan'] pa_zoom = ['freepan+pan'] pa_zoom_origin = ['freepan+shift+pan'] +pa_naxis = ['naxis+pan'] # This controls what operations the pinch gesture controls. Possibilities are # (empty list or) some combination of 'zoom' and 'rotate'. @@ -214,8 +220,11 @@ # ds9 uses opposite sense of zooming scroll wheel zoom_scroll_reverse = True +pan_min_scroll_thumb_pct = 0.0 +pan_max_scroll_thumb_pct = 0.9 + # No messages for color map warps or setting pan position -msg_cmap = False +#msg_cmap = False msg_panset = False #END diff -Nru ginga-3.0.0/ginga/examples/bindings/bindings.cfg.standard ginga-3.1.0/ginga/examples/bindings/bindings.cfg.standard --- ginga-3.0.0/ginga/examples/bindings/bindings.cfg.standard 2018-09-11 02:59:35.000000000 +0000 +++ ginga-3.1.0/ginga/examples/bindings/bindings.cfg.standard 2020-07-20 21:06:00.000000000 +0000 @@ -21,6 +21,8 @@ btn_left = 0x1 btn_middle= 0x2 btn_right = 0x4 +btn_back = 0x8 +btn_forward = 0x10 # Set up our standard modifiers. # These should not contain "normal" keys--they should be valid modifier @@ -92,6 +94,8 @@ kp_cut_255 = ['cuts+A'] kp_cut_minmax = ['cuts+S'] kp_cut_auto = ['a', 'cuts+a'] +kp_autocuts_alg_prev = ['cuts+up', 'cuts+b'] +kp_autocuts_alg_next = ['cuts+down', 'cuts+n'] kp_autocuts_toggle = [':', 'cuts+:'] kp_autocuts_override = [';', 'cuts+;'] kp_autocenter_toggle = ['?', 'pan+?'] @@ -110,8 +114,9 @@ kp_flip_y = [']', '}', 'rotate+]', 'rotate+}'] kp_swap_xy = ['backslash', '|', 'rotate+backslash', 'rotate+|'] kp_rotate_reset = ['R', 'rotate+R'] -kp_rotate_inc90 = [',', 'rotate+.'] -kp_rotate_dec90 = ['.', 'rotate+,'] +kp_save_profile = ['S'] +kp_rotate_inc90 = ['.', 'rotate+.'] +kp_rotate_dec90 = [',', 'rotate+,'] kp_orient_lh = ['o', 'rotate+o'] kp_orient_rh = ['O', 'rotate+O'] kp_poly_add = ['v', 'draw+v'] @@ -198,6 +203,7 @@ pa_pan = ['pan'] pa_zoom = ['freepan+pan'] pa_zoom_origin = ['freepan+shift+pan'] +pa_naxis = ['naxis+pan'] # This controls what operations the pinch gesture controls. Possibilities are # (empty list or) some combination of 'zoom' and 'rotate'. @@ -214,6 +220,9 @@ # Use opposite sense of zooming scroll wheel zoom_scroll_reverse = False +pan_min_scroll_thumb_pct = 0.0 +pan_max_scroll_thumb_pct = 0.9 + # No messages for color map warps or setting pan position #msg_cmap = False msg_panset = False diff -Nru ginga-3.0.0/ginga/examples/bindings/bindings.cfg.trackpad ginga-3.1.0/ginga/examples/bindings/bindings.cfg.trackpad --- ginga-3.0.0/ginga/examples/bindings/bindings.cfg.trackpad 2018-09-11 02:59:35.000000000 +0000 +++ ginga-3.1.0/ginga/examples/bindings/bindings.cfg.trackpad 2020-07-20 21:06:00.000000000 +0000 @@ -21,6 +21,8 @@ btn_left = 0x1 btn_middle= 0x2 btn_right = 0x4 +btn_back = 0x8 +btn_forward = 0x10 # Set up our standard modifiers. # These should not contain "normal" keys--they should be valid modifier @@ -92,6 +94,8 @@ kp_cut_255 = ['cuts+A'] kp_cut_minmax = ['cuts+S'] kp_cut_auto = ['a', 'cuts+a'] +kp_autocuts_alg_prev = ['cuts+up', 'cuts+b'] +kp_autocuts_alg_next = ['cuts+down', 'cuts+n'] kp_autocuts_toggle = [':', 'cuts+:'] kp_autocuts_override = [';', 'cuts+;'] kp_autocenter_toggle = ['?', 'pan+?'] @@ -110,8 +114,9 @@ kp_flip_y = [']', '}', 'rotate+]', 'rotate+}'] kp_swap_xy = ['backslash', '|', 'rotate+backslash', 'rotate+|'] kp_rotate_reset = ['R', 'rotate+R'] -kp_rotate_inc90 = [',', 'rotate+.'] -kp_rotate_dec90 = ['.', 'rotate+,'] +kp_save_profile = ['S'] +kp_rotate_inc90 = ['.', 'rotate+.'] +kp_rotate_dec90 = [',', 'rotate+,'] kp_orient_lh = ['o', 'rotate+o'] kp_orient_rh = ['O', 'rotate+O'] kp_poly_add = ['v', 'draw+v'] @@ -198,12 +203,14 @@ pa_pan = ['pan'] pa_zoom = ['freepan+pan'] pa_zoom_origin = ['freepan+shift+pan'] +pa_naxis = ['naxis+pan'] # This controls what operations the pinch gesture controls. Possibilities are # (empty list or) some combination of 'zoom' and 'rotate'. pinch_actions = [] pinch_zoom_acceleration = 1.0 pinch_rotate_acceleration = 1.0 +pan_pan_acceleration = 1.0 # Use opposite sense of panning direction pan_reverse = False @@ -213,6 +220,9 @@ # Use opposite sense of zooming scroll wheel zoom_scroll_reverse = False +pan_min_scroll_thumb_pct = 0.0 +pan_max_scroll_thumb_pct = 0.9 + # No messages for color map warps or setting pan position #msg_cmap = False #msg_panset = False diff -Nru ginga-3.0.0/ginga/examples/configs/general.cfg ginga-3.1.0/ginga/examples/configs/general.cfg --- ginga-3.0.0/ginga/examples/configs/general.cfg 2019-08-26 19:08:48.000000000 +0000 +++ ginga-3.1.0/ginga/examples/configs/general.cfg 2020-07-20 21:06:00.000000000 +0000 @@ -60,11 +60,14 @@ #widgetSet = 'qt4' # Speeds up rotation a lot if you have python OpenCv module! -# warning: OpenCv is buggy for some Mac OS X version/installations and causes -# a crash *on import* so we can't just try to import it to see if available. # Enable this if you have it installed and can import "cv2" without problems. +# NOTE: OpenCv is now used by default if it is installed #use_opencv = True +# Enables the "opengl" renderer for backends Qt and Gtk +# NOTE: some minor features are not supported well under OpenGL yet +use_opengl = False + # Force of package for handling WCS # Possibilities are 'choose', 'kapteyn', 'astlib', 'starlink', 'astropy', # and 'astropy_ape14' @@ -96,10 +99,15 @@ # NOTE: overridden by channel setting of the same name, if any scrollbars = 'auto' +# Name of a layout file in ~/.ginga that specifies the UI layout of the +# program (default is 'layout.json'). This is only used if 'save_layout' +# (see below) is True +layout_file = 'layout.json' + # Set this to True to have Ginga remember the size and position of the -# last session. This creates/loads ~/.ginga/layout file. +# last session. This creates/loads the layout file specified by 'layout_file' # If you experience trouble starting up Ginga, you may need to either -# remove the ~/.ginga/layout file and restart the program, or start with +# remove the layout file and restart the program, or start with # the --norestore option # Setting this to False would cause Ginga to ignore the file, if exists. save_layout = True @@ -132,3 +140,10 @@ # Default value of None will use an area in the standard platform-specific # temp directory (as defined by Python's 'tempfile' module) #download_folder = None + +# Name of a layout file to configure the GUI +layout_file = 'layout.json' + +# Name of a file to configure the set of available plugins and where they +# should appear +plugin_file = 'plugins.json' diff -Nru ginga-3.0.0/ginga/examples/configs/plugin_Collage.cfg ginga-3.1.0/ginga/examples/configs/plugin_Collage.cfg --- ginga-3.0.0/ginga/examples/configs/plugin_Collage.cfg 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/configs/plugin_Collage.cfg 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,17 @@ +# +# Collage plugin preferences file +# +# Place this in file under ~/.ginga with the name "plugin_Collage.cfg" + +# Set to True when you want to collage image HDUs in a file +collage_hdus = False + +# annotate images with their names +annotate_images = False + +# Try to match backgrounds +match_bg = False + +# Number of threads to devote to opening images +num_threads = 4 + diff -Nru ginga-3.0.0/ginga/examples/configs/plugin_Zoom.cfg ginga-3.1.0/ginga/examples/configs/plugin_Zoom.cfg --- ginga-3.0.0/ginga/examples/configs/plugin_Zoom.cfg 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/examples/configs/plugin_Zoom.cfg 2019-11-29 07:54:06.000000000 +0000 @@ -3,9 +3,6 @@ # # Place this in file under ~/.ginga with the name "plugin_Zoom.cfg" -# default zoom radius in pixels -zoom_radius = 30 - # default zoom level zoom_amount = 3 @@ -13,11 +10,3 @@ # NOTE: usually a small delay speeds things up refresh_interval = 0.02 -# rotate the zoom image if the channel image is rotated? -rotate_zoom_image = True - -# use a different color map than channel image? -zoom_cmap_name = None - -# use a different intensity map than channel image? -zoom_imap_name = None diff -Nru ginga-3.0.0/ginga/examples/gl/example2_qt.py ginga-3.1.0/ginga/examples/gl/example2_qt.py --- ginga-3.0.0/ginga/examples/gl/example2_qt.py 2019-09-03 20:32:15.000000000 +0000 +++ ginga-3.1.0/ginga/examples/gl/example2_qt.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,282 +0,0 @@ -#! /usr/bin/env python -# -# example2_qt.py -- Simple, configurable 3D viewer. -# -# This is open-source software licensed under a BSD license. -# Please see the file LICENSE.txt for details. -# - -import sys - -from ginga import colors -from ginga.opengl.ImageViewQtGL import CanvasView -from ginga.canvas.CanvasObject import get_canvas_types -from ginga.misc import log -from ginga.qtw.QtHelp import QtGui, QtCore -from ginga.util.loader import load_data - -STD_FORMAT = '%(asctime)s | %(levelname)1.1s | %(filename)s:%(lineno)d (%(funcName)s) | %(message)s' - - -class FitsViewer(QtGui.QMainWindow): - - def __init__(self, logger): - super(FitsViewer, self).__init__() - self.logger = logger - self.drawcolors = colors.get_colors() - self.dc = get_canvas_types() - - fi = CanvasView(logger) - fi.enable_autocuts('on') - fi.set_autocut_params('zscale') - fi.enable_autozoom('on') - fi.set_zoom_algorithm('rate') - fi.set_zoomrate(1.4) - fi.show_pan_mark(True) - #fi.enable_draw(False) - fi.set_callback('drag-drop', self.drop_file_cb) - fi.set_callback('none-move', self.cursor_cb) - fi.ui_set_active(True) - self.fitsimage = fi - - # quick hack to get 'u' to invoke hidden camera mode - bm = fi.get_bindmap() - bm.mode_map['u'] = bm.mode_map['mode_camera'] - - bd = fi.get_bindings() - bd.enable_all(True) - - # canvas that we will draw on - canvas = self.dc.DrawingCanvas() - canvas.enable_draw(True) - canvas.set_drawtype('rectangle', color='lightblue') - canvas.set_surface(fi) - self.canvas = canvas - # add canvas to view - #fi.add(canvas) - private_canvas = fi.get_canvas() - private_canvas.register_for_cursor_drawing(fi) - private_canvas.add(canvas) - canvas.ui_set_active(True) - self.drawtypes = canvas.get_drawtypes() - self.drawtypes.sort() - - # add little mode indicator that shows keyboard modal states - fi.show_mode_indicator(True, corner='ur') - - w = fi.get_widget() - w.resize(512, 512) - - vbox = QtGui.QVBoxLayout() - vbox.setContentsMargins(QtCore.QMargins(2, 2, 2, 2)) - vbox.setSpacing(1) - vbox.addWidget(w, stretch=1) - - self.readout = QtGui.QLabel("") - vbox.addWidget(self.readout, stretch=0, - alignment=QtCore.Qt.AlignCenter) - - hbox = QtGui.QHBoxLayout() - hbox.setContentsMargins(QtCore.QMargins(4, 2, 4, 2)) - - wdrawtype = QtGui.QComboBox() - for name in self.drawtypes: - wdrawtype.addItem(name) - index = self.drawtypes.index('rectangle') - wdrawtype.setCurrentIndex(index) - wdrawtype.activated.connect(self.set_drawparams) - self.wdrawtype = wdrawtype - - wdrawcolor = QtGui.QComboBox() - for name in self.drawcolors: - wdrawcolor.addItem(name) - index = self.drawcolors.index('lightblue') - wdrawcolor.setCurrentIndex(index) - wdrawcolor.activated.connect(self.set_drawparams) - self.wdrawcolor = wdrawcolor - - wfill = QtGui.QCheckBox("Fill") - wfill.stateChanged.connect(self.set_drawparams) - self.wfill = wfill - - walpha = QtGui.QDoubleSpinBox() - walpha.setRange(0.0, 1.0) - walpha.setSingleStep(0.1) - walpha.setValue(1.0) - walpha.valueChanged.connect(self.set_drawparams) - self.walpha = walpha - - wclear = QtGui.QPushButton("Clear Canvas") - wclear.clicked.connect(self.clear_canvas) - wopen = QtGui.QPushButton("Open File") - wopen.clicked.connect(self.open_file) - wquit = QtGui.QPushButton("Quit") - wquit.clicked.connect(self.quit) - - hbox.addStretch(1) - for w in (wopen, wdrawtype, wdrawcolor, wfill, - QtGui.QLabel('Alpha:'), walpha, wclear, wquit): - hbox.addWidget(w, stretch=0) - - hw = QtGui.QWidget() - hw.setLayout(hbox) - vbox.addWidget(hw, stretch=0) - - vw = QtGui.QWidget() - self.setCentralWidget(vw) - vw.setLayout(vbox) - - def set_drawparams(self, kind): - index = self.wdrawtype.currentIndex() - kind = self.drawtypes[index] - index = self.wdrawcolor.currentIndex() - fill = (self.wfill.checkState() != 0) - alpha = self.walpha.value() - - params = {'color': self.drawcolors[index], - 'alpha': alpha} - if kind in ('circle', 'rectangle', 'polygon', 'triangle', - 'righttriangle', 'ellipse', 'square', 'box'): - params['fill'] = fill - params['fillalpha'] = alpha - - self.canvas.set_drawtype(kind, **params) - - def clear_canvas(self): - self.canvas.delete_all_objects() - - def load_file(self, filepath): - image = load_data(filepath, logger=self.logger) - self.fitsimage.set_image(image) - self.setWindowTitle(filepath) - - def open_file(self): - res = QtGui.QFileDialog.getOpenFileName(self, "Open FITS file", - ".", "FITS files (*.fits)") - if isinstance(res, tuple): - fileName = res[0] - else: - fileName = str(res) - if len(fileName) != 0: - self.load_file(fileName) - - def drop_file_cb(self, viewer, paths): - filename = paths[0] - self.load_file(filename) - - def cursor_cb(self, viewer, button, data_x, data_y): - """This gets called when the data position relative to the cursor - changes. - """ - # Get the value under the data coordinates - try: - # We report the value across the pixel, even though the coords - # change halfway across the pixel - value = viewer.get_data(int(data_x + viewer.data_off), - int(data_y + viewer.data_off)) - - except Exception: - value = None - - fits_x, fits_y = data_x + 1, data_y + 1 - - # Calculate WCS RA - try: - # NOTE: image function operates on DATA space coords - image = viewer.get_image() - if image is None: - # No image loaded - return - ra_txt, dec_txt = image.pixtoradec(fits_x, fits_y, - format='str', coords='fits') - except Exception as e: - self.logger.warning("Bad coordinate conversion: %s" % ( - str(e))) - ra_txt = 'BAD WCS' - dec_txt = 'BAD WCS' - - text = "RA: %s DEC: %s X: %.2f Y: %.2f Value: %s" % ( - ra_txt, dec_txt, fits_x, fits_y, value) - self.readout.setText(text) - - def quit(self, *args): - self.logger.info("Attempting to shut down the application...") - self.deleteLater() - - -def main(options, args): - - #QtGui.QApplication.setGraphicsSystem('raster') - app = QtGui.QApplication(args) - - logger = log.get_logger("example2", options=options) - - # Check whether user wants to use OpenCv - if options.opencv: - from ginga import trcalc - try: - trcalc.use('opencv') - except Exception as e: - logger.warning("failed to set OpenCv preference: %s" % (str(e))) - - # Check whether user wants to use OpenCL - elif options.opencl: - from ginga import trcalc - try: - trcalc.use('opencl') - except Exception as e: - logger.warning("failed to set OpenCL preference: %s" % (str(e))) - - w = FitsViewer(logger) - w.resize(524, 1000) - w.show() - app.setActiveWindow(w) - w.raise_() - w.activateWindow() - - if len(args) > 0: - w.load_file(args[0]) - - app.exec_() - - -if __name__ == "__main__": - - # Parse command line options - from argparse import ArgumentParser - - argprs = ArgumentParser() - - argprs.add_argument("--debug", dest="debug", default=False, - action="store_true", - help="Enter the pdb debugger on main()") - argprs.add_argument("--opencv", dest="opencv", default=False, - action="store_true", - help="Use OpenCv acceleration") - argprs.add_argument("--opencl", dest="opencl", default=False, - action="store_true", - help="Use OpenCL acceleration") - argprs.add_argument("--profile", dest="profile", action="store_true", - default=False, - help="Run the profiler on main()") - log.addlogopts(argprs) - - (options, args) = argprs.parse_known_args(sys.argv[1:]) - - # Are we debugging this? - if options.debug: - import pdb - - pdb.run('main(options, args)') - - # Are we profiling this? - elif options.profile: - import profile - - print(("%s profile:" % sys.argv[0])) - profile.run('main(options, args)') - - else: - main(options, args) - -# END diff -Nru ginga-3.0.0/ginga/examples/gl/example_wireframe.py ginga-3.1.0/ginga/examples/gl/example_wireframe.py --- ginga-3.0.0/ginga/examples/gl/example_wireframe.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/examples/gl/example_wireframe.py 2020-07-08 20:09:29.000000000 +0000 @@ -20,8 +20,9 @@ toolkit.use('qt5') from ginga.gw import Widgets # noqa -from ginga.opengl.ImageViewQtGL import CanvasView # noqa +from ginga.qtw.ImageViewQt import CanvasView # noqa from ginga.canvas.CanvasObject import get_canvas_types # noqa +from ginga.canvas import transform # noqa from ginga.misc import log # noqa @@ -34,7 +35,7 @@ self.top = app.make_window(title="Simple Ginga 3D Viewer") - vw = CanvasView(self.logger) + vw = CanvasView(self.logger, render='opengl') vw.ui_set_active(True) self.vw = vw @@ -53,9 +54,6 @@ private_canvas = vw.get_canvas() private_canvas.add(canvas) - # add little mode indicator that shows keyboard modal states - #vw.show_mode_indicator(True, corner='ur') - # little hack because we don't have a way yet to ask for this # variation of back end through ginga.toolkit ww = Widgets.wrap(vw.get_widget()) diff -Nru ginga-3.0.0/ginga/examples/gtk3/example2.py ginga-3.1.0/ginga/examples/gtk3/example2.py --- ginga-3.0.0/ginga/examples/gtk3/example2.py 2019-09-03 20:32:15.000000000 +0000 +++ ginga-3.1.0/ginga/examples/gtk3/example2.py 2020-07-08 20:09:29.000000000 +0000 @@ -22,7 +22,7 @@ class FitsViewer(object): - def __init__(self, logger): + def __init__(self, logger, render='widget'): self.logger = logger self.drawcolors = colors.get_colors() @@ -36,9 +36,9 @@ vbox = Gtk.VBox(spacing=2) - fi = CanvasView(logger) + fi = CanvasView(logger, render=render) fi.enable_autocuts('on') - fi.set_autocut_params('zscale') + fi.set_autocut_params('histogram') fi.enable_autozoom('on') fi.set_zoom_algorithm('rate') fi.set_zoomrate(1.4) @@ -47,6 +47,7 @@ fi.set_callback('cursor-changed', self.cursor_cb) fi.set_bg(0.2, 0.2, 0.2) fi.ui_set_active(True) + fi.set_enter_focus(True) self.fitsimage = fi bd = fi.get_bindings() @@ -67,9 +68,9 @@ self.drawtypes.sort() # add a color bar - fi.show_color_bar(True) + #fi.show_color_bar(True) - fi.show_focus_indicator(True) + #fi.show_focus_indicator(True) # add little mode indicator that shows keyboard modal states fi.show_mode_indicator(True, corner='ur') @@ -228,7 +229,7 @@ except Exception as e: logger.warning("failed to set OpenCL preference: %s" % (str(e))) - fv = FitsViewer(logger) + fv = FitsViewer(logger, render=options.render) root = fv.get_widget() root.show_all() @@ -254,6 +255,8 @@ argprs.add_argument("--opencl", dest="opencl", default=False, action="store_true", help="Use OpenCL acceleration") + argprs.add_argument("-r", "--render", dest="render", default='widget', + help="Set render type {widget|opengl}") argprs.add_argument("--profile", dest="profile", action="store_true", default=False, help="Run the profiler on main()") diff -Nru ginga-3.0.0/ginga/examples/gw/example1_video.py ginga-3.1.0/ginga/examples/gw/example1_video.py --- ginga-3.0.0/ginga/examples/gw/example1_video.py 2019-09-03 20:32:15.000000000 +0000 +++ ginga-3.1.0/ginga/examples/gw/example1_video.py 2020-07-08 20:09:29.000000000 +0000 @@ -89,7 +89,7 @@ vbox.set_border_width(2) vbox.set_spacing(1) - fi = Viewers.CanvasView(logger=logger) + fi = Viewers.CanvasView(logger=logger, render=options.render) fi.set_autocut_params('histogram') fi.enable_autozoom('off') fi.enable_autocenter('once') @@ -294,6 +294,8 @@ argprs.add_argument("--profile", dest="profile", action="store_true", default=False, help="Run the profiler on main()") + argprs.add_argument("-r", "--render", dest="render", default='widget', + help="Set render type {widget|opengl}") log.addlogopts(argprs) (options, args) = argprs.parse_known_args(sys.argv[1:]) diff -Nru ginga-3.0.0/ginga/examples/layouts/ds9ish/bindings.cfg ginga-3.1.0/ginga/examples/layouts/ds9ish/bindings.cfg --- ginga-3.0.0/ginga/examples/layouts/ds9ish/bindings.cfg 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/ds9ish/bindings.cfg 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,230 @@ +# +# bindings.cfg -- Ginga user interface bindings customization +# +# Put this in your $HOME/.ginga directory as "bindings.cfg" +# +# Troubleshooting: +# Run the examples/xyz/example2_xyz.py, where "xyz" is the toolkit you want +# to use. Run it from a terminal like this: +# ./examples/qt/example2_qt.py --loglevel=10 --stderr +# Further commentary in sections below. +# + +# BUTTON SET UP +# You should rarely have to change these, but if you have a non-standard +# mouse or setup it might be useful. +# To find out what buttons are generating what codes, start up things as +# described in "Troubleshooting" above and look for messages like this as +# you click around in the window: +# ... | D | Bindings.py:1260 (window_button_press) | x,y=70,-69 btncode=0x1 +btn_nobtn = 0x0 +btn_left = 0x1 +btn_middle= 0x2 +btn_right = 0x4 +btn_back = 0x8 +btn_forward = 0x10 + +# Set up our standard modifiers. +# These should not contain "normal" keys--they should be valid modifier +# keys for your platform. +# To find out what symbol is used for a keystroke on your platform, +# start up things as described above in "Troubleshooting" and look for +# messages like this as you press keys while focus is in the window: +# ... | D | Bindings.py:1203 (window_key_press) | keyname=shift_l +mod_shift = ['shift_l', 'shift_r'] +# same setting ends up as "Ctrl" on a pc and "Command" on a mac: +mod_ctrl = ['control_l', 'control_r'] +# "Control" key on a mac, "Windows" key on a pc keyboard: +mod_win = ['meta_right'] + +# Define up our custom modifiers. +# These are typically "normal" keys. The modifier is defined by a triplet: +# [ keyname, modtype, msg ], where +# keyname is a key whose press initiates the modifier, +# modtype is either None or a type in {'held', 'oneshot', 'locked', 'softlock'} +# msg is a string to be shown in the display or None for no indicator +# Mode 'meta' is special: it is an intermediate mode that +# is used primarily to launch other modes +# If the mode initiation character is preceeded by a double +# underscore, then the mode must be initiated from the "meta" +# mode. +dmod_meta = ['space', None, None] +dmod_draw = ['__b', None, None] +dmod_cmap = ['__y', None, None] +dmod_cuts = ['__s', None, None] +dmod_dist = ['__d', None, None] +dmod_contrast = ['__t', None, None] +dmod_rotate = ['__r', None, None] +dmod_pan = ['__q', None, 'pan'] +dmod_freepan = ['__w', None, 'pan'] +dmod_camera = ['__c', None, 'pan'] +dmod_naxis = ['__n', None, None] + +default_mode_type = 'locked' +default_lock_mode_type = 'softlock' + +# KEYPRESS commands +kp_zoom_in = ['+', '='] +kp_zoom_out = ['-', '_'] +kp_zoom = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'] +kp_zoom_inv = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'] +kp_zoom_fit = ['backquote', 'pan+backquote', 'freepan+backquote'] +kp_autozoom_toggle = ['doublequote', 'pan+doublequote'] +kp_autozoom_override = ['singlequote', 'pan+singlequote'] +kp_dist_reset = ['D', 'dist+D'] +kp_dist_prev = ['dist+up', 'dist+b'] +kp_dist_next = ['dist+down', 'dist+n'] +kp_pan_set = ['p', 'pan+p', 'freepan+p'] +kp_pan_zoom_set = ['pan+1', 'freepan+1'] +kp_pan_zoom_save = ['pan+z', 'freepan+z'] +kp_pan_left = ['pan+*+left', 'freepan+*+left'] +kp_pan_right = ['pan+*+right', 'freepan+*+right'] +kp_pan_up = ['pan+*+up', 'freepan+*+up'] +kp_pan_down = ['pan+*+down', 'freepan+*+down'] +kp_pan_home = ['pan+*+home', 'freepan+*+home'] +kp_pan_end = ['pan+*+end', 'freepan+*+end'] +kp_pan_page_up = ['pan+*+page_up', 'freepan+*+page_up'] +kp_pan_page_down = ['pan+*+page_down', 'freepan+*+page_down'] +kp_pan_px_xminus = ['shift+left'] +kp_pan_px_xplus = ['shift+right'] +kp_pan_px_yminus = ['shift+down'] +kp_pan_px_yplus = ['shift+up'] +kp_pan_px_center = ['shift+home'] +kp_center = ['c', 'pan+c', 'freepan+c'] +kp_cut_255 = ['cuts+A'] +kp_cut_minmax = ['cuts+S'] +kp_cut_auto = ['a', 'cuts+a'] +kp_autocuts_alg_prev = ['cuts+up', 'cuts+b'] +kp_autocuts_alg_next = ['cuts+down', 'cuts+n'] +kp_autocuts_toggle = [':', 'cuts+:'] +kp_autocuts_override = [';', 'cuts+;'] +kp_autocenter_toggle = ['?', 'pan+?'] +kp_autocenter_override = ['/', 'pan+/'] +kp_contrast_restore = ['T', 'contrast+T'] +kp_cmap_reset = ['Y', 'cmap+Y'] +kp_cmap_restore = ['cmap+r'] +kp_cmap_invert = ['I', 'cmap+I'] +kp_cmap_prev = ['cmap+up', 'cmap+b'] +kp_cmap_next = ['cmap+down', 'cmap+n'] +kp_toggle_cbar = ['cmap+c'] +kp_imap_reset = ['cmap+i'] +kp_imap_prev = ['cmap+left', 'cmap+j'] +kp_imap_next = ['cmap+right', 'cmap+k'] +kp_flip_x = ['[', '{', 'rotate+[', 'rotate+{'] +kp_flip_y = [']', '}', 'rotate+]', 'rotate+}'] +kp_swap_xy = ['backslash', '|', 'rotate+backslash', 'rotate+|'] +kp_rotate_reset = ['R', 'rotate+R'] +kp_save_profile = ['S'] +kp_rotate_inc90 = ['.', 'rotate+.'] +kp_rotate_dec90 = [',', 'rotate+,'] +kp_orient_lh = ['o', 'rotate+o'] +kp_orient_rh = ['O', 'rotate+O'] +kp_poly_add = ['v', 'draw+v'] +kp_poly_del = ['z', 'draw+z'] +kp_edit_del = ['draw+x'] +kp_reset = ['escape'] +kp_lock = ['L', 'meta+L'] +kp_softlock = ['l', 'meta+l'] +kp_camera_save = ['camera+s'] +kp_camera_reset = ['camera+r'] +kp_camera_toggle3d = ['camera+3'] + +# pct of a window of data to move with pan key commands +key_pan_pct = 0.666667 +# amount to move (in pixels) when using key pan arrow +key_pan_px_delta = 1.0 + +# SCROLLING/WHEEL commands +sc_pan = ['ctrl+scroll'] +sc_pan_fine = ['pan+shift+scroll'] +sc_pan_coarse = ['pan+ctrl+scroll'] +sc_zoom = ['scroll', 'freepan+scroll'] +sc_zoom_fine = [] +sc_zoom_coarse = [] +sc_zoom_origin = ['shift+scroll', 'freepan+shift+scroll'] +sc_cuts_fine = ['cuts+ctrl+scroll'] +sc_cuts_coarse = ['cuts+scroll'] +sc_cuts_alg = [] +sc_dist = ['dist+scroll'] +sc_cmap = ['cmap+scroll'] +sc_imap = ['cmap+ctrl+scroll'] +sc_camera_track = ['camera+scroll'] +sc_naxis = ['naxis+scroll'] + +# This controls how fast panning occurs with the sc_pan* functions. +# Increase to speed up panning +scroll_pan_acceleration = 1.0 +# For trackpads you can adjust this down if it seems too sensitive. +# 1.0 is appropriate for a mouse, 0.1 for most trackpads +scroll_zoom_acceleration = 1.0 +# If set to True, then don't zoom by "zoom steps", but by a more direct +# scaling call that uses scroll_zoom_acceleration +scroll_zoom_direct_scale = False + +# MOUSE/BUTTON commands +# NOTE: most plugins in the reference viewer need "none", "cursor" and "draw" +# events to work! If you want to use them you need to provide a valid +# non-conflicting binding +ms_none = ['nobtn'] +ms_cursor = ['left'] +ms_wheel = [] +ms_draw = ['draw+left', 'win+left'] + +# mouse commands initiated by a preceeding keystroke (see above) +ms_rotate = ['rotate+left'] +ms_rotate_reset = ['rotate+right'] +ms_contrast = ['contrast+left', 'right'] +ms_contrast_restore = ['contrast+right', 'ctrl+middle'] +ms_pan = ['pan+left', 'ctrl+left'] +ms_zoom = ['pan+right'] +ms_freepan = ['freepan+middle'] +ms_zoom_in = ['freepan+left'] +ms_zoom_out = ['freepan+right', 'freepan+ctrl+left'] +ms_cutlo = ['cuts+shift+left'] +ms_cuthi = ['cuts+ctrl+left'] +ms_cutall = ['cuts+left'] +ms_cut_auto = ['cuts+right'] +ms_panset = ['pan+middle', 'shift+left', 'middle'] +ms_cmap_rotate = ['cmap+left'] +ms_cmap_restore = ['cmap+right'] +ms_camera_orbit = ['camera+left'] +ms_camera_pan_delta = ['camera+right'] +ms_naxis = ['naxis+left'] + +mouse_zoom_acceleration = 1.085 +mouse_rotate_acceleration = 0.75 + +# GESTURES (some back ends only) +# NOTE: if using Qt4 back end, it is *highly* recommended to disable any +# "scroll zoom" (sc_zoom*) features above because the two kinds don't play +# well together. +pi_zoom = ['pinch'] +pi_zoom_origin = ['shift+pinch'] +pa_pan = ['pan'] +pa_zoom = ['freepan+pan'] +pa_zoom_origin = ['freepan+shift+pan'] +pa_naxis = ['naxis+pan'] + +# This controls what operations the pinch gesture controls. Possibilities are +# (empty list or) some combination of 'zoom' and 'rotate'. +pinch_actions = ['zoom'] +pinch_zoom_acceleration = 1.0 +pinch_rotate_acceleration = 1.0 +pan_pan_acceleration = 1.0 + +# ds9 uses opposite sense of panning direction +pan_reverse = True +# 1.0 is a proportional drag pan. Increase to "accelerate" panning speed. +pan_multiplier = 1.0 + +# ds9 uses opposite sense of zooming scroll wheel +zoom_scroll_reverse = True + +pan_min_scroll_thumb_pct = 0.0 +pan_max_scroll_thumb_pct = 0.9 + +# No messages for color map warps or setting pan position +#msg_cmap = False +msg_panset = False + +#END diff -Nru ginga-3.0.0/ginga/examples/layouts/ds9ish/general.cfg ginga-3.1.0/ginga/examples/layouts/ds9ish/general.cfg --- ginga-3.0.0/ginga/examples/layouts/ds9ish/general.cfg 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/ds9ish/general.cfg 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,23 @@ +FITSpkg = 'choose' +WCSpkg = 'choose' +channel_follows_focus = False +channel_prefix = 'Image' +cursor_interval = 0.05 +download_folder = None +fixedFont = None +font_scaling_factor = None +icc_working_profile = None +inherit_primary_header = False +layout_file = 'layout' +numImages = 10 +pixel_coords_offset = 1.0 +plugin_file = 'plugins.json' +recursion_limit = 2000 +sansFont = None +save_layout = False +scrollbars = 'on' +serifFont = None +showBanner = False +useMatplotlibColormaps = False +use_opengl = False +widgetSet = 'choose' diff -Nru ginga-3.0.0/ginga/examples/layouts/ds9ish/layout ginga-3.1.0/ginga/examples/layouts/ds9ish/layout --- ginga-3.0.0/ginga/examples/layouts/ds9ish/layout 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/ds9ish/layout 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,26 @@ +['seq', + {}, + ['vbox', + {'height': 1000, 'name': 'top', 'width': 1000}, + {'row': ['hbox', {'name': 'menu'}], 'stretch': 0}, + {'row': ['ws', + {'group': 3, 'height': 300, 'name': 'upper', 'wstype': 'grid'}], + 'stretch': 0}, + {'row': ['ws', + {'group': 2, 'height': 40, 'name': 'toolbar', 'wstype': 'stack'}], + 'stretch': 0}, + {'row': ['ws', + {'default': True, + 'group': 1, + 'height': 700, + 'name': 'channels', + 'use_toolbar': True, + 'wstype': 'tabs'}], + 'stretch': 1}, + {'row': ['ws', {'group': 99, 'name': 'cbar', 'wstype': 'stack'}], + 'stretch': 0}, + {'row': ['ws', {'group': 99, 'name': 'readout', 'wstype': 'stack'}], + 'stretch': 0}, + {'row': ['ws', {'group': 99, 'name': 'operations', 'wstype': 'stack'}], + 'stretch': 0}, + {'row': ['hbox', {'name': 'status'}], 'stretch': 0}]] diff -Nru ginga-3.0.0/ginga/examples/layouts/ds9ish/plugins.json ginga-3.1.0/ginga/examples/layouts/ds9ish/plugins.json --- ginga-3.0.0/ginga/examples/layouts/ds9ish/plugins.json 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/ds9ish/plugins.json 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,393 @@ +[ + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Operations [G]", + "module": "Operations", + "ptype": "global", + "start": true, + "workspace": "operations" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Toolbar [G]", + "module": "Toolbar", + "ptype": "global", + "start": true, + "workspace": "toolbar" + }, + { + "__bunch__": true, + "category": "System", + "hidden": false, + "menu": "Info [G]", + "module": "Info", + "ptype": "global", + "start": false, + "tab": "Synopsis", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "hidden": true, + "menu": "Header [G]", + "module": "Header", + "ptype": "global", + "start": true, + "tab": "Header", + "workspace": "upper" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Pan [G]", + "module": "Pan", + "ptype": "global", + "start": true, + "workspace": "upper" + }, + { + "__bunch__": true, + "category": "System", + "hidden": false, + "menu": "Thumbs [G]", + "module": "Thumbs", + "ptype": "global", + "start": false, + "tab": "Thumbs", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "System", + "hidden": false, + "menu": "Contents [G]", + "module": "Contents", + "ptype": "global", + "start": false, + "tab": "Contents", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Colorbar [G]", + "module": "Colorbar", + "ptype": "global", + "start": true, + "workspace": "cbar" + }, + { + "__bunch__": true, + "category": "System", + "enabled": true, + "hidden": true, + "menu": "Cursor [G]", + "module": "Cursor", + "ptype": "global", + "start": true, + "workspace": "readout" + }, + { + "__bunch__": true, + "category": "System", + "hidden": false, + "menu": "Errors [G]", + "module": "Errors", + "ptype": "global", + "start": false, + "tab": "Errors", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "Downloads [G]", + "module": "Downloads", + "ptype": "global", + "start": false, + "tab": "Downloads", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "menu": "Blink Channels [G]", + "module": "Blink", + "ptype": "global", + "start": false, + "tab": "Blink Channels", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "menu": "Blink Images", + "module": "Blink", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "Cuts", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis.Datacube", + "module": "LineProfile", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "Histogram", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "Overlays", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "Pick", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "PixTable", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "TVMark", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "TVMask", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Analysis", + "menu": "WCS Match [G]", + "module": "WCSMatch", + "ptype": "global", + "start": false, + "tab": "WCSMatch", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Debug", + "menu": "Command Line [G]", + "module": "Command", + "ptype": "global", + "start": false, + "tab": "Command", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Debug", + "menu": "Logger Info [G]", + "module": "Log", + "ptype": "global", + "start": false, + "tab": "Log", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Navigation", + "module": "MultiDim", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Remote", + "menu": "Remote Control [G]", + "module": "RC", + "ptype": "global", + "start": false, + "tab": "RC", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Remote", + "menu": "SAMP Client [G]", + "module": "SAMP", + "ptype": "global", + "start": false, + "tab": "SAMP", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "RGB", + "module": "Compose", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "RGB", + "module": "ScreenShot", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "RGB", + "menu": "Set Color Map [G]", + "module": "ColorMapPicker", + "ptype": "global", + "start": false, + "tab": "ColorMapPicker", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Table", + "module": "PlotTable", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Catalogs", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Crosshair", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Drawing", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "FBrowser", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "History [G]", + "module": "ChangeHistory", + "ptype": "global", + "start": false, + "tab": "History", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Mosaic", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Collage", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "Open File [G]", + "module": "FBrowser", + "ptype": "global", + "start": false, + "tab": "Open File", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Preferences", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Ruler", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "Save File [G]", + "module": "SaveImage", + "ptype": "global", + "start": false, + "tab": "SaveImage", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "WCSAxes", + "ptype": "local", + "workspace": "in:toplevel" + }, + { + "__bunch__": true, + "category": "Help", + "menu": "Help [G]", + "module": "WBrowser", + "ptype": "global", + "start": false, + "tab": "Help", + "workspace": "channels" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "Zoom [G]", + "module": "Zoom", + "ptype": "global", + "start": false, + "tab": "Zoom", + "workspace": "in:toplevel" + } +] diff -Nru ginga-3.0.0/ginga/examples/layouts/ds9ish/README.md ginga-3.1.0/ginga/examples/layouts/ds9ish/README.md --- ginga-3.0.0/ginga/examples/layouts/ds9ish/README.md 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/ds9ish/README.md 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,15 @@ +### About + +This directory defines a set of files that provide a "vaguely ds9-ish" look +and feel to the reference viewer layout. It still uses Ginga concents +throughout however, using channels/workspace instead of frames and using the +plugin architecture. + +To try it out, specify this directory to the `--basedir` command-line option +when starting the reference viewer. e.g. +```bash +ginga --basedir=.../ds9ish +``` + +See `screenshot.png` for an example of how it looks. + Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/ginga/examples/layouts/ds9ish/screenshot.png and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/ginga/examples/layouts/ds9ish/screenshot.png differ diff -Nru ginga-3.0.0/ginga/examples/layouts/standard/bindings.cfg ginga-3.1.0/ginga/examples/layouts/standard/bindings.cfg --- ginga-3.0.0/ginga/examples/layouts/standard/bindings.cfg 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/standard/bindings.cfg 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,231 @@ +# +# bindings.cfg -- Ginga user interface bindings customization +# +# Put this in your $HOME/.ginga directory as "bindings.cfg" +# +# Troubleshooting: +# Run the examples/xyz/example2_xyz.py, where "xyz" is the toolkit you want +# to use. Run it from a terminal like this: +# ./examples/qt/example2_qt.py --loglevel=10 --stderr +# Further commentary in sections below. +# + +# BUTTON SET UP +# You should rarely have to change these, but if you have a non-standard +# mouse or setup it might be useful. +# To find out what buttons are generating what codes, start up things as +# described in "Troubleshooting" above and look for messages like this as +# you click around in the window: +# ... | D | Bindings.py:1260 (window_button_press) | x,y=70,-69 btncode=0x1 +btn_nobtn = 0x0 +btn_left = 0x1 +btn_middle= 0x2 +btn_right = 0x4 +btn_back = 0x8 +btn_forward = 0x10 + +# Set up our standard modifiers. +# These should not contain "normal" keys--they should be valid modifier +# keys for your platform. +# To find out what symbol is used for a keystroke on your platform, +# start up things as described above in "Troubleshooting" and look for +# messages like this as you press keys while focus is in the window: +# ... | D | Bindings.py:1203 (window_key_press) | keyname=shift_l +mod_shift = ['shift_l', 'shift_r'] +# same setting ends up as "Ctrl" on a pc and "Command" on a mac: +mod_ctrl = ['control_l', 'control_r'] +# "Control" key on a mac, "Windows" key on a pc keyboard: +mod_win = ['meta_right'] + +# Define up our custom modifiers. +# These are typically "normal" keys. The modifier is defined by a triplet: +# [ keyname, modtype, msg ], where +# keyname is a key whose press initiates the modifier, +# modtype is either None or a type in {'held', 'oneshot', 'locked', 'softlock'} +# msg is a string to be shown in the display or None for no indicator +# Mode 'meta' is special: it is an intermediate mode that +# is used primarily to launch other modes +# If the mode initiation character is preceeded by a double +# underscore, then the mode must be initiated from the "meta" +# mode. +dmod_meta = ['space', None, None] +dmod_draw = ['__b', None, None] +dmod_cmap = ['__y', None, None] +dmod_cuts = ['__s', None, None] +dmod_dist = ['__d', None, None] +dmod_contrast = ['__t', None, None] +dmod_rotate = ['__r', None, None] +dmod_pan = ['__q', None, 'pan'] +dmod_freepan = ['__w', None, 'pan'] +dmod_camera = ['__c', None, 'pan'] +dmod_naxis = ['__n', None, None] + +default_mode_type = 'locked' +default_lock_mode_type = 'softlock' + +# KEYPRESS commands +kp_zoom_in = ['+', '='] +kp_zoom_out = ['-', '_'] +kp_zoom = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'] +kp_zoom_inv = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'] +kp_zoom_fit = ['backquote', 'pan+backquote', 'freepan+backquote'] +kp_autozoom_toggle = ['doublequote', 'pan+doublequote'] +kp_autozoom_override = ['singlequote', 'pan+singlequote'] +kp_dist_reset = ['D', 'dist+D'] +kp_dist_prev = ['dist+up', 'dist+b'] +kp_dist_next = ['dist+down', 'dist+n'] +kp_pan_set = ['p', 'pan+p', 'freepan+p'] +kp_pan_zoom_set = ['pan+1', 'freepan+1'] +kp_pan_zoom_save = ['pan+z', 'freepan+z'] +kp_pan_left = ['pan+*+left', 'freepan+*+left'] +kp_pan_right = ['pan+*+right', 'freepan+*+right'] +kp_pan_up = ['pan+*+up', 'freepan+*+up'] +kp_pan_down = ['pan+*+down', 'freepan+*+down'] +kp_pan_home = ['pan+*+home', 'freepan+*+home'] +kp_pan_end = ['pan+*+end', 'freepan+*+end'] +kp_pan_page_up = ['pan+*+page_up', 'freepan+*+page_up'] +kp_pan_page_down = ['pan+*+page_down', 'freepan+*+page_down'] +kp_pan_px_xminus = ['shift+left'] +kp_pan_px_xplus = ['shift+right'] +kp_pan_px_yminus = ['shift+down'] +kp_pan_px_yplus = ['shift+up'] +kp_pan_px_center = ['shift+home'] +kp_center = ['c', 'pan+c', 'freepan+c'] +kp_cut_255 = ['cuts+A'] +kp_cut_minmax = ['cuts+S'] +kp_cut_auto = ['a', 'cuts+a'] +kp_autocuts_alg_prev = ['cuts+up', 'cuts+b'] +kp_autocuts_alg_next = ['cuts+down', 'cuts+n'] +kp_autocuts_toggle = [':', 'cuts+:'] +kp_autocuts_override = [';', 'cuts+;'] +kp_autocenter_toggle = ['?', 'pan+?'] +kp_autocenter_override = ['/', 'pan+/'] +kp_contrast_restore = ['T', 'contrast+T'] +kp_cmap_reset = ['Y', 'cmap+Y'] +kp_cmap_restore = ['cmap+r'] +kp_cmap_invert = ['I', 'cmap+I'] +kp_cmap_prev = ['cmap+up', 'cmap+b'] +kp_cmap_next = ['cmap+down', 'cmap+n'] +kp_toggle_cbar = ['cmap+c'] +kp_imap_reset = ['cmap+i'] +kp_imap_prev = ['cmap+left', 'cmap+j'] +kp_imap_next = ['cmap+right', 'cmap+k'] +kp_flip_x = ['[', '{', 'rotate+[', 'rotate+{'] +kp_flip_y = [']', '}', 'rotate+]', 'rotate+}'] +kp_swap_xy = ['backslash', '|', 'rotate+backslash', 'rotate+|'] +kp_rotate_reset = ['R', 'rotate+R'] +kp_save_profile = ['S'] +kp_rotate_inc90 = ['.', 'rotate+.'] +kp_rotate_dec90 = [',', 'rotate+,'] +kp_orient_lh = ['o', 'rotate+o'] +kp_orient_rh = ['O', 'rotate+O'] +kp_poly_add = ['v', 'draw+v'] +kp_poly_del = ['z', 'draw+z'] +kp_edit_del = ['draw+x'] +kp_reset = ['escape'] +kp_lock = ['L', 'meta+L'] +kp_softlock = ['l', 'meta+l'] +kp_camera_save = ['camera+s'] +kp_camera_reset = ['camera+r'] +kp_camera_toggle3d = ['camera+3'] + +# pct of a window of data to move with pan key commands +key_pan_pct = 0.666667 +# amount to move (in pixels) when using key pan arrow +key_pan_px_delta = 1.0 + +# SCROLLING/WHEEL commands +sc_pan = ['ctrl+scroll'] +sc_pan_fine = ['pan+shift+scroll'] +sc_pan_coarse = ['pan+ctrl+scroll'] +sc_zoom = ['scroll', 'freepan+scroll'] +sc_zoom_fine = [] +sc_zoom_coarse = [] +sc_zoom_origin = ['shift+scroll', 'freepan+shift+scroll'] +sc_cuts_fine = ['cuts+ctrl+scroll'] +sc_cuts_coarse = ['cuts+scroll'] +sc_cuts_alg = [] +sc_dist = ['dist+scroll'] +sc_cmap = ['cmap+scroll'] +sc_imap = ['cmap+ctrl+scroll'] +sc_camera_track = ['camera+scroll'] +sc_naxis = ['naxis+scroll'] + +# This controls how fast panning occurs with the sc_pan* functions. +# Increase to speed up panning +scroll_pan_acceleration = 1.0 +# For trackpads you can adjust this down if it seems too sensitive. +# 1.0 is appropriate for a mouse, 0.1 for most trackpads +scroll_zoom_acceleration = 1.0 +# If set to True, then don't zoom by "zoom steps", but by a more direct +# scaling call that uses scroll_zoom_acceleration +scroll_zoom_direct_scale = False + +# MOUSE/BUTTON commands +# NOTE: most plugins in the reference viewer need "none", "cursor" and "draw" +# events to work! If you want to use them you need to provide a valid +# non-conflicting binding +ms_none = ['nobtn'] +ms_cursor = ['left'] +ms_wheel = [] +ms_draw = ['draw+left', 'win+left', 'right'] + +# mouse commands initiated by a preceeding keystroke (see above) +ms_rotate = ['rotate+left'] +ms_rotate_reset = ['rotate+right'] +ms_contrast = ['contrast+left', 'ctrl+right'] +ms_contrast_restore = ['contrast+right', 'ctrl+middle'] +ms_pan = ['pan+left', 'ctrl+left'] +ms_zoom = ['pan+right'] +ms_freepan = ['freepan+middle'] +ms_zoom_in = ['freepan+left'] +ms_zoom_out = ['freepan+right', 'freepan+ctrl+left'] +ms_cutlo = ['cuts+shift+left'] +ms_cuthi = ['cuts+ctrl+left'] +ms_cutall = ['cuts+left'] +ms_cut_auto = ['cuts+right'] +ms_panset = ['pan+middle', 'shift+left', 'middle'] +ms_cmap_rotate = ['cmap+left'] +ms_cmap_restore = ['cmap+right'] +ms_camera_orbit = ['camera+left'] +ms_camera_pan_delta = ['camera+right'] +ms_naxis = ['naxis+left'] + +mouse_zoom_acceleration = 1.085 +mouse_rotate_acceleration = 0.75 + +# GESTURES (some back ends only) +# NOTE: if using Qt4 back end, it is *highly* recommended to disable any +# "scroll zoom" (sc_zoom*) features above because the two kinds don't play +# well together. +pi_zoom = ['pinch'] +pi_zoom_origin = ['shift+pinch'] +pa_pan = ['pan'] +pa_zoom = ['freepan+pan'] +pa_zoom_origin = ['freepan+shift+pan'] +pa_naxis = ['naxis+pan'] + +# This controls what operations the pinch gesture controls. Possibilities are +# (empty list or) some combination of 'zoom' and 'rotate'. +pinch_actions = ['zoom'] +pinch_zoom_acceleration = 1.0 +pinch_rotate_acceleration = 1.0 +pan_pan_acceleration = 1.0 + +# Use opposite sense of panning direction +pan_reverse = False +# 1.0 is a proportional drag pan. Increase to "accelerate" panning speed. +pan_multiplier = 1.0 + +# Use opposite sense of zooming scroll wheel +zoom_scroll_reverse = False + +pan_min_scroll_thumb_pct = 0.0 +pan_max_scroll_thumb_pct = 0.9 + +# No messages for color map warps or setting pan position +#msg_cmap = False +msg_panset = False + + +#END diff -Nru ginga-3.0.0/ginga/examples/layouts/standard/general.cfg ginga-3.1.0/ginga/examples/layouts/standard/general.cfg --- ginga-3.0.0/ginga/examples/layouts/standard/general.cfg 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/standard/general.cfg 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,23 @@ +FITSpkg = 'choose' +WCSpkg = 'choose' +channel_follows_focus = False +channel_prefix = 'Image' +cursor_interval = 0.05 +download_folder = None +fixedFont = None +font_scaling_factor = None +icc_working_profile = None +inherit_primary_header = False +layout_file = 'layout' +numImages = 10 +pixel_coords_offset = 1.0 +plugin_file = 'plugins.json' +recursion_limit = 2000 +sansFont = None +save_layout = False +scrollbars = 'off' +serifFont = None +showBanner = False +useMatplotlibColormaps = False +use_opengl = False +widgetSet = 'choose' diff -Nru ginga-3.0.0/ginga/examples/layouts/standard/layout ginga-3.1.0/ginga/examples/layouts/standard/layout --- ginga-3.0.0/ginga/examples/layouts/standard/layout 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/standard/layout 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,55 @@ +['seq', + {}, + ['vbox', + {'height': 700, 'name': 'top', 'width': 1400}, + {'row': ['hbox', {'name': 'menu'}], 'stretch': 0}, + {'row': ['hpanel', + {'name': 'hpnl'}, + ['ws', + {'group': 2, + 'height': -1, + 'name': 'left', + 'width': 300, + 'wstype': 'tabs'}, + [('Info', + ['vpanel', + {}, + ['ws', + {'group': 3, + 'height': 250, + 'name': 'uleft', + 'wstype': 'stack'}], + ['ws', + {'group': 3, + 'height': 330, + 'name': 'lleft', + 'wstype': 'tabs'}]])]], + ['vbox', + {'name': 'main', 'width': 600}, + {'row': ['ws', + {'default': True, + 'group': 1, + 'name': 'channels', + 'use_toolbar': True, + 'wstype': 'tabs'}], + 'stretch': 1}, + {'row': ['ws', {'group': 99, 'name': 'cbar', 'wstype': 'stack'}], + 'stretch': 0}, + {'row': ['ws', {'group': 99, 'name': 'readout', 'wstype': 'stack'}], + 'stretch': 0}, + {'row': ['ws', + {'group': 99, 'name': 'operations', 'wstype': 'stack'}], + 'stretch': 0}], + ['ws', + {'group': 2, + 'height': -1, + 'name': 'right', + 'width': 400, + 'wstype': 'tabs'}, + [('Dialogs', + ['ws', {'group': 2, 'name': 'dialogs', 'wstype': 'tabs'}])]]], + 'stretch': 1}, + {'row': ['ws', + {'group': 2, 'height': 40, 'name': 'toolbar', 'wstype': 'stack'}], + 'stretch': 0}, + {'row': ['hbox', {'name': 'status'}], 'stretch': 0}]] diff -Nru ginga-3.0.0/ginga/examples/layouts/standard/plugins.json ginga-3.1.0/ginga/examples/layouts/standard/plugins.json --- ginga-3.0.0/ginga/examples/layouts/standard/plugins.json 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/standard/plugins.json 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,392 @@ +[ + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Operations [G]", + "module": "Operations", + "ptype": "global", + "start": true, + "workspace": "operations" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Toolbar [G]", + "module": "Toolbar", + "ptype": "global", + "start": true, + "workspace": "toolbar" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Pan [G]", + "module": "Pan", + "ptype": "global", + "start": true, + "workspace": "uleft" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Info [G]", + "module": "Info", + "ptype": "global", + "start": true, + "tab": "Synopsis", + "workspace": "lleft" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Thumbs [G]", + "module": "Thumbs", + "ptype": "global", + "start": true, + "tab": "Thumbs", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Contents [G]", + "module": "Contents", + "ptype": "global", + "start": true, + "tab": "Contents", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Colorbar [G]", + "module": "Colorbar", + "ptype": "global", + "start": true, + "workspace": "cbar" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Cursor [G]", + "module": "Cursor", + "ptype": "global", + "start": true, + "workspace": "readout" + }, + { + "__bunch__": true, + "category": "System", + "hidden": true, + "menu": "Errors [G]", + "module": "Errors", + "ptype": "global", + "start": true, + "tab": "Errors", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "Downloads [G]", + "module": "Downloads", + "ptype": "global", + "start": false, + "tab": "Downloads", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Analysis", + "menu": "Blink Channels [G]", + "module": "Blink", + "ptype": "global", + "start": false, + "tab": "Blink Channels", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Analysis", + "menu": "Blink Images", + "module": "Blink", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "Cuts", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Analysis.Datacube", + "module": "LineProfile", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "Histogram", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "Overlays", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "Pick", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "PixTable", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "TVMark", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Analysis", + "module": "TVMask", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Analysis", + "menu": "WCS Match [G]", + "module": "WCSMatch", + "ptype": "global", + "start": false, + "tab": "WCSMatch", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Debug", + "menu": "Command Line [G]", + "module": "Command", + "ptype": "global", + "start": false, + "tab": "Command", + "workspace": "lleft" + }, + { + "__bunch__": true, + "category": "Debug", + "menu": "Logger Info [G]", + "module": "Log", + "ptype": "global", + "start": false, + "tab": "Log", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Navigation", + "module": "MultiDim", + "ptype": "local", + "workspace": "lleft" + }, + { + "__bunch__": true, + "category": "Remote", + "menu": "Remote Control [G]", + "module": "RC", + "ptype": "global", + "start": false, + "tab": "RC", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Remote", + "menu": "SAMP Client [G]", + "module": "SAMP", + "ptype": "global", + "start": false, + "tab": "SAMP", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "RGB", + "module": "Compose", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "RGB", + "module": "ScreenShot", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "RGB", + "menu": "Set Color Map [G]", + "module": "ColorMapPicker", + "ptype": "global", + "start": false, + "tab": "ColorMapPicker", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Table", + "module": "PlotTable", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Catalogs", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Crosshair", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Drawing", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "FBrowser", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "History [G]", + "module": "ChangeHistory", + "ptype": "global", + "start": false, + "tab": "History", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Mosaic", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Collage", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "Open File [G]", + "module": "FBrowser", + "ptype": "global", + "start": false, + "tab": "Open File", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Preferences", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "Ruler", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "Save File [G]", + "module": "SaveImage", + "ptype": "global", + "start": false, + "tab": "SaveImage", + "workspace": "right" + }, + { + "__bunch__": true, + "category": "Utils", + "module": "WCSAxes", + "ptype": "local", + "workspace": "dialogs" + }, + { + "__bunch__": true, + "category": "Help", + "menu": "Help [G]", + "module": "WBrowser", + "ptype": "global", + "start": false, + "tab": "Help", + "workspace": "channels" + }, + { + "__bunch__": true, + "category": "Utils", + "hidden": false, + "menu": "Header [G]", + "module": "Header", + "ptype": "global", + "start": false, + "tab": "Header", + "workspace": "left" + }, + { + "__bunch__": true, + "category": "Utils", + "menu": "Zoom [G]", + "module": "Zoom", + "ptype": "global", + "start": false, + "tab": "Zoom", + "workspace": "left" + } +] diff -Nru ginga-3.0.0/ginga/examples/layouts/standard/README.md ginga-3.1.0/ginga/examples/layouts/standard/README.md --- ginga-3.0.0/ginga/examples/layouts/standard/README.md 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/standard/README.md 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,13 @@ +### About + +This directory defines a set of files that provide the "standard" look +and feel to the reference viewer layout. It is provided here as a starting +point for customization. + +To try it out, specify this directory to the `--basedir` command-line option +when starting the reference viewer. e.g. +```bash +ginga --basedir=.../ds9ish +``` + +See `screenshot.png` for an example of how it looks. Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/ginga/examples/layouts/standard/screenshot.png and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/ginga/examples/layouts/standard/screenshot.png differ diff -Nru ginga-3.0.0/ginga/examples/layouts/twofer/general.cfg ginga-3.1.0/ginga/examples/layouts/twofer/general.cfg --- ginga-3.0.0/ginga/examples/layouts/twofer/general.cfg 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/twofer/general.cfg 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,23 @@ +FITSpkg = 'choose' +WCSpkg = 'choose' +channel_follows_focus = False +channel_prefix = 'Image' +cursor_interval = 0.05 +download_folder = None +fixedFont = None +font_scaling_factor = None +icc_working_profile = None +inherit_primary_header = False +layout_file = 'layout.json' +numImages = 10 +pixel_coords_offset = 1.0 +plugin_file = 'plugins.json' +recursion_limit = 2000 +sansFont = None +save_layout = False +scrollbars = 'off' +serifFont = None +showBanner = False +useMatplotlibColormaps = False +use_opengl = False +widgetSet = 'choose' diff -Nru ginga-3.0.0/ginga/examples/layouts/twofer/layout.json ginga-3.1.0/ginga/examples/layouts/twofer/layout.json --- ginga-3.0.0/ginga/examples/layouts/twofer/layout.json 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/twofer/layout.json 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,293 @@ +[ + "seq", + { + "__bunch__": true, + "name": "top_1", + "height": 1009, + "width": 1720, + "xpos": 3862, + "ypos": 26, + "spacing": null + }, + [ + "vbox", + { + "__bunch__": true, + "name": "top", + "width": 1720, + "height": 1009, + "xpos": -1, + "ypos": -1, + "spacing": null + }, + { + "row": [ + "hbox", + { + "__bunch__": true, + "name": "menu", + "height": 20, + "width": 1720, + "xpos": -1, + "ypos": -1, + "spacing": null + } + ], + "stretch": 0 + }, + { + "row": [ + "hpanel", + { + "__bunch__": true, + "name": "hpnl", + "height": 938, + "width": 1720, + "xpos": -1, + "ypos": -1, + "spacing": null, + "sizes": [ + 308, + 902, + 502 + ] + }, + [ + "ws", + { + "__bunch__": true, + "name": "left", + "wstype": "tabs", + "width": 308, + "height": 938, + "group": 2, + "title": null, + "show_tabs": true, + "show_border": false, + "scrollable": true, + "detachable": false, + "tabpos": "top", + "use_toolbar": false, + "xpos": -1, + "ypos": -1, + "spacing": null + }, + [] + ], + [ + "vpanel", + { + "__bunch__": true, + "width": 902, + "height": 938, + "xpos": -1, + "ypos": -1, + "spacing": null, + "name": "vpanel_0", + "sizes": [ + 621, + 313 + ] + }, + [ + "vbox", + { + "__bunch__": true, + "name": "main", + "width": 902, + "height": 621, + "xpos": -1, + "ypos": -1, + "spacing": null + }, + { + "row": [ + "ws", + { + "__bunch__": true, + "name": "channels", + "wstype": "tabs", + "group": 1, + "use_toolbar": true, + "default": true, + "title": null, + "height": 538, + "width": 902, + "show_tabs": true, + "show_border": false, + "scrollable": true, + "detachable": false, + "tabpos": "top", + "xpos": -1, + "ypos": -1, + "spacing": null + }, + [] + ], + "stretch": 1 + }, + { + "row": [ + "ws", + { + "__bunch__": true, + "name": "cbar", + "wstype": "stack", + "group": 99, + "title": null, + "height": 36, + "width": 902, + "show_tabs": true, + "show_border": false, + "scrollable": true, + "detachable": false, + "tabpos": "top", + "use_toolbar": false, + "xpos": -1, + "ypos": -1, + "spacing": null + }, + [] + ], + "stretch": 0 + }, + { + "row": [ + "ws", + { + "__bunch__": true, + "name": "readout", + "wstype": "stack", + "group": 99, + "title": null, + "height": 24, + "width": 902, + "show_tabs": true, + "show_border": false, + "scrollable": true, + "detachable": false, + "tabpos": "top", + "use_toolbar": false, + "xpos": -1, + "ypos": -1, + "spacing": null + }, + [] + ], + "stretch": 0 + }, + { + "row": [ + "ws", + { + "__bunch__": true, + "name": "operations", + "wstype": "stack", + "group": 99, + "title": null, + "height": 23, + "width": 902, + "show_tabs": true, + "show_border": false, + "scrollable": true, + "detachable": false, + "tabpos": "top", + "use_toolbar": false, + "xpos": -1, + "ypos": -1, + "spacing": null + }, + [] + ], + "stretch": 0 + } + ], + [ + "ws", + { + "__bunch__": true, + "name": "bottom", + "wstype": "mdi", + "width": 902, + "height": 313, + "group": 2, + "title": null, + "show_tabs": true, + "show_border": false, + "scrollable": true, + "detachable": false, + "tabpos": "top", + "use_toolbar": true, + "xpos": -1, + "ypos": -1, + "spacing": null + }, + [] + ] + ], + [ + "ws", + { + "__bunch__": true, + "name": "dialogs", + "wstype": "tabs", + "width": 502, + "height": 938, + "group": 2, + "title": null, + "show_tabs": true, + "show_border": false, + "scrollable": true, + "detachable": false, + "tabpos": "top", + "use_toolbar": true, + "xpos": -1, + "ypos": -1, + "spacing": null + } + ] + ], + "stretch": 1 + }, + { + "row": [ + "ws", + { + "__bunch__": true, + "name": "toolbar", + "wstype": "stack", + "height": 29, + "group": 2, + "title": null, + "width": 1720, + "show_tabs": true, + "show_border": false, + "scrollable": true, + "detachable": false, + "tabpos": "top", + "use_toolbar": false, + "xpos": -1, + "ypos": -1, + "spacing": null + }, + [] + ], + "stretch": 0 + }, + { + "row": [ + "hbox", + { + "__bunch__": true, + "name": "status", + "height": 22, + "width": 1720, + "xpos": -1, + "ypos": -1, + "spacing": null + } + ], + "stretch": 0 + } + ] +] diff -Nru ginga-3.0.0/ginga/examples/layouts/twofer/plugins.json ginga-3.1.0/ginga/examples/layouts/twofer/plugins.json --- ginga-3.0.0/ginga/examples/layouts/twofer/plugins.json 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/twofer/plugins.json 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,392 @@ +[ + { + "__bunch__": true, + "module": "Operations", + "workspace": "operations", + "start": true, + "hidden": true, + "category": "System", + "menu": "Operations [G]", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Toolbar", + "workspace": "toolbar", + "start": true, + "hidden": true, + "category": "System", + "menu": "Toolbar [G]", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Pan", + "workspace": "bottom", + "start": false, + "hidden": false, + "category": "System", + "menu": "Pan [G]", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Info", + "tab": "Synopsis", + "workspace": "bottom", + "start": false, + "hidden": false, + "category": "System", + "menu": "Info [G]", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Thumbs", + "tab": "Thumbs", + "workspace": "dialogs", + "start": false, + "hidden": false, + "category": "System", + "menu": "Thumbs [G]", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Contents", + "tab": "Contents", + "workspace": "dialogs", + "start": false, + "hidden": false, + "category": "System", + "menu": "Contents [G]", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Colorbar", + "workspace": "cbar", + "start": true, + "hidden": true, + "category": "System", + "menu": "Colorbar [G]", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Cursor", + "workspace": "readout", + "start": true, + "hidden": true, + "category": "System", + "menu": "Cursor [G]", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Errors", + "tab": "Errors", + "workspace": "bottom", + "start": false, + "hidden": false, + "category": "System", + "menu": "Errors [G]", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Downloads", + "tab": "Downloads", + "workspace": "dialogs", + "start": false, + "menu": "Downloads [G]", + "category": "Utils", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Blink", + "tab": "Blink Channels", + "workspace": "dialogs", + "start": false, + "menu": "Blink Channels [G]", + "category": "Analysis", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Blink", + "workspace": "dialogs", + "menu": "Blink Images", + "category": "Analysis", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "Cuts", + "workspace": "dialogs", + "category": "Analysis", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "LineProfile", + "workspace": "dialogs", + "category": "Analysis.Datacube", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "Histogram", + "workspace": "dialogs", + "category": "Analysis", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "Overlays", + "workspace": "dialogs", + "category": "Analysis", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "Pick", + "workspace": "dialogs", + "category": "Analysis", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "PixTable", + "workspace": "dialogs", + "category": "Analysis", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "TVMark", + "workspace": "dialogs", + "category": "Analysis", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "TVMask", + "workspace": "dialogs", + "category": "Analysis", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "WCSMatch", + "tab": "WCSMatch", + "workspace": "dialogs", + "start": false, + "menu": "WCS Match [G]", + "category": "Analysis", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Command", + "tab": "Command", + "workspace": "bottom", + "start": false, + "menu": "Command Line [G]", + "category": "Debug", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Log", + "tab": "Log", + "workspace": "dialogs", + "start": false, + "menu": "Logger Info [G]", + "category": "Debug", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "MultiDim", + "workspace": "bottom", + "category": "Navigation", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "RC", + "tab": "RC", + "workspace": "bottom", + "start": false, + "menu": "Remote Control [G]", + "category": "Remote", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "SAMP", + "tab": "SAMP", + "workspace": "bottom", + "start": false, + "menu": "SAMP Client [G]", + "category": "Remote", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Compose", + "workspace": "dialogs", + "category": "RGB", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "ScreenShot", + "workspace": "dialogs", + "category": "RGB", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "ColorMapPicker", + "tab": "ColorMapPicker", + "menu": "Set Color Map [G]", + "workspace": "dialogs", + "start": false, + "category": "RGB", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "PlotTable", + "workspace": "dialogs", + "category": "Table", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "Catalogs", + "workspace": "dialogs", + "category": "Utils", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "Crosshair", + "workspace": "dialogs", + "category": "Utils", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "Drawing", + "workspace": "dialogs", + "category": "Utils", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "FBrowser", + "workspace": "bottom", + "category": "Utils", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "ChangeHistory", + "tab": "History", + "workspace": "dialogs", + "menu": "History [G]", + "start": false, + "category": "Utils", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Mosaic", + "workspace": "dialogs", + "category": "Utils", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "Collage", + "workspace": "dialogs", + "category": "Utils", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "FBrowser", + "tab": "Open File", + "workspace": "bottom", + "menu": "Open File [G]", + "start": false, + "category": "Utils", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Preferences", + "workspace": "dialogs", + "category": "Utils", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "Ruler", + "workspace": "dialogs", + "category": "Utils", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "SaveImage", + "tab": "SaveImage", + "workspace": "dialogs", + "menu": "Save File [G]", + "start": false, + "category": "Utils", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "WCSAxes", + "workspace": "dialogs", + "category": "Utils", + "ptype": "local" + }, + { + "__bunch__": true, + "module": "WBrowser", + "tab": "Help", + "workspace": "channels", + "start": false, + "menu": "Help [G]", + "category": "Help", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Header", + "tab": "Header", + "workspace": "left", + "start": true, + "menu": "Header [G]", + "hidden": false, + "category": "Utils", + "ptype": "global" + }, + { + "__bunch__": true, + "module": "Zoom", + "tab": "Zoom", + "workspace": "bottom", + "start": false, + "menu": "Zoom [G]", + "category": "Utils", + "ptype": "global" + } +] diff -Nru ginga-3.0.0/ginga/examples/layouts/twofer/README.md ginga-3.1.0/ginga/examples/layouts/twofer/README.md --- ginga-3.0.0/ginga/examples/layouts/twofer/README.md 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/examples/layouts/twofer/README.md 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,14 @@ +### About + +This directory defines a set of files that provide a riff on the standard +look and feel of the reference viewer layout called "twofer". It provides +a second large pane on the bottom to take some of the plugins that would +normally go on the right. + +To try it out, specify this directory to the `--basedir` command-line option +when starting the reference viewer. e.g. +```bash +ginga --basedir=.../twofer +``` + +See `screenshot.png` for an example of how it looks. Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/ginga/examples/layouts/twofer/screenshot.png and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/ginga/examples/layouts/twofer/screenshot.png differ diff -Nru ginga-3.0.0/ginga/examples/matplotlib/example4_mpl.py ginga-3.1.0/ginga/examples/matplotlib/example4_mpl.py --- ginga-3.0.0/ginga/examples/matplotlib/example4_mpl.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/examples/matplotlib/example4_mpl.py 2020-07-08 20:09:29.000000000 +0000 @@ -56,7 +56,7 @@ cmap.add_matplotlib_cmaps() # Set to True to get diagnostic logging output -use_logger = False +use_logger = True logger = log.get_logger(null=not use_logger, log_stderr=True) # create a regular matplotlib figure diff -Nru ginga-3.0.0/ginga/examples/matplotlib/example5_mpl.py ginga-3.1.0/ginga/examples/matplotlib/example5_mpl.py --- ginga-3.0.0/ginga/examples/matplotlib/example5_mpl.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/examples/matplotlib/example5_mpl.py 2020-07-08 20:09:29.000000000 +0000 @@ -40,7 +40,7 @@ import matplotlib.pyplot as plt from ginga.mplw.ImageViewCanvasMpl import ImageViewCanvas -from ginga.mplw.ImageViewCanvasTypesMpl import DrawingCanvas +from ginga.canvas.CanvasObject import get_canvas_types from ginga.misc import log from ginga.util.loader import load_data @@ -59,10 +59,12 @@ fi.set_figure(fig) self.fitsimage = fi + self.dc = get_canvas_types() + # enable all interactive features fi.get_bindings().enable_all(True) - canvas = DrawingCanvas() + canvas = self.dc.DrawingCanvas() canvas.enable_draw(True) canvas.set_callback('button-press', self.btn_down) canvas.set_callback('button-release', self.btn_up) diff -Nru ginga-3.0.0/ginga/examples/qt/example2_qt.py ginga-3.1.0/ginga/examples/qt/example2_qt.py --- ginga-3.0.0/ginga/examples/qt/example2_qt.py 2019-09-03 20:32:15.000000000 +0000 +++ ginga-3.1.0/ginga/examples/qt/example2_qt.py 2020-07-08 20:09:29.000000000 +0000 @@ -8,7 +8,7 @@ import sys -from ginga.qtw.QtHelp import QtGui, QtCore +from ginga.qtw.QtHelp import QtGui, QtCore, set_default_opengl_context from ginga import colors from ginga.qtw.ImageViewQt import CanvasView from ginga.canvas.CanvasObject import get_canvas_types @@ -20,19 +20,19 @@ class FitsViewer(QtGui.QMainWindow): - def __init__(self, logger): + def __init__(self, logger, render='widget'): super(FitsViewer, self).__init__() self.logger = logger self.drawcolors = colors.get_colors() self.dc = get_canvas_types() - fi = CanvasView(logger, render='widget') + fi = CanvasView(logger, render=render) fi.enable_autocuts('on') fi.set_autocut_params('zscale') fi.enable_autozoom('on') fi.set_zoom_algorithm('rate') fi.set_zoomrate(1.4) - #fi.show_pan_mark(True) + fi.show_pan_mark(True) #fi.enable_draw(False) fi.add_callback('drag-drop', self.drop_file_cb) fi.add_callback('cursor-changed', self.cursor_cb) @@ -64,7 +64,7 @@ # add a color bar #fi.show_color_bar(True) - fi.show_focus_indicator(True) + #fi.show_focus_indicator(True) # add little mode indicator that shows keyboard modal states fi.show_mode_indicator(True, corner='ur') @@ -267,6 +267,9 @@ def main(options, args): + if options.render == 'opengl': + set_default_opengl_context() + #QtGui.QApplication.setGraphicsSystem('raster') app = QtGui.QApplication(args) @@ -288,7 +291,7 @@ except Exception as e: logger.warning("failed to set OpenCL preference: %s" % (str(e))) - w = FitsViewer(logger) + w = FitsViewer(logger, render=options.render) w.resize(524, 540) w.show() app.setActiveWindow(w) @@ -317,6 +320,8 @@ argprs.add_argument("--opencl", dest="opencl", default=False, action="store_true", help="Use OpenCL acceleration") + argprs.add_argument("-r", "--render", dest="render", default='widget', + help="Set render type {widget|scene|opengl}") argprs.add_argument("--profile", dest="profile", action="store_true", default=False, help="Run the profiler on main()") diff -Nru ginga-3.0.0/ginga/fonts/setup_package.py ginga-3.1.0/ginga/fonts/setup_package.py --- ginga-3.0.0/ginga/fonts/setup_package.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/fonts/setup_package.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst - - -def get_package_data(): - return {'ginga.fonts': ['*/*.ttf', '*/*.txt']} diff -Nru ginga-3.0.0/ginga/gtk3w/GtkHelp.py ginga-3.1.0/ginga/gtk3w/GtkHelp.py --- ginga-3.0.0/ginga/gtk3w/GtkHelp.py 2019-09-14 00:59:46.000000000 +0000 +++ ginga-3.1.0/ginga/gtk3w/GtkHelp.py 2020-07-08 20:09:29.000000000 +0000 @@ -8,6 +8,9 @@ import os.path import math import random +import time + +import numpy as np from ginga.misc import Bunch, Callback from ginga.fonts import font_asst @@ -20,6 +23,7 @@ from gi.repository import GdkPixbuf # noqa from gi.repository import GObject # noqa from gi.repository import Pango # noqa +import cairo ginga.toolkit.use('gtk3') @@ -730,6 +734,494 @@ self._update_area_size() +class Dial(Gtk.DrawingArea): + + __gtype_name__ = "Dial" + + __gsignals__ = { + "value-changed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, + (GObject.TYPE_FLOAT,)), + } + + def __init__(self): + Gtk.DrawingArea.__init__(self) + self.set_can_focus(True) + self.dims = np.array((0, 0)) + self.center = np.array((0.0, 0.0)) + self.bg = (0.94, 0.94, 0.94) + self.fg = (0.4, 0.4, 0.4) + self.knob_fg = (1.0, 1.0, 1.0) + self.knob_fill = (0.2, 0.2, 0.2) + self.focus_fg = (0.2, 0.6, 0.9) + self.fontname = 'Sans Serif' + self.fontsize = 10.0 + self._has_focus = False + self.surface = None + + # draw labels + self.draw_scale = True + # how to rotate the labels + self.label_style = 1 + self.values = [] + self.draw_value_pos = 0 + + self.value = 0.0 + self.value_text = str(self.value) + + # internal state + self._dragging = False + self.tracking = False + self.wrap = False + self.angle = 0.0 + self.ang_offset = 0.0 + self.ang_invert = False + self.turn_delta = 6.0 + self.min_ang_deg = 0.0 + self.max_ang_deg = 360.0 + + self.connect("draw", self.draw_event) + self.connect("configure-event", self.configure_event) + self.set_app_paintable(True) + # prevents extra redraws, because we manually redraw on a size + # change + self.set_redraw_on_allocate(False) + + self.connect('button-press-event', self.button_press_event) + self.connect('button-release-event', self.button_release_event) + self.connect('motion-notify-event', self.motion_notify_event) + self.connect('scroll-event', self.scroll_event) + self.connect('focus_in_event', self.focus_event, True) + self.connect('focus_out_event', self.focus_event, False) + mask = self.get_events() + self.set_events(mask | + Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.BUTTON_RELEASE_MASK | + Gdk.EventMask.POINTER_MOTION_MASK | + Gdk.EventMask.SCROLL_MASK | + Gdk.EventMask.FOCUS_CHANGE_MASK | + Gdk.EventMask.EXPOSURE_MASK) + + def button_press_event(self, widget, event): + if event.button == 1: + self._dragging = True + self._calc_action(event.x, event.y) + return True + + def button_release_event(self, widget, event): + self._dragging = False + self._calc_action(event.x, event.y) + return True + + def motion_notify_event(self, widget, event): + # Are we holding down the left mouse button? + if not self._dragging: + return False + + self._calc_action(event.x, event.y) + return True + + def scroll_event(self, widget, event): + degrees, direction = get_scroll_info(event) + if direction < 180.0: + self.turn_ccw() + else: + self.turn_cw() + + self.draw() + return True + + def focus_event(self, widget, event, tf): + self._has_focus = tf + self.draw() + return True + + def _calc_action(self, x, y): + ang_deg = np.degrees(np.arctan2(x - self.center[0], + y - self.center[1]) + np.pi * 1.5) + ang_deg = self.normalize_angle(ang_deg + self.ang_offset) + if self.ang_invert: + ang_deg = 360.0 - ang_deg + + self.angle_action(x, y, ang_deg) + + def draw(self): + if self.surface is None: + return + cr = cairo.Context(self.surface) + + cr.select_font_face(self.fontname) + cr.set_font_size(self.fontsize) + + # fill background + wd, ht = self.dims + cr.rectangle(0, 0, wd, ht) + r, g, b = self.bg + cr.set_source_rgba(r, g, b) + cr.fill() + + r, g, b = self.fg + cr.set_source_rgba(r, g, b) + cr.set_line_width(2.0) + + cr.save() + cx, cy = self.center + cr.translate(cx, cy) + cr.move_to(0, 0) + + # draw circle + cradius = min(cx, cy) + cradius *= 0.66 + cr.arc(0, 0, cradius, 0, 2 * np.pi) + cr.fill() + + if self._has_focus: + r, g, b = self.focus_fg + else: + r, g, b = (0.0, 0.0, 0.0) + cr.set_source_rgba(r, g, b) + cr.new_path() + cr.set_line_width(2) + cr.arc(0, 0, cradius, 0, 2 * np.pi) + cr.stroke() + cr.new_path() + cr.set_line_width(1) + + if self.draw_scale: + cr.new_path() + cr.set_source_rgba(0.0, 0.0, 0.0) + for tup in self.values: + if len(tup) == 3: + label, value, theta = tup + else: + value, theta = tup + label = str(value) + if self.ang_invert: + theta = 360.0 - theta + theta_pos = self.normalize_angle(theta + 90.0 - self.ang_offset) + theta_rad = np.radians(theta_pos) + a, b, wd, ht, i, j = cr.text_extents(label) + crad2 = cradius + ht / 2.0 + if self.label_style == 0: + crad2 += wd + + # draw small filled dot as position marker + cx, cy = (np.sin(theta_rad) * cradius, + np.cos(theta_rad) * cradius) + cr.move_to(cx, cy) + r, g, b = self.knob_fill + cr.set_source_rgba(r, g, b) + cr.arc(cx, cy, 2, 0, 2 * np.pi) + cr.stroke_preserve() + cr.fill() + + # draw label + cx, cy = np.sin(theta_rad) * crad2, np.cos(theta_rad) * crad2 + cr.move_to(cx, cy) + + text_rad = np.arctan2(cx, cy) + if self.label_style == 0: + text_rad = 0.0 + elif self.label_style == 1: + text_rad += np.pi + elif self.label_style == 2: + text_rad += - np.pi / 2 + cr.save() + cr.translate(cx, cy) + cr.rotate(-text_rad) + if self.label_style == 1: + cr.move_to(-wd / 2, 0) + cr.show_text(label) + #cr.rotate(text_rad) + cr.restore() + + cr.new_path() + + cr.move_to(0, 0) + theta = self.angle + if self.ang_invert: + theta = 360.0 - theta + theta = self.normalize_angle(theta - self.ang_offset) + + cr.rotate(-np.radians(theta)) + + # draw knob (pointer) + r, g, b = self.knob_fg + cr.set_source_rgba(r, g, b) + crad2 = cradius + cr.new_path() + x1, y1, x2, y2 = -crad2, 0, crad2, 0 + cx1, cy1, cx2, cy2 = self.calc_vertexes(x1, y1, x2, y2, + arrow_length=crad2) + cr.move_to(x2, y2) + cr.line_to(cx1, cy1) + #cr.line_to(0, 0) + cr.line_to(cx2, cy2) + cr.close_path() + r, g, b = self.knob_fg + cr.set_source_rgba(r, g, b) + cr.stroke_preserve() + r, g, b = self.knob_fill + cr.set_source_rgba(r, g, b) + cr.fill() + cr.move_to(0, 0) + cr.arc(0, 0, abs(cx1 + cx2) * 2.1, 0, 2 * np.pi) + cr.stroke_preserve() + cr.fill() + + text = self.value_text + if self.draw_value_pos == 1: + r, g, b = self.bg + cr.set_source_rgba(r, g, b) + cr.move_to(0, 0) + cr.show_text(text) + + cr.restore() + + if self.draw_value_pos == 2: + a, b, wd, ht, i, j = cr.text_extents(text) + r, g, b = self.fg + cr.set_source_rgba(r, g, b) + x, y = self.center + cr.move_to(x - wd / 2, (y - cradius) * 0.5 + ht) + cr.show_text(text) + + cr.move_to(0, 0) + + self.update_widget() + + def normalize_angle(self, ang_deg): + ang_deg = np.fmod(ang_deg + 360.0, 360.0) + return ang_deg + + def finalize_angle(self, ang_deg): + self.angle = ang_deg + self.draw() + + def get_angle(self): + return self.angle + + def set_labels(self, val_ang_pairs): + self.values = val_ang_pairs + + self.draw() + + def set_tracking(self, tf): + self.tracking = tf + + def configure_event(self, widget, event): + rect = widget.get_allocation() + x, y, width, height = rect.x, rect.y, rect.width, rect.height + + self.dims = np.array((width, height)) + self.center = np.array((width / 2, height / 2)) + + self.surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + + self.draw() + return True + + def update_widget(self): + if self.surface is None: + # window is not mapped/configured yet + return + + win = self.get_window() + if win is not None and self.surface is not None: + wd, ht = self.dims + + self.queue_draw_area(0, 0, wd, ht) + + def draw_event(self, widget, cr): + # redraw the screen from backing surface + cr.set_source_surface(self.surface, 0, 0) + + cr.set_operator(cairo.OPERATOR_SOURCE) + cr.paint() + return False + + def calc_vertexes(self, start_cx, start_cy, end_cx, end_cy, + arrow_length=10, arrow_degrees=0.35): + + angle = np.arctan2(end_cy - start_cy, end_cx - start_cx) + np.pi + + cx1 = end_cx + arrow_length * np.cos(angle - arrow_degrees) + cy1 = end_cy + arrow_length * np.sin(angle - arrow_degrees) + cx2 = end_cx + arrow_length * np.cos(angle + arrow_degrees) + cy2 = end_cy + arrow_length * np.sin(angle + arrow_degrees) + + return (cx1, cy1, cx2, cy2) + + def angle_action(self, x, y, ang_deg): + """Subclass overrides to provide custom behavior""" + self._set_value(ang_deg) + + def turn_ccw(self): + """Subclass overrides to provide custom behavior""" + self._set_value(self.angle + self.turn_delta) + + def turn_cw(self): + """Subclass overrides to provide custom behavior""" + self._set_value(self.angle - self.turn_delta) + + def _set_value(self, ang_deg): + """Subclass overrides to provide custom behavior""" + ang_deg = self.normalize_angle(ang_deg) + ang_deg = np.clip(ang_deg, self.min_ang_deg, self.max_ang_deg) + self.value = ang_deg + self.finalize_angle(ang_deg) + + if not self._dragging or self.tracking: + self.emit("value-changed", self.value) + + def set_value(self, ang_deg): + """Subclass overrides to provide custom behavior""" + self._set_value(ang_deg) + + def get_value(self): + """Subclass overrides to provide custom behavior""" + return self.value + + +class ValueDial(Dial): + + __gtype_name__ = "ValueDial" + + def __init__(self): + Dial.__init__(self) + + # for drawing value + self.label_style = 1 + self.draw_value_pos = 2 + + # setup axis orientation to match value + self.ang_offset = 140.0 + self.ang_invert = True + self.min_ang_deg = 0.0 + self.max_ang_deg = 260.0 + self.set_labels([("min", 0.0), ("max", 260.0)]) + + self.min_val = 0.0 + self.max_val = 0.0 + self.inc_val = 0.0 + + def angle_action(self, x, y, ang_deg): + value = self._angle_to_value(ang_deg) + self._set_value(value) + + def turn_ccw(self): + ang_deg = np.clip(self.angle + self.turn_delta, + 0.0, self.max_ang_deg) + value = self._angle_to_value(ang_deg) + self._set_value(value) + + def turn_cw(self): + ang_deg = np.clip(self.angle - self.turn_delta, + 0.0, self.max_ang_deg) + value = self._angle_to_value(ang_deg) + self._set_value(value) + + def _set_value(self, value): + if value < self.min_val or value > self.max_val: + raise ValueError("value '{}' is out of range".format(value)) + + self.value = value + self.value_text = "%.2f" % self.value + + ang_deg = self._value_to_angle(value) + self.finalize_angle(ang_deg) + + if not self._dragging or self.tracking: + self.emit("value-changed", self.value) + + def get_value(self): + return self.value + + def _value_to_angle(self, value): + # make angle match value + rng = self.max_val - self.min_val + pct = (value - self.min_val) / rng + ang_deg = pct * self.max_ang_deg + ang_deg = np.clip(ang_deg, 0.0, self.max_ang_deg) + return ang_deg + + def _angle_to_value(self, ang_deg): + # make value match angle + pct = ang_deg / self.max_ang_deg + rng = self.max_val - self.min_val + value = self.min_val + pct * rng + value = np.clip(value, self.min_val, self.max_val) + return value + + def set_limits(self, min_val, max_val, inc_val): + self.min_val = min_val + self.max_val = max_val + self.inc_val = inc_val + + pct = inc_val / (max_val - min_val) + self.turn_delta = pct * self.max_ang_deg + + +class IndexDial(Dial): + + __gtype_name__ = "IndexDial" + + def __init__(self): + Dial.__init__(self) + + self.idx = 0 + self.label_style = 1 + + def angle_action(self, x, y, ang_deg): + idx = self.best_index(ang_deg) + self.set_index(idx) + + def turn_ccw(self): + idx = self.idx - 1 + if idx < 0: + if self.wrap: + self.set_index(len(self.values) - 1) + else: + self.set_index(idx) + + def turn_cw(self): + idx = self.idx + 1 + if idx >= len(self.values): + if self.wrap: + self.set_index(0) + else: + self.set_index(idx) + + def set_index(self, idx): + idx = int(idx) + if idx < 0 or idx >= len(self.values): + raise ValueError("index '{}' is outside range 0-{}".format(idx, + len(self.values))) + self.idx = idx + tup = self.values[idx] + self.value = tup[0] if len(tup) == 2 else tup[1] + self.value_text = str(self.value) + + self.angle = tup[-1] + self.draw() + + if not self._dragging or self.tracking: + self.emit("value-changed", idx) + + def get_index(self): + return self.idx + + def get_value(self): + return self.value + + def best_index(self, ang_deg): + # find the index that is closest to the angle ang_deg + angles = np.array([tup[-1] for tup in self.values]) + ang_deg = self.normalize_angle(ang_deg) + angles = np.abs(angles - ang_deg) + idx = np.argmin(angles) + return idx + + class FileSelection(object): def __init__(self, parent_w, action=Gtk.FileChooserAction.OPEN, @@ -809,6 +1301,8 @@ self.data = Bunch.Bunch() self._timer = None + self.start_time = 0.0 + self.deadline = 0.0 for name in ('expired', 'canceled'): self.enable_callback(name) @@ -826,6 +1320,8 @@ self.stop() + self.start_time = time.time() + self.deadline = self.start_time + duration # Gtk timer set in milliseconds time_ms = int(duration * 1000.0) self._timer = GObject.timeout_add(time_ms, self._redirect_cb) @@ -834,13 +1330,30 @@ self._timer = None self.make_callback('expired') + def is_set(self): + return self._timer is not None + + def cond_set(self, time_sec): + if not self.is_set(): + # TODO: probably a race condition here + self.set(time_sec) + + def elapsed_time(self): + return time.time() - self.start_time + + def time_left(self): + return max(0.0, self.deadline - time.time()) + + def get_deadline(self): + return self.deadline + def stop(self): try: if self._timer is not None: GObject.source_remove(self._timer) - self._timer = None except Exception: pass + self._timer = None def cancel(self): """Cancel this timer. If the timer is not running, there diff -Nru ginga-3.0.0/ginga/gtk3w/ImageViewCanvasGtk.py ginga-3.1.0/ginga/gtk3w/ImageViewCanvasGtk.py --- ginga-3.0.0/ginga/gtk3w/ImageViewCanvasGtk.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/gtk3w/ImageViewCanvasGtk.py 2020-07-08 20:09:29.000000000 +0000 @@ -17,10 +17,11 @@ DrawingMixin, CanvasMixin, CompoundMixin): def __init__(self, logger=None, rgbmap=None, settings=None, - bindmap=None, bindings=None): + render=None, bindmap=None, bindings=None): ImageViewGtk.ImageViewZoom.__init__(self, logger=logger, rgbmap=rgbmap, settings=settings, + render=render, bindmap=bindmap, bindings=bindings) CompoundMixin.__init__(self) diff -Nru ginga-3.0.0/ginga/gtk3w/ImageViewCanvasTypesGtk.py ginga-3.1.0/ginga/gtk3w/ImageViewCanvasTypesGtk.py --- ginga-3.0.0/ginga/gtk3w/ImageViewCanvasTypesGtk.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/gtk3w/ImageViewCanvasTypesGtk.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# TODO: this line is for backward compatibility with files importing -# this module--to be removed -from ginga.canvas.types.all import * # noqa - -# END diff -Nru ginga-3.0.0/ginga/gtk3w/ImageViewGtk.py ginga-3.1.0/ginga/gtk3w/ImageViewGtk.py --- ginga-3.0.0/ginga/gtk3w/ImageViewGtk.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/gtk3w/ImageViewGtk.py 2020-07-08 20:09:29.000000000 +0000 @@ -18,6 +18,14 @@ from gi.repository import GdkPixbuf import cairo +have_opengl = False +try: + from ginga.opengl.GlHelp import get_transforms + from ginga.opengl.glsl import req + have_opengl = True +except ImportError: + pass + class ImageViewGtkError(ImageView.ImageViewError): pass @@ -25,40 +33,72 @@ class ImageViewGtk(ImageView.ImageViewBase): - def __init__(self, logger=None, rgbmap=None, settings=None): + def __init__(self, logger=None, rgbmap=None, settings=None, render=None): ImageView.ImageViewBase.__init__(self, logger=logger, rgbmap=rgbmap, settings=settings) - imgwin = Gtk.DrawingArea() - imgwin.connect("draw", self.draw_event) - imgwin.connect("configure-event", self.configure_event) - imgwin.set_events(Gdk.EventMask.EXPOSURE_MASK) - # prevents some flickering - imgwin.set_double_buffered(True) - imgwin.set_app_paintable(True) - # prevents extra redraws, because we manually redraw on a size - # change - imgwin.set_redraw_on_allocate(False) - self.imgwin = imgwin - self.imgwin.show_all() + if render is None: + render = self.t_.get('render_widget', 'widget') + self.wtype = render + self.surface = None + if self.wtype == 'widget': + imgwin = Gtk.DrawingArea() + + imgwin.connect("draw", self.draw_event) + imgwin.connect("configure-event", self.configure_event) + imgwin.set_events(Gdk.EventMask.EXPOSURE_MASK) + # prevents some flickering + imgwin.set_double_buffered(True) + imgwin.set_app_paintable(True) + # prevents extra redraws, because we manually redraw on a size + # change + imgwin.set_redraw_on_allocate(False) + + renderers = ['cairo', 'agg', 'pil', 'opencv'] + self.t_.set_defaults(renderer='cairo') + if self.t_['renderer'] == 'opengl': + # currently cannot use opengl renderer except with GLArea + self.t_.set(renderer='cairo') - self.t_.set_defaults(renderer='cairo') + if sys.byteorder == 'little': + self.rgb_order = 'BGRA' + else: + self.rgb_order = 'RGBA' + + elif self.wtype == 'opengl': + if not have_opengl: + raise ImageViewGtkError("Please install 'pyopengl' to use render: '%s'" % (render)) + # NOTE: See https://gitlab.gnome.org/GNOME/gtk/issues/1270 for + # an issue regarding a buggy GLX/Mesa driver for X11 on Linux; + # if you experience non-GL widgets flashing when using the + # opengl renderer then try setting the following environment + # variable: + # GDK_GL=software-draw-surface + # + imgwin = Gtk.GLArea() + imgwin.set_required_version(req.major, req.minor) + imgwin.set_has_depth_buffer(False) + imgwin.set_has_stencil_buffer(False) + imgwin.set_auto_render(False) + + imgwin.connect('realize', self.on_realize_cb) + imgwin.connect('render', self.on_render_cb) + imgwin.connect("resize", self.configure_glarea_cb) + + renderers = ['opengl'] + # currently can only use opengl renderer with GLArea + #self.t_.set_defaults(renderer='opengl') + self.t_.set(renderer='opengl') + self.rgb_order = 'RGBA' + + # we replace some transforms in the catalog for OpenGL rendering + self.tform = get_transforms(self) - # create our default double-buffered surface area that we copy - # to the widget - rect = self.imgwin.get_allocation() - x, y, wd, ht = rect.x, rect.y, rect.width, rect.height - arr = np.zeros((ht, wd, 4), dtype=np.uint8) - stride = cairo.ImageSurface.format_stride_for_width(cairo.FORMAT_ARGB32, - wd) - self.surface = cairo.ImageSurface.create_for_data(arr, - cairo.FORMAT_ARGB32, - wd, ht, stride) - if sys.byteorder == 'little': - self.rgb_order = 'BGRA' else: - self.rgb_order = 'ARGB' + raise ImageViewGtkError("Undefined render type: '%s'" % (render)) + self.imgwin = imgwin + self.imgwin.show_all() # see reschedule_redraw() method self._defer_task = GtkHelp.Timer() @@ -70,7 +110,6 @@ self.renderer = None # Pick a renderer that can work with us - renderers = ['cairo', 'agg', 'pil', 'opencv'] preferred = self.t_['renderer'] if preferred in renderers: renderers.remove(preferred) @@ -81,6 +120,10 @@ return self.imgwin def choose_renderer(self, name): + if self.wtype == 'opengl': + if name != 'opengl': + raise ValueError("Only possible renderer for this widget " + "is 'opengl'") klass = render.get_render_class(name) self.renderer = klass(self) @@ -108,8 +151,8 @@ return pixbuf def get_plain_image_as_widget(self): - """Used for generating thumbnails. Does not include overlaid - graphics. + """Returns a Gtk.Image widget of the images displayed. + Does not include overlaid graphics. """ pixbuf = self.get_plain_image_as_pixbuf() image = Gtk.Image() @@ -118,9 +161,7 @@ return image def save_plain_image_as_file(self, filepath, format='png', quality=90): - """Used for generating thumbnails. Does not include overlaid - graphics. - """ + """Does not include overlaid graphics.""" pixbuf = self.get_plain_image_as_pixbuf() options, values = [], [] if format == 'jpeg': @@ -128,23 +169,6 @@ values.append(str(quality)) pixbuf.savev(filepath, format, options, values) - def get_rgb_image_as_pixbuf(self): - arr8 = self.renderer.get_surface_as_array(order='RGB') - daht, dawd = arr8.shape[:2] - rgb_buf = arr8.tobytes(order='C') - pixbuf = GtkHelp.pixbuf_new_from_data(rgb_buf, GdkPixbuf.Colorspace.RGB, - False, 8, dawd, daht, dawd * 3) - - return pixbuf - - def save_rgb_image_as_file(self, filepath, format='png', quality=90): - pixbuf = self.get_rgb_image_as_pixbuf() - options, values = [], [] - if format == 'jpeg': - options.append('quality') - values.append(str(quality)) - pixbuf.savev(filepath, format, options, values) - def reschedule_redraw(self, time_sec): self._defer_task.stop() self._defer_task.start(time_sec) @@ -170,9 +194,13 @@ cairo.FORMAT_ARGB32, dawd, daht, stride) - def update_image(self): - if self.surface is None: - # window is not mapped/configured yet + def update_widget(self): + if self.imgwin is None: + return + + self.logger.debug("updating window") + if self.wtype == 'opengl': + self.imgwin.queue_render() return self._renderer_to_surface() @@ -188,7 +216,10 @@ win.process_updates(True) def draw_event(self, widget, cr): - self.logger.debug("updating window from surface") + if self.surface is None: + # window is not mapped/configured yet + return + #self.logger.debug("updating window from surface") # redraw the screen from backing surface cr.set_source_surface(self.surface, 0, 0) @@ -197,9 +228,12 @@ return False def configure_window(self, width, height): - self.configure_surface(width, height) + self.logger.debug("window size reconfigured to %dx%d" % ( + width, height)) + self.configure(width, height) def configure_event(self, widget, event): + # NOTE: this callback is only for the DrawingArea widget rect = widget.get_allocation() x, y, width, height = rect.x, rect.y, rect.width, rect.height @@ -218,6 +252,29 @@ self.configure_window(width, height) return True + def configure_glarea_cb(self, widget, width, height): + # NOTE: this callback is only for the GLArea (OpenGL) widget + self.logger.debug("allocation is %dx%d" % (width, height)) + self.configure_window(width, height) + return True + + def make_context_current(self): + ctx = self.imgwin.get_context() + if ctx is not None: + ctx.make_current() + return ctx + + def on_realize_cb(self, area): + # NOTE: this callback is only for the GLArea (OpenGL) widget + self.renderer.gl_initialize() + + def on_render_cb(self, area, ctx): + # NOTE: this callback is only for the GLArea (OpenGL) widget + self.renderer.gl_paint() + + def prepare_image(self, cvs_img, cache, whence): + self.renderer.prepare_image(cvs_img, cache, whence) + def set_cursor(self, cursor): win = self.imgwin.get_window() if win is not None: @@ -264,12 +321,9 @@ self.imgwin.grab_focus() -class ImageViewEvent(ImageViewGtk): - - def __init__(self, logger=None, rgbmap=None, settings=None): - ImageViewGtk.__init__(self, logger=logger, rgbmap=rgbmap, - settings=settings) +class GtkEventMixin(object): + def __init__(self): imgwin = self.imgwin imgwin.set_can_focus(True) imgwin.connect("map_event", self.map_event) @@ -404,7 +458,8 @@ return self._keytbl def map_event(self, widget, event): - super(ImageViewZoom, self).configure_event(widget, event) + #super(GtkEventMixin, self).configure_event(widget, event) + self.configure_event(widget, event) return self.make_callback('map') def focus_event(self, widget, event, hasFocus): @@ -431,7 +486,7 @@ keyname = Gdk.keyval_name(event.keyval) keyname = self.transkey(keyname) self.logger.debug("key press event, key=%s" % (keyname)) - return self.make_ui_callback('key-press', keyname) + return self.make_ui_callback_viewer(self, 'key-press', keyname) def key_release_event(self, widget, event): #Gdk.keyboard_ungrab(event.time) @@ -439,7 +494,7 @@ keyname = Gdk.keyval_name(event.keyval) keyname = self.transkey(keyname) self.logger.debug("key release event, key=%s" % (keyname)) - return self.make_ui_callback('key-release', keyname) + return self.make_ui_callback_viewer(self, 'key-release', keyname) def button_press_event(self, widget, event): # event.button, event.x, event.y @@ -454,7 +509,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-press', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-press', button, + data_x, data_y) def button_release_event(self, widget, event): # event.button, event.x, event.y @@ -469,7 +525,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-release', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-release', button, + data_x, data_y) def motion_notify_event(self, widget, event): button = 0 @@ -490,7 +547,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('motion', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'motion', button, + data_x, data_y) def scroll_event(self, widget, event): # event.button, event.x, event.y @@ -513,8 +571,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('scroll', direction, degrees, - data_x, data_y) + return self.make_ui_callback_viewer(self, 'scroll', direction, degrees, + data_x, data_y) def drag_drop_cb(self, widget, context, x, y, time): self.logger.debug("drag_drop_cb initiated") @@ -569,12 +627,21 @@ self.logger.debug("dropped filename(s): %s" % (str(paths))) if len(paths) > 0: - self.make_ui_callback('drag-drop', paths) + self.make_ui_callback_viewer(self, 'drag-drop', paths) return True -class ImageViewZoom(Mixins.UIMixin, ImageViewEvent): +class ImageViewEvent(Mixins.UIMixin, GtkEventMixin, ImageViewGtk): + + def __init__(self, logger=None, rgbmap=None, settings=None, render=None): + ImageViewGtk.__init__(self, logger=logger, rgbmap=rgbmap, + settings=settings, render=render) + Mixins.UIMixin.__init__(self) + GtkEventMixin.__init__(self) + + +class ImageViewZoom(ImageViewEvent): # class variables for binding map and bindings can be set bindmapClass = Bindings.BindingMapper @@ -589,12 +656,11 @@ cls.bindmapClass = klass def __init__(self, logger=None, rgbmap=None, settings=None, - bindmap=None, bindings=None): + render=None, bindmap=None, bindings=None): ImageViewEvent.__init__(self, logger=logger, rgbmap=rgbmap, - settings=settings) - Mixins.UIMixin.__init__(self) + settings=settings, render=render) - self.ui_set_active(True) + self.ui_set_active(True, viewer=self) if bindmap is None: bindmap = ImageViewZoom.bindmapClass(self.logger) @@ -620,9 +686,9 @@ """A Ginga viewer for viewing 2D slices of image data.""" def __init__(self, logger=None, settings=None, rgbmap=None, - bindmap=None, bindings=None): + render=None, bindmap=None, bindings=None): ImageViewZoom.__init__(self, logger=logger, settings=settings, - rgbmap=rgbmap, + rgbmap=rgbmap, render=render, bindmap=bindmap, bindings=bindings) # Needed for UIMixin to propagate events correctly diff -Nru ginga-3.0.0/ginga/gtk3w/setup_package.py ginga-3.1.0/ginga/gtk3w/setup_package.py --- ginga-3.0.0/ginga/gtk3w/setup_package.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/gtk3w/setup_package.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst - - -def get_package_data(): - return {'ginga.gtk3w': ['gtk_css']} diff -Nru ginga-3.0.0/ginga/gtk3w/Widgets.py ginga-3.1.0/ginga/gtk3w/Widgets.py --- ginga-3.0.0/ginga/gtk3w/Widgets.py 2019-09-03 23:54:06.000000000 +0000 +++ ginga-3.1.0/ginga/gtk3w/Widgets.py 2020-07-20 21:06:00.000000000 +0000 @@ -9,7 +9,7 @@ from ginga.gtk3w import GtkHelp -from ginga.misc import Callback, Bunch, LineHistory +from ginga.misc import Callback, Bunch, Settings, LineHistory from functools import reduce from gi.repository import Gtk @@ -32,7 +32,7 @@ __all__ = ['WidgetError', 'WidgetBase', 'TextEntry', 'TextEntrySet', 'TextArea', 'Label', 'Button', 'ComboBox', - 'SpinBox', 'Slider', 'ScrollBar', 'CheckBox', 'ToggleButton', + 'SpinBox', 'Slider', 'Dial', 'ScrollBar', 'CheckBox', 'ToggleButton', 'RadioButton', 'Image', 'ProgressBar', 'StatusBar', 'TreeView', 'WebView', 'ContainerBase', 'Box', 'HBox', 'VBox', 'Frame', 'Expander', 'TabWidget', 'StackWidget', 'MDIWidget', 'ScrollArea', @@ -103,6 +103,7 @@ def delete(self): self.widget.destroy() + self.widget = None def show(self): # self.widget.show() @@ -571,6 +572,39 @@ adj.configure(minval, minval, maxval, incr_value, incr_value, 0) +class Dial(WidgetBase): + def __init__(self, dtype=float, wrap=False, track=False): + super(Dial, self).__init__() + + w = GtkHelp.ValueDial() + self.widget = w + + w.draw_value = False + w.wrap = wrap + w.set_tracking(track) + w.connect('value-changed', self._cb_redirect) + self.dtype = dtype + + self.enable_callback('value-changed') + + def _cb_redirect(self, dial, val): + ext_val = self.dtype(val) + self.make_callback('value-changed', ext_val) + + def get_value(self): + int_val = self.widget.get_value() + return self.dtype(int_val) + + def set_value(self, val): + self.widget.set_value(val) + + def set_tracking(self, tf): + self.widget.set_tracking(tf) + + def set_limits(self, minval, maxval, incr_value=1): + self.widget.set_limits(minval, maxval, incr_value) + + class ScrollBar(WidgetBase): def __init__(self, orientation='horizontal'): super(ScrollBar, self).__init__() @@ -1982,13 +2016,17 @@ class Application(Callback.Callbacks): - def __init__(self, logger=None): + def __init__(self, logger=None, settings=None): global _app super(Application, self).__init__() self.logger = logger - self.window_list = [] + if settings is None: + settings = Settings.SettingGroup(logger=self.logger) + self.settings = settings + self.settings.add_defaults(font_scaling_factor=None) + self.window_list = [] self.window_dict = {} self.wincnt = 0 @@ -2004,8 +2042,10 @@ self.screen_res = screen.get_resolution() - # hack for Gtk--scale fonts on HiDPI displays - scale = self.screen_res / 72.0 + scale = self.settings.get('font_scaling_factor', None) + if scale is None: + # hack for Gtk--scale fonts on HiDPI displays + scale = self.screen_res / 72.0 self.logger.debug("setting default font_scaling_factor={}".format(scale)) from ginga.fonts import font_asst font_asst.default_scaling_factor = scale diff -Nru ginga-3.0.0/ginga/gw/Desktop.py ginga-3.1.0/ginga/gw/Desktop.py --- ginga-3.0.0/ginga/gw/Desktop.py 2019-08-26 19:08:48.000000000 +0000 +++ ginga-3.1.0/ginga/gw/Desktop.py 2020-07-20 21:18:19.000000000 +0000 @@ -6,9 +6,11 @@ # import time import math +import os.path from ginga.misc import Bunch, Callback from ginga.gw import Widgets, Viewers +from ginga.util import json class Desktop(Callback.Callbacks): @@ -22,6 +24,7 @@ self.tab = Bunch.caselessDict() self.tabcount = 0 self.workspace = Bunch.caselessDict() + self.default_ws_name = None self.toplevels = [] self.node = {} @@ -75,6 +78,11 @@ def get_nb(self, name): return self.workspace[name].nb + def get_default_ws(self): + if self.default_ws_name is not None: + return self.get_ws(self.default_ws_name) + return None + def get_size(self, widget): return widget.get_size() @@ -349,6 +357,8 @@ use_toolbar=params.use_toolbar) widget = ws.widget # debug(widget) + if params.get('default', False): + self.default_ws_name = params.name # If a title was passed as a parameter, then make a frame to # wrap the widget using the title. @@ -642,31 +652,41 @@ if layout is None: layout = self.layout - import pprint self.record_sizes() + _n, ext = os.path.splitext(lo_file) # write layout with open(lo_file, 'w') as out_f: - pprint.pprint(layout, out_f) + if ext.lower() == '.json': + out_f.write(json.dumps(layout, indent=2)) + else: + # older, python format + import pprint + pprint.pprint(layout, out_f) def read_layout_conf(self, lo_file): - import ast - # read layout with open(lo_file, 'r') as in_f: buf = in_f.read() - layout = ast.literal_eval(buf) + _n, ext = os.path.splitext(lo_file) + if ext.lower() == '.json': + layout = json.loads(buf) + else: + # older, python format + import ast + layout = ast.literal_eval(buf) return layout def build_desktop(self, layout, lo_file=None, widget_dict=None): - if lo_file is not None: + if lo_file is not None and os.path.exists(lo_file): alt_layout = layout try: layout = self.read_layout_conf(lo_file) except Exception as e: - self.logger.info("Error reading saved layout: %s" % (str(e))) + self.logger.warning("Error reading saved layout: %s" % (str(e))) + self.logger.info("reverting to default layout") layout = alt_layout return self.make_desktop(layout, widget_dict=widget_dict) diff -Nru ginga-3.0.0/ginga/gw/PluginManager.py ginga-3.1.0/ginga/gw/PluginManager.py --- ginga-3.0.0/ginga/gw/PluginManager.py 2019-03-12 22:15:40.000000000 +0000 +++ ginga-3.1.0/ginga/gw/PluginManager.py 2020-07-20 21:06:00.000000000 +0000 @@ -423,6 +423,7 @@ topw.add_callback('close', lambda *args: self.deactivate(p_info.name)) topw.resize(wd, ht) + topw.set_title(p_info.tabname) topw.set_widget(vbox) p_info.widget = topw p_info.is_toplevel = True diff -Nru ginga-3.0.0/ginga/gw/Widgets.py ginga-3.1.0/ginga/gw/Widgets.py --- ginga-3.0.0/ginga/gw/Widgets.py 2019-08-03 02:20:19.000000000 +0000 +++ ginga-3.1.0/ginga/gw/Widgets.py 2020-07-08 20:09:29.000000000 +0000 @@ -16,20 +16,24 @@ # MODULE FUNCTIONS -def get_orientation(container): +def get_orientation(container, aspect=1.0): if not hasattr(container, 'size'): return 'vertical' (wd, ht) = container.size # wd, ht = container.get_size() # print('container size is %dx%d' % (wd, ht)) - if wd < ht: + if ht == 0: + return 'horizontal' if wd > 0 else 'vertical' + calc_aspect = wd / ht + if calc_aspect <= aspect: return 'vertical' else: return 'horizontal' -def get_oriented_box(container, scrolled=True, fill=False): - orientation = get_orientation(container) +def get_oriented_box(container, scrolled=True, fill=False, + aspect=2.0): + orientation = get_orientation(container, aspect=aspect) if orientation == 'vertical': box1 = VBox() # noqa Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/ginga/icons/ginga.fits and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/ginga/icons/ginga.fits differ Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/ginga/icons/Ginga.icns and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/ginga/icons/Ginga.icns differ Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/ginga/icons/ginga-splash.ppm and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/ginga/icons/ginga-splash.ppm differ diff -Nru ginga-3.0.0/ginga/icons/setup_package.py ginga-3.1.0/ginga/icons/setup_package.py --- ginga-3.0.0/ginga/icons/setup_package.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/icons/setup_package.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst - - -def get_package_data(): - return {'ginga.icons': ['*.ppm', '*.png']} diff -Nru ginga-3.0.0/ginga/ImageView.py ginga-3.1.0/ginga/ImageView.py --- ginga-3.0.0/ginga/ImageView.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/ImageView.py 2020-07-20 21:06:00.000000000 +0000 @@ -14,6 +14,7 @@ import sys import traceback import time +import uuid import numpy as np @@ -23,7 +24,7 @@ from ginga import colors, trcalc from ginga.canvas import coordmap, transform from ginga.canvas.types.layer import DrawingCanvas -from ginga.util import rgb_cms, addons +from ginga.util import addons, vip __all__ = ['ImageViewBase'] @@ -105,7 +106,8 @@ self.renderer = None # for debugging - self.name = str(self) + self.viewer_id = str(uuid.uuid4()) + self.name = self.viewer_id # Initialize RGBMap rgbmap.add_callback('changed', self.rgbmap_cb) @@ -191,9 +193,7 @@ icc_black_point_compensation=False) self.t_.add_defaults(**d) for key in d: - # Note: transform_cb will redraw enough to pick up - # ICC profile change - self.t_.get_setting(key).add_callback('set', self.transform_cb) + self.t_.get_setting(key).add_callback('set', self.icc_profile_cb) # viewer profile support self.use_image_profile = False @@ -211,27 +211,19 @@ klass = AutoCuts.get_autocuts(name) self.autocuts = klass(self.logger) + self.vip = vip.ViewerImageProxy(self) + # PRIVATE IMPLEMENTATION STATE - # image window width and height (see set_window_dimensions()) + # flag indicating whether our size has been set + self._imgwin_set = False self._imgwin_wd = 0 self._imgwin_ht = 0 - self._imgwin_set = False # desired size # on gtk, this seems to set a boundary on the lower size, so we # default to very small, set it larger with set_desired_size() #self._desired_size = (300, 300) self._desired_size = (1, 1) - # center (and reference) pixel in the screen image (in pixel coords) - self._ctr_x = 1 - self._ctr_y = 1 - # data indexes at the reference pixel (in data coords) - self._org_x = 0 - self._org_y = 0 - self._org_z = 0 - # offset from pan position (at center) in this array - self._org_xoff = 0 - self._org_yoff = 0 # viewer window backend has its canvas origin (0, 0) in upper left self.origin_upper = True @@ -239,28 +231,7 @@ # (pixels are centered on the coordinate) self.data_off = 0.5 - # Origin in the data array of what is currently displayed (LL, UR) - self._org_x1 = 0 - self._org_y1 = 0 - self._org_x2 = 0 - self._org_y2 = 0 - # offsets in the screen image for drawing (in screen coords) - self._dst_x = 0 - self._dst_y = 0 self._invert_y = True - self._self_scaling = False - # offsets in the screen image (in data coords) - self._off_x = 0 - self._off_y = 0 - - # actual scale factors produced from desired ones - self._org_scale_x = 1.0 - self._org_scale_y = 1.0 - self._org_scale_z = 1.0 - - self._rgbarr = None - self._rgbarr2 = None - self._rgbobj = None # optimization of redrawing self.defer_redraw = self.t_.get('defer_redraw', True) @@ -297,7 +268,7 @@ self.canvas.initialize(None, self, self.logger) self.canvas.add_callback('modified', self.canvas_changed_cb) self.canvas.set_surface(self) - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self) # private canvas for drawing self.private_canvas = self.canvas @@ -384,20 +355,25 @@ The height of the window in pixels. """ - self._imgwin_wd = int(width) - self._imgwin_ht = int(height) - self._ctr_x = width // 2 - self._ctr_y = height // 2 + width, height = int(width), int(height) + self._imgwin_wd = width + self._imgwin_ht = height self.logger.debug("widget resized to %dx%d" % (width, height)) + self.renderer.resize((width, height)) + self.make_callback('configure', width, height) - self.redraw(whence=0) def configure(self, width, height): """See :meth:`set_window_size`.""" self._imgwin_set = True self.set_window_size(width, height) + def configure_surface(self, width, height): + """See :meth:`configure`.""" + # legacy API--to be deprecated + self.configure(width, height) + def set_desired_size(self, width, height): """See :meth:`set_window_size`.""" self._desired_size = (width, height) @@ -424,8 +400,6 @@ Window size in the form of ``(width, height)``. """ - ## if not self._imgwin_set: - ## raise ImageViewError("Dimensions of actual window are not yet determined") return (self._imgwin_wd, self._imgwin_ht) def get_dims(self, data): @@ -441,20 +415,6 @@ height, width = data.shape[:2] return (width, height) - def get_data_size(self): - """Get the dimensions of the image currently being displayed. - - Returns - ------- - size : tuple - Image dimensions in the form of ``(width, height)``. - - """ - image = self.get_image() - if image is None: - raise ImageViewNoDataError("No data found") - return image.get_size() - def get_settings(self): """Get the settings used by this instance. @@ -477,6 +437,17 @@ """ return self.logger + def get_vip(self): + """Get the ViewerImageProxy object used by this instance. + + Returns + ------- + vip : `~ginga.util.vip.ViewerImageProxy` + A ViewerImageProxy object. + + """ + return self.vip + def set_renderer(self, renderer): """Set and initialize the renderer used by this instance. """ @@ -512,31 +483,31 @@ canvas.initialize(None, self, self.logger) canvas.add_callback('modified', self.canvas_changed_cb) canvas.set_surface(self) - canvas.ui_set_active(True) + canvas.ui_set_active(True, viewer=self) self._imgobj = None # private canvas set? if private_canvas is not None: self.private_canvas = private_canvas + self.initialize_private_canvas(self.private_canvas) if private_canvas != canvas: private_canvas.set_surface(self) - private_canvas.ui_set_active(True) + private_canvas.ui_set_active(True, viewer=self) private_canvas.add_callback('modified', self.canvas_changed_cb) # sanity check that we have a private canvas, and if not, # set it to the "advertised" canvas if self.private_canvas is None: self.private_canvas = canvas + self.initialize_private_canvas(self.private_canvas) # make sure private canvas has our non-private one added if (self.private_canvas != self.canvas) and ( not self.private_canvas.has_object(canvas)): self.private_canvas.add(canvas) - self.initialize_private_canvas(self.private_canvas) - def get_private_canvas(self): """Get the private canvas object used by this instance. @@ -678,7 +649,7 @@ def rgbmap_cb(self, rgbmap): """Handle callback for when RGB map has changed.""" self.logger.debug("RGB map has changed.") - self.redraw(whence=2) + self.renderer.rgbmap_change(rgbmap) def get_rgbmap(self): """Get the RGB map object used by this instance. @@ -705,7 +676,8 @@ t_ = rgbmap.get_settings() t_.share_settings(self.t_, keylist=rgbmap.settings_keys) rgbmap.add_callback('changed', self.rgbmap_cb) - self.redraw(whence=2) + + self.renderer.rgbmap_change(rgbmap) def get_image(self): """Get the image currently being displayed. @@ -717,7 +689,7 @@ """ if self._imgobj is not None: - # quick optomization + # quick optimization return self._imgobj.get_image() canvas_img = self.get_canvas_image() @@ -746,17 +718,11 @@ except KeyError: # add a normalized image item to this canvas if we don't # have one already--then just keep reusing it - NormImage = self.canvas.getDrawClass('normimage') - interp = self.t_.get('interpolation', 'basic') - - # previous choice might not be available if preferences - # were saved when opencv was being used (and not used now) - # --if so, default to "basic" - if interp not in trcalc.interpolation_methods: - interp = 'basic' + NormImage = self.canvas.get_draw_class('normimage') self._imgobj = NormImage(0, 0, None, alpha=1.0, - interpolation=interp) + interpolation=None) + self._imgobj.is_data = True self._imgobj.add_callback('image-set', self._image_set_cb) return self._imgobj @@ -787,6 +753,13 @@ with self.suppress_redraw: + # update viewer limits + wd, ht = image.get_size() + limits = ((-self.data_off, -self.data_off), + (float(wd - self.data_off), + float(ht - self.data_off))) + self.t_.set(limits=limits) + # this line should force the callback of _image_set_cb() canvas_img.set_image(image) @@ -796,17 +769,17 @@ except KeyError: self.canvas.add(canvas_img, tag=self._canvas_img_tag) - #self.logger.debug("adding image to canvas %s" % self.canvas) # move image to bottom of layers self.canvas.lower_object(canvas_img) - #self.canvas.update_canvas(whence=0) + self.canvas.update_canvas(whence=0) # for compatibility with other viewers set_dataobj = set_image def _image_set_cb(self, canvas_img, image): + try: self.apply_profile_or_settings(image) @@ -894,8 +867,6 @@ if self.use_image_profile: self.checkpoint_profile() - self.redraw(whence=0) - def apply_profile(self, profile, keylist=None): """Apply a profile to the viewer. @@ -911,7 +882,6 @@ with self.suppress_redraw: profile.copy_settings(self.t_, keylist=keylist, callback=True) - self.redraw(whence=0) def capture_profile(self, profile): self.t_.copy_settings(profile) @@ -963,6 +933,13 @@ with self.suppress_redraw: + # update viewer limits + wd, ht = image.get_size() + limits = ((-self.data_off, -self.data_off), + (float(wd - self.data_off), + float(ht - self.data_off))) + self.t_.set(limits=limits) + canvas_img.reset_optimize() # Per issue #111, zoom and pan and cuts probably should @@ -995,7 +972,7 @@ tb_str = "Traceback information unavailable." self.logger.error(tb_str) - self.canvas.update_canvas(whence=0) + self.canvas.update_canvas(whence=1) def set_data(self, data, metadata=None): """Set an image to be displayed by providing raw data. @@ -1043,8 +1020,17 @@ Parameters ---------- - whence - See :meth:`get_rgb_object`. + whence : int or float + Optimization flag that reduces the time to refresh the + viewer by only recalculating what is necessary: + + 0: New image, pan/scale has changed + 1: Cut levels or similar has changed + 2: Color mapping has changed + 2.3: ICC profile has changed + 2.5: Transforms have changed + 2.6: Rotation has changed + 3: Graphical overlays have changed """ with self._defer_lock: @@ -1282,15 +1268,19 @@ Parameters ---------- whence - See :meth:`get_rgb_object`. + See :meth:`redraw`. """ try: time_start = time.time() + self.renderer.initialize() + self.redraw_data(whence=whence) + self.renderer.finalize() + # finally update the window drawable from the offscreen surface - self.update_image() + self.update_widget() time_done = time.time() time_delta = time_start - self.time_last_redraw @@ -1321,22 +1311,21 @@ Parameters ---------- whence - See :meth:`get_rgb_object`. + See :meth:`redraw`. """ if not self._imgwin_set: # window has not been realized yet return - if not self._self_scaling: - rgbobj = self.get_rgb_object(whence=whence) - self.renderer.render_image(rgbobj, self._dst_x, self._dst_y) + self._whence = whence + self.renderer.render_whence(whence) self.private_canvas.draw(self) self.make_callback('redraw', whence) - if whence < 2: + if whence < 3: self.check_cursor_location() def check_cursor_location(self): @@ -1362,77 +1351,22 @@ return data_x, data_y def getwin_array(self, order='RGB', alpha=1.0, dtype=None): - """Get Numpy data array for display window. - - Parameters - ---------- - order : str - The desired order of RGB color layers. - - alpha : float - Opacity. - - dtype : numpy dtype - Numpy data type desired; defaults to rgb mapper setting. - - Returns - ------- - outarr : ndarray - Numpy data array for display window. - - """ - order = order.upper() - depth = len(order) - - if dtype is None: - rgbmap = self.get_rgbmap() - dtype = rgbmap.dtype - - # Prepare data array for rendering - data = self._rgbobj.get_array(order, dtype=dtype) - - # NOTE [A] - height, width, depth = data.shape - - imgwin_wd, imgwin_ht = self.get_window_size() - - # create RGBA image array with the background color for output - r, g, b = self.img_bg - outarr = trcalc.make_filled_array((imgwin_ht, imgwin_wd, len(order)), - dtype, order, r, g, b, alpha) - - # overlay our data - trcalc.overlay_image(outarr, (self._dst_x, self._dst_y), - data, dst_order=order, src_order=order, - flipy=False, fill=False, copy=False) - - return outarr + return self.renderer.getwin_array(order=order, alpha=alpha, + dtype=dtype) def getwin_buffer(self, order='RGB', alpha=1.0, dtype=None): """Same as :meth:`getwin_array`, but with the output array converted to C-order Python bytes. """ - outarr = self.getwin_array(order=order, alpha=alpha, dtype=dtype) + outarr = self.renderer.getwin_array(order=order, alpha=alpha, + dtype=dtype) if not hasattr(outarr, 'tobytes'): # older versions of numpy return outarr.tostring(order='C') return outarr.tobytes(order='C') - def get_datarect(self): - """Get the approximate bounding box of the displayed image. - - Returns - ------- - rect : tuple - Bounding box in data coordinates in the form of - ``(x1, y1, x2, y2)``. - - """ - x1, y1, x2, y2 = self._org_x1, self._org_y1, self._org_x2, self._org_y2 - return (x1, y1, x2, y2) - def get_limits(self, coord='data'): """Get the bounding box of the viewer extents. @@ -1446,24 +1380,15 @@ limits = self.t_['limits'] if limits is None: - # No user defined limits. If there is an image loaded - # use its dimensions as the limits - image = self.get_image() - if image is not None: - wd, ht = image.get_size() - limits = ((self.data_off, self.data_off), - (float(wd - 1 + self.data_off), - float(ht - 1 + self.data_off))) - + # No user defined limits. + # Calculate limits based on plotted points, if any + canvas = self.get_canvas() + pts = canvas.get_points() + if len(pts) > 0: + limits = trcalc.get_bounds(pts) else: - # Calculate limits based on plotted points, if any - canvas = self.get_canvas() - pts = canvas.get_points() - if len(pts) > 0: - limits = trcalc.get_bounds(pts) - else: - # No limits found, go to default - limits = ((0.0, 0.0), (0.0, 0.0)) + # No limits found, go to default + limits = ((0.0, 0.0), (0.0, 0.0)) # convert to desired coordinates crdmap = self.get_coordmap(coord) @@ -1490,319 +1415,32 @@ self.t_.set(limits=limits) - def _set_limits_cb(self, setting, limits): - # TODO: deprecate this chained callback and have users just use - # 'set' callback for "limits" setting ? - self.make_callback('limits-set', limits) - - def get_rgb_object(self, whence=0): - """Create and return RGB slices representing the data - that should be rendered at the current zoom level and pan settings. - - Parameters - ---------- - whence : {0, 1, 2, 3} - Optimization flag that reduces the time to create - the RGB object by only recalculating what is necessary: - - 0. New image, pan/scale has changed, or rotation/transform - has changed; Recalculate everything - 1. Cut levels or similar has changed - 2. Color mapping has changed - 3. Graphical overlays have changed - - Returns - ------- - rgbobj : `~ginga.RGBMap.RGBPlanes` - RGB object. - - """ - time_start = t2 = t3 = time.time() - win_wd, win_ht = self.get_window_size() - order = self.get_rgb_order() - - if (whence <= 0.0) or (self._rgbarr is None): - # calculate dimensions of window RGB backing image - pan_x, pan_y = self.get_pan(coord='data')[:2] - scale_x, scale_y = self.get_scale_xy() - wd, ht = self._calc_bg_dimensions(scale_x, scale_y, - pan_x, pan_y, - win_wd, win_ht) - - # create backing image - depth = len(order) - rgbmap = self.get_rgbmap() - # make backing image with the background color - r, g, b = self.img_bg - rgba = trcalc.make_filled_array((ht, wd, depth), rgbmap.dtype, - order, r, g, b, 1.0) - - self._rgbarr = rgba - t2 = time.time() - - if (whence <= 2.0) or (self._rgbarr2 is None): - # Apply any RGB image overlays - self._rgbarr2 = np.copy(self._rgbarr) - self.overlay_images(self.private_canvas, self._rgbarr2, - whence=whence) - - # convert to output ICC profile, if one is specified - output_profile = self.t_.get('icc_output_profile', None) - working_profile = rgb_cms.working_profile - if (working_profile is not None) and (output_profile is not None): - self.convert_via_profile(self._rgbarr2, order, - working_profile, output_profile) - t3 = time.time() - - if (whence <= 2.5) or (self._rgbobj is None): - rotimg = self._rgbarr2 - - # Apply any viewing transformations or rotations - # if not applied earlier - rotimg = self.apply_transforms(rotimg, - self.t_['rot_deg']) - rotimg = np.ascontiguousarray(rotimg) - - self._rgbobj = RGBMap.RGBPlanes(rotimg, order) - - time_end = time.time() - ## self.logger.debug("times: total=%.4f" % ( - ## (time_end - time_start))) - self.logger.debug("times: t1=%.4f t2=%.4f t3=%.4f total=%.4f" % ( - t2 - time_start, t3 - t2, time_end - t3, - (time_end - time_start))) - - return self._rgbobj - - def _calc_bg_dimensions(self, scale_x, scale_y, - pan_x, pan_y, win_wd, win_ht): - """ - Parameters - ---------- - scale_x, scale_y : float - desired scale of viewer in each axis. - - pan_x, pan_y : float - pan position in data coordinates. - - win_wd, win_ht : int - window dimensions in pixels - """ - - # Sanity check on the scale - sx = float(win_wd) / scale_x - sy = float(win_ht) / scale_y - if (sx < 1.0) or (sy < 1.0): - #self.logger.warning("new scale would exceed max/min; scale unchanged") - raise ImageViewError("new scale would exceed pixel max; scale unchanged") - - # It is necessary to store these so that the get_pan_rect() - # (below) calculation can proceed - self._org_x, self._org_y = pan_x - self.data_off, pan_y - self.data_off - self._org_scale_x, self._org_scale_y = scale_x, scale_y - self._org_scale_z = (scale_x + scale_y) / 2.0 - - # calc minimum size of pixel image we will generate - # necessary to fit the window in the desired size - - # get the data points in the four corners - a, b = trcalc.get_bounds(self.get_pan_rect()) - - # determine bounding box - a1, b1 = a[:2] - a2, b2 = b[:2] - - # constrain to integer indexes - x1, y1, x2, y2 = int(a1), int(b1), int(np.round(a2)), int(np.round(b2)) - x1 = max(0, x1) - y1 = max(0, y1) - - self.logger.debug("approx area covered is %dx%d to %dx%d" % ( - x1, y1, x2, y2)) - - self._org_x1 = x1 - self._org_y1 = y1 - self._org_x2 = x2 - self._org_y2 = y2 - - # Make a square from the scaled cutout, with room to rotate - slop = 20 - side = int(math.sqrt(win_wd**2 + win_ht**2) + slop) - wd = ht = side - - # Find center of new array - ncx, ncy = wd // 2, ht // 2 - self._org_xoff, self._org_yoff = ncx, ncy - - return (wd, ht) - - def _reset_bbox(self): - """This function should only be called internally. It resets - the viewers bounding box based on changes to pan or scale. - """ - scale_x, scale_y = self.get_scale_xy() - pan_x, pan_y = self.get_pan(coord='data')[:2] - win_wd, win_ht = self.get_window_size() - # NOTE: need to set at least a minimum 1-pixel dimension on - # the window or we get a scale calculation exception. See github - # issue 431 - win_wd, win_ht = max(1, win_wd), max(1, win_ht) - - self._calc_bg_dimensions(scale_x, scale_y, - pan_x, pan_y, win_wd, win_ht) - - def apply_transforms(self, data, rot_deg): - """Apply transformations to the given data. - These include flip/swap X/Y, invert Y, and rotation. + def reset_limits(self): + """Reset the bounding box of the viewer extents. Parameters ---------- - data : ndarray - Data to be transformed. - - rot_deg : float - Rotate the data by the given degrees. - - Returns - ------- - data : ndarray - Transformed data. - + None """ - start_time = time.time() - - wd, ht = self.get_dims(data) - xoff, yoff = self._org_xoff, self._org_yoff - - # Do transforms as necessary - flip_x, flip_y = self.t_['flip_x'], self.t_['flip_y'] - swap_xy = self.t_['swap_xy'] - - data = trcalc.transform(data, flip_x=flip_x, flip_y=flip_y, - swap_xy=swap_xy) - if flip_y: - yoff = ht - yoff - if flip_x: - xoff = wd - xoff - if swap_xy: - xoff, yoff = yoff, xoff - - split_time = time.time() - self.logger.debug("reshape time %.3f sec" % ( - split_time - start_time)) - - # Rotate the image as necessary - if rot_deg != 0: - # This is the slowest part of the rendering--install the OpenCv or pyopencl - # packages to speed it up - data = np.ascontiguousarray(data) - data = trcalc.rotate_clip(data, -rot_deg, out=data, - logger=self.logger) + self.t_.set(limits=None) - split2_time = time.time() - - # apply other transforms - if self._invert_y: - # Flip Y for natural natural Y-axis inversion between FITS coords - # and screen coords - data = np.flipud(data) - - self.logger.debug("rotate time %.3f sec, total reshape %.3f sec" % ( - split2_time - split_time, split2_time - start_time)) - - # dimensions may have changed in transformations - wd, ht = self.get_dims(data) - - ctr_x, ctr_y = self._ctr_x, self._ctr_y - dst_x, dst_y = ctr_x - xoff, ctr_y - (ht - yoff) - self._dst_x, self._dst_y = dst_x, dst_y - self.logger.debug("ctr=%d,%d off=%d,%d dst=%d,%d cutout=%dx%d" % ( - ctr_x, ctr_y, xoff, yoff, dst_x, dst_y, wd, ht)) - - win_wd, win_ht = self.get_window_size() - self.logger.debug("win=%d,%d coverage=%d,%d" % ( - win_wd, win_ht, dst_x + wd, dst_y + ht)) - - return data - - def overlay_images(self, canvas, data, whence=0.0): - """Overlay data from any canvas image objects. - - Parameters - ---------- - canvas : `~ginga.canvas.types.layer.DrawingCanvas` - Canvas containing possible images to overlay. - - data : ndarray - Output array on which to overlay image data. - - whence - See :meth:`get_rgb_object`. - - """ - #if not canvas.is_compound(): - if not hasattr(canvas, 'objects'): - return - - for obj in canvas.get_objects(): - if hasattr(obj, 'draw_image'): - obj.draw_image(self, data, whence=whence) - elif obj.is_compound() and (obj != canvas): - self.overlay_images(obj, data, whence=whence) - - def convert_via_profile(self, data_np, order, inprof_name, outprof_name): - """Convert the given RGB data from the working ICC profile - to the output profile in-place. - - Parameters - ---------- - data_np : ndarray - RGB image data to be displayed. - - order : str - Order of channels in the data (e.g. "BGRA"). - - inprof_name, outprof_name : str - ICC profile names (see :func:`ginga.util.rgb_cms.get_profiles`). - - """ - # get rest of necessary conversion parameters - to_intent = self.t_.get('icc_output_intent', 'perceptual') - proofprof_name = self.t_.get('icc_proof_profile', None) - proof_intent = self.t_.get('icc_proof_intent', 'perceptual') - use_black_pt = self.t_.get('icc_black_point_compensation', False) - - try: - rgbobj = RGBMap.RGBPlanes(data_np, order) - arr_np = rgbobj.get_array('RGB') + def _set_limits_cb(self, setting, limits): + self.renderer.limits_change(limits) - arr = rgb_cms.convert_profile_fromto(arr_np, inprof_name, outprof_name, - to_intent=to_intent, - proof_name=proofprof_name, - proof_intent=proof_intent, - use_black_pt=use_black_pt, - logger=self.logger) - ri, gi, bi = rgbobj.get_order_indexes('RGB') - - out = data_np - out[..., ri] = arr[..., 0] - out[..., gi] = arr[..., 1] - out[..., bi] = arr[..., 2] + # TODO: deprecate this chained callback and have users just use + # 'set' callback for "limits" setting ? + self.make_callback('limits-set', limits) - self.logger.debug("Converted from '%s' to '%s' profile" % ( - inprof_name, outprof_name)) - - except Exception as e: - self.logger.warning("Error converting output from working profile: %s" % (str(e))) - # TODO: maybe should have a traceback here - self.logger.info("Output left unprofiled") + def icc_profile_cb(self, setting, value): + """Handle callback related to changes in output ICC profiles.""" + self.renderer.icc_profile_change() def get_data_pt(self, win_pt): """Similar to :meth:`get_data_xy`, except that it takes a single array of points. """ - return self.tform['data_to_native'].from_(win_pt) + return self.tform['mouse_to_data'].to_(win_pt) def get_data_xy(self, win_x, win_y, center=None): """Get the closest coordinates in the data array to those @@ -1829,7 +1467,7 @@ self.logger.warning("`center` keyword is ignored and will be deprecated") arr_pts = np.asarray((win_x, win_y)).T - return self.tform['data_to_native'].from_(arr_pts).T[:2] + return self.tform['mouse_to_data'].to_(arr_pts).T[:2] def offset_to_data(self, off_x, off_y, center=None): """Get the closest coordinates in the data array to those @@ -1912,6 +1550,20 @@ arr_pts = np.asarray((data_x, data_y)).T return self.tform['data_to_native'].to_(arr_pts).T[:2] + def get_data_size(self): + """Get the dimensions of the image currently being displayed. + + Returns + ------- + size : tuple + Image dimensions in the form of ``(width, height)``. + + """ + xy_mn, xy_mx = self.get_limits() + ht = abs(xy_mx[1] - xy_mn[1]) + wd = abs(xy_mx[0] - xy_mn[0]) + return (wd, ht) + def get_data_pct(self, xpct, ypct): """Calculate new data size for the given axis ratios. See :meth:`get_limits`. @@ -1927,11 +1579,8 @@ Scaled dimensions. """ - xy_mn, xy_mx = self.get_limits() - width = abs(xy_mx[0] - xy_mn[0]) - height = abs(xy_mx[1] - xy_mn[1]) - - x, y = int(float(xpct) * width), int(float(ypct) * height) + wd, ht = self.get_data_size() + x, y = int(float(xpct) * wd), int(float(ypct) * ht) return (x, y) def get_pan_rect(self): @@ -1947,11 +1596,53 @@ """ wd, ht = self.get_window_size() - #win_pts = np.asarray([(0, 0), (wd-1, 0), (wd-1, ht-1), (0, ht-1)]) win_pts = np.asarray([(0, 0), (wd, 0), (wd, ht), (0, ht)]) arr_pts = self.tform['data_to_window'].from_(win_pts) return arr_pts + def get_draw_rect(self): + """Get the coordinates in the actual data corresponding to the + area needed for drawing images for the current zoom level and pan. + Unlike get_pan_rect(), this includes areas outside of the + current viewport, but that might be viewed with a transformation + or rotation subsequently applied. + + Returns + ------- + points : list + Coordinates in the form of + ``[(x0, y0), (x1, y1), (x2, y2), (x3, y3)]`` + corresponding to the four corners. + + """ + wd, ht = self.get_window_size() + radius = int(np.ceil(math.sqrt(wd**2 + ht**2) * 0.5)) + ctr_x, ctr_y = self.get_center()[:2] + win_pts = np.asarray([(ctr_x - radius, ctr_y - radius), + (ctr_x + radius, ctr_y - radius), + (ctr_x + radius, ctr_y + radius), + (ctr_x - radius, ctr_y + radius)]) + arr_pts = self.tform['data_to_window'].from_(win_pts) + return arr_pts + + def get_datarect(self): + """Get the approximate LL and UR corners of the displayed image. + + Returns + ------- + rect : tuple + Bounding box in data coordinates in the form of + ``(x1, y1, x2, y2)``. + + """ + # get the data points in the four corners + a, b = trcalc.get_bounds(self.get_pan_rect()) + + # determine bounding box + x1, y1 = a[:2] + x2, y2 = b[:2] + return (x1, y1, x2, y2) + def get_data(self, data_x, data_y): """Get the data value at the given position. Indices are zero-based, as in Numpy. @@ -1964,19 +1655,9 @@ Returns ------- value - Data slice. - - Raises - ------ - ginga.ImageView.ImageViewNoDataError - Image not found. - + Data value. """ - image = self.get_image() - if image is not None: - return image.get_data_xy(data_x, data_y) - - raise ImageViewNoDataError("No image found") + return self.vip.get_data_xy(data_x, data_y) def get_pixel_distance(self, x1, y1, x2, y2): """Calculate distance between the given pixel positions. @@ -2007,11 +1688,6 @@ raise ImageViewError("window size undefined") # final sanity check on resulting output image size - if (win_wd * scale_x < 1) or (win_ht * scale_y < 1): - raise ValueError( - "resulting scale (%f, %f) would result in image size of " - "<1 in width or height" % (scale_x, scale_y)) - sx = float(win_wd) / scale_x sy = float(win_ht) / scale_y if (sx < 1.0) or (sy < 1.0): @@ -2019,6 +1695,21 @@ "resulting scale (%f, %f) would result in pixel size " "approaching window size" % (scale_x, scale_y)) + def _reset_bbox(self): + """This function should only be called internally. It resets + the viewers bounding box based on changes to pan or scale. + """ + scale_x, scale_y = self.get_scale_xy() + pan_x, pan_y = self.get_pan(coord='data')[:2] + win_wd, win_ht = self.get_window_size() + # NOTE: need to set at least a minimum 1-pixel dimension on + # the window or we get a scale calculation exception. See github + # issue 431 + win_wd, win_ht = max(1, win_wd), max(1, win_ht) + + self.renderer._confirm_pan_and_scale(scale_x, scale_y, + pan_x, pan_y, win_wd, win_ht) + def set_scale(self, scale, no_reset=False): """Scale the image in a channel. Also see :meth:`zoom_to`. @@ -2106,7 +1797,7 @@ zoomlevel = self.zoom.calc_level(value) self.t_.set(zoomlevel=zoomlevel) - self.redraw(whence=0) + self.renderer.scale(value) def get_scale(self): """Same as :meth:`get_scale_max`.""" @@ -2147,7 +1838,6 @@ Scale factors for X and Y, in that order. """ - #return (self._org_scale_x, self._org_scale_y) return self.t_['scale'][:2] def get_scale_base_xy(self): @@ -2248,11 +1938,7 @@ """ # calculate actual width of the limits/image, considering rotation - try: - xy_mn, xy_mx = self.get_limits() - - except ImageViewNoDataError: - return + xy_mn, xy_mx = self.get_limits() try: wwidth, wheight = self.get_window_size() @@ -2366,10 +2052,7 @@ def interpolation_change_cb(self, setting, value): """Handle callback related to changes in interpolation.""" - canvas_img = self.get_canvas_image() - canvas_img.interpolation = value - canvas_img.reset_optimize() - self.redraw(whence=0) + self.renderer.interpolation_change(value) def set_name(self, name): """Set viewer name.""" @@ -2414,8 +2097,8 @@ """ option = option.lower() - assert(option in self.autozoom_options), \ - ImageViewError("Bad autozoom option '%s': must be one of %s" % ( + if option not in self.autozoom_options: + raise ImageViewError("Bad autozoom option '%s': must be one of %s" % ( str(self.autozoom_options))) self.t_.set(autozoom=option) @@ -2461,7 +2144,7 @@ pan_x, pan_y = value[:2] self.logger.debug("pan set to %.2f,%.2f" % (pan_x, pan_y)) - self.redraw(whence=0) + self.renderer.pan(value) def get_pan(self, coord='data'): """Get pan positions. @@ -2524,12 +2207,7 @@ scale factors, where 1 is 100%. """ - try: - xy_mn, xy_mx = self.get_limits() - - except Exception as e: - self.logger.error("Can't compute limits: %s" % (str(e))) - return + xy_mn, xy_mx = self.get_limits() data_x = (xy_mn[0] + xy_mx[0]) * pct_x data_y = (xy_mn[1] + xy_mx[1]) * pct_y @@ -2545,20 +2223,10 @@ See :meth:`set_pan`. """ - try: - xy_mn, xy_mx = self.get_limits() - data_x = float(xy_mn[0] + xy_mx[0]) / 2.0 - data_y = float(xy_mn[1] + xy_mx[1]) / 2.0 + xy_mn, xy_mx = self.get_limits() - except ImageViewNoDataError: - # No data, so try to get center of any plotted objects - canvas = self.get_canvas() - try: - data_x, data_y = canvas.get_center_pt()[:2] - - except Exception as e: - self.logger.error("Can't compute center point: %s" % (str(e))) - return + data_x = (xy_mn[0] + xy_mx[0]) * 0.5 + data_y = (xy_mn[1] + xy_mx[1]) * 0.5 self.panset_xy(data_x, data_y, no_reset=no_reset) @@ -2656,6 +2324,7 @@ image = self.get_image() if image is None: + #image = self.vip return loval, hival = autocuts.calc_cut_levels(image) @@ -2683,13 +2352,13 @@ # Redo the auto levels #if self.t_['autocuts'] != 'off': - # NOTE: users seems to expect that when the auto cuts parameters + # NOTE: users seem to expect that when the auto cuts parameters # are changed that the cuts should be immediately recalculated self.auto_levels() def cut_levels_cb(self, setting, value): """Handle callback related to changes in cut levels.""" - self.redraw(whence=1) + self.renderer.levels_change(value) def enable_autocuts(self, option): """Set ``autocuts`` behavior. @@ -2783,13 +2452,10 @@ """Handle callback related to changes in transformations.""" self.make_callback('transform') - # whence=0 because need to calculate new extents for proper - # cutout for rotation (TODO: always make extents consider - # room for rotation) - whence = 0 - self.redraw(whence=whence) + state = (self.t_['flip_x'], self.t_['flip_y'], self.t_['swap_xy']) + self.renderer.transform_2d(state) - def copy_attributes(self, dst_fi, attrlist, share=False): + def copy_attributes(self, dst_fi, attrlist, share=False, whence=0): """Copy interesting attributes of our configuration to another image view. @@ -2801,7 +2467,8 @@ attrlist : list A list of attribute names to copy. They can be ``'transforms'``, ``'rotation'``, ``'cutlevels'``, ``'rgbmap'``, ``'zoom'``, - ``'pan'``, ``'autocuts'``. + ``'pan'``, ``'autocuts'``, ``'limits'``, ``'icc'`` or + ``'interpolation'``. share : bool If True, the designated settings will be shared, otherwise the @@ -2809,28 +2476,59 @@ """ # TODO: change API to just go with settings names? keylist = [] - if 'transforms' in attrlist: - keylist.extend(['flip_x', 'flip_y', 'swap_xy']) - - if 'rotation' in attrlist: - keylist.extend(['rot_deg']) - - if 'autocuts' in attrlist: - keylist.extend(['autocut_method', 'autocut_params']) + _whence = 3.0 - if 'cutlevels' in attrlist: - keylist.extend(['cuts']) - - if 'rgbmap' in attrlist: - keylist.extend(['color_algorithm', 'color_hashsize', - 'color_map', 'intensity_map', - 'color_array', 'shift_array']) + if whence <= 0.0: + if 'limits' in attrlist: + keylist.extend(['limits']) + _whence = min(_whence, 0.0) + + if 'zoom' in attrlist: + keylist.extend(['scale']) + _whence = min(_whence, 0.0) + + if 'interpolation' in attrlist: + keylist.extend(['interpolation']) + _whence = min(_whence, 0.0) + + if 'pan' in attrlist: + keylist.extend(['pan']) + _whence = min(_whence, 0.0) + + if whence <= 1.0: + if 'autocuts' in attrlist: + keylist.extend(['autocut_method', 'autocut_params']) + _whence = min(_whence, 1.0) + + if 'cutlevels' in attrlist: + keylist.extend(['cuts']) + _whence = min(_whence, 1.0) + + if whence <= 2.0: + if 'rgbmap' in attrlist: + keylist.extend(['color_algorithm', 'color_hashsize', + 'color_map', 'intensity_map', + 'color_array', 'shift_array']) + _whence = min(_whence, 2.0) - if 'zoom' in attrlist: - keylist.extend(['scale']) + if whence <= 2.3: + if 'icc' in attrlist: + keylist.extend(['icc_output_profile', 'icc_output_intent', + 'icc_proof_profile', 'icc_proof_intent', + 'icc_black_point_compensation']) + _whence = min(_whence, 2.3) + + if whence <= 2.5: + if 'transforms' in attrlist: + keylist.extend(['flip_x', 'flip_y', 'swap_xy']) + _whence = min(_whence, 2.5) + + if whence <= 2.6: + if 'rotation' in attrlist: + keylist.extend(['rot_deg']) + _whence = min(_whence, 2.6) - if 'pan' in attrlist: - keylist.extend(['pan']) + whence = max(_whence, whence) with dst_fi.suppress_redraw: if share: @@ -2839,7 +2537,6 @@ else: self.t_.copy_settings(dst_fi.get_settings(), keylist=keylist) - dst_fi.redraw(whence=0) def get_rotation(self): """Get image rotation angle. @@ -2870,11 +2567,7 @@ def rotation_change_cb(self, setting, value): """Handle callback related to changes in rotation angle.""" - # whence=0 because need to calculate new extents for proper - # cutout for rotation (TODO: always make extents consider - # room for rotation) - whence = 0 - self.redraw(whence=whence) + self.renderer.rotate_2d(value) def get_center(self): """Get image center. @@ -2885,7 +2578,8 @@ X and Y positions, in that order. """ - return (self._ctr_x, self._ctr_y) + center = self.renderer.get_center()[:2] + return center def get_rgb_order(self): """Get RGB order. @@ -2908,7 +2602,8 @@ X and Y positions, and rotation angle in degrees, in that order. """ - return (self._ctr_x, self._ctr_y, self.t_['rot_deg']) + win_x, win_y = self.get_center() + return (win_x, win_y, self.t_['rot_deg']) def enable_auto_orient(self, tf): """Set ``auto_orient`` behavior. @@ -2997,30 +2692,35 @@ self.tform = { 'window_to_native': trcat.WindowNativeTransform(self), 'cartesian_to_window': trcat.CartesianWindowTransform(self), - 'cartesian_to_native': (trcat.RotationTransform(self) + + 'cartesian_to_native': (trcat.RotationFlipTransform(self) + trcat.CartesianNativeTransform(self)), 'data_to_cartesian': (trcat.DataCartesianTransform(self) + trcat.ScaleTransform(self)), 'data_to_scrollbar': (trcat.DataCartesianTransform(self) + - trcat.RotationTransform(self)), + trcat.RotationFlipTransform(self)), + 'mouse_to_data': ( + trcat.InvertedTransform(trcat.DataCartesianTransform(self) + + trcat.ScaleTransform(self) + + trcat.RotationFlipTransform(self) + + trcat.CartesianNativeTransform(self))), 'data_to_window': (trcat.DataCartesianTransform(self) + trcat.ScaleTransform(self) + - trcat.RotationTransform(self) + + trcat.RotationFlipTransform(self) + trcat.CartesianWindowTransform(self)), 'data_to_percentage': (trcat.DataCartesianTransform(self) + trcat.ScaleTransform(self) + - trcat.RotationTransform(self) + + trcat.RotationFlipTransform(self) + trcat.CartesianWindowTransform(self) + trcat.WindowPercentageTransform(self)), 'data_to_native': (trcat.DataCartesianTransform(self) + trcat.ScaleTransform(self) + - trcat.RotationTransform(self) + + trcat.RotationFlipTransform(self) + trcat.CartesianNativeTransform(self)), 'wcs_to_data': trcat.WCSDataTransform(self), 'wcs_to_native': (trcat.WCSDataTransform(self) + trcat.DataCartesianTransform(self) + trcat.ScaleTransform(self) + - trcat.RotationTransform(self) + + trcat.RotationFlipTransform(self) + trcat.CartesianNativeTransform(self)), } @@ -3033,8 +2733,19 @@ RGB values, which should be between 0 and 1, inclusive. """ - self.img_bg = (r, g, b) - self.redraw(whence=0) + self.set_background((r, g, b)) + + def set_background(self, bg): + """Set the background color. + + Parameters + ---------- + bg : str or tuple of float + color name or tuple of floats, between 0 and 1, inclusive. + + """ + self.img_bg = colors.resolve_color(bg) + self.renderer.bg_change(self.img_bg) def get_bg(self): """Get the background color. @@ -3056,8 +2767,19 @@ RGB values, which should be between 0 and 1, inclusive. """ - self.img_fg = (r, g, b) - self.redraw(whence=3) + self.set_foreground((r, g, b)) + + def set_foreground(self, fg): + """Set the foreground color. + + Parameters + ---------- + fg : str or tuple of float + color name or tuple of floats, between 0 and 1, inclusive. + + """ + self.img_fg = colors.resolve_color(fg) + self.renderer.fg_change(self.img_fg) def get_fg(self): """Get the foreground color. @@ -3147,27 +2869,16 @@ """ self.t_.set(enter_focus=tf) - def update_image(self): - """Update image. + def update_widget(self): + """Update the area corresponding to the backend widget. This must be implemented by subclasses. """ self.logger.warning("Subclass should override this abstract method!") - def render_image(self, rgbobj, dst_x, dst_y): - """Render image. - This must be implemented by subclasses. - - Parameters - ---------- - rgbobj : `~ginga.RGBMap.RGBPlanes` - RGB object. - - dst_x, dst_y : float - Offsets in screen coordinates. - - """ - self.logger.warning("Subclass should override this abstract method!") + # TO BE DEPRECATED--please use update_widget + def update_image(self): + return self.update_widget() def reschedule_redraw(self, time_sec): """Reschedule redraw event. @@ -3289,26 +3000,22 @@ """ self.set_cursor(self.cursor[cname]) - def configure_surface(self, width, height): - """Reconfigure the renderer for a new size, then reconfigure - our viewer for the same. - - This can be overridden by subclasses. + def prepare_image(self, cvs_img, cache, whence): + """This can be overridden by subclasses. """ - self.renderer.resize((width, height)) - - self.configure(width, height) + self.renderer.prepare_image(cvs_img, cache, whence) - def get_image_as_array(self): + def get_image_as_array(self, order=None): """Get the current image shown in the viewer, with any overlaid - graphics, in a numpy array with channels as needed and ordered - by the back end widget. + graphics, in a numpy array with channels as needed and ordered. This can be overridden by subclasses. """ - return self.renderer.get_surface_as_array(order=self.rgb_order) + if order is None: + order = self.rgb_order + return self.renderer.get_surface_as_array(order=order) - def get_image_as_buffer(self, output=None): + def get_image_as_buffer(self, output=None, order=None): """Get the current image shown in the viewer, with any overlaid graphics, in a IO buffer with channels as needed and ordered by the back end widget. @@ -3331,7 +3038,7 @@ if obuf is None: obuf = BytesIO() - arr8 = self.get_image_as_array() + arr8 = self.get_image_as_array(order=order) if not hasattr(arr8, 'tobytes'): # older versions of numpy obuf.write(arr8.tostring(order='C')) @@ -3591,8 +3298,7 @@ self.viewer._hold_redraw_cnt -= 1 if (self.viewer._hold_redraw_cnt <= 0): - # TODO: whence should be largest possible - #whence = 0 + # whence should be largest possible whence = self.viewer._defer_whence self.viewer.redraw(whence=whence) return False diff -Nru ginga-3.0.0/ginga/imap.py ginga-3.1.0/ginga/imap.py --- ginga-3.0.0/ginga/imap.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/imap.py 2020-07-08 20:09:29.000000000 +0000 @@ -265,7 +265,7 @@ 1.00000, 1.00000, 1.00000, - ) +) imap_ultrasmooth = ( 0.0, @@ -783,7 +783,7 @@ 1.00000, 1.00000, 1.00000, - ) +) imap_expo = ( 0.00458, # noqa @@ -1042,7 +1042,7 @@ 0.99888, 1.00000, 1.00000, - ) +) imap_jigsaw = ( 0.00000, # noqa @@ -1301,7 +1301,7 @@ 0.96471, 0.98039, 0.99608, - ) +) imap_ramp = ( 0.00000, # noqa @@ -1819,7 +1819,7 @@ 0.00784, 0.00392, 0.00000, - ) +) imap_stairs = ( 0.00000, # noqa @@ -2078,7 +2078,7 @@ 0.94118, 1.00000, 1.00000, - ) +) iamp_equa = ( 0.06275, # noqa @@ -2337,7 +2337,7 @@ 1.00000, 1.00000, 1.00000, - ) +) imap_neglog = ( 1.00000, # noqa @@ -2596,7 +2596,7 @@ 0.00000, 0.00000, 0.00000, - ) +) imap_log = ( 0.00000, # noqa @@ -3373,7 +3373,7 @@ 0.00000, 0.00000, 0.00000, - ) +) min_imap_len = 256 @@ -3407,8 +3407,7 @@ def get_names(): - res = list(imaps.keys()) - res.sort() + res = sorted(imaps.keys()) return res diff -Nru ginga-3.0.0/ginga/__init__.py ginga-3.1.0/ginga/__init__.py --- ginga-3.0.0/ginga/__init__.py 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/ginga/__init__.py 2020-04-10 20:19:26.000000000 +0000 @@ -1,43 +1,15 @@ -""" -Ginga is a toolkit designed for building viewers for scientific image -data in Python, visualizing 2D pixel data in numpy arrays. -The Ginga toolkit centers around an image display class which supports -zooming and panning, color and intensity mapping, a choice of several -automatic cut levels algorithms and canvases for plotting scalable -geometric forms. In addition to this widget, a general purpose -'reference' FITS viewer is provided, based on a plugin framework. +# Licensed under a 3-clause BSD style license - see LICENSE.rst +"""See LONG_DESC.txt""" -Ginga is distributed under an open-source BSD licence. Please see the -file LICENSE.txt in the top-level directory for details. -""" - -# Affiliated packages may add whatever they like to this file, but +# Packages may add whatever they like to this file, but # should keep this content at the top. # ---------------------------------------------------------------------------- from ._astropy_init import * # noqa # ---------------------------------------------------------------------------- -import sys - -__minimum_python_version__ = '3.5' - - -class UnsupportedPythonError(Exception): - pass - - -# This is the same check as the one in setup.cfg -if sys.version_info < tuple((int(val) for val in __minimum_python_version__.split('.'))): - raise UnsupportedPythonError("Ginga does not support Python < {}".format(__minimum_python_version__)) - -# For egg_info test builds to pass, put package imports here. -if not _ASTROPY_SETUP_: # noqa - #from example_mod import * - pass - try: # As long as we're using setuptools/distribute, we need to do this the - # setuptools way or else pkg_resources will throw up unncessary and + # setuptools way or else pkg_resources will throw up unnecessary and # annoying warnings (even though the namespace mechanism will still # otherwise work without it). __import__('pkg_resources').declare_namespace(__name__) diff -Nru ginga-3.0.0/ginga/misc/ModuleManager.py ginga-3.1.0/ginga/misc/ModuleManager.py --- ginga-3.0.0/ginga/misc/ModuleManager.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/misc/ModuleManager.py 2020-07-08 20:09:29.000000000 +0000 @@ -76,7 +76,7 @@ except Exception as e: self.logger.error("Failed to load module '%s': %s" % ( - module_name, str(e))) + module_name, str(e)), exc_info=True) raise ModuleManagerError(e) def get_module(self, module_name): diff -Nru ginga-3.0.0/ginga/misc/Timer.py ginga-3.1.0/ginga/misc/Timer.py --- ginga-3.0.0/ginga/misc/Timer.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/misc/Timer.py 2020-07-08 20:09:29.000000000 +0000 @@ -104,11 +104,15 @@ def cond_set(self, time_sec): self.timer.cond_start(time_sec) + def elapsed_time(self): + """Return the elapsed time since the timer was started.""" + return self.timer.elapsed_time() + def time_left(self): return self.timer.remaining_time() def get_deadline(self): - return self.timer.expiration_time() + return time.time() + self.time_left() def stop(self): self.timer.stop() diff -Nru ginga-3.0.0/ginga/Mixins.py ginga-3.1.0/ginga/Mixins.py --- ginga-3.0.0/ginga/Mixins.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/Mixins.py 2020-07-08 20:09:29.000000000 +0000 @@ -11,6 +11,7 @@ def __init__(self): self.ui_active = False + self.ui_viewer = set([]) for name in ('motion', 'button-press', 'button-release', 'key-press', 'key-release', 'drag-drop', @@ -21,19 +22,16 @@ def ui_is_active(self): return self.ui_active - def ui_set_active(self, tf): + def ui_set_active(self, tf, viewer=None): self.ui_active = tf + if viewer is not None: + if tf: + self.ui_viewer.add(viewer) + else: + if viewer in self.ui_viewer: + self.ui_viewer.remove(viewer) - ## def make_callback(self, name, *args, **kwdargs): - ## if hasattr(self, 'objects'): - ## # Invoke callbacks on all our layers that have the UI mixin - ## for obj in self.objects: - ## if isinstance(obj, UIMixin) and obj.ui_isActive(): - ## obj.make_callback(name, *args, **kwdargs) - - ## return super(UIMixin, self).make_callback(name, *args, **kwdargs) - - def make_ui_callback(self, name, *args, **kwdargs): + def make_ui_callback(self, name, *args, **kwargs): """Invoke callbacks on all objects (i.e. layers) from the top to the bottom, returning when the first one returns True. If none returns True, then make the callback on our 'native' layer. @@ -43,16 +41,37 @@ num = len(self.objects) - 1 while num >= 0: obj = self.objects[num] - if isinstance(obj, UIMixin) and obj.ui_isActive(): - res = obj.make_ui_callback(name, *args, **kwdargs) + if isinstance(obj, UIMixin) and obj.ui_is_active(): + res = obj.make_ui_callback(name, *args, **kwargs) if res: return res num -= 1 if self.ui_active: - return super(UIMixin, self).make_callback(name, *args, **kwdargs) + return super(UIMixin, self).make_callback(name, *args, **kwargs) + + def make_ui_callback_viewer(self, viewer, name, *args, **kwargs): + """Invoke callbacks on all objects (i.e. layers) from the top to + the bottom, returning when the first one returns True. If none + returns True, then make the callback on our 'native' layer. + """ + if len(self.ui_viewer) == 0 or viewer in self.ui_viewer: + if hasattr(self, 'objects'): + # Invoke callbacks on all our layers that have the UI mixin + num = len(self.objects) - 1 + while num >= 0: + obj = self.objects[num] + if isinstance(obj, UIMixin) and obj.ui_is_active(): + res = obj.make_ui_callback_viewer(viewer, name, + *args, **kwargs) + if res: + return res + num -= 1 + + if self.ui_active: + return super(UIMixin, self).make_callback(name, *args, **kwargs) - def make_callback_children(self, name, *args, **kwdargs): + def make_callback_children(self, name, *args, **kwargs): """Invoke callbacks on all objects (i.e. layers) from the top to the bottom, returning when the first one returns True. If none returns True, then make the callback on our 'native' layer. @@ -63,10 +82,10 @@ while num >= 0: obj = self.objects[num] if isinstance(obj, Callbacks): - obj.make_callback(name, *args, **kwdargs) + obj.make_callback(name, *args, **kwargs) num -= 1 - return super(UIMixin, self).make_callback(name, *args, **kwdargs) + return super(UIMixin, self).make_callback(name, *args, **kwargs) ### NON-PEP8 EQUIVALENTS -- TO BE DEPRECATED ### ui_isActive = ui_is_active diff -Nru ginga-3.0.0/ginga/mockw/CanvasRenderMock.py ginga-3.1.0/ginga/mockw/CanvasRenderMock.py --- ginga-3.0.0/ginga/mockw/CanvasRenderMock.py 2018-09-11 02:59:35.000000000 +0000 +++ ginga-3.1.0/ginga/mockw/CanvasRenderMock.py 2020-07-08 20:09:29.000000000 +0000 @@ -4,6 +4,8 @@ # This is open-source software licensed under a BSD license. # Please see the file LICENSE.txt for details. # +import numpy as np + from ginga.canvas import render from ginga.fonts import font_asst # force registration of all canvas types @@ -59,8 +61,15 @@ height = 15 return width, height + def setup_pen_brush(self, pen, brush): + pass + ##### DRAWING OPERATIONS ##### + def draw_image(self, cvs_img, cpoints, rgb_arr, whence, order='RGBA'): + # no-op for this renderer + pass + def draw_text(self, cx, cy, text, rot_deg=0.0): #self.cr.draw_text(cx, cy, text) pass @@ -93,13 +102,26 @@ pass -class CanvasRenderer(render.RendererBase): +class CanvasRenderer(render.StandardPixelRenderer): def __init__(self, viewer): - render.RendererBase.__init__(self, viewer) + render.StandardPixelRenderer.__init__(self, viewer) - self.rgb_order = 'BGRA' + self.kind = 'mock' + self.rgb_order = 'RGBA' self.surface = None + self._rgb_arr = None + + def resize(self, dims): + super(CanvasRenderer, self).resize(dims) + + def get_surface_as_array(self, order=None): + # adjust according to viewer's needed order + return self.reorder(order, self._rgb_arr) + + def render_image(self, rgbobj, win_x, win_y): + self._rgb_arr = self.viewer.getwin_array(order=self.rgb_order, + dtype=np.uint8) def setup_cr(self, shape): cr = RenderContext(self, self.viewer, self.surface) @@ -111,4 +133,8 @@ cr.set_font_from_shape(shape) return cr.text_extents(shape.text) -#END + def text_extents(self, text, font): + cr = RenderContext(self, self.viewer, self.surface) + cr.set_font(font.fontname, font.fontsize, color=font.color, + alpha=font.alpha) + return cr.text_extents(text) diff -Nru ginga-3.0.0/ginga/mockw/ImageViewCanvasTypesMock.py ginga-3.1.0/ginga/mockw/ImageViewCanvasTypesMock.py --- ginga-3.0.0/ginga/mockw/ImageViewCanvasTypesMock.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/mockw/ImageViewCanvasTypesMock.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# TODO: this line is for backward compatibility with files importing -# this module--to be removed -from ginga.canvas.types.all import * # noqa - -# END diff -Nru ginga-3.0.0/ginga/mockw/ImageViewMock.py ginga-3.1.0/ginga/mockw/ImageViewMock.py --- ginga-3.0.0/ginga/mockw/ImageViewMock.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/mockw/ImageViewMock.py 2020-07-08 20:09:29.000000000 +0000 @@ -5,7 +5,6 @@ # Please see the file LICENSE.txt for details. # import os -import numpy as np from ginga import ImageView, Mixins, Bindings from ginga.util.io_rgb import RGBFileHandler @@ -30,9 +29,6 @@ self.renderer = CanvasRenderer(self) - self.t_.setDefaults(show_pan_position=False, - onscreen_ff='Sans Serif') - # This holds the off-screen pixel map. It's creation is usually # deferred until we know the final size of the window. self.pixmap = None @@ -56,38 +52,6 @@ surface = self.getwin_array(order=self.rgb_order) return surface - def _render_offscreen(self, drawable, data, dst_x, dst_y, - width, height): - # NOTE [A] - daht, dawd, depth = data.shape - self.logger.debug("data shape is %dx%dx%d" % (dawd, daht, depth)) - - # fill pixmap with background color - imgwin_wd, imgwin_ht = self.get_window_size() - # fillRect(Rect(0, 0, imgwin_wd, imgwin_ht), bgclr) - - # draw image data from buffer to offscreen pixmap at - # (dst_x, dst_y) with size (width x height) - ## painter.drawImage(Rect(dst_x, dst_y, width, height), - ## data, - ## Rect(0, 0, width, height)) - - def render_image(self, rgbobj, dst_x, dst_y): - """Render the image represented by (rgbobj) at dst_x, dst_y - in the offscreen pixmap. - """ - self.logger.debug("redraw pixmap=%s" % (self.pixmap)) - if self.pixmap is None: - return - self.logger.debug("drawing to pixmap") - - # Prepare array for rendering - arr = rgbobj.get_array(self.rgb_order, dtype=np.uint8) - (height, width) = arr.shape[:2] - - return self._render_offscreen(self.pixmap, arr, dst_x, dst_y, - width, height) - def configure_window(self, width, height): """ This method is called by the event handler when the @@ -95,63 +59,8 @@ We allocate an off-screen pixmap of the appropriate size and inform the superclass of our window size. """ - self.configure_surface(width, height) - - def configure_surface(self, width, height): - self.logger.debug("window size reconfigured to %dx%d" % ( - width, height)) - # TODO: allocate pixmap of width x height - self.pixmap = None - self.configure(width, height) - def get_image_as_array(self): - return self.getwin_array(order=self.rgb_order, dtype=np.uint8) - - def get_rgb_image_as_buffer(self, output=None, format='png', - quality=90): - # copy pixmap to buffer - data_np = self.getwin_array(order=self.rgb_order, dtype=np.uint8) - header = {} - fmt_buf = self.rgb_fh.get_buffer(data_np, header, format, - output=output) - return fmt_buf - - def get_rgb_image_as_widget(self, output=None, format='png', - quality=90): - imgwin_wd, imgwin_ht = self.get_window_size() - # copy pixmap to native widget type - # ... - # image_w = self.pixmap.copy(0, 0, imgwin_wd, imgwin_ht) - image_w = None - - return image_w - - def save_rgb_image_as_file(self, filepath, format='png', quality=90): - img_w = self.get_rgb_image_as_widget() - # assumes that the image widget has some method for saving to - # a file - img_w.save(filepath, format=format, quality=quality) - - def get_plain_image_as_widget(self): - """Used for generating thumbnails. Does not include overlaid - graphics. - """ - arr = self.getwin_array(order=self.rgb_order) - - # convert numpy array to native image widget - image_w = self._get_wimage(arr) - return image_w - - def save_plain_image_as_file(self, filepath, format='png', quality=90): - """Used for generating thumbnails. Does not include overlaid - graphics. - """ - img_w = self.get_plain_image_as_widget() - # assumes that the image widget has some method for saving to - # a file - img_w.save(filepath, format=format, quality=quality) - def reschedule_redraw(self, time_sec): # stop any pending redraws, if possible # ... @@ -161,7 +70,7 @@ # ... pass - def update_image(self): + def update_widget(self): if (not self.pixmap) or (not self.imgwin): return @@ -181,30 +90,6 @@ cursorw = None return cursorw - def _get_wimage(self, arr_np): - """Convert the numpy array (which is in our expected order) - to a native image object in this widget set. - """ - #return result - raise NotImplementedError - - def _get_color(self, r, g, b): - """Convert red, green and blue values specified in floats with - range 0-1 to whatever the native widget color object is. - """ - clr = (r, g, b) - return clr - - def onscreen_message(self, text, delay=None, redraw=True): - # stop any scheduled updates of the message - - # set new message text - self.set_onscreen_message(text, redraw=redraw) - if delay: - # schedule a call to onscreen_message_off after - # `delay` sec - pass - def take_focus(self): # have the widget grab the keyboard focus pass @@ -316,7 +201,7 @@ # keycode = keyname = '' # self.transkey(keyname, keycode) self.logger.debug("key press event, key=%s" % (keyname)) - return self.make_ui_callback('key-press', keyname) + return self.make_ui_callback_viewer(self, 'key-press', keyname) def key_release_event(self, widget, event): """ @@ -328,7 +213,7 @@ # keycode = keyname = '' # self.transkey(keyname, keycode) self.logger.debug("key release event, key=%s" % (keyname)) - return self.make_ui_callback('key-release', keyname) + return self.make_ui_callback_viewer(self, 'key-release', keyname) def button_press_event(self, widget, event): """ @@ -350,7 +235,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-press', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-press', button, + data_x, data_y) def button_release_event(self, widget, event): """ @@ -367,7 +253,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-release', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-release', button, + data_x, data_y) def motion_notify_event(self, widget, event): """ @@ -384,7 +271,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('motion', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'motion', button, + data_x, data_y) def scroll_event(self, widget, event): """ @@ -408,8 +296,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('scroll', direction, num_degrees, - data_x, data_y) + return self.make_ui_callback_viewer(self, 'scroll', direction, + num_degrees, data_x, data_y) def drop_event(self, widget, event): """ @@ -418,7 +306,7 @@ """ # make a call back with a list of URLs that were dropped #self.logger.debug("dropped filename(s): %s" % (str(paths))) - #self.make_ui_callback('drag-drop', paths) + #self.make_ui_callback_viewer(self, 'drag-drop', paths) raise NotImplementedError @@ -442,7 +330,7 @@ rgbmap=rgbmap) Mixins.UIMixin.__init__(self) - self.ui_set_active(True) + self.ui_set_active(True, viewer=self) if bindmap is None: bindmap = ImageViewZoom.bindmapClass(self.logger) diff -Nru ginga-3.0.0/ginga/mplw/CanvasRenderMpl.py ginga-3.1.0/ginga/mplw/CanvasRenderMpl.py --- ginga-3.0.0/ginga/mplw/CanvasRenderMpl.py 2018-09-11 02:59:35.000000000 +0000 +++ ginga-3.1.0/ginga/mplw/CanvasRenderMpl.py 2020-07-08 20:09:29.000000000 +0000 @@ -90,8 +90,15 @@ def text_extents(self, text): return self.cr.text_extents(text, self.font) + def setup_pen_brush(self, pen, brush): + self.pen = pen + self.brush = brush + ##### DRAWING OPERATIONS ##### + def draw_image(self, cvs_img, cpoints, rgb_arr, whence, order='RGBA'): + return + def draw_text(self, cx, cy, text, rot_deg=0.0): fontdict = self.font.get_fontdict() self.cr.push(allow=['alpha', 'color']) @@ -164,10 +171,10 @@ self.cr.axes.add_patch(p) -class CanvasRenderer(render.RendererBase): +class CanvasRenderer(render.StandardPixelRenderer): def __init__(self, viewer): - render.RendererBase.__init__(self, viewer) + render.StandardPixelRenderer.__init__(self, viewer) self.kind = 'mpl' self.rgb_order = viewer.rgb_order @@ -177,7 +184,7 @@ """Resize our drawing area to encompass a space defined by the given dimensions. """ - pass + super(CanvasRenderer, self).resize(dims) def render_image(self, rgbobj, dst_x, dst_y): # for compatibility with the other renderers @@ -196,5 +203,11 @@ cr.set_font_from_shape(shape) return cr.text_extents(shape.text) + def text_extents(self, text, font): + cr = RenderContext(self, self.viewer, self.viewer.ax_util) + cr.set_font(font.fontname, font.fontsize, color=font.color, + alpha=font.alpha) + return cr.text_extents(text) + #END diff -Nru ginga-3.0.0/ginga/mplw/ImageViewCanvasTypesMpl.py ginga-3.1.0/ginga/mplw/ImageViewCanvasTypesMpl.py --- ginga-3.0.0/ginga/mplw/ImageViewCanvasTypesMpl.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/mplw/ImageViewCanvasTypesMpl.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# TODO: this line is for backward compatibility with files importing -# this module--to be removed -from ginga.canvas.types.all import * # noqa - -# END diff -Nru ginga-3.0.0/ginga/mplw/ImageViewMpl.py ginga-3.1.0/ginga/mplw/ImageViewMpl.py --- ginga-3.0.0/ginga/mplw/ImageViewMpl.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/mplw/ImageViewMpl.py 2020-07-08 20:09:29.000000000 +0000 @@ -83,6 +83,7 @@ self.figure = figure ax = self.figure.add_axes((0, 0, 1, 1), frame_on=False, + zorder=0.0, #viewer=self, #projection='ginga' ) @@ -103,7 +104,8 @@ # Add an overlapped axis for drawing graphics newax = self.figure.add_axes(self.ax_img.get_position(), sharex=ax, sharey=ax, - frameon=False, + frame_on=False, + zorder=1.0, #viewer=self, #projection='ginga' ) @@ -257,17 +259,12 @@ # Set the axis limits # TODO: should we do this only for those who have autoaxis=True? - ## wd, ht = self.get_window_size() - ## x0, y0 = self.get_data_xy(0, 0) - ## x1, tm = self.get_data_xy(wd-1, 0) - ## tm, y1 = self.get_data_xy(0, ht-1) + ## x0, y0, x1, y1 = self.get_datarect() ## for ax in self.figure.axes: ## ax.set_xlim(x0, x1) ## ax.set_ylim(y0, y1) def configure_window(self, width, height): - #self.renderer.resize((width, height)) - self.configure(width, height) def _resize_cb(self, event): @@ -277,7 +274,8 @@ def add_axes(self): ax = self.figure.add_axes(self.ax_img.get_position(), - frameon=False, + frame_on=False, + zorder=1.0, viewer=self, projection='ginga') ax.get_xaxis().set_visible(False) @@ -307,7 +305,10 @@ buf = self.get_rgb_image_as_buffer(format=format, quality=quality) return buf.getvalue() - def update_image(self): + def update_widget(self): + # force an update of the figure + if self.figure is not None and self.figure.canvas is not None: + self.figure.canvas.draw() pass def set_cursor(self, cursor): @@ -451,14 +452,14 @@ keyname = self.transkey(keyname) if keyname is not None: self.logger.debug("key press event, key=%s" % (keyname)) - return self.make_ui_callback('key-press', keyname) + return self.make_ui_callback_viewer(self, 'key-press', keyname) def key_release_event(self, event): keyname = event.key keyname = self.transkey(keyname) if keyname is not None: self.logger.debug("key release event, key=%s" % (keyname)) - return self.make_ui_callback('key-release', keyname) + return self.make_ui_callback_viewer(self, 'key-release', keyname) def button_press_event(self, event): x, y = event.x, event.y @@ -471,7 +472,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-press', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-press', button, + data_x, data_y) def button_release_event(self, event): x, y = event.x, event.y @@ -484,7 +486,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-release', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-release', button, + data_x, data_y) def motion_notify_event(self, event): button = 0 @@ -498,7 +501,8 @@ data_x, data_y = self.check_cursor_location() self.logger.debug("motion event at DATA %dx%d" % (data_x, data_y)) - return self.make_ui_callback('motion', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'motion', button, + data_x, data_y) def scroll_event(self, event): x, y = event.x, event.y @@ -519,8 +523,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('scroll', direction, amount, - data_x, data_y) + return self.make_ui_callback_viewer(self, 'scroll', direction, amount, + data_x, data_y) class ImageViewZoom(Mixins.UIMixin, ImageViewEvent): @@ -543,7 +547,7 @@ settings=settings) Mixins.UIMixin.__init__(self) - self.ui_set_active(True) + self.ui_set_active(True, viewer=self) if bindmap is None: bindmap = ImageViewZoom.bindmapClass(self.logger) diff -Nru ginga-3.0.0/ginga/mplw/MplHelp.py ginga-3.1.0/ginga/mplw/MplHelp.py --- ginga-3.0.0/ginga/mplw/MplHelp.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/mplw/MplHelp.py 2020-01-20 03:17:53.000000000 +0000 @@ -100,12 +100,8 @@ self.kwdargs['linestyle'] = pen.linestyle def get_color(self, color, alpha): - if isinstance(color, str) or isinstance(color, type(u"")): - r, g, b = colors.lookup_color(color) - elif isinstance(color, tuple): - # color is assumed to be a 3-tuple of RGBA values as floats - # between 0 and 1 - r, g, b = color + if color is not None: + r, g, b = colors.resolve_color(color) else: r, g, b = 1.0, 1.0, 1.0 diff -Nru ginga-3.0.0/ginga/opencl/CL.py ginga-3.1.0/ginga/opencl/CL.py --- ginga-3.0.0/ginga/opencl/CL.py 2018-08-07 21:29:17.000000000 +0000 +++ ginga-3.1.0/ginga/opencl/CL.py 2020-07-08 20:09:29.000000000 +0000 @@ -88,8 +88,7 @@ else: out_np = np.empty(out_shape, dtype=np.float64) - cl.enqueue_read_buffer(self.queue, dst_buf, out_np).wait() - #cl.enqueue_copy(self.queue, out_np, dst_buf).wait() + cl.enqueue_copy(self.queue, out_np, dst_buf).wait() res = out_np.astype(dtype, copy=False) if out is not None: @@ -136,8 +135,7 @@ np.float64(clip_val)) out_np = np.empty_like(data_np) - cl.enqueue_read_buffer(self.queue, dst_buf, out_np).wait() - #cl.enqueue_copy(self.queue, out_np, dst_buf).wait() + cl.enqueue_copy(self.queue, out_np, dst_buf).wait() res = out_np.astype(dtype, copy=False) if out is not None: @@ -180,7 +178,7 @@ np.float64(sin_theta), np.float64(cos_theta), np.uint32(clip_val)) - cl.enqueue_read_buffer(self.queue, dst_buf, out).wait() + cl.enqueue_copy(self.queue, out, dst_buf).wait() return out @@ -222,7 +220,7 @@ np.float64(sin_theta), np.float64(cos_theta), np.uint32(clip_val)) - cl.enqueue_read_buffer(self.queue, dst_buf, out).wait() + cl.enqueue_copy(self.queue, out, dst_buf).wait() return out @@ -253,7 +251,7 @@ if out is None: out = np.empty_like(data_np).reshape(new_size) - cl.enqueue_read_buffer(self.queue, dst_buf, out).wait() + cl.enqueue_copy(self.queue, out, dst_buf).wait() return out @@ -282,7 +280,7 @@ if out is None: out = np.empty(new_shape, dtype=data_np.dtype) - cl.enqueue_read_buffer(self.queue, dst_buf, out).wait() + cl.enqueue_copy(self.queue, out, dst_buf).wait() return out @@ -316,7 +314,7 @@ else: out_np = np.empty(new_shape, dtype=np.float64) - cl.enqueue_read_buffer(self.queue, dst_buf, out_np).wait() + cl.enqueue_copy(self.queue, out, dst_buf).wait() res = out_np.astype(dtype, copy=False) if out is not None: diff -Nru ginga-3.0.0/ginga/opencl/setup_package.py ginga-3.1.0/ginga/opencl/setup_package.py --- ginga-3.0.0/ginga/opencl/setup_package.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/opencl/setup_package.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst - - -def get_package_data(): - return {'ginga.opencl': ['trcalc.cl']} diff -Nru ginga-3.0.0/ginga/opengl/Camera.py ginga-3.1.0/ginga/opengl/Camera.py --- ginga-3.0.0/ginga/opengl/Camera.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/Camera.py 2020-07-08 20:09:29.000000000 +0000 @@ -9,7 +9,6 @@ # Please see the file LICENSE.txt for details. # import numpy as np -from OpenGL import GL as gl from .geometry_helper import Point3D, Vector3D, Matrix4x4 @@ -22,8 +21,8 @@ self.orbit_speed = 300.0 # These are in world-space units. - self.near_plane = 1.0 - self.far_plane = 10000.0 + self.near_plane = 0.001 + self.far_plane = 1000000.0 # During dollying (i.e. when the camera is translating into # the scene), if the camera gets too close to the target @@ -42,6 +41,7 @@ self.vport_radius_px = 5 self.scene_radius = 10 + self.magic_z = 1000.0 # point of view, or center of camera; the ego-center; the eye-point self.position = Point3D() @@ -75,6 +75,13 @@ self.vport_ht_px = ht_px self.vport_radius_px = 0.5 * wd_px if (wd_px < ht_px) else 0.5 * ht_px + ht_px = self.vport_radius_px * 2.0 + fov_deg = fov_for_height_and_distance(ht_px, self.magic_z) + self.fov_deg = fov_deg + + scene_radius = self.vport_radius_px * 2.0 + self.set_scene_radius(scene_radius) + def get_viewport_dimensions(self): return (self.vport_wd_px, self.vport_ht_px) @@ -98,9 +105,54 @@ self.home_position = self.position.copy() self.tgt_position = self.target.copy() - def set_gl_transform(self): - """This side effects the OpenGL context to set the view to match - the camera. + ## def pan_2d(self, pos): + ## pan_x, pan_y = pos[:2] + ## self.target = Point3D(pan_x, pan_y, self.target.z) + ## self.position = Point3D(pan_x, pan_y, self.position.z) + + ## self.calc_gl_transform() + + def rotate_2d(self, ang_deg): + ang_rad = np.radians(ang_deg) + + t2p = self.position - self.target + + pos = Vector3D(0, 0, -1) + M = Matrix4x4.rotation(ang_rad, pos, self.position) + t2p = M * t2p + up = self.ground.copy() + self.up = M * up + self.position = self.target + t2p + + self.calc_gl_transform() + + def scale_2d(self, scales): + # for now, scale is uniform for X and Y + scale = scales[0] + + # set distance ro target based on scale + direction = self.target - self.position + direction = direction.normalized() + + dolly_distance = 1.0 / scale * self.magic_z + self.position = self.target - direction * dolly_distance + + self.calc_gl_transform() + + def get_scale_2d(self): + # solve for scale based on distance from target + direction = self.target - self.position + dolly_distance = direction.length() + scale = 1.0 / (dolly_distance / self.magic_z) + return scale, scale + + def transform_2d(self, state): + # this is handled by a transform on the viewer side + pass + + def calc_gl_transform(self): + """This calculates the transformation matrices necessary to match + the camera's position. """ tangent = np.tan(self.fov_deg / 2.0 / 180.0 * np.pi) vport_radius = self.near_plane * tangent @@ -113,14 +165,28 @@ vport_ht = 2.0 * vport_radius vport_wd = vport_ht * self.vport_wd_px / float(self.vport_ht_px) - gl.glFrustum( - -0.5 * vport_wd, 0.5 * vport_wd, # left, right - -0.5 * vport_ht, 0.5 * vport_ht, # bottom, top - self.near_plane, self.far_plane - ) + left, right = -0.5 * vport_wd, 0.5 * vport_wd + bottom, top = -0.5 * vport_ht, 0.5 * vport_ht + z_near, z_far = self.near_plane, self.far_plane + # view matrix M = Matrix4x4.look_at(self.position, self.target, self.up, False) - gl.glMultMatrixf(M.get()) + + self.view_mtx = M.get().astype(np.float32) + + A = (right + left) / (right - left) + B = (top + bottom) / (top - bottom) + C = - (z_far + z_near) / (z_far - z_near) + # D = (-2.0 * z_far * z_near) / (z_far - z_near) + # WHY DO WE NEED TO DO THIS ??? + D = - 1.0 + + # projection matrix + P = Matrix4x4([((2 * z_near) / (right - left), 0.0, A, 0.0), + (0.0, (2 * z_near) / (top - bottom), B, 0.0), + (0.0, 0.0, C, D), + (0.0, 0.0, -1.0, 0.0)]) + self.proj_mtx = P.get().astype(np.float32) def get_translation_speed(self, distance_from_target): """Returns the translation speed for ``distance_from_target`` @@ -216,7 +282,6 @@ distance_from_target = direction.length() fov_deg = fov_for_height_and_distance(initial_ht, distance_from_target) - #print("fov is now %.4f" % fov_deg) self.fov_deg = fov_deg def frustum_dimensions_at_target(self, vfov_deg=None): diff -Nru ginga-3.0.0/ginga/opengl/CanvasRenderGL.py ginga-3.1.0/ginga/opengl/CanvasRenderGL.py --- ginga-3.0.0/ginga/opengl/CanvasRenderGL.py 2019-09-09 18:09:55.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/CanvasRenderGL.py 2020-07-08 20:09:29.000000000 +0000 @@ -4,22 +4,29 @@ # This is open-source software licensed under a BSD license. # Please see the file LICENSE.txt for details. # +import time +import os.path import numpy as np +import ctypes +import threading +from distutils.version import LooseVersion -# NOTE: we need GLU, but even if we didn't we should import it to -# workaround for a bug: http://bugs.python.org/issue26245 -from OpenGL import GLU as glu from OpenGL import GL as gl -from ginga.canvas import render -# force registration of all canvas types -import ginga.canvas.types.all # noqa -from ginga.canvas.transform import BaseTransform +from ginga.vec import CanvasRenderVec as vec +from ginga.canvas import render, transform from ginga.cairow import CairoHelp +from ginga import trcalc, RGBMap +from ginga.util import rgb_cms # Local imports from .Camera import Camera from . import GlHelp +from .glsl import __file__ +shader_dir, _ = os.path.split(__file__) + +# NOTE: we update the version later in gl_initialize() +opengl_version = LooseVersion('3.0') class RenderContext(render.RenderContextBase): @@ -86,6 +93,16 @@ else: self.brush = self.cr.get_brush(color, alpha=alpha) + def setup_pen_brush(self, pen, brush): + # pen, brush are from ginga.vec + self.pen = self.cr.get_pen(pen.color, alpha=pen.alpha, + linewidth=pen.linewidth, + linestyle=pen.linestyle) + if brush is None: + self.brush = None + else: + self.brush = self.cr.get_brush(brush.color, alpha=brush.alpha) + def set_font(self, fontname, fontsize, color='black', alpha=1.0): fontsize = self.scale_fontsize(fontsize) self.font = self.cr.get_font(fontname, fontsize, color, @@ -96,57 +113,50 @@ ##### DRAWING OPERATIONS ##### + def draw_image(self, cvs_img, cp, rgb_arr, whence, order='RGB'): + """Render the image represented by (rgb_arr) at (cx, cy) + in the pixel space. + """ + cp = np.asarray(cp, dtype=np.float32) + + # This bit is necessary because OpenGL assumes that pixels are + # centered at 0.5 offsets from the start of the pixel. Need to + # follow the flip/swap transform of the viewer to make sure we + # are applying the correct corner offset to each image corner + # TODO: see if there is something we can set in OpenGL so that + # we don't have to do this hack + off = 0.5 + off = np.array(((-off, -off), (off, -off), (off, off), (-off, off))) + tr = transform.FlipSwapTransform(self.viewer) + cp += tr.to_(off) + + self.renderer.gl_draw_image(cvs_img, cp) + def draw_text(self, cx, cy, text, rot_deg=0.0): # TODO: this draws text as polygons, since there is no native # text support in OpenGL. It uses cairo to convert the text to # paths. Currently the paths are drawn, but not filled correctly. paths = CairoHelp.text_to_paths(text, self.font, flip_y=True, cx=cx, cy=cy, rot_deg=rot_deg) + scale = self.viewer.get_scale() + base = np.array((cx, cy)) for pts in paths: + # we have to rotate and scale the polygons to account for the + # odd transform we use for OpenGL + rot_deg = -self.viewer.get_rotation() + if rot_deg != 0.0: + pts = trcalc.rotate_coord(pts, [rot_deg], (cx, cy)) + pts = (pts - base) * (1 / scale) + base self.set_line(self.font.color, alpha=self.font.alpha) + # NOTE: since non-convex polygons are not filled correctly, it + # doesn't work to set any fill here self.set_fill(None) self.draw_polygon(pts) - def _draw_pts(self, shape, cpoints): - - if not self.renderer._drawing: - # this test ensures that we are not trying to draw before - # the OpenGL context is set for us correctly - return - - z_pts = cpoints - - gl.glEnableClientState(gl.GL_VERTEX_ARRAY) - - # draw fill, if any - if self.brush is not None: - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) - gl.glColor4f(*self.brush.color) - - gl.glVertexPointerf(z_pts) - gl.glDrawArrays(shape, 0, len(z_pts)) - - if self.pen is not None and self.pen.linewidth > 0: - # draw outline - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) - gl.glColor4f(*self.pen.color) - gl.glLineWidth(self.pen.linewidth) - - if self.pen.linestyle == 'dash': - gl.glEnable(gl.GL_LINE_STIPPLE) - gl.glLineStipple(3, 0x1C47) - - gl.glVertexPointerf(z_pts) - gl.glDrawArrays(shape, 0, len(z_pts)) - - if self.pen.linestyle == 'dash': - gl.glDisable(gl.GL_LINE_STIPPLE) - - gl.glDisableClientState(gl.GL_VERTEX_ARRAY) - def draw_polygon(self, cpoints): - self._draw_pts(gl.GL_POLYGON, cpoints) + self.renderer.gl_draw_shape(gl.GL_LINE_LOOP, cpoints, + self.brush, self.pen) def draw_circle(self, cx, cy, cradius): # we have to approximate a circle in OpenGL @@ -160,23 +170,29 @@ dy = cradius * np.sin(theta) cpoints.append((cx + dx, cy + dy)) - self._draw_pts(gl.GL_POLYGON, cpoints) + self.renderer.gl_draw_shape(gl.GL_LINE_LOOP, cpoints, + self.brush, self.pen) def draw_line(self, cx1, cy1, cx2, cy2): cpoints = [(cx1, cy1), (cx2, cy2)] - self._draw_pts(gl.GL_LINES, cpoints) + self.renderer.gl_draw_shape(gl.GL_LINES, cpoints, + self.brush, self.pen) def draw_path(self, cpoints): - self._draw_pts(gl.GL_LINE_STRIP, cpoints) + self.renderer.gl_draw_shape(gl.GL_LINE_STRIP, cpoints, + self.brush, self.pen) -class CanvasRenderer(render.RendererBase): +class CanvasRenderer(vec.VectorRenderMixin, render.StandardPixelRenderer): def __init__(self, viewer): - render.RendererBase.__init__(self, viewer) + render.StandardPixelRenderer.__init__(self, viewer) + vec.VectorRenderMixin.__init__(self) - self.kind = 'gl' + self.kind = 'opengl' self.rgb_order = 'RGBA' + self.surface = self.viewer.get_widget() + self.use_offscreen_fbo = True # size of our GL viewport # these will change when the resize() is called @@ -184,237 +200,776 @@ self.camera = Camera() self.camera.set_scene_radius(2) - self.camera.set_camera_home_position((0, 0, -1000)) + self.camera.set_camera_home_position((0, 0, 1000)) self.camera.reset() - self.draw_wrapper = False - self.draw_spines = True - self.mode3d = False + self.mode3d = True self._drawing = False - self._img_pos = None - - # initial values, will be recalculated at window map/resize - self.lim_x, self.lim_y, self.lim_z = 10, 10, 10 - self.mn_x, self.mx_x = -self.lim_x, self.lim_x - self.mn_y, self.mx_y = -self.lim_y, self.lim_y - self.mn_z, self.mx_z = -self.lim_z, self.lim_z + self._initialized = False + self._tex_cache = dict() + self._levels = (0.0, 0.0) + self._cmap_len = 256 + self.max_texture_dim = 0 + self.image_uploads = [] + self.cmap_uploads = [] + + self.pgm_mgr = GlHelp.ShaderManager(self.logger) + + self.fbo = None + self.fbo_size = (0, 0) + self.color_buf = None + self.depth_buf = None + self.lock = threading.RLock() + + def set_3dmode(self, tf): + self.mode3d = tf + self.gl_resize(self.wd, self.ht) + scales = self.viewer.get_scale_xy() + self.scale(scales) def resize(self, dims): """Resize our drawing area to encompass a space defined by the given dimensions. """ - width, height = dims[:2] - self.gl_resize(width, height) + if self._initialized: + self._resize(dims) - def render_image(self, rgbobj, dst_x, dst_y): - """Render the image represented by (rgbobj) at dst_x, dst_y - in the pixel space. - """ - pos = (0, 0) - arr = self.viewer.getwin_array(order=self.rgb_order, alpha=1.0, - dtype=np.uint8) - #pos = (dst_x, dst_y) - #print('dst', pos) - #pos = self.tform['window_to_native'].to_(pos) - #print('dst(c)', pos) - self.gl_set_image(arr, pos) + width, height = dims[:2] + self.gl_resize(width, height) - def get_surface_as_array(self, order=None): - raise render.RenderError("This renderer can only be used with an opengl viewer") + self.viewer.update_widget() - def setup_cr(self, shape): - surface = self.viewer.get_widget() - cr = RenderContext(self, self.viewer, surface) - cr.initialize_from_shape(shape, font=False) - return cr + # this is necessary for other widgets to get the same kind of + # callback as for the standard pixel renderer + self.viewer.make_callback('redraw', 0.0) + + def scale(self, scales): + self.camera.scale_2d(scales[:2]) + + self.viewer.update_widget() + # this is necessary for other widgets to get the same kind of + # callback as for the standard pixel renderer + self.viewer.make_callback('redraw', 0.0) + + def pan(self, pos): + self.viewer.update_widget() + # this is necessary for other widgets to get the same kind of + # callback as for the standard pixel renderer + self.viewer.make_callback('redraw', 0.0) + + def rotate_2d(self, ang_deg): + self.camera.rotate_2d(ang_deg) + + self.viewer.update_widget() + # this is necessary for other widgets to get the same kind of + # callback as for the standard pixel renderer + self.viewer.make_callback('redraw', 2.6) + + def rgbmap_change(self, rgbmap): + if rgbmap not in self.cmap_uploads: + self.cmap_uploads.append(rgbmap) + #self.gl_set_cmap(rgbmap) + + self.viewer.update_widget() + # this is necessary for other widgets to get the same kind of + # callback as for the standard pixel renderer + self.viewer.make_callback('redraw', 2.0) + + def bg_change(self, bg): + self.viewer.update_widget() + # this is necessary for other widgets to get the same kind of + # callback as for the standard pixel renderer + self.viewer.make_callback('redraw', 3.0) + + def levels_change(self, levels): + self._levels = levels + + self.viewer.update_widget() + # this is necessary for other widgets to get the same kind of + # callback as for the standard pixel renderer + self.viewer.make_callback('redraw', 1.0) + + def icc_profile_change(self): + self.viewer.redraw(whence=0.1) + + def interpolation_change(self, interp): + self.logger.warning("Interpolation setting currently not supported fully by OpenGL renderer--defaulting to nearest neighbor") + + def _common_draw(self, cvs_img, cache, whence): + # internal common drawing phase for all images + image = cvs_img.image + if image is None: + return + viewer = self.viewer - def get_dimensions(self, shape): - cr = self.setup_cr(shape) - cr.set_font_from_shape(shape) - return cr.text_extents(shape.text) + if (whence <= 0.0) or (cache.cutout is None) or (not cvs_img.optimize): - def get_camera(self): - return self.camera + # get destination location in data_coords + dst_x, dst_y = cvs_img.crdmap.to_data((cvs_img.x, cvs_img.y)) - def setup_3D(self, mode3d): - gl.glMatrixMode(gl.GL_PROJECTION) - gl.glLoadIdentity() - - if mode3d: - gl.glDepthFunc(gl.GL_LEQUAL) - gl.glEnable(gl.GL_DEPTH_TEST) + a1, b1, a2, b2 = 0, 0, image.width - 1, image.height - 1 + + # scale by our scale + _scale_x, _scale_y = cvs_img.scale_x, cvs_img.scale_y + + interp = cvs_img.interpolation + if interp is None: + t_ = viewer.get_settings() + interp = t_.get('interpolation', 'basic') + + # previous choice might not be available if preferences + # were saved when opencv was being used (and not used now); + # if so, silently default to "basic" + if interp not in trcalc.interpolation_methods: + interp = 'basic' + res = image.get_scaled_cutout2((a1, b1), (a2, b2), + (_scale_x, _scale_y), + method=interp) + data = res.data + + # We are limited by maximum texture size supported for the + # OpenGl implementation. Images larger than the maximum + # in any dimension need to be downsampled to fit. + ht, wd = data.shape[:2] + extra = max(wd, ht) - self.max_texture_dim + if extra > 0: + new_wd, new_ht = wd - extra, ht - extra + tup = trcalc.get_scaled_cutout_wdht(data, 0, 0, wd, ht, + new_wd, new_ht, + logger=self.logger) + data = tup[0] + + if cvs_img.flipy: + data = np.flipud(data) + + # calculate our offset + pan_off = viewer.data_off + cvs_x, cvs_y = dst_x - pan_off, dst_y - pan_off + + cache.cutout = data + cache.cvs_pos = (cvs_x, cvs_y) + + def _prep_rgb_image(self, cvs_img, cache, whence): + image = cvs_img.get_image() + image_order = image.get_order() + + if whence <= 0.1 or cache.rgbarr is None: + # convert to output ICC profile, if one is specified + working_profile = rgb_cms.working_profile + output_profile = self.viewer.t_.get('icc_output_profile', None) + + if working_profile is not None and output_profile is not None: + rgbarr = np.copy(cache.cutout) + self.convert_via_profile(rgbarr, image_order, + working_profile, output_profile) + else: + rgbarr = self.reorder(self.rgb_order, cache.cutout, + image_order) + cache.rgbarr = rgbarr + + depth = cache.rgbarr.shape[2] + if depth < 4: + # add an alpha channel if missing + cache.rgbarr = trcalc.add_alpha(cache.rgbarr, alpha=255) + + # array needs to be contiguous to transfer properly as buffer + # to OpenGL + cache.rgbarr = np.ascontiguousarray(cache.rgbarr, dtype=np.uint8) + + def _prepare_image(self, cvs_img, cache, whence): + self._common_draw(cvs_img, cache, whence) + + self._prep_rgb_image(cvs_img, cache, whence) + + cache.image_type = 0 + cache.drawn = True + + def _prepare_norm_image(self, cvs_img, cache, whence): + t1 = t2 = t3 = t4 = time.time() + + self._common_draw(cvs_img, cache, whence) + + if cache.cutout is None: + return + + t2 = time.time() + if cvs_img.rgbmap is not None: + rgbmap = cvs_img.rgbmap + else: + rgbmap = self.viewer.get_rgbmap() + + image = cvs_img.get_image() + image_order = image.get_order() + + if (whence <= 0.0) or (cache.rgbarr is None) or (not cvs_img.optimize): + + img_arr = cache.cutout + + if len(img_arr.shape) == 2: + # <-- monochrome image with no alpha + cache.rgbarr = img_arr.astype(np.float32) + cache.image_type = 2 + + elif img_arr.shape[2] < 3: + cache.image_type = 3 + # <-- monochrome image with alpha + cache.rgbarr = img_arr.astype(np.float32) + + else: + cache.image_type = 1 + + if cache.image_type == 1: + self._prep_rgb_image(cvs_img, cache, whence) + + cache.drawn = True + t5 = time.time() + self.logger.debug("draw: t2=%.4f t3=%.4f t4=%.4f t5=%.4f total=%.4f" % ( + t2 - t1, t3 - t2, t4 - t3, t5 - t4, t5 - t1)) + + def prepare_image(self, cvs_img, cache, whence): + if cvs_img.kind == 'image': + self._prepare_image(cvs_img, cache, whence) + + elif cvs_img.kind == 'normimage': + self._prepare_norm_image(cvs_img, cache, whence) - self.camera.set_gl_transform() else: - gl.glDisable(gl.GL_DEPTH_TEST) - gl.glOrtho(self.mn_x, self.mx_x, self.mn_y, self.mx_y, - self.mn_z, self.mx_z) + raise render.RenderError("I don't know how to render canvas type '{}'".format(cvs_img.kind)) + + img_arr = cache.rgbarr + #tex_id = self.get_texture_id(cvs_img.image_id) + #self.gl_set_image(tex_id, img_arr, cache.image_type) + self.image_uploads.append((cvs_img.image_id, img_arr, cache.image_type)) + + def convert_via_profile(self, data_np, order, inprof_name, outprof_name): + """Convert the given RGB data from the working ICC profile + to the output profile in-place. + + Parameters + ---------- + data_np : ndarray + RGB image data to be displayed. + + order : str + Order of channels in the data (e.g. "BGRA"). + + inprof_name, outprof_name : str + ICC profile names (see :func:`ginga.util.rgb_cms.get_profiles`). - gl.glMatrixMode(gl.GL_MODELVIEW) - gl.glLoadIdentity() + """ + t_ = self.viewer.get_settings() + # get rest of necessary conversion parameters + to_intent = t_.get('icc_output_intent', 'perceptual') + proofprof_name = t_.get('icc_proof_profile', None) + proof_intent = t_.get('icc_proof_intent', 'perceptual') + use_black_pt = t_.get('icc_black_point_compensation', False) + + try: + rgbobj = RGBMap.RGBPlanes(data_np, order) + arr_np = rgbobj.get_array('RGB') + + arr = rgb_cms.convert_profile_fromto(arr_np, inprof_name, outprof_name, + to_intent=to_intent, + proof_name=proofprof_name, + proof_intent=proof_intent, + use_black_pt=use_black_pt, + logger=self.logger) + ri, gi, bi = rgbobj.get_order_indexes('RGB') + + out = data_np + out[..., ri] = arr[..., 0] + out[..., gi] = arr[..., 1] + out[..., bi] = arr[..., 2] + + self.logger.debug("Converted from '%s' to '%s' profile" % ( + inprof_name, outprof_name)) + + except Exception as e: + self.logger.warning("Error converting output from working profile: %s" % (str(e))) + # TODO: maybe should have a traceback here + self.logger.info("Output left unprofiled") + + def get_texture_id(self, image_id): + tex_id = self._tex_cache.get(image_id, None) + if tex_id is None: + context = self.viewer.make_context_current() + tex_id = gl.glGenTextures(1) + self._tex_cache[image_id] = tex_id + return tex_id + + def render_whence(self, whence): + if whence <= 2.0: + p_canvas = self.viewer.get_private_canvas() + self._overlay_images(p_canvas, whence=whence) + + def get_camera(self): + return self.camera + + def getOpenGLInfo(self): + self.max_texture_dim = gl.glGetIntegerv(gl.GL_MAX_TEXTURE_SIZE) + info = dict(vendor=gl.glGetString(gl.GL_VENDOR).decode(), + renderer=gl.glGetString(gl.GL_RENDERER).decode(), + opengl_version=gl.glGetString(gl.GL_VERSION).decode(), + shader_version=gl.glGetString(gl.GL_SHADING_LANGUAGE_VERSION).decode(), + max_tex="{}x{}".format(self.max_texture_dim, + self.max_texture_dim)) + return info def gl_initialize(self): - r, g, b = self.viewer.img_bg - gl.glClearColor(r, g, b, 1.0) - gl.glClearDepth(1.0) + global opengl_version + context = self.viewer.make_context_current() + + d = self.getOpenGLInfo() + self.logger.info("OpenGL info--Vendor: '%(vendor)s' " + "Renderer: '%(renderer)s' " + "Version: '%(opengl_version)s' " + "Shader: '%(shader_version)s' " + "Max texture: '%(max_tex)s'" % d) + + opengl_version = LooseVersion(d['opengl_version'].split(' ')[0]) + + if self.use_offscreen_fbo: + self.create_offscreen_fbo() + + # --- line drawing shaders --- + self.pgm_mgr.load_program('shape', shader_dir) + + # --- setup VAO for line drawing --- + shader = self.pgm_mgr.setup_program('shape') + + # Create a new VAO (Vertex Array Object) and bind it + self.vao_line = gl.glGenVertexArrays(1) + gl.glBindVertexArray(self.vao_line) + + # Generate buffers to hold our vertices + self.vbo_line = gl.glGenBuffers(1) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo_line) + gl.glBufferData(gl.GL_ARRAY_BUFFER, None, gl.GL_DYNAMIC_DRAW) + # Get the position of the 'position' in parameter of our shader + # and bind it. + _pos = gl.glGetAttribLocation(shader, 'position') + gl.glEnableVertexAttribArray(_pos) + # Describe the position data layout in the buffer + gl.glVertexAttribPointer(_pos, 3, gl.GL_FLOAT, False, 0, + ctypes.c_void_p(0)) + + # Unbind the VAO first (important) + gl.glBindVertexArray(0) + #gl.glDisableVertexAttribArray(_pos) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) + self.pgm_mgr.setup_program(None) + + # --- image drawing shaders --- + self.pgm_mgr.load_program('image', shader_dir) + shader = self.pgm_mgr.setup_program('image') + + # --- setup VAO for image drawing --- + self.vao_img = gl.glGenVertexArrays(1) + gl.glBindVertexArray(self.vao_img) + + # Generate buffers to hold our vertices + self.vbo_img = gl.glGenBuffers(1) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo_img) + gl.glBufferData(gl.GL_ARRAY_BUFFER, None, gl.GL_DYNAMIC_DRAW) + # Get the position of the 'position' in parameter of our shader + # and bind it. + _pos = gl.glGetAttribLocation(shader, 'position') + gl.glEnableVertexAttribArray(_pos) + # Describe the position data layout in the buffer + gl.glVertexAttribPointer(_pos, 3, gl.GL_FLOAT, False, 5 * 4, + ctypes.c_void_p(0)) + _pos2 = gl.glGetAttribLocation(shader, 'i_tex_coord') + gl.glEnableVertexAttribArray(_pos2) + gl.glVertexAttribPointer(_pos2, 2, gl.GL_FLOAT, False, 5 * 4, + ctypes.c_void_p(3 * 4)) + + # Unbind the VAO first + gl.glBindVertexArray(0) + #gl.glDisableVertexAttribArray(_pos) + #gl.glDisableVertexAttribArray(_pos2) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) + self.pgm_mgr.setup_program(None) + + self.cmap_buf = gl.glGenBuffers(1) + gl.glBindBuffer(gl.GL_TEXTURE_BUFFER, self.cmap_buf) + gl.glBufferData(gl.GL_TEXTURE_BUFFER, self._cmap_len * 4, None, + gl.GL_DYNAMIC_DRAW) + gl.glBindBuffer(gl.GL_TEXTURE_BUFFER, 0) + + rgbmap = self.viewer.get_rgbmap() + if rgbmap not in self.cmap_uploads: + self.cmap_uploads.append(rgbmap) gl.glDisable(gl.GL_CULL_FACE) gl.glFrontFace(gl.GL_CCW) - gl.glDisable(gl.GL_LIGHTING) - gl.glShadeModel(gl.GL_FLAT) - #gl.glShadeModel(gl.GL_SMOOTH) - - self.setup_3D(self.mode3d) - - gl.glEnable(gl.GL_TEXTURE_2D) - gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) - self.tex_id = gl.glGenTextures(1) - gl.glBindTexture(gl.GL_TEXTURE_2D, self.tex_id) - ## gl.glTexParameterf(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, - ## gl.GL_CLAMP) - ## gl.glTexParameterf(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, - ## gl.GL_CLAMP) + self._initialized = True + + def gl_set_image(self, tex_id, img_arr, image_type): + """NOTE: this is a slow operation--downloading a texture.""" + #context = self.viewer.make_context_current() + + ht, wd = img_arr.shape[:2] + + gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_R, + gl.GL_CLAMP_TO_EDGE) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, + gl.GL_CLAMP_TO_EDGE) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, + gl.GL_CLAMP_TO_EDGE) + + if image_type in (0, 1): + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1) + gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, wd, ht, 0, + gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, img_arr) + self.logger.debug("uploaded rgbarr as texture {}".format(tex_id)) + + elif image_type == 2: + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 4) + gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_R32F, wd, ht, 0, + gl.GL_RED, gl.GL_FLOAT, img_arr) + self.logger.debug("uploaded mono as texture {}".format(tex_id)) + + else: + gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 4) + gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RG32F, wd, ht, 0, + gl.GL_RG, gl.GL_FLOAT, img_arr) + self.logger.debug("uploaded mono w/alpha as texture {}".format(tex_id)) + + gl.glBindTexture(gl.GL_TEXTURE_2D, 0) + + def gl_set_cmap(self, rgbmap): + # TODO: this does not yet work with 'histeq' color distribution + # Downsample color distribution hash to our opengl colormap length + hashsize = rgbmap.get_hash_size() + idx = rgbmap.get_hasharray(np.arange(0, hashsize)) + xi = (np.arange(0, self._cmap_len) * (hashsize / self._cmap_len)).clip(0, hashsize).astype(np.uint) + if len(xi) != self._cmap_len: + raise render.RenderError("Error generating color hash table index: size mismatch {} != {}".format(len(xi), self._cmap_len)) + + idx = idx[xi] + img_arr = np.ascontiguousarray(rgbmap.arr[rgbmap.sarr[idx]], + dtype=np.uint8) + + # append alpha channel + wd = img_arr.shape[0] + alpha = np.full((wd, 1), self._cmap_len - 1, dtype=np.uint8) + img_arr = np.concatenate((img_arr, alpha), axis=1) + map_id = self.get_texture_id(rgbmap.mapper_id) + + # transfer colormap info to GPU buffer + #context = self.viewer.make_context_current() + gl.glBindBuffer(gl.GL_TEXTURE_BUFFER, self.cmap_buf) + gl.glBufferSubData(gl.GL_TEXTURE_BUFFER, 0, img_arr) + gl.glBindBuffer(gl.GL_TEXTURE_BUFFER, 0) + self.logger.debug("uploaded cmap as texture buffer {}".format(map_id)) + + def gl_set_image_interpolation(self, interp): + # TODO + pass + + def gl_draw_image(self, cvs_img, cp): + if not self._drawing: + # this test ensures that we are not trying to draw before + # the OpenGL context is set for us correctly + return + + cache = cvs_img.get_cache(self.viewer) + # TODO: put tex_id in cache? + tex_id = self.get_texture_id(cvs_img.image_id) + rgbmap = self.viewer.get_rgbmap() + map_id = self.get_texture_id(rgbmap.mapper_id) + self.pgm_mgr.setup_program('image') + gl.glBindVertexArray(self.vao_img) + + _loc = self.pgm_mgr.get_uniform_loc("projection") + gl.glUniformMatrix4fv(_loc, 1, False, self.camera.proj_mtx) + _loc = self.pgm_mgr.get_uniform_loc("view") + gl.glUniformMatrix4fv(_loc, 1, False, self.camera.view_mtx) + + gl.glActiveTexture(gl.GL_TEXTURE0 + 0) + gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) + _loc = self.pgm_mgr.get_uniform_loc("img_texture") + gl.glUniform1i(_loc, 0) + + gl.glActiveTexture(gl.GL_TEXTURE0 + 1) + gl.glBindTexture(gl.GL_TEXTURE_BUFFER, map_id) + gl.glTexBuffer(gl.GL_TEXTURE_BUFFER, gl.GL_RGBA8UI, self.cmap_buf) + _loc = self.pgm_mgr.get_uniform_loc("color_map") + gl.glUniform1i(_loc, 1) + + _loc = self.pgm_mgr.get_uniform_loc("image_type") + gl.glUniform1i(_loc, cache.image_type) + + # if image has fixed cut levels, use those + cuts = getattr(cvs_img, 'cuts', None) + if cuts is not None: + loval, hival = cuts + else: + loval, hival = self._levels + + _loc = self.pgm_mgr.get_uniform_loc("loval") + gl.glUniform1f(_loc, loval) + + _loc = self.pgm_mgr.get_uniform_loc("hival") + gl.glUniform1f(_loc, hival) + + # pad with z=0 coordinate if lacking + vertices = trcalc.pad_z(cp, dtype=np.float32) + + # Send the data over to the buffer + # NOTE: we swap elements 0 and 1, because we will also swap + # vertices 0 and 1, this allows us to draw two triangles to complete + # the image + texcoord = np.array([(1.0, 0.0), (0.0, 0.0), + (1.0, 1.0), (0.0, 1.0)], dtype=np.float32) + # swap vertices of rows 0 and 1 + vertices[[0, 1]] = vertices[[1, 0]] + data = np.concatenate((vertices, texcoord), axis=1) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo_img) + # see https://www.khronos.org/opengl/wiki/Buffer_Object_Streaming + #gl.glBufferData(gl.GL_ARRAY_BUFFER, None, gl.GL_DYNAMIC_DRAW) + gl.glBufferData(gl.GL_ARRAY_BUFFER, data, gl.GL_DYNAMIC_DRAW) + + gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) + + # See NOTE above + gl.glDrawArrays(gl.GL_TRIANGLES, 0, 3) + gl.glDrawArrays(gl.GL_TRIANGLES, 1, 4) + + gl.glBindVertexArray(0) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) + self.pgm_mgr.setup_program(None) + + def gl_draw_shape(self, gl_shape, cpoints, brush, pen): + + if not self._drawing: + # this test ensures that we are not trying to draw before + # the OpenGL context is set for us correctly + return - def gl_set_image(self, img_np, pos): - dst_x, dst_y = pos[:2] - # TODO: can we avoid this transformation? - data = np.flipud(img_np[0:self.ht, 0:self.wd]) - ht, wd = data.shape[:2] - self._img_pos = ((dst_x, dst_y), (dst_x + wd, dst_y + ht)) + # pad with z=0 coordinate if lacking + z_pts = trcalc.pad_z(cpoints, dtype=np.float32) - gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, wd, ht, 0, - gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, data) + gl.glEnable(gl.GL_BLEND) + gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) + + self.pgm_mgr.setup_program('shape') + gl.glBindVertexArray(self.vao_line) + + # Update the vertices data in the VBO + vertices = z_pts.astype(np.float32) + + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo_line) + # see https://www.khronos.org/opengl/wiki/Buffer_Object_Streaming + #gl.glBufferData(gl.GL_ARRAY_BUFFER, None, gl.GL_DYNAMIC_DRAW) + gl.glBufferData(gl.GL_ARRAY_BUFFER, vertices, gl.GL_DYNAMIC_DRAW) + + _loc = self.pgm_mgr.get_uniform_loc("projection") + gl.glUniformMatrix4fv(_loc, 1, False, self.camera.proj_mtx) + _loc = self.pgm_mgr.get_uniform_loc("view") + gl.glUniformMatrix4fv(_loc, 1, False, self.camera.view_mtx) + + # update color uniform + _loc = self.pgm_mgr.get_uniform_loc("fg_clr") + + # draw fill, if any + if brush is not None and brush.color is not None: + _c = brush.color + gl.glUniform4f(_loc, _c[0], _c[1], _c[2], _c[3]) + + gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) + + # TODO: this will not fill in non-convex polygons correctly + gl.glDrawArrays(gl.GL_TRIANGLE_FAN, 0, len(vertices)) + + # draw line, if any + # TODO: support line stippling (dash) + if pen is not None and pen.linewidth > 0: + _c = pen.color + gl.glUniform4f(_loc, _c[0], _c[1], _c[2], _c[3]) + + # draw outline + gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) + gl.glLineWidth(pen.linewidth) + + gl.glDrawArrays(gl_shape, 0, len(vertices)) + + gl.glBindVertexArray(0) + gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0) + self.pgm_mgr.setup_program(None) + + def show_errors(self): + while True: + err = gl.glGetError() + if err == gl.GL_NO_ERROR: + return + self.logger.error("gl error: {}".format(err)) def gl_resize(self, width, height): self.wd, self.ht = width, height - self.lim_x, self.lim_y = width / 2.0, height / 2.0 - self.lim_z = (self.lim_x + self.lim_y) / 2.0 + context = self.viewer.make_context_current() - self.mn_x, self.mx_x = -self.lim_x, self.lim_x - self.mn_y, self.mx_y = -self.lim_y, self.lim_y - self.mn_z, self.mx_z = -self.lim_z, self.lim_z + gl.glViewport(0, 0, width, height) self.camera.set_viewport_dimensions(width, height) - gl.glViewport(0, 0, width, height) + self.camera.calc_gl_transform() def gl_paint(self): - self._drawing = True - try: - self.setup_3D(self.mode3d) + with self.lock: + context = self.viewer.make_context_current() - r, g, b = self.viewer.img_bg - gl.glClearColor(r, g, b, 1.0) - gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) - - image = self.viewer.get_image() - if (image is not None) and (self._img_pos is not None): - # Draw the image portion of the plot - gl.glColor4f(1, 1, 1, 1.0) - gl.glEnable(gl.GL_TEXTURE_2D) - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) - gl.glBegin(gl.GL_QUADS) - try: - gl.glTexCoord(0, 0) - gl.glVertex(self.mn_x, self.mn_y) - gl.glTexCoord(1, 0) - gl.glVertex(self.mx_x, self.mn_y) - gl.glTexCoord(1, 1) - gl.glVertex(self.mx_x, self.mx_y) - gl.glTexCoord(0, 1) - gl.glVertex(self.mn_x, self.mx_y) - finally: - gl.glEnd() + # perform any necessary image updates + uploads, self.image_uploads = self.image_uploads, [] + for image_id, img_arr, image_type in uploads: + tex_id = self.get_texture_id(image_id) + self.gl_set_image(tex_id, img_arr, image_type) + + # perform any necessary rgbmap updates + rgbmap = self.viewer.get_rgbmap() + if rgbmap not in self.cmap_uploads: + self.cmap_uploads.append(rgbmap) + uploads, self.cmap_uploads = self.cmap_uploads, [] + for rgbmap in uploads: + self.gl_set_cmap(rgbmap) + + self._drawing = True + try: + gl.glDepthFunc(gl.GL_LEQUAL) + gl.glEnable(gl.GL_DEPTH_TEST) + + r, g, b = self.viewer.img_bg + gl.glClearColor(r, g, b, 1.0) + gl.glClearDepth(1.0) + gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) + + cr = RenderContext(self, self.viewer, self.surface) + self.draw_vector(cr) + + finally: + self._drawing = False + gl.glFlush() + self.show_errors() + + def create_offscreen_fbo(self): + if self.fbo is not None: + self.delete_fbo_buffers() + width, height = self.dims + self.fbo_size = self.dims + self.color_buf = gl.glGenRenderbuffers(1) + self.depth_buf = gl.glGenRenderbuffers(1) + + # binds created FBO to context both for read and draw + self.fbo = gl.glGenFramebuffers(1) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo) + gl.glViewport(0, 0, width, height) - gl.glDisable(gl.GL_TEXTURE_2D) - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) + # bind color render buffer + gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self.color_buf) + gl.glRenderbufferStorage(gl.GL_RENDERBUFFER, gl.GL_RGBA8, width, height) + gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, gl.GL_COLOR_ATTACHMENT0, + gl.GL_RENDERBUFFER, self.color_buf) + + # bind depth render buffer + ## gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self.depth_buf) + ## gl.glRenderbufferStorage(gl.GL_RENDERBUFFER, gl.GL_DEPTH_COMPONENT, + ## width, height) + ## gl.glFramebufferRenderbuffer(gl.GL_FRAMEBUFFER, gl.GL_DEPTH_ATTACHMENT, + ## gl.GL_RENDERBUFFER, self.depth_buf) + + self.drawbuffers = [gl.GL_COLOR_ATTACHMENT0] + gl.glDrawBuffers(1, self.drawbuffers) + + # check FBO status + # TODO: returning a non-zero status, even though it seems to be working + # fine. + ## status = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) + ## if status != gl.GL_FRAMEBUFFER_COMPLETE: + ## raise render.RenderError("Error initializing offscreen framebuffer: status={}".format(status)) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) + + def delete_fbo_buffers(self): + if self.color_buf is not None: + gl.glDeleteRenderbuffers(1, [self.color_buf]) + self.color_buf = None + if self.depth_buf is not None: + gl.glDeleteRenderbuffers(1, [self.depth_buf]) + self.depth_buf = None + if self.fbo is not None: + gl.glDeleteFramebuffers(1, [self.fbo]) + self.fbo = None + self.fbo_size = (0, 0) + + def get_surface_as_array(self, order='RGBA'): + if self.dims != self.fbo_size: + self.create_offscreen_fbo() + width, height = self.dims + + context = self.viewer.make_context_current() + + # some widget sets use a non-default FBO for rendering, so save + # and restore + cur_fbo = gl.glGetIntegerv(gl.GL_FRAMEBUFFER_BINDING) - gl.glEnable(gl.GL_BLEND) - gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo) + gl.glViewport(0, 0, width, height) + ## if self.use_offscreen_fbo: + ## gl.glReadBuffer(gl.GL_COLOR_ATTACHMENT0) - if self.mode3d and self.draw_spines: - # draw orienting spines radiating in x, y and z - gl.glColor(1.0, 0.0, 0.0) - gl.glBegin(gl.GL_LINES) - gl.glVertex(self.mn_x, 0, 0) - gl.glVertex(self.mx_x, 0, 0) - gl.glEnd() - gl.glColor(0.0, 1.0, 0.0) - gl.glBegin(gl.GL_LINES) - gl.glVertex(0, self.mn_y, 0) - gl.glVertex(0, self.mx_y, 0) - gl.glEnd() - gl.glColor(0.0, 0.0, 1.0) - gl.glBegin(gl.GL_LINES) - gl.glVertex(0, 0, self.mn_z) - gl.glVertex(0, 0, self.mx_z) - gl.glEnd() + try: + self.gl_paint() + img_buf = gl.glReadPixels(0, 0, width, height, gl.GL_RGBA, + gl.GL_UNSIGNED_BYTE) + finally: + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, cur_fbo) - # Draw the overlays - p_canvas = self.viewer.get_private_canvas() - p_canvas.draw(self.viewer) + # seems to be necessary to redraw the main window + self.viewer.update_widget() - finally: - self._drawing = False - gl.glFlush() + img_np = np.frombuffer(img_buf, dtype=np.uint8).reshape(height, + width, 4) + img_np = np.flipud(img_np) + if order is None or order == 'RGBA': + return img_np -class WindowGLTransform(BaseTransform): - """ - A transform from window coordinates to OpenGL coordinates of a viewer. - """ + img_np = trcalc.reorder_image(order, img_np, 'RGBA') + return img_np - def __init__(self, viewer): - super(WindowGLTransform, self).__init__() - self.viewer = viewer + def initialize(self): + self.rl = [] - def pix2canvas(self, pt): - """Takes a 2-tuple of (x, y) in window coordinates and gives - the (cx, cy, cz) coordinates on the canvas. - """ - x, y = pt[:2] - #print('p2c in', x, y) - mm = gl.glGetDoublev(gl.GL_MODELVIEW_MATRIX) - pm = gl.glGetDoublev(gl.GL_PROJECTION_MATRIX) - vp = gl.glGetIntegerv(gl.GL_VIEWPORT) - - win_x, win_y = float(x), float(vp[3] - y) - win_z = gl.glReadPixels(int(win_x), int(win_y), 1, 1, - gl.GL_DEPTH_COMPONENT, gl.GL_FLOAT) - pos = glu.gluUnProject(win_x, win_y, win_z, mm, pm, vp) - #print('out', pos) - return pos - - def canvas2pix(self, pos): - """Takes a 3-tuple of (cx, cy, cz) in canvas coordinates and gives - the (x, y, z) pixel coordinates in the window. - """ - x, y, z = pos - #print('c2p in', x, y, z) - mm = gl.glGetDoublev(gl.GL_MODELVIEW_MATRIX) - pm = gl.glGetDoublev(gl.GL_PROJECTION_MATRIX) - vp = gl.glGetIntegerv(gl.GL_VIEWPORT) - - pt = glu.gluProject(x, y, z, mm, pm, vp) - #print('c2p out', pt) - return pt - - def get_bbox(self, wd, ht): - return (self.pix2canvas((0, 0)), - self.pix2canvas((wd, 0)), - self.pix2canvas((wd, ht)), - self.pix2canvas((0, ht)) - ) + def finalize(self): + # for this renderer, this is handled in gl_paint() + pass - def to_(self, pts): - return np.asarray([self.pix2canvas(pt) for pt in pts]) + def setup_cr(self, shape): + # special cr that just stores up a render list + cr = vec.RenderContext(self, self.viewer, self.surface) + cr.initialize_from_shape(shape, font=False) + return cr - def from_(self, cvs_pts): - return np.asarray([self.canvas2pix(pt) for pt in cvs_pts]) + def text_extents(self, text, font): + cr = RenderContext(self, self.viewer, self.surface) + cr.set_font(font.fontname, font.fontsize, color=font.color, + alpha=font.alpha) + return cr.text_extents(text) + + def calc_const_len(self, clen): + # zoom is accomplished by viewing distance in OpenGL, so we + # have to adjust clen by scale to get a constant size + scale = self.viewer.get_scale_max() + return clen / scale + def scale_fontsize(self, fontsize): + return fontsize -# END + def get_dimensions(self, shape): + cr = RenderContext(self, self.viewer, self.surface) + cr.set_font_from_shape(shape) + return cr.text_extents(shape.text) diff -Nru ginga-3.0.0/ginga/opengl/geometry_helper.py ginga-3.1.0/ginga/opengl/geometry_helper.py --- ginga-3.0.0/ginga/opengl/geometry_helper.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/geometry_helper.py 2020-07-08 20:09:29.000000000 +0000 @@ -152,8 +152,11 @@ class Matrix4x4(object): - def __init__(self): - self.set_to_identity() + def __init__(self, arr4x4=None): + if arr4x4 is None: + self.m = np.eye(4) + else: + self.m = np.asarray(arr4x4).reshape((4, 4)) def __str__(self): return str(self.m) @@ -166,9 +169,7 @@ return self.m def copy(self): - M = Matrix4x4() - M.m = self.m.copy() - return M + return Matrix4x4(self.m) def set_to_identity(self): self.m = np.eye(4) @@ -176,9 +177,10 @@ @staticmethod def translation(vector3D): M = Matrix4x4() - M.f[12] = vector3D.x - M.f[13] = vector3D.y - M.f[14] = vector3D.z + ## M.f[12] = vector3D.x + ## M.f[13] = vector3D.y + ## M.f[14] = vector3D.z + M.f[12:15] = vector3D.coord return M @staticmethod @@ -187,30 +189,21 @@ c = np.cos(angle_rad) s = np.sin(angle_rad) one_minus_c = 1 - c - M = Matrix4x4() - M.f[0] = c + one_minus_c * axis_vector.x * axis_vector.x - M.f[5] = c + one_minus_c * axis_vector.y * axis_vector.y - M.f[10] = c + one_minus_c * axis_vector.z * axis_vector.z - M.f[1] = M.f[4] = one_minus_c * axis_vector.x * axis_vector.y - M.f[2] = M.f[8] = one_minus_c * axis_vector.x * axis_vector.z - M.f[6] = M.f[9] = one_minus_c * axis_vector.y * axis_vector.z + + c0 = c + one_minus_c * axis_vector.x * axis_vector.x + c1 = one_minus_c * axis_vector.x * axis_vector.y + c2 = one_minus_c * axis_vector.x * axis_vector.z + c3 = c + one_minus_c * axis_vector.y * axis_vector.y + c4 = one_minus_c * axis_vector.y * axis_vector.z + c5 = c + one_minus_c * axis_vector.z * axis_vector.z xs = axis_vector.x * s ys = axis_vector.y * s zs = axis_vector.z * s - M.f[1] += zs - M.f[4] -= zs - M.f[2] -= ys - M.f[8] += ys - M.f[6] += xs - M.f[9] -= xs - - M.f[12] = 0.0 - M.f[13] = 0.0 - M.f[14] = 0.0 - M.f[3] = 0.0 - M.f[7] = 0.0 - M.f[11] = 0.0 - M.f[15] = 1.0 + + M = Matrix4x4((c0, c1 + zs, c2 - ys, 0.0, + c1 - zs, c3, c4 + xs, 0.0, + c2 + ys, c4 - xs, c5, 0.0, + 0.0, 0.0, 0.0, 1.0)) return M @staticmethod @@ -244,48 +237,22 @@ x = x.normalized() y = y.normalized() - M = Matrix4x4() - if is_inverted: # the rotation matrix - M.f[0] = x.x - M.f[4] = y.x - M.f[8] = z.x - M.f[12] = 0.0 - M.f[1] = x.y - M.f[5] = y.y - M.f[9] = z.y - M.f[13] = 0.0 - M.f[2] = x.z - M.f[6] = y.z - M.f[10] = z.z - M.f[14] = 0.0 - M.f[3] = 0.0 - M.f[7] = 0.0 - M.f[11] = 0.0 - M.f[15] = 1.0 + M = Matrix4x4((x.x, x.y, x.z, 0.0, + y.x, y.y, y.z, 0.0, + z.x, z.y, z.z, 0.0, + 0.0, 0.0, 0.0, 1.0)) # step two: premultiply by a translation matrix return Matrix4x4.translation(eye_point.as_Vector3D()) * M else: # the rotation matrix - M.f[0] = x.x - M.f[4] = x.y - M.f[8] = x.z - M.f[12] = 0.0 - M.f[1] = y.x - M.f[5] = y.y - M.f[9] = y.z - M.f[13] = 0.0 - M.f[2] = z.x - M.f[6] = z.y - M.f[10] = z.z - M.f[14] = 0.0 - M.f[3] = 0.0 - M.f[7] = 0.0 - M.f[11] = 0.0 - M.f[15] = 1.0 + M = Matrix4x4((x.x, y.x, z.x, 0.0, + x.y, y.y, z.y, 0.0, + x.z, y.z, z.z, 0.0, + 0.0, 0.0, 0.0, 1.0)) # step two: postmultiply by a translation matrix return M * Matrix4x4.translation(- eye_point.as_Vector3D()) @@ -293,42 +260,7 @@ def __mul__(self, b): a = self if isinstance(b, Matrix4x4): - M = Matrix4x4() - M.f[0] = (a.f[0] * b.f[0] + a.f[4] * b.f[1] + - a.f[8] * b.f[2] + a.f[12] * b.f[3]) - M.f[1] = (a.f[1] * b.f[0] + a.f[5] * b.f[1] + - a.f[9] * b.f[2] + a.f[13] * b.f[3]) - M.f[2] = (a.f[2] * b.f[0] + a.f[6] * b.f[1] + - a.f[10] * b.f[2] + a.f[14] * b.f[3]) - M.f[3] = (a.f[3] * b.f[0] + a.f[7] * b.f[1] + - a.f[11] * b.f[2] + a.f[15] * b.f[3]) - - M.f[4] = (a.f[0] * b.f[4] + a.f[4] * b.f[5] + - a.f[8] * b.f[6] + a.f[12] * b.f[7]) - M.f[5] = (a.f[1] * b.f[4] + a.f[5] * b.f[5] + - a.f[9] * b.f[6] + a.f[13] * b.f[7]) - M.f[6] = (a.f[2] * b.f[4] + a.f[6] * b.f[5] + - a.f[10] * b.f[6] + a.f[14] * b.f[7]) - M.f[7] = (a.f[3] * b.f[4] + a.f[7] * b.f[5] + - a.f[11] * b.f[6] + a.f[15] * b.f[7]) - - M.f[8] = (a.f[0] * b.f[8] + a.f[4] * b.f[9] + - a.f[8] * b.f[10] + a.f[12] * b.f[11]) - M.f[9] = (a.f[1] * b.f[8] + a.f[5] * b.f[9] + - a.f[9] * b.f[10] + a.f[13] * b.f[11]) - M.f[10] = (a.f[2] * b.f[8] + a.f[6] * b.f[9] + - a.f[10] * b.f[10] + a.f[14] * b.f[11]) - M.f[11] = (a.f[3] * b.f[8] + a.f[7] * b.f[9] + - a.f[11] * b.f[10] + a.f[15] * b.f[11]) - - M.f[12] = (a.f[0] * b.f[12] + a.f[4] * b.f[13] + - a.f[8] * b.f[14] + a.f[12] * b.f[15]) - M.f[13] = (a.f[1] * b.f[12] + a.f[5] * b.f[13] + - a.f[9] * b.f[14] + a.f[13] * b.f[15]) - M.f[14] = (a.f[2] * b.f[12] + a.f[6] * b.f[13] + - a.f[10] * b.f[14] + a.f[14] * b.f[15]) - M.f[15] = (a.f[3] * b.f[12] + a.f[7] * b.f[13] + - a.f[11] * b.f[14] + a.f[15] * b.f[15]) + M = Matrix4x4(np.dot(b.m, a.m)) return M elif isinstance(b, Vector3D): diff -Nru ginga-3.0.0/ginga/opengl/GlHelp.py ginga-3.1.0/ginga/opengl/GlHelp.py --- ginga-3.0.0/ginga/opengl/GlHelp.py 2019-08-24 00:57:36.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/GlHelp.py 2020-07-08 20:09:29.000000000 +0000 @@ -6,8 +6,11 @@ import os.path +from OpenGL import GL as gl + from ginga import colors import ginga.fonts +from ginga.canvas import transform # Set up known fonts fontdir, xx = os.path.split(ginga.fonts.__file__) @@ -72,12 +75,8 @@ self.widget = widget def get_color(self, color, alpha=1.0): - if isinstance(color, str) or isinstance(color, type(u"")): - r, g, b = colors.lookup_color(color) - elif isinstance(color, tuple): - # color is assumed to be a 3-tuple of RGB values as floats - # between 0 and 1 - r, g, b = color[:3] + if color is not None: + r, g, b = colors.resolve_color(color) else: r, g, b = 1.0, 1.0, 1.0 @@ -103,4 +102,178 @@ ht = font.fontsize return wd, ht -#END + +class ShaderManager: + """Class for building/managing/using GLSL shader programs. + """ + def __init__(self, logger): + self.logger = logger + + self.pgms = {} + self.program = None + self.shader = None + + def build_program(self, name, vertex_source, fragment_source): + """Build a GL shader program from vertex and fragment sources. + + Parameters + ---------- + name : str + Name under which to store this program + + vertex_source : str + source code for the vertex shader + + fragment_source : str + source code for the fragment shader + + Returns + ------- + pgm_id : int + program id of the compiled shader + """ + pgm_id = gl.glCreateProgram() + vert_id = self._add_shader(vertex_source, gl.GL_VERTEX_SHADER) + frag_id = self._add_shader(fragment_source, gl.GL_FRAGMENT_SHADER) + + gl.glAttachShader(pgm_id, vert_id) + gl.glAttachShader(pgm_id, frag_id) + gl.glLinkProgram(pgm_id) + + if gl.glGetProgramiv(pgm_id, gl.GL_LINK_STATUS) != gl.GL_TRUE: + info = gl.glGetProgramInfoLog(pgm_id) + gl.glDeleteProgram(pgm_id) + gl.glDeleteShader(vert_id) + gl.glDeleteShader(frag_id) + self.logger.error('Error linking GLSL program: %s' % (info)) + raise RuntimeError('Error linking GLSL program: %s' % (info)) + + gl.glDeleteShader(vert_id) + gl.glDeleteShader(frag_id) + + self.pgms[name] = pgm_id + return pgm_id + + def load_program(self, name, dirpath): + """Load a GL shader program from sources on disk. + + Parameters + ---------- + name : str + Name under which to store this program + + dirpath : str + path to where the vertex and fragment shader sources are stored + + Returns + ------- + pgm_id : int + program id of the compiled shader + """ + vspath = os.path.join(dirpath, name + '.vert') + with open(vspath, 'r') as in_f: + vert_source = in_f.read().encode() + + fgpath = os.path.join(dirpath, name + '.frag') + with open(fgpath, 'r') as in_f: + frag_source = in_f.read().encode() + + return self.build_program(name, vert_source, frag_source) + + def setup_program(self, name): + """Set up to use a shader program. + + Parameters + ---------- + name : str + Name of the shader program to use + + Returns + ------- + shader : + The OpenGL shader program + """ + self.program = name + if name is None: + gl.glUseProgram(0) + self.shader = None + else: + self.shader = self.pgms[name] + gl.glUseProgram(self.shader) + return self.shader + + def get_uniform_loc(self, attr_name): + """Get the location of a shader program uniform variable. + + Parameters + ---------- + attr_name : str + Name of the shader program attribute + + Returns + ------- + loc : int + The location of the attribute + """ + _loc = gl.glGetUniformLocation(self.shader, attr_name) + return _loc + + def _add_shader(self, source, shader_type): + try: + shader_id = gl.glCreateShader(shader_type) + gl.glShaderSource(shader_id, source) + gl.glCompileShader(shader_id) + if gl.glGetShaderiv(shader_id, gl.GL_COMPILE_STATUS) != gl.GL_TRUE: + info = gl.glGetShaderInfoLog(shader_id) + raise RuntimeError('Shader compilation failed: %s' % (info)) + + return shader_id + + except Exception as e: + gl.glDeleteShader(shader_id) + raise + + +def get_transforms(v): + tform = { + 'window_to_native': (transform.CartesianWindowTransform(v).invert() + + transform.RotationTransform(v).invert() + + transform.ScaleTransform(v).invert()), + 'cartesian_to_window': (transform.FlipSwapTransform(v) + + transform.CartesianWindowTransform(v)), + 'cartesian_to_native': (transform.FlipSwapTransform(v) + + transform.RotationTransform(v) + + transform.CartesianNativeTransform(v)), + 'data_to_cartesian': (transform.DataCartesianTransform(v) + + transform.ScaleTransform(v)), + 'data_to_scrollbar': (transform.DataCartesianTransform(v) + + transform.FlipSwapTransform(v) + + transform.RotationTransform(v)), + 'mouse_to_data': ( + transform.InvertedTransform(transform.DataCartesianTransform(v) + + transform.ScaleTransform(v) + + transform.FlipSwapTransform(v) + + transform.RotationTransform(v) + + transform.CartesianWindowTransform(v) + )), + 'data_to_window': (transform.DataCartesianTransform(v) + + transform.ScaleTransform(v) + + transform.FlipSwapTransform(v) + + transform.RotationTransform(v) + + transform.CartesianWindowTransform(v) + ), + 'data_to_percentage': (transform.DataCartesianTransform(v) + + transform.ScaleTransform(v) + + transform.FlipSwapTransform(v) + + transform.RotationTransform(v) + + transform.CartesianWindowTransform(v) + + transform.WindowPercentageTransform(v)), + 'data_to_native': (transform.DataCartesianTransform(v) + + transform.FlipSwapTransform(v) + ), + 'wcs_to_data': transform.WCSDataTransform(v), + 'wcs_to_native': (transform.WCSDataTransform(v) + + transform.DataCartesianTransform(v) + + transform.FlipSwapTransform(v)), + } + return tform diff -Nru ginga-3.0.0/ginga/opengl/glsl/image.frag ginga-3.1.0/ginga/opengl/glsl/image.frag --- ginga-3.0.0/ginga/opengl/glsl/image.frag 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/glsl/image.frag 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,95 @@ +#version 330 core +/* + * image.frag -- fragment shader for Ginga images + * + * This is open-source software licensed under a BSD license. + * Please see the file LICENSE.txt for details. + * + */ +out vec4 outputColor; + +in vec2 o_tex_coord; + +uniform sampler2D img_texture; +uniform usamplerBuffer color_map; + +// for cut levels +uniform float loval; +uniform float hival; + +uniform int image_type; + +float cut_levels(float value, float vmax) +{ + float f, delta, _hival; + const float vmin = 0.0; + + // ensure hival >= loval + _hival = max(loval, hival); + delta = _hival - loval; + if (delta > 0.0) { + f = ((value - loval) / delta) * vmax; + f = clamp(f, 0.0, vmax); + return f; + } + + // hival == loval, so thresholding operation + f = clamp(value - loval, 0.0, vmax); + if (f > 0.0) { f = vmax; } + return f; +} + +void main() +{ + vec4 color; + int clen = textureSize(color_map); + float vmax = clen - 1; + + if (image_type == 0) { + // RGBA traditional image, no interactive RGB map + color = texture(img_texture, o_tex_coord); + } + else if (image_type == 1) { + // color image to be colored + vec4 value = texture(img_texture, o_tex_coord); + + // cut levels + // RGBA textures are normalized to 0..1 when unpacked + int idx_r = int(cut_levels(value.r * vmax, vmax)); + int idx_g = int(cut_levels(value.g * vmax, vmax)); + int idx_b = int(cut_levels(value.b * vmax, vmax)); + + // apply RGB mapping + float r = texelFetch(color_map, idx_r).r / vmax; + float g = texelFetch(color_map, idx_g).g / vmax; + float b = texelFetch(color_map, idx_b).b / vmax; + color = vec4(r, g, b, value.a); + } + else if (image_type == 2) { + // monochrome image to be colored + // get source value, passed in single red channel + float value = texture(img_texture, o_tex_coord).r; + + // cut levels + int idx = int(cut_levels(value, vmax)); + + // apply RGB mapping + uvec4 clr = texelFetch(color_map, idx); + color = vec4(clr.r / vmax, clr.g / vmax, clr.b / vmax, + clr.a / vmax); + } + else if (image_type == 3) { + // monochrome image to be colored + // get source and alpha value, passed in red and green channels + vec2 value = texture(img_texture, o_tex_coord).rg; + + // cut levels + int idx = int(cut_levels(value.r, vmax)); + + // apply RGB mapping + uvec4 clr = texelFetch(color_map, idx); + color = vec4(clr.r / vmax, clr.g / vmax, clr.b / vmax, + value.g); + } + outputColor = color; +} diff -Nru ginga-3.0.0/ginga/opengl/glsl/image.vert ginga-3.1.0/ginga/opengl/glsl/image.vert --- ginga-3.0.0/ginga/opengl/glsl/image.vert 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/glsl/image.vert 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,26 @@ +#version 330 core +/* + * image.vert -- vertex shader for Ginga images + * + * This is open-source software licensed under a BSD license. + * Please see the file LICENSE.txt for details. + * + */ +layout (location = 0) in vec3 position; +// input texture coordinate +layout (location = 1) in vec2 i_tex_coord; + +out vec2 o_tex_coord; + +// uniform mat4 model; +uniform mat4 view; +uniform mat4 projection; + +void main() +{ + // note that we read the multiplication from right to left + // gl_Position = projection * view * model * vec4(aPos, 1.0); + gl_Position = projection * view * vec4(position, 1.0); + + o_tex_coord = i_tex_coord; +} diff -Nru ginga-3.0.0/ginga/opengl/glsl/req.py ginga-3.1.0/ginga/opengl/glsl/req.py --- ginga-3.0.0/ginga/opengl/glsl/req.py 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/glsl/req.py 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,6 @@ +# This is open-source software licensed under a BSD license. +# Please see the file LICENSE.txt for details. + +# OpenGL version requirements to use these shaders +major = 4 +minor = 5 diff -Nru ginga-3.0.0/ginga/opengl/glsl/shape.frag ginga-3.1.0/ginga/opengl/glsl/shape.frag --- ginga-3.0.0/ginga/opengl/glsl/shape.frag 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/glsl/shape.frag 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,18 @@ +#version 330 core +/* + * shape.frag -- fragment shader for Ginga shapes + * + * This is open-source software licensed under a BSD license. + * Please see the file LICENSE.txt for details. + * + */ +out vec4 outputColor; + +uniform vec4 fg_clr; + +void main() +{ + // pass thru + outputColor = fg_clr; + +} diff -Nru ginga-3.0.0/ginga/opengl/glsl/shape.vert ginga-3.1.0/ginga/opengl/glsl/shape.vert --- ginga-3.0.0/ginga/opengl/glsl/shape.vert 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/glsl/shape.vert 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,21 @@ +#version 330 core +/* + * shape.vert -- vertex shader for Ginga shapes + * + * This is open-source software licensed under a BSD license. + * Please see the file LICENSE.txt for details. + * + */ +layout (location = 0) in vec3 position; + +// uniform mat4 model; +uniform mat4 view; +uniform mat4 projection; + +void main() +{ + // note that we read the multiplication from right to left + // gl_Position = projection * view * model * vec4(aPos, 1.0); + gl_Position = projection * view * vec4(position, 1.0); + +} diff -Nru ginga-3.0.0/ginga/opengl/ImageViewQtGL.py ginga-3.1.0/ginga/opengl/ImageViewQtGL.py --- ginga-3.0.0/ginga/opengl/ImageViewQtGL.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/opengl/ImageViewQtGL.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,179 +0,0 @@ -# -# ImageViewQtGL.py -- a backend for Ginga using Qt's QGLWidget -# -# This is open-source software licensed under a BSD license. -# Please see the file LICENSE.txt for details. -# -from io import BytesIO - -from ginga.qtw.QtHelp import QtCore -from ginga.qtw import ImageViewQt -from ginga import Mixins, Bindings -from ginga.canvas import transform - -# Local imports -from .CanvasRenderGL import CanvasRenderer - -# GL imports -# TODO: find how to import this from qtpy -from PyQt5.QtOpenGL import QGLWidget as QOpenGLWidget - - -class ImageViewQtGLError(ImageViewQt.ImageViewQtError): - pass - - -class RenderGLWidget(QOpenGLWidget): - - def __init__(self, *args, **kwdargs): - QOpenGLWidget.__init__(self, *args, **kwdargs) - - self.viewer = None - - def initializeGL(self): - self.viewer.renderer.gl_initialize() - - def resizeGL(self, width, height): - self.viewer.configure_window(width, height) - - def paintGL(self): - self.viewer.renderer.gl_paint() - - def sizeHint(self): - width, height = 300, 300 - if self.viewer is not None: - width, height = self.viewer.get_desired_size() - return QtCore.QSize(width, height) - - -class ImageViewQtGL(ImageViewQt.ImageViewQt): - - def __init__(self, logger=None, rgbmap=None, settings=None): - ImageViewQt.ImageViewQt.__init__(self, logger=logger, - rgbmap=rgbmap, settings=settings) - - self.imgwin = RenderGLWidget() - self.imgwin.viewer = self - # Qt expects 32bit BGRA data for color images - self.rgb_order = 'RGBA' - - self.renderer = CanvasRenderer(self) - - # we replace two transforms in the catalog for OpenGL rendering - #self.trcat['WindowNativeTransform'] = WindowGLTransform - self.trcat['WindowNativeTransform'] = \ - transform.CartesianWindowTransform.inverted_class() - self.trcat['CartesianNativeTransform'] = transform.PassThruTransform - self.recalc_transforms() - - def configure_window(self, width, height): - self.logger.debug("window size reconfigured to %dx%d" % ( - width, height)) - self.renderer.resize((width, height)) - - self.configure(width, height) - - def get_rgb_image_as_widget(self): - return self.imgwin.grabFrameBuffer() - - def get_rgb_image_as_buffer(self, output=None, format='png', - quality=90): - ibuf = output - if ibuf is None: - ibuf = BytesIO() - - qimg = self.get_rgb_image_as_widget() - qimg.save(ibuf, format=format, quality=quality) - return ibuf - - def update_image(self): - if self.imgwin is None: - return - - self.logger.debug("updating window") - self.imgwin.update() - - def gl_update(self): - self.imgwin.update() - - -class RenderGLWidgetZoom(ImageViewQt.RenderMixin, RenderGLWidget): - pass - - -class ImageViewEvent(ImageViewQtGL, ImageViewQt.QtEventMixin): - - def __init__(self, logger=None, rgbmap=None, settings=None): - ImageViewQtGL.__init__(self, logger=logger, rgbmap=rgbmap, - settings=settings) - - # replace the widget our parent provided - imgwin = RenderGLWidgetZoom() - - imgwin.viewer = self - self.imgwin = imgwin - - ImageViewQt.QtEventMixin.__init__(self) - - -class ImageViewZoom(Mixins.UIMixin, ImageViewEvent): - - # class variables for binding map and bindings can be set - bindmapClass = Bindings.BindingMapper - bindingsClass = Bindings.ImageViewBindings - - @classmethod - def set_bindingsClass(cls, klass): - cls.bindingsClass = klass - - @classmethod - def set_bindmapClass(cls, klass): - cls.bindmapClass = klass - - def __init__(self, logger=None, settings=None, rgbmap=None, - bindmap=None, bindings=None): - ImageViewEvent.__init__(self, logger=logger, settings=settings, - rgbmap=rgbmap) - Mixins.UIMixin.__init__(self) - - self.ui_set_active(True) - - if bindmap is None: - bindmap = ImageViewZoom.bindmapClass(self.logger) - self.bindmap = bindmap - bindmap.register_for_events(self) - - if bindings is None: - bindings = ImageViewZoom.bindingsClass(self.logger) - self.set_bindings(bindings) - - def get_bindmap(self): - return self.bindmap - - def get_bindings(self): - return self.bindings - - def set_bindings(self, bindings): - self.bindings = bindings - bindings.set_bindings(self) - - -class CanvasView(ImageViewZoom): - - def __init__(self, logger=None, settings=None, rgbmap=None, - bindmap=None, bindings=None): - ImageViewZoom.__init__(self, logger=logger, settings=settings, - rgbmap=rgbmap, - bindmap=bindmap, bindings=bindings) - - # Needed for UIMixin to propagate events correctly - self.objects = [self.private_canvas] - - def set_canvas(self, canvas, private_canvas=None): - super(CanvasView, self).set_canvas(canvas, - private_canvas=private_canvas) - - self.objects[0] = self.private_canvas - - -#END diff -Nru ginga-3.0.0/ginga/pilw/CanvasRenderPil.py ginga-3.1.0/ginga/pilw/CanvasRenderPil.py --- ginga-3.0.0/ginga/pilw/CanvasRenderPil.py 2019-09-09 18:09:55.000000000 +0000 +++ ginga-3.1.0/ginga/pilw/CanvasRenderPil.py 2020-07-08 20:09:29.000000000 +0000 @@ -79,6 +79,15 @@ else: self.brush = self.cr.get_brush(color, alpha=alpha) + def setup_pen_brush(self, pen, brush): + # pen, brush are from ginga.vec + self.pen = self.cr.get_pen(pen.color, alpha=pen.alpha, + linewidth=pen.linewidth) + if brush is None: + self.brush = None + else: + self.brush = self.cr.get_brush(brush.color, alpha=brush.alpha) + def set_font(self, fontname, fontsize, color='black', alpha=1.0): fontsize = self.scale_fontsize(fontsize) self.font = self.cr.get_font(fontname, fontsize, color, @@ -103,6 +112,10 @@ ##### DRAWING OPERATIONS ##### + def draw_image(self, cvs_img, cpoints, rgb_arr, whence, order='RGBA'): + # no-op for this renderer + pass + def draw_text(self, cx, cy, text, rot_deg=0.0): wd, ht = self.cr.text_extents(text, self.font) @@ -124,10 +137,10 @@ self.cr.path(cpoints, self.pen) -class CanvasRenderer(render.RendererBase): +class CanvasRenderer(render.StandardPixelRenderer): def __init__(self, viewer): - render.RendererBase.__init__(self, viewer) + render.StandardPixelRenderer.__init__(self, viewer) self.kind = 'pil' self.rgb_order = 'RGBA' @@ -139,7 +152,6 @@ given dimensions. """ width, height = dims[:2] - self.dims = (width, height) self.logger.debug("renderer reconfigured to %dx%d" % ( width, height)) @@ -148,6 +160,12 @@ # blending, not RGBA self.surface = Image.new('RGB', (width, height), color=0) + super(CanvasRenderer, self).resize(dims) + + ## def finalize(self): + ## cr = RenderContext(self, self.viewer, self.surface) + ## self.draw_vector(cr) + def render_image(self, rgbobj, dst_x, dst_y): """Render the image represented by (rgbobj) at dst_x, dst_y in the pixel space. @@ -179,7 +197,7 @@ # was last updated? wd, ht = self.dims[:2] - # Get agg surface as a numpy array + # Get PIL surface as a numpy array arr8 = np.fromstring(self.surface.tobytes(), dtype=np.uint8) arr8 = arr8.reshape((ht, wd, 3)) @@ -197,4 +215,10 @@ cr.set_font_from_shape(shape) return cr.text_extents(shape.text) + def text_extents(self, text, font): + cr = RenderContext(self, self.viewer, self.surface) + cr.set_font(font.fontname, font.fontsize, color=font.color, + alpha=font.alpha) + return cr.text_extents(text) + #END diff -Nru ginga-3.0.0/ginga/pilw/ImageViewPil.py ginga-3.1.0/ginga/pilw/ImageViewPil.py --- ginga-3.0.0/ginga/pilw/ImageViewPil.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/pilw/ImageViewPil.py 2020-07-08 20:09:29.000000000 +0000 @@ -32,12 +32,12 @@ # time_sec self.delayed_redraw() - def update_image(self): + def update_widget(self): # no widget to update pass def configure_window(self, width, height): - self.configure_surface(width, height) + self.configure(width, height) class CanvasView(ImageViewPil): diff -Nru ginga-3.0.0/ginga/pilw/PilHelp.py ginga-3.1.0/ginga/pilw/PilHelp.py --- ginga-3.0.0/ginga/pilw/PilHelp.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/pilw/PilHelp.py 2020-07-08 20:09:29.000000000 +0000 @@ -5,7 +5,7 @@ # Please see the file LICENSE.txt for details. import numpy as np -from PIL import ImageFont, ImageDraw +from PIL import Image, ImageFont, ImageDraw from ginga import colors from ginga.fonts import font_asst @@ -32,6 +32,35 @@ return font_name +def text_size(text, font): + f = get_cached_font(font.fontname, font.fontsize) + i = Image.new('RGBA', (1, 1)) + d = ImageDraw.Draw(i, 'RGBA') + return d.textsize(text) + + +def text_to_array(text, font, rot_deg=0.0): + wd, ht = text_size(text, font) + f = get_cached_font(font.fontname, font.fontsize) + color = get_color(font.color) + i = Image.new('RGBA', (wd, ht)) + d = ImageDraw.Draw(i, 'RGBA') + d.text((0, 0), text, font=f, fill=color) + i.rotate(rot_deg, expand=1) + arr8 = np.fromstring(i.tobytes(), dtype=np.uint8) + arr8 = arr8.reshape((ht, wd, 4)) + return arr8 + + +def get_color(color, alpha=1.0): + if color is not None: + r, g, b = colors.resolve_color(color) + else: + r, g, b = 1.0, 1.0, 1.0 + + return (int(r * 255), int(g * 255), int(b * 255), int(alpha * 255)) + + class Pen(object): def __init__(self, color='black', linewidth=1, alpha=1.0): self.color = color @@ -68,15 +97,12 @@ self.set_canvas(surface) def set_canvas(self, surface): + self.surface = surface self.ctx = ImageDraw.Draw(surface, 'RGBA') def get_color(self, color, alpha=1.0): - if isinstance(color, str) or isinstance(color, type(u"")): - r, g, b = colors.lookup_color(color) - elif isinstance(color, tuple): - # color is assumed to be a 3-tuple of RGB values as floats - # between 0 and 1 - r, g, b = color + if color is not None: + r, g, b = colors.resolve_color(color) else: r, g, b = 1.0, 1.0, 1.0 @@ -109,6 +135,11 @@ wd, ht = retval return wd, ht + def image(self, pt, rgb_arr): + p_image = Image.fromarray(rgb_arr) + + self.surface.paste(p_image) + def text(self, pt, text, font, pen): x, y = pt self.ctx.text((x, y), text, fill=pen.color, font=font.font) diff -Nru ginga-3.0.0/ginga/qtw/CanvasRenderQt.py ginga-3.1.0/ginga/qtw/CanvasRenderQt.py --- ginga-3.0.0/ginga/qtw/CanvasRenderQt.py 2019-09-10 04:03:28.000000000 +0000 +++ ginga-3.1.0/ginga/qtw/CanvasRenderQt.py 2020-07-08 20:09:29.000000000 +0000 @@ -6,11 +6,12 @@ # import numpy as np -from ginga.qtw.QtHelp import (QtCore, QPen, QPolygon, QColor, +from ginga.qtw.QtHelp import (QtCore, QPen, QPolygon, QColor, QFontMetrics, QPainterPath, QImage, QPixmap, get_font, get_painter) from ginga import colors +from ginga.vec import CanvasRenderVec as vec from ginga.canvas import render # force registration of all canvas types import ginga.canvas.types.all # noqa @@ -108,12 +109,28 @@ def text_extents(self, text): fm = self.cr.fontMetrics() - width = fm.width(text) + if hasattr(fm, 'horizontalAdvance'): + width = fm.horizontalAdvance(text) + else: + width = fm.width(text) height = fm.height() return width, height + def setup_pen_brush(self, pen, brush): + if pen is not None: + self.set_line(pen.color, alpha=pen.alpha, linewidth=pen.linewidth, + style=pen.linestyle) + + self.cr.setBrush(QtCore.Qt.NoBrush) + if brush is not None: + self.set_fill(brush.color, alpha=brush.alpha) + ##### DRAWING OPERATIONS ##### + def draw_image(self, cvs_img, cpoints, rgb_arr, whence, order='RGBA'): + # no-op for this renderer + pass + def draw_text(self, cx, cy, text, rot_deg=0.0): self.cr.save() self.cr.translate(cx, cy) @@ -124,6 +141,7 @@ self.cr.restore() def draw_polygon(self, cpoints): + ## cpoints = trcalc.strip_z(cpoints) qpoints = [QtCore.QPoint(p[0], p[1]) for p in cpoints] p = cpoints[0] qpoints.append(QtCore.QPoint(p[0], p[1])) @@ -159,6 +177,7 @@ self.cr.drawLine(cx1, cy1, cx2, cy2) def draw_path(self, cp): + ## cp = trcalc.strip_z(cp) self.cr.pen().setCapStyle(QtCore.Qt.RoundCap) pts = [QtCore.QLineF(QtCore.QPointF(cp[i][0], cp[i][1]), QtCore.QPointF(cp[i + 1][0], cp[i + 1][1])) @@ -166,10 +185,10 @@ self.cr.drawLines(pts) -class CanvasRenderer(render.RendererBase): +class CanvasRenderer(render.StandardPixelRenderer): def __init__(self, viewer, surface_type='qimage'): - render.RendererBase.__init__(self, viewer) + render.StandardPixelRenderer.__init__(self, viewer) self.kind = 'qt' # Qt needs this to be in BGRA @@ -186,19 +205,16 @@ width, height = dims[:2] self.logger.debug("renderer reconfigured to %dx%d" % ( width, height)) + new_wd, new_ht = width * 2, height * 2 + if self.surface_type == 'qpixmap': - self.surface = QPixmap(width, height) + if ((self.surface is None) or (self.surface.width() < width) or + (self.surface.height() < height)): + self.surface = QPixmap(new_wd, new_ht) else: self.surface = QImage(width, height, self.qimg_fmt) - # fill surface with background color; - # this reduces unwanted garbage in the resizing window - painter = get_painter(self.surface) - size = self.surface.size() - sf_wd, sf_ht = size.width(), size.height() - bg = self.viewer.img_bg - bgclr = self._get_color(*bg) - painter.fillRect(QtCore.QRect(0, 0, sf_wd, sf_ht), bgclr) + super(CanvasRenderer, self).resize(dims) def _get_qimage(self, rgb_data): ht, wd, channels = rgb_data.shape @@ -214,8 +230,36 @@ clr = QColor(int(r * n), int(g * n), int(b * n)) return clr - def render_image(self, rgbobj, dst_x, dst_y): - """Render the image represented by (rgbobj) at dst_x, dst_y + def get_surface_as_array(self, order=None): + + if self.surface_type == 'qpixmap': + qimg = self.surface.toImage() + else: + qimg = self.surface + #qimg = qimg.convertToFormat(QImage.Format_RGBA32) + + width, height = qimg.width(), qimg.height() + + if hasattr(qimg, 'bits'): + # PyQt + ptr = qimg.bits() + ptr.setsize(qimg.byteCount()) + else: + # PySide + ptr = qimg.constBits() + + arr = np.array(ptr).reshape(height, width, 4) + + # rendering surface is usually larger than window, so cutout + # just enough to show what has been drawn + win_wd, win_ht = self.dims[:2] + arr = np.ascontiguousarray(arr[:win_ht, :win_wd, :]) + + # adjust according to viewer's needed order + return self.reorder(order, arr) + + def render_image(self, rgbobj, win_x, win_y): + """Render the image represented by (rgbobj) at win_x, win_y in the pixel space. *** internal method-- do not use *** """ @@ -227,7 +271,6 @@ # Prepare array for rendering # TODO: what are options for high bit depth under Qt? data = rgbobj.get_array(self.rgb_order, dtype=np.uint8) - (height, width) = data.shape[:2] daht, dawd, depth = data.shape self.logger.debug("data shape is %dx%dx%d" % (dawd, daht, depth)) @@ -237,7 +280,6 @@ drawable = self.surface painter = get_painter(drawable) - #painter.setWorldMatrixEnabled(True) # fill surface with background color size = drawable.size() @@ -247,41 +289,63 @@ painter.fillRect(QtCore.QRect(0, 0, sf_wd, sf_ht), bgclr) # draw image data from buffer to offscreen pixmap - painter.drawImage(QtCore.QRect(dst_x, dst_y, width, height), + painter.drawImage(QtCore.QRect(win_x, win_y, dawd, daht), qimage, - QtCore.QRect(0, 0, width, height)) + QtCore.QRect(0, 0, dawd, daht)) - def get_surface_as_array(self, order=None): - if self.surface_type == 'qpixmap': - qimg = self.surface.toImage() - else: - qimg = self.surface - #qimg = qimg.convertToFormat(QImage.Format_RGBA32) + def setup_cr(self, shape): + cr = RenderContext(self, self.viewer, self.surface) + cr.initialize_from_shape(shape, font=False) + return cr - width, height = qimg.width(), qimg.height() + def get_dimensions(self, shape): + cr = self.setup_cr(shape) + cr.set_font_from_shape(shape) + return cr.text_extents(shape.text) - if hasattr(qimg, 'bits'): - # PyQt - ptr = qimg.bits() - ptr.setsize(qimg.byteCount()) + def text_extents(self, text, font): + qfont = get_font(font.fontname, font.fontsize) + fm = QFontMetrics(qfont) + if hasattr(fm, 'horizontalAdvance'): + width = fm.horizontalAdvance(text) else: - # PySide - ptr = qimg.constBits() + width = fm.width(text) + height = fm.height() + return width, height - arr = np.array(ptr).reshape(height, width, 4) - # adjust according to viewer's needed order - return self.reorder(order, arr) +class VectorCanvasRenderer(vec.VectorRenderMixin, CanvasRenderer): + + def __init__(self, viewer, surface_type='qimage'): + CanvasRenderer.__init__(self, viewer, surface_type=surface_type) + vec.VectorRenderMixin.__init__(self) + + self._img_args = None + + def initialize(self): + self.rl = [] + self._img_args = None + + def finalize(self): + if self._img_args is not None: + super(VectorCanvasRenderer, self).render_image(*self._img_args) - def setup_cr(self, shape): cr = RenderContext(self, self.viewer, self.surface) + self.draw_vector(cr) + + def render_image(self, rgbobj, win_x, win_y): + # just save the parameters to be called at finalize() + self._img_args = (rgbobj, win_x, win_y) + + def setup_cr(self, shape): + # special cr that just stores up a render list + cr = vec.RenderContext(self, self.viewer, self.surface) cr.initialize_from_shape(shape, font=False) return cr def get_dimensions(self, shape): - cr = self.setup_cr(shape) + cr = super(VectorCanvasRenderer, self).setup_cr(shape) cr.set_font_from_shape(shape) return cr.text_extents(shape.text) - #END diff -Nru ginga-3.0.0/ginga/qtw/ImageViewCanvasTypesQt.py ginga-3.1.0/ginga/qtw/ImageViewCanvasTypesQt.py --- ginga-3.0.0/ginga/qtw/ImageViewCanvasTypesQt.py 2017-11-21 03:33:26.000000000 +0000 +++ ginga-3.1.0/ginga/qtw/ImageViewCanvasTypesQt.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# TODO: this line is for backward compatibility with files importing -# this module--to be removed -from ginga.canvas.types.all import * # noqa - -# END diff -Nru ginga-3.0.0/ginga/qtw/ImageViewQt.py ginga-3.1.0/ginga/qtw/ImageViewQt.py --- ginga-3.0.0/ginga/qtw/ImageViewQt.py 2019-09-10 04:03:28.000000000 +0000 +++ ginga-3.1.0/ginga/qtw/ImageViewQt.py 2020-07-08 20:09:29.000000000 +0000 @@ -5,17 +5,31 @@ # Please see the file LICENSE.txt for details. # import os -from io import BytesIO +import tempfile import numpy as np from ginga import ImageView, Mixins, Bindings from ginga.util.paths import icondir from ginga.qtw.QtHelp import (QtGui, QtCore, QImage, QPixmap, QCursor, - QPainter, Timer, get_scroll_info, get_painter) + QPainter, QOpenGLWidget, QSurfaceFormat, + Timer, get_scroll_info, get_painter) from .CanvasRenderQt import CanvasRenderer +have_opengl = False +try: + from ginga.opengl.CanvasRenderGL import CanvasRenderer as OpenGLRenderer + from ginga.opengl.GlHelp import get_transforms + from ginga.opengl.glsl import req + + have_opengl = True +except ImportError: + pass + +# set to True to debug window painting +DEBUG_MODE = False + class ImageViewQtError(ImageView.ImageViewError): pass @@ -27,13 +41,13 @@ super(RenderGraphicsView, self).__init__(*args, **kwdargs) self.viewer = None - self.pixmap = None def drawBackground(self, painter, rect): """When an area of the window is exposed, we just copy out of the server-side, off-screen pixmap to that area. """ - if not self.pixmap: + pixmap = self.viewer.pixmap + if pixmap is None: return x1, y1, x2, y2 = rect.getCoords() width = x2 - x1 + 1 @@ -41,7 +55,7 @@ # redraw the screen from backing pixmap rect = QtCore.QRect(x1, y1, width, height) - painter.drawPixmap(rect, self.pixmap, rect) + painter.drawPixmap(rect, pixmap, rect) def resizeEvent(self, event): rect = self.geometry() @@ -57,9 +71,6 @@ width, height = self.viewer.get_desired_size() return QtCore.QSize(width, height) - def set_pixmap(self, pixmap): - self.pixmap = pixmap - class RenderWidget(QtGui.QWidget): @@ -67,14 +78,14 @@ super(RenderWidget, self).__init__(*args, **kwdargs) self.viewer = None - self.pixmap = None self.setAttribute(QtCore.Qt.WA_OpaquePaintEvent) def paintEvent(self, event): """When an area of the window is exposed, we just copy out of the server-side, off-screen pixmap to that area. """ - if not self.pixmap: + pixmap = self.viewer.pixmap + if pixmap is None: return rect = event.rect() x1, y1, x2, y2 = rect.getCoords() @@ -83,8 +94,12 @@ # redraw the screen from backing pixmap painter = QPainter(self) + #painter = get_painter(self) rect = QtCore.QRect(x1, y1, width, height) - painter.drawPixmap(rect, self.pixmap, rect) + painter.drawPixmap(rect, pixmap, rect) + if DEBUG_MODE: + qimage = pixmap.toImage() + save_debug_image(qimage, 'final_image.png', format='png') def resizeEvent(self, event): rect = self.geometry() @@ -101,8 +116,35 @@ width, height = self.viewer.get_desired_size() return QtCore.QSize(width, height) - def set_pixmap(self, pixmap): - self.pixmap = pixmap + +class RenderGLWidget(QOpenGLWidget): + + def __init__(self, *args, **kwdargs): + QOpenGLWidget.__init__(self, *args, **kwdargs) + + self.viewer = None + + # ensure we are using correct version of opengl + fmt = QSurfaceFormat() + fmt.setVersion(req.major, req.minor) + fmt.setProfile(QSurfaceFormat.CoreProfile) + #fmt.setDefaultFormat(fmt) + self.setFormat(fmt) + + def initializeGL(self): + self.viewer.renderer.gl_initialize() + + def resizeGL(self, width, height): + self.viewer.configure_window(width, height) + + def paintGL(self): + self.viewer.renderer.gl_paint() + + def sizeHint(self): + width, height = 300, 300 + if self.viewer is not None: + width, height = self.viewer.get_desired_size() + return QtCore.QSize(width, height) class ImageViewQt(ImageView.ImageViewBase): @@ -112,13 +154,17 @@ rgbmap=rgbmap, settings=settings) if render is None: - render = 'widget' + render = self.t_.get('render_widget', 'widget') self.wtype = render if self.wtype == 'widget': self.imgwin = RenderWidget() elif self.wtype == 'scene': self.scene = QtGui.QGraphicsScene() self.imgwin = RenderGraphicsView(self.scene) + elif self.wtype == 'opengl': + if not have_opengl: + raise ImageViewQtError("Please install 'pyopengl' to use render: '%s'" % (render)) + self.imgwin = RenderGLWidget() else: raise ImageViewQtError("Undefined render type: '%s'" % (render)) self.imgwin.viewer = self @@ -126,11 +172,19 @@ self.qimg_fmt = QImage.Format_RGB32 # find out optimum format for backing store #self.qimg_fmt = QPixmap(1, 1).toImage().format() - # Qt needs this to be in BGR(A) - self.rgb_order = 'BGRA' - # default renderer is Qt one - self.renderer = CanvasRenderer(self, surface_type='qpixmap') + if self.wtype == 'opengl': + if not have_opengl: + raise ValueError("OpenGL imports failed") + self.rgb_order = 'RGBA' + self.renderer = OpenGLRenderer(self) + # we replace some transforms in the catalog for OpenGL rendering + self.tform = get_transforms(self) + else: + # Qt needs this to be in BGR(A) + self.rgb_order = 'BGRA' + # default renderer is Qt one + self.renderer = CanvasRenderer(self, surface_type='qpixmap') self.msgtimer = Timer() self.msgtimer.add_callback('expired', @@ -156,13 +210,15 @@ self.scene.setSceneRect(1, 1, width - 2, height - 2) # tell renderer about our new size - self.renderer.resize((width, height)) + #self.renderer.resize((width, height)) + + if self.wtype == 'opengl': + pass - if isinstance(self.renderer.surface, QPixmap): + elif isinstance(self.renderer.surface, QPixmap): # optimization when Qt is used as the renderer: # renderer surface is already a QPixmap self.pixmap = self.renderer.surface - self.imgwin.set_pixmap(self.pixmap) else: # If we need to build a new pixmap do it here. We allocate one @@ -172,45 +228,24 @@ (self.pixmap.height() < height)): pixmap = QPixmap(width * 2, height * 2) self.pixmap = pixmap - self.imgwin.set_pixmap(pixmap) self.configure(width, height) - def get_rgb_image_as_buffer(self, output=None, format='png', - quality=90): - ibuf = output - if ibuf is None: - ibuf = BytesIO() - imgwin_wd, imgwin_ht = self.get_window_size() - qpix = self.pixmap.copy(0, 0, - imgwin_wd, imgwin_ht) - qbuf = QtCore.QBuffer() - qbuf.open(QtCore.QIODevice.ReadWrite) - qpix.save(qbuf, format=format, quality=quality) - ibuf.write(bytes(qbuf.data())) - qbuf.close() - return ibuf - def get_rgb_image_as_widget(self): - imgwin_wd, imgwin_ht = self.get_window_size() - qpix = self.pixmap.copy(0, 0, imgwin_wd, imgwin_ht) - return qpix.toImage() - - def save_rgb_image_as_file(self, filepath, format='png', quality=90): - qimg = self.get_rgb_image_as_widget() - qimg.save(filepath, format=format, quality=quality) + arr = self.renderer.get_surface_as_array(order=self.rgb_order) + image = self._get_qimage(arr, self.qimg_fmt) + return image def get_plain_image_as_widget(self): - """Used for generating thumbnails. Does not include overlaid - graphics. + """Returns a QImage of the drawn images. + Does not include overlaid graphics. """ arr = self.getwin_array(order=self.rgb_order) image = self._get_qimage(arr, self.qimg_fmt) return image def save_plain_image_as_file(self, filepath, format='png', quality=90): - """Used for generating thumbnails. Does not include overlaid - graphics. + """Does not include overlaid graphics. """ qimg = self.get_plain_image_as_widget() qimg.save(filepath, format=format, quality=quality) @@ -219,18 +254,36 @@ self._defer_task.stop() self._defer_task.start(time_sec) - def update_image(self): - if (not self.pixmap) or (not self.imgwin): + def make_context_current(self): + ctx = self.imgwin.context() + self.imgwin.makeCurrent() + return ctx + + def prepare_image(self, cvs_img, cache, whence): + self.renderer.prepare_image(cvs_img, cache, whence) + + def update_widget(self): + if self.imgwin is None: return - if isinstance(self.renderer.surface, QPixmap): + if self.wtype == 'opengl': + pass + + elif isinstance(self.renderer.surface, QPixmap): # optimization when Qt is used as the renderer: # if renderer surface is already an offscreen QPixmap # then we can update the window directly from it - #self.pixmap = self.renderer.surface - pass + if self.pixmap is not self.renderer.surface: + self.pixmap = self.renderer.surface + + if DEBUG_MODE: + qimage = self.pixmap.toImage() + save_debug_image(qimage, 'offscreen_image.png', format='png') else: + if self.pixmap is None: + return + if isinstance(self.renderer.surface, QImage): # optimization when Qt is used as the renderer: # renderer surface is already a QImage @@ -249,9 +302,7 @@ # copy image from renderer to offscreen pixmap painter = get_painter(self.pixmap) - #painter.setWorldMatrixEnabled(True) - # fill surface with background color size = self.pixmap.size() width, height = size.width(), size.height() @@ -260,6 +311,9 @@ qimage, QtCore.QRect(0, 0, width, height)) + if DEBUG_MODE: + save_debug_image(qimage, 'offscreen_image.png', format='png') + self.logger.debug("updating window from pixmap") if hasattr(self, 'scene'): imgwin_wd, imgwin_ht = self.get_window_size() @@ -396,6 +450,10 @@ pass +class RenderGLWidgetZoom(RenderMixin, RenderGLWidget): + pass + + class QtEventMixin(object): def __init__(self): @@ -530,14 +588,14 @@ keyname2 = "%s" % (event.text()) keyname = self.transkey(keyname, keyname2) self.logger.debug("key press event, key=%s" % (keyname)) - return self.make_ui_callback('key-press', keyname) + return self.make_ui_callback_viewer(self, 'key-press', keyname) def key_release_event(self, widget, event): keyname = event.key() keyname2 = "%s" % (event.text()) keyname = self.transkey(keyname, keyname2) self.logger.debug("key release event, key=%s" % (keyname)) - return self.make_ui_callback('key-release', keyname) + return self.make_ui_callback_viewer(self, 'key-release', keyname) def button_press_event(self, widget, event): buttons = event.buttons() @@ -559,7 +617,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-press', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-press', button, + data_x, data_y) def button_release_event(self, widget, event): # note: for mouseRelease this needs to be button(), not buttons()! @@ -581,7 +640,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-release', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-release', button, + data_x, data_y) def motion_notify_event(self, widget, event): @@ -608,7 +668,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('motion', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'motion', button, + data_x, data_y) def scroll_event(self, widget, event): x, y = event.x(), event.y() @@ -632,16 +693,16 @@ dx, dy = point.x(), point.y() # Synthesize this as a pan gesture event - self.make_ui_callback('pan', 'start', 0, 0) - self.make_ui_callback('pan', 'move', dx, dy) - return self.make_ui_callback('pan', 'stop', 0, 0) + self.make_ui_callback_viewer(self, 'pan', 'start', 0, 0) + self.make_ui_callback_viewer(self, 'pan', 'move', dx, dy) + return self.make_ui_callback_viewer(self, 'pan', 'stop', 0, 0) num_degrees, direction = get_scroll_info(event) self.logger.debug("scroll deg={} direction={}".format( num_degrees, direction)) - return self.make_ui_callback('scroll', direction, num_degrees, - data_x, data_y) + return self.make_ui_callback_viewer(self, 'scroll', direction, + num_degrees, data_x, data_y) def gesture_event(self, widget, event): gesture = event.gestures()[0] @@ -697,7 +758,8 @@ self.logger.debug("swipe gesture hdir=%s vdir=%s" % ( hdir, vdir)) - return self.make_ui_callback('swipe', gstate, hdir, vdir) + return self.make_ui_callback_viewer(self, 'swipe', gstate, + hdir, vdir) def gs_pinching(self, event, gesture, gstate): rot = gesture.rotationAngle() @@ -705,7 +767,7 @@ self.logger.debug("pinch gesture rot=%f scale=%f state=%s" % ( rot, scale, gstate)) - return self.make_ui_callback('pinch', gstate, rot, scale) + return self.make_ui_callback_viewer(self, 'pinch', gstate, rot, scale) def gs_panning(self, event, gesture, gstate): d = gesture.delta() @@ -713,7 +775,7 @@ self.logger.debug("pan gesture dx=%f dy=%f state=%s" % ( dx, dy, gstate)) - return self.make_ui_callback('pan', gstate, dx, dy) + return self.make_ui_callback_viewer(self, 'pan', gstate, dx, dy) def gs_tapping(self, event, gesture, gstate): self.logger.debug("tapping gesture state=%s" % ( @@ -748,7 +810,7 @@ event.setAccepted(True) #event.acceptProposedAction() - self.make_ui_callback('drag-drop', data) + self.make_ui_callback_viewer(self, 'drag-drop', data) class ImageViewEvent(QtEventMixin, ImageViewQt): @@ -758,7 +820,9 @@ settings=settings, render=render) # replace the widget our parent provided - if self.wtype == 'scene': + if self.wtype == 'opengl': + imgwin = RenderGLWidgetZoom() + elif self.wtype == 'scene': imgwin = RenderGraphicsViewZoom() imgwin.setScene(self.scene) else: @@ -791,7 +855,7 @@ rgbmap=rgbmap, render=render) Mixins.UIMixin.__init__(self) - self.ui_set_active(True) + self.ui_set_active(True, viewer=self) if bindmap is None: bindmap = ImageViewZoom.bindmapClass(self.logger) @@ -980,4 +1044,6 @@ raise ValueError("Bad scroll bar option: '%s'; should be one of ('on', 'off' or 'auto')" % (vertical)) -#END +def save_debug_image(qimage, filename, format='png'): + path = os.path.join(tempfile.gettempdir(), filename) + qimage.save(path, format=format) diff -Nru ginga-3.0.0/ginga/qtw/QtHelp.py ginga-3.1.0/ginga/qtw/QtHelp.py --- ginga-3.0.0/ginga/qtw/QtHelp.py 2019-09-10 04:03:28.000000000 +0000 +++ ginga-3.1.0/ginga/qtw/QtHelp.py 2020-07-08 20:09:29.000000000 +0000 @@ -8,6 +8,7 @@ import os import math import weakref +import time import ginga.toolkit from ginga.util import iohelper @@ -40,7 +41,11 @@ try: from qtpy import QtCore from qtpy import QtWidgets as QtGui - from qtpy.QtGui import QImage, QColor, QFont, QPixmap, QIcon, QCursor, QPainter, QPen, QPolygonF, QPolygon, QTextCursor, QDrag, QPainterPath, QBrush, QFontDatabase # noqa + from qtpy.QtGui import (QImage, QColor, QFont, QPixmap, QIcon, # noqa + QPainter, QPen, QPolygonF, QPolygon, QTextCursor, + QDrag, QPainterPath, QBrush, QFontDatabase, + QCursor, QFontMetrics, QSurfaceFormat) + from qtpy.QtWidgets import QOpenGLWidget # noqa from qtpy.QtCore import QItemSelectionModel # noqa from qtpy.QtWidgets import QApplication # noqa try: @@ -265,6 +270,8 @@ self.timer.setSingleShot(True) self.timer.setTimerType(QtCore.Qt.PreciseTimer) self.timer.timeout.connect(self._expired_cb) + self.start_time = 0.0 + self.deadline = 0.0 for name in ('expired', 'canceled'): self.enable_callback(name) @@ -281,6 +288,8 @@ def set(self, duration): self.stop() + self.start_time = time.time() + self.deadline = self.start_time + duration # QTimer set in milliseconds ms = int(duration * 1000.0) self.timer.start(ms) @@ -288,6 +297,27 @@ def _expired_cb(self): self.make_callback('expired') + def is_set(self): + return self.timer.isActive() + + def cond_set(self, time_sec): + if not self.is_set(): + # TODO: probably a race condition here + self.set(time_sec) + + def elapsed_time(self): + return time.time() - self.start_time + + def time_left(self): + #return max(0.0, self.deadline - time.time()) + # remainingTime() returns value in msec, or -1 if timer is not set + t = self.timer.remainingTime() + t = max(0.0, t) + return t / 1000.0 + + def get_deadline(self): + return self.deadline + def stop(self): try: self.timer.stop() @@ -402,14 +432,24 @@ def get_painter(surface): - if surface in _painters: - return _painters[surface] + # QImage is not hashable + if not isinstance(surface, QImage): + if surface in _painters: + return _painters[surface] painter = QPainter(surface) painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.TextAntialiasing) - _painters[surface] = painter + if not isinstance(surface, QImage): + _painters[surface] = painter return painter +def set_default_opengl_context(): + from ginga.opengl.glsl import req + fmt = QSurfaceFormat() + fmt.setVersion(req.major, req.minor) + fmt.setProfile(QSurfaceFormat.CoreProfile) + fmt.setDefaultFormat(fmt) + # END diff -Nru ginga-3.0.0/ginga/qtw/Widgets.py ginga-3.1.0/ginga/qtw/Widgets.py --- ginga-3.0.0/ginga/qtw/Widgets.py 2019-09-10 04:03:28.000000000 +0000 +++ ginga-3.1.0/ginga/qtw/Widgets.py 2020-07-20 21:06:00.000000000 +0000 @@ -11,7 +11,7 @@ QImage, QCursor, QFont, have_pyqt4) from ginga.qtw import QtHelp -from ginga.misc import Callback, Bunch, LineHistory +from ginga.misc import Callback, Bunch, Settings, LineHistory import ginga.icons has_webkit = False @@ -23,7 +23,7 @@ __all__ = ['WidgetError', 'WidgetBase', 'TextEntry', 'TextEntrySet', 'GrowingTextEdit', 'TextArea', 'Label', 'Button', 'ComboBox', - 'SpinBox', 'Slider', 'ScrollBar', 'CheckBox', 'ToggleButton', + 'SpinBox', 'Slider', 'Dial', 'ScrollBar', 'CheckBox', 'ToggleButton', 'RadioButton', 'Image', 'ProgressBar', 'StatusBar', 'TreeView', 'WebView', 'ContainerBase', 'Box', 'HBox', 'VBox', 'Frame', 'Expander', 'TabWidget', 'StackWidget', 'MDIWidget', 'ScrollArea', @@ -82,6 +82,7 @@ def delete(self): self.widget.deleteLater() + self.widget = None def focus(self): self.widget.activateWindow() @@ -501,6 +502,76 @@ adj.setSingleStep(incr_value) +class Dial(WidgetBase): + def __init__(self, dtype=float, wrap=False, track=False): + super(Dial, self).__init__() + + w = QtGui.QDial() + w.setWrapping(wrap) + w.setNotchesVisible(True) + + self._precision = 10000 + w.setRange(0, self._precision) + w.setSingleStep(int(self._precision / 100)) + self.dtype = dtype + self.min_val = dtype(0) + self.max_val = dtype(100) + self.inc_val = dtype(1) + + # this controls whether the callbacks are made *as the user + # moves the slider* or afterwards + w.setTracking(track) + self.widget = w + w.valueChanged.connect(self._cb_redirect) + + self.enable_callback('value-changed') + + def _cb_redirect(self, val): + # It appears that Qt uses set_value() to set the value of the + # slider when it is dragged, so we cannot use the usual method + # of setting a hidden "changed" variable to suppress the callback + # when setting the value programmatically. + # if self.changed: + # self.changed = False + # return + val = self.get_value() + self.make_callback('value-changed', val) + + def _cvt_value_out(self, int_val): + pct = int_val / self._precision + rng = self.max_val - self.min_val + val = self.dtype(self.min_val + pct * rng) + return val + + def _cvt_value_in(self, ext_val): + rng = self.max_val - self.min_val + pct = (ext_val - self.min_val) / rng + val = int(pct * self._precision) + return val + + def get_value(self): + int_val = self.widget.value() + return self._cvt_value_out(int_val) + + def set_value(self, val): + if val < self.min_val or val > self.max_val: + raise ValueError("Value '{}' is out of range".format(val)) + self.changed = True + int_val = self._cvt_value_in(val) + self.widget.setValue(int_val) + + def set_tracking(self, tf): + self.widget.setTracking(tf) + + def set_limits(self, minval, maxval, incr_value=1): + self.min_val = minval + self.max_val = maxval + self.inc_val = incr_value + + int_val = self._cvt_value_in(incr_value) + self.widget.setSingleStep(int_val) + + class ScrollBar(WidgetBase): def __init__(self, orientation='horizontal'): super(ScrollBar, self).__init__() @@ -1811,16 +1882,27 @@ class Application(Callback.Callbacks): - def __init__(self, logger=None): + def __init__(self, logger=None, settings=None): global _app super(Application, self).__init__() self.logger = logger - self.window_list = [] + if settings is None: + settings = Settings.SettingGroup(logger=self.logger) + self.settings = settings + self.settings.add_defaults(use_opengl=False) + self.window_list = [] self.window_dict = {} self.wincnt = 0 + if self.settings.get('use_opengl', False): + # ensure we are using correct version of opengl + # NOTE: On MacOSX w/Qt it is necessary to set the default OpenGL + # profile BEFORE creating the QApplication object, because it + # shares the OpenGL context + QtHelp.set_default_opengl_context() + if have_pyqt4: QtGui.QApplication.setGraphicsSystem('raster') app = QtGui.QApplication([]) @@ -2100,5 +2182,4 @@ wrapper.widget = native_widget return wrapper - # END diff -Nru ginga-3.0.0/ginga/RGBMap.py ginga-3.1.0/ginga/RGBMap.py --- ginga-3.0.0/ginga/RGBMap.py 2019-08-24 00:57:36.000000000 +0000 +++ ginga-3.1.0/ginga/RGBMap.py 2020-07-08 20:09:29.000000000 +0000 @@ -4,6 +4,8 @@ # This is open-source software licensed under a BSD license. # Please see the file LICENSE.txt for details. # +import time +import uuid import numpy as np from ginga.misc import Callback, Settings @@ -62,6 +64,7 @@ res = trcalc.reorder_image(order, self.rgbarr, self.order) res = res.astype(dtype, copy=False, casting='unsafe') + return res def get_size(self): @@ -83,6 +86,7 @@ Callback.Callbacks.__init__(self) self.logger = logger + self.mapper_id = str(uuid.uuid4()) # Create settings and set defaults if settings is None: @@ -97,7 +101,7 @@ # add our defaults self.t_.add_defaults(color_map='gray', intensity_map='ramp', color_algorithm='linear', - color_hashsize=65535, + color_hashsize=65536, color_array=None, shift_array=None) self.t_.get_setting('color_map').add_callback('set', self.color_map_set_cb) @@ -261,9 +265,7 @@ assert (index >= 0) and (index <= self.maxc), \ RGBMapError("Index must be in range 0-%d !" % (self.maxc)) index = int(self.sarr[index].clip(0, self.maxc)) - return (self.arr[0][index], - self.arr[1][index], - self.arr[2][index]) + return self.arr[index] def set_intensity_map(self, imap_name): self.t_.set(intensity_map=imap_name) @@ -310,18 +312,18 @@ def set_sarr(self, sarr, callback=True): if sarr is not None: - sarr = np.asarray(sarr, dtype=np.uint) + sarr = np.asarray(sarr) # TEMP: ignore passed callback parameter self.t_.set(shift_array=sarr) def shift_array_set_cb(self, setting, sarr): if sarr is not None: - sarr = np.asarray(sarr) + sarr = np.asarray(sarr).clip(0, self.maxc).astype(np.uint) maxlen = self.maxc + 1 _len = len(sarr) if _len != maxlen: raise RGBMapError("shift map length %d != %d" % (_len, maxlen)) - self.sarr = sarr.astype(np.uint, copy=False) + self.sarr = sarr # NOTE: can't reset scale_pct here because it results in a # loop with e.g. scale_and_shift() #self.scale_pct = 1.0 @@ -340,9 +342,9 @@ def color_array_set_cb(self, setting, carr): if carr is not None: - carr = np.asarray(carr) + carr = np.asarray(carr).clip(0, self.maxc).astype(self.dtype) maxlen = self.maxc + 1 - self.carr = carr.astype(self.dtype, copy=False) + self.carr = carr _len = carr.shape[1] if _len != maxlen: raise RGBMapError("color map length %d != %d" % (_len, maxlen)) @@ -351,13 +353,10 @@ self.recalc(callback=True) def recalc(self, callback=True): - self.arr = np.copy(self.carr) + self.arr = np.copy(self.carr.T) # Apply intensity map to rearrange colors if self.iarr is not None: - idx = self.iarr - self.arr[0] = self.arr[0][idx] - self.arr[1] = self.arr[1][idx] - self.arr[2] = self.arr[2][idx] + self.arr = self.arr[self.iarr] # NOTE: don't reset shift array #self.reset_sarr(callback=False) @@ -413,31 +412,36 @@ def _get_rgbarray(self, idx, rgbobj, image_order=''): # NOTE: data is assumed to be in the range 0-maxc at this point # but clip as a precaution - # See NOTE [A]: idx is always an array calculated in the caller and + # NOTE [A]: idx is always an array calculated in the caller and # discarded afterwards # idx = idx.clip(0, self.maxc).astype(np.uint, copy=False) - idx.clip(0, self.maxc, out=idx) + #idx.clip(0, self.maxc, out=idx) # run it through the shift array and clip the result # See NOTE [A] # idx = self.sarr[idx].clip(0, self.maxc).astype(np.uint, copy=False) idx = self.sarr[idx] - idx.clip(0, self.maxc, out=idx) + # TODO: I think we can avoid this operation, if shift array contents + # can be limited to 0..maxc + #idx.clip(0, self.maxc, out=idx) ri, gi, bi = self.get_order_indexes(rgbobj.get_order(), 'RGB') out = rgbobj.rgbarr - # change [A] - if (image_order is None) or (len(image_order) < 3): - out[..., ri] = self.arr[0][idx] - out[..., gi] = self.arr[1][idx] - out[..., bi] = self.arr[2][idx] + # See NOTE [A] + if (image_order is None) or (len(image_order) == 1): + out[..., [ri, gi, bi]] = self.arr[idx] + + elif len(image_order) == 2: + mj, aj = self.get_order_indexes(image_order, 'MA') + out[..., [ri, gi, bi]] = self.arr[idx[..., mj]] + else: # <== indexes already contain RGB info. rj, gj, bj = self.get_order_indexes(image_order, 'RGB') - out[..., ri] = self.arr[0][idx[..., rj]] - out[..., gi] = self.arr[1][idx[..., gj]] - out[..., bi] = self.arr[2][idx[..., bj]] + out[..., ri] = self.arr[:, 0][idx[..., rj]] + out[..., gi] = self.arr[:, 1][idx[..., gj]] + out[..., bi] = self.arr[:, 2][idx[..., bj]] def get_rgbarray(self, idx, out=None, order='RGB', image_order=''): """ @@ -455,6 +459,7 @@ image_order : str or None The order of channels if indexes already contain RGB info. """ + t1 = time.time() # prepare output array shape = idx.shape depth = len(order) @@ -469,9 +474,9 @@ out = np.empty(res_shape, dtype=self.dtype, order='C') else: # TODO: assertion check on shape of out - assert res_shape == out.shape, \ - RGBMapError("Output array shape %s doesn't match result " - "shape %s" % (str(out.shape), str(res_shape))) + if res_shape != out.shape: + raise RGBMapError("Output array shape %s doesn't match result " + "shape %s" % (str(out.shape), str(res_shape))) res = RGBPlanes(out, order) @@ -480,10 +485,15 @@ aa = res.get_slice('A') aa.fill(self.maxc) + t2 = time.time() idx = self.get_hasharray(idx) + t3 = time.time() self._get_rgbarray(idx, res, image_order=image_order) + t4 = time.time() + self.logger.debug("rgbmap: t2=%.4f t3=%.4f t4=%.4f total=%.4f" % ( + t2 - t1, t3 - t2, t4 - t3, t4 - t1)) return res def get_hasharray(self, idx): @@ -590,20 +600,18 @@ # but clip as a precaution # See NOTE [A]: idx is always an array calculated in the caller and # discarded afterwards - idx.clip(0, self.maxc, out=idx) + #idx.clip(0, self.maxc, out=idx) # run it through the shift array and clip the result # See NOTE [A] idx = self.sarr[idx] - idx.clip(0, self.maxc, out=idx) + #idx.clip(0, self.maxc, out=idx) ri, gi, bi = self.get_order_indexes(rgbobj.get_order(), 'RGB') rj, gj, bj = self.get_order_indexes(image_order, 'RGB') out = rgbobj.rgbarr - out[..., ri] = idx[..., rj] - out[..., gi] = idx[..., gj] - out[..., bi] = idx[..., bj] + out[..., [ri, gi, bi]] = idx[..., [rj, gj, bj]] class PassThruRGBMapper(RGBMapper): @@ -631,7 +639,7 @@ # but clip as a precaution # See NOTE [A]: idx is always an array calculated in the caller and # discarded afterwards - idx.clip(0, self.maxc, out=idx) + #idx.clip(0, self.maxc, out=idx) # bypass the shift array and skip color mapping, # index is the final data @@ -639,9 +647,7 @@ rj, gj, bj = self.get_order_indexes(image_order, 'RGB') out = rgbobj.rgbarr - out[..., ri] = idx[..., rj] - out[..., gi] = idx[..., gj] - out[..., bi] = idx[..., bj] + out[..., [ri, gi, bi]] = idx[..., [rj, gj, bj]] #END diff -Nru ginga-3.0.0/ginga/rv/Control.py ginga-3.1.0/ginga/rv/Control.py --- ginga-3.0.0/ginga/rv/Control.py 2019-08-29 00:23:59.000000000 +0000 +++ ginga-3.1.0/ginga/rv/Control.py 2020-07-20 21:06:00.000000000 +0000 @@ -23,13 +23,11 @@ # Local application imports from ginga import cmap, imap -from ginga import BaseImage from ginga.misc import Bunch, Timer, Future from ginga.util import catalog, iohelper, loader, toolbox from ginga.util import viewer as gviewer from ginga.canvas.CanvasObject import drawCatalog from ginga.canvas.types.layer import DrawingCanvas -from ginga.canvas import render # GUI imports from ginga.gw import GwHelp, GwMain, PluginManager @@ -86,11 +84,29 @@ ev_quit=None): GwMain.GwMain.__init__(self, logger=logger, ev_quit=ev_quit, app=self, thread_pool=thread_pool) - Widgets.Application.__init__(self, logger=logger) - self.logger = logger - self.mm = module_manager + # Create general preferences self.prefs = preferences + settings = self.prefs.create_category('general') + settings.add_defaults(fixedFont=None, + serifFont=None, + sansFont=None, + channel_follows_focus=False, + scrollbars='off', + numImages=10, + # Offset to add to numpy-based coords + pixel_coords_offset=1.0, + # inherit from primary header + inherit_primary_header=False, + cursor_interval=0.050, + download_folder=None, + save_layout=False, + channel_prefix="Image") + settings.load(onError='silent') + # this will set self.logger and self.settings + Widgets.Application.__init__(self, logger=logger, settings=settings) + + self.mm = module_manager # event for controlling termination of threads executing in this # object if not ev_quit: @@ -117,29 +133,11 @@ self.channel = {} self.channel_names = [] self.cur_channel = None - self.main_wsname = 'channels' self.wscount = 0 self.statustask = None self.preload_lock = threading.RLock() self.preload_list = deque([], 4) - # Create general preferences - self.settings = self.prefs.create_category('general') - self.settings.add_defaults(fixedFont=None, - serifFont=None, - sansFont=None, - channel_follows_focus=False, - scrollbars='off', - numImages=10, - # Offset to add to numpy-based coords - pixel_coords_offset=1.0, - # inherit from primary header - inherit_primary_header=False, - cursor_interval=0.050, - download_folder=None, - save_layout=False, - channel_prefix="Image") - self.settings.load(onError='silent') # Load bindings preferences bindprefs = self.prefs.create_category('bindings') bindprefs.load(onError='silent') @@ -160,7 +158,7 @@ self.imgsrv = catalog.ServerBank(self.logger) # state for implementing field-info callback - self._cursor_task = self.get_timer() + self._cursor_task = self.get_backend_timer() self._cursor_task.set_callback('expired', self._cursor_timer_cb) self._cursor_last_update = time.time() self.cursor_interval = self.settings.get('cursor_interval', 0.050) @@ -195,7 +193,9 @@ # GUI initialization self.w = Bunch.Bunch() self.iconpath = icon_path - self._lastwsname = self.main_wsname + self.main_wsname = None + self._lastwsname = None + self.ds = None self.layout = None self.layout_file = None self._lsize = None @@ -219,6 +219,9 @@ def get_timer(self): return self.timer_factory.timer() + def get_backend_timer(self): + return GwHelp.Timer() + def stop(self): self.logger.info("shutting down Ginga...") self.timer_factory.quit() @@ -374,6 +377,8 @@ name, str(e))) def add_plugin(self, spec): + if not spec.get('enabled', True): + return ptype = spec.get('ptype', 'local') if ptype == 'global': self.add_global_plugin(spec) @@ -1424,8 +1429,8 @@ settings.load(onError='raise') except Exception as e: - self.logger.warning("no saved preferences found for channel " - "'%s': %s" % (name, str(e))) + self.logger.info("no saved preferences found for channel " + "'%s', using default: %s" % (name, str(e))) # copy template settings to new channel if settings_template is not None: @@ -1651,16 +1656,21 @@ """ return imap.get_names() - def set_layout(self, layout, layout_file=None, main_wsname=None): + def set_layout(self, layout, layout_file=None, save_layout=False, + main_wsname=None): self.layout = layout self.layout_file = layout_file + self.save_layout = save_layout if main_wsname is not None: self.main_wsname = main_wsname def get_screen_dimensions(self): return (self.screen_wd, self.screen_ht) - def build_toplevel(self): + def build_toplevel(self, ignore_saved_layout=False): + lo_file = self.layout_file + if ignore_saved_layout: + lo_file = None self.font = self.get_font('fixed', 12) self.font11 = self.get_font('fixed', 11) @@ -1670,8 +1680,17 @@ self.w.tooltips = None self.ds = Desktop.Desktop(self) - self.ds.build_desktop(self.layout, lo_file=self.layout_file, + self.ds.build_desktop(self.layout, lo_file=lo_file, widget_dict=self.w) + if self.main_wsname is None: + ws = self.ds.get_default_ws() + if ws is not None: + self.main_wsname = ws.name + else: + # legacy value for layouts that don't define a default + # workspace + self.main_wsname = 'channels' + self._lastwsname = self.main_wsname # TEMP: FIX ME! self.gpmon.ds = self.ds @@ -1911,18 +1930,17 @@ bindprefs = self.prefs.create_category('bindings') bd = bclass(self.logger, settings=bindprefs) + wtype = 'widget' + if self.settings.get('use_opengl', False): + wtype = 'opengl' + fi = Viewers.ImageViewCanvas(logger=self.logger, rgbmap=rgbmap, settings=settings, + render=wtype, bindings=bd) fi.set_desired_size(size[0], size[1]) - # Custom renderer set in channel settings? - r_name = settings.get('renderer', None) - if r_name is not None: - render_class = render.get_render_class(r_name) - fi.set_renderer(render_class(fi)) - canvas = DrawingCanvas() canvas.enable_draw(False) fi.set_canvas(canvas) @@ -1943,7 +1961,7 @@ fi.add_callback('cursor-down', self.force_focus_cb) fi.add_callback('key-down-none', self.keypress) fi.add_callback('drag-drop', self.dragdrop) - fi.ui_set_active(True) + fi.ui_set_active(True, viewer=fi) bd = fi.get_bindings() bd.enable_all(True) @@ -2383,7 +2401,7 @@ """Quit the application. """ self.logger.info("Attempting to shut down the application...") - if self.layout_file is not None: + if self.layout_file is not None and self.save_layout: self.error_wrap(self.ds.write_layout_conf, self.layout_file) self.stop() @@ -2758,12 +2776,9 @@ """Update the info from the last position recorded under the cursor. """ self._cursor_last_update = time.time() - try: - image = viewer.get_dataobj() - if (image is None) or not isinstance(image, BaseImage.BaseImage): - # No compatible image loaded for this channel - return + try: + image = viewer.get_vip() if image.ndim < 2: return @@ -2774,10 +2789,14 @@ off = self.settings.get('pixel_coords_offset', 0.0) info.x += off info.y += off + if 'image_x' in info: + info.image_x += off + if 'image_y' in info: + info.image_y += off except Exception as e: self.logger.warning( - "Can't get info under the cursor: %s" % (str(e))) + "Can't get info under the cursor: %s" % (str(e)), exc_info=True) return # TODO: can this be made more efficient? diff -Nru ginga-3.0.0/ginga/rv/main.py ginga-3.1.0/ginga/rv/main.py --- ginga-3.0.0/ginga/rv/main.py 2019-09-14 00:59:46.000000000 +0000 +++ ginga-3.1.0/ginga/rv/main.py 2020-07-20 21:18:19.000000000 +0000 @@ -20,7 +20,7 @@ from ginga.misc import Task, ModuleManager, Settings, log import ginga.version as version import ginga.toolkit as ginga_toolkit -from ginga.util import paths, rgb_cms +from ginga.util import paths, rgb_cms, json # Catch warnings logging.captureWarnings(True) @@ -32,7 +32,7 @@ dict(row=['hbox', dict(name='menu')], stretch=0), dict(row=['hpanel', dict(name='hpnl'), - ['ws', dict(name='left', wstype='tabs', + ['ws', dict(name='left', wstype='tabs', # noqa width=300, height=-1, group=2), # (tabname, layout), ... [("Info", ['vpanel', {}, @@ -44,7 +44,8 @@ )]], ['vbox', dict(name='main', width=600), dict(row=['ws', dict(name='channels', wstype='tabs', - group=1, use_toolbar=True)], + group=1, use_toolbar=True, + default=True)], stretch=1), dict(row=['ws', dict(name='cbar', wstype='stack', group=99)], stretch=0), @@ -61,7 +62,7 @@ ] )] ], - ], stretch=1), + ], stretch=1), # noqa dict(row=['ws', dict(name='toolbar', wstype='stack', height=40, group=2)], stretch=0), @@ -144,6 +145,7 @@ Bunch(module='ChangeHistory', tab='History', workspace='right', menu="History [G]", start=False, category='Utils', ptype='global'), Bunch(module='Mosaic', workspace='dialogs', category='Utils', ptype='local'), + Bunch(module='Collage', workspace='dialogs', category='Utils', ptype='local'), Bunch(module='FBrowser', tab='Open File', workspace='right', menu="Open File [G]", start=False, category='Utils', ptype='global'), Bunch(module='Preferences', workspace='dialogs', category='Utils', @@ -168,10 +170,11 @@ This class exists solely to be able to customize the reference viewer startup. """ - def __init__(self, layout=default_layout): - self.plugins = [] + def __init__(self, layout=default_layout, plugins=plugins): self.layout = layout self.channels = ['Image'] + self.default_plugins = plugins + self.plugins = [] def add_plugin_spec(self, spec): self.plugins.append(spec) @@ -184,8 +187,8 @@ Add the ginga-distributed default set of plugins to the reference viewer. """ - # add default global plugins - for spec in plugins: + # add default plugins + for spec in self.default_plugins: ptype = spec.get('ptype', 'local') if ptype == 'global' and spec.module not in except_global: self.add_plugin_spec(spec) @@ -229,6 +232,8 @@ # newer ArgParse add_argument = argprs.add_argument + add_argument("--basedir", dest="basedir", metavar="NAME", + help="Specify Ginga configuration area") add_argument("--bufsize", dest="bufsize", metavar="NUM", type=int, default=10, help="Buffer length to NUM") @@ -265,6 +270,9 @@ add_argument("--opencl", dest="opencl", default=False, action="store_true", help="Use OpenCL acceleration") + add_argument("--opengl", dest="opengl", default=False, + action="store_true", + help="Use OpenGL acceleration") add_argument("--plugins", dest="plugins", metavar="NAMES", help="Specify additional plugins to load") add_argument("--profile", dest="profile", action="store_true", @@ -296,6 +304,9 @@ # Create a logger logger = log.get_logger(name='ginga', options=options) + if options.basedir is not None: + paths.ginga_home = os.path.expanduser(options.basedir) + # Get settings (preferences) basedir = paths.ginga_home if not os.path.exists(basedir): @@ -317,6 +328,9 @@ icc_working_profile=None, font_scaling_factor=None, save_layout=True, + use_opengl=False, + layout_file='layout', + plugin_file='plugins.json', channel_prefix="Image") settings.load(onError='silent') @@ -443,6 +457,9 @@ logger.warning( "failed to set OpenCL preference: %s" % (str(e))) + if options.opengl: + settings.set(use_opengl=True) + # Create the dynamic module manager mm = ModuleManager.ModuleManager(logger) @@ -456,20 +473,10 @@ ginga_shell = GingaShell(logger, thread_pool, mm, prefs, ev_quit=ev_quit) - # user wants to set font scaling. - # NOTE: this happens *after* creation of shell object, since - # Application object constructor will also set this - font_scaling = settings.get('font_scaling_factor', None) - if font_scaling is not None: - logger.debug("overriding font_scaling_factor to {}".format(font_scaling)) - from ginga.fonts import font_asst - font_asst.default_scaling_factor = font_scaling - - layout_file = None - if not options.norestore and settings.get('save_layout', False): - layout_file = os.path.join(basedir, 'layout') - - ginga_shell.set_layout(self.layout, layout_file=layout_file) + layout_file = os.path.join(basedir, settings.get('layout_file', + 'layout')) + ginga_shell.set_layout(self.layout, layout_file=layout_file, + save_layout=settings.get('save_layout', True)) # User configuration (custom star catalogs, etc.) if have_ginga_config: @@ -489,7 +496,21 @@ logger.error("Traceback:\n%s" % (tb_str)) # Build desired layout - ginga_shell.build_toplevel() + ginga_shell.build_toplevel(ignore_saved_layout=options.norestore) + + # Does user have a customized plugin setup? If so, override the + # default plugins to be that + plugin_file = settings.get('plugin_file', None) + if plugin_file is not None: + plugin_file = os.path.join(basedir, plugin_file) + if os.path.exists(plugin_file): + logger.info("Reading plugin file '%s'..." % (plugin_file)) + try: + with open(plugin_file, 'r') as in_f: + buf = in_f.read() + self.plugins = json.loads(buf) + except Exception as e: + logger.error("Error reading plugin file: %s" % (str(e))) # Did user specify a particular geometry? if options.geometry: @@ -554,10 +575,13 @@ hidden=False, pfx=pfx) self.add_plugin_spec(spec) - # Add non-disabled plugins - enabled_plugins = [spec for spec in self.plugins - if spec.module.lower() not in disabled_plugins] - ginga_shell.set_plugins(enabled_plugins) + # Mark disabled plugins + for spec in self.plugins: + if spec.get('enabled', None) is None: + spec['enabled'] = (False if spec.module.lower() in disabled_plugins + else True) + # submit plugin specs to shell + ginga_shell.set_plugins(self.plugins) # start any plugins that have start=True ginga_shell.boot_plugins() diff -Nru ginga-3.0.0/ginga/rv/plugins/Catalogs.py ginga-3.1.0/ginga/rv/plugins/Catalogs.py --- ginga-3.0.0/ginga/rv/plugins/Catalogs.py 2019-07-31 04:01:11.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Catalogs.py 2020-07-08 20:09:29.000000000 +0000 @@ -341,7 +341,7 @@ # turn off any mode user may be in self.modes_off() - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) #self.fv.show_status("Draw a rectangle with the right mouse button") def stop(self): diff -Nru ginga-3.0.0/ginga/rv/plugins/Collage.py ginga-3.1.0/ginga/rv/plugins/Collage.py --- ginga-3.0.0/ginga/rv/plugins/Collage.py 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Collage.py 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,449 @@ +# This is open-source software licensed under a BSD license. +# Please see the file LICENSE.txt for details. +""" +Plugin to create an image mosaic via the collage method. + +**Plugin Type: Local** + +``Collage`` is a local plugin, which means it is associated with a +channel. An instance can be opened for each channel. + +**Usage** + +This plugin is used to automatically create a mosaic collage in the +channel viewer using images provided by the user. The position of an image +on the collage is determined by its WCS without distortion correction. +This is meant as a quick-look tool, not a replacement for image drizzling +that takes account of image distortion, etc. + +The collage only exists as a plot on the Ginga canvas. No new single image +is actually built (if you want that, see the "Mosaic" plugin). Some plugins +that expect to operate on single images may not work correctly with a collage. + +To create a new collage, click the "New Collage" button and drag files onto +the display window (e.g. files can be dragged from the `FBrowser` plugin). +Images must have a working WCS. The first image processed will be loaded +and its WCS will be used to orient the other tiles. +You can add new images to an existing collage simply by dragging addtional +files. + +**Controls** + +Check the "Collage HDUs" button to have `Collage` attempt to plot all the +image HDUs in a dragged file instead of just the first found one. + +Check "Label Images" to have the plugin draw the name of each image over each +plotted tile. + +If "Match bg" is checked, the background of each tile is adjusted relative +to the median of the first tile plotted (a kind of rough smoothing). + +The "Num Threads" box assigns how many threads will be used from the thread +pool to load the data. Using several threads will usually speed up loading +of many files. + +**Difference from `Mosaic` plugin** + +- Doesn't allocate a large array to hold all the mosaic contents +- No need to specify output FOV or worry about it +- Can be quicker to show result (depends a bit on constituent images) +- Some plugins will not work correctly with a collage, or will be slower +- Cannot save the collage as a data file (although you can use "ScreenShot") + +""" +import time +import threading + +from ginga.AstroImage import AstroImage +from ginga.util import dp, io_fits +from ginga.util.mosaic import CanvasMosaicer +from ginga import GingaPlugin +from ginga.gw import Widgets + + +__all__ = ['Collage'] + + +class Collage(GingaPlugin.LocalPlugin): + + def __init__(self, fv, fitsimage): + # superclass defines some variables for us, like logger + super(Collage, self).__init__(fv, fitsimage) + + self.mosaicer = CanvasMosaicer(self.logger) + self.mosaicer.add_callback('progress', self._plot_progress_cb) + self.mosaicer.add_callback('finished', self._plot_finished_cb) + + self.ev_intr = threading.Event() + self.lock = threading.RLock() + # holds processed images to be inserted into collage image + self.images = [] + self.ingest_count = 0 + self.total_files = 0 + self.num_groups = 0 + # can set this to annotate images with a specific + # value drawn from the FITS kwd + self.ann_fits_kwd = None + + self.dc = self.fv.get_draw_classes() + + canvas = self.dc.DrawingCanvas() + canvas.enable_draw(False) + canvas.add_callback('drag-drop', self.drop_cb) + canvas.set_surface(fitsimage) + canvas.ui_set_active(True, viewer=fitsimage) + self.canvas = canvas + self.layertag = 'collage-canvas' + + # Load plugin preferences + prefs = self.fv.get_preferences() + self.settings = prefs.create_category('plugin_Collage') + self.settings.set_defaults(annotate_images=False, + match_bg=False, + num_threads=4, + collage_hdus=False) + self.settings.load(onError='silent') + + # hook to allow special processing before inlining + self.preprocess = lambda x: x + + self.gui_up = False + + def build_gui(self, container): + top = Widgets.VBox() + top.set_border_width(4) + + vbox, sw, orientation = Widgets.get_oriented_box(container) + vbox.set_border_width(4) + vbox.set_spacing(2) + + fr = Widgets.Frame("Collage") + + captions = [ + ("New Collage", 'button'), + ("Collage HDUs", 'checkbutton', "Label images", 'checkbutton', + "Match bg", 'checkbutton'), + ("Num Threads:", 'label', 'Num Threads', 'llabel', + 'set_num_threads', 'entry'), + ] + w, b = Widgets.build_info(captions, orientation=orientation) + self.w.update(b) + + b.new_collage.add_callback('activated', lambda w: self.new_collage_cb()) + + collage_hdus = self.settings.get('collage_hdus', False) + b.collage_hdus.set_tooltip("Collage data HDUs in each file") + b.collage_hdus.set_state(collage_hdus) + b.collage_hdus.add_callback('activated', self.collage_hdus_cb) + + labelem = self.settings.get('annotate_images', False) + b.label_images.set_state(labelem) + b.label_images.set_tooltip("Label tiles with their names") + b.label_images.add_callback('activated', self.annotate_cb) + + match_bg = self.settings.get('match_bg', False) + b.match_bg.set_tooltip("Try to match background levels") + b.match_bg.set_state(match_bg) + b.match_bg.add_callback('activated', self.match_bg_cb) + + num_threads = self.settings.get('num_threads', 4) + b.num_threads.set_text(str(num_threads)) + #b.set_num_threads.set_length(8) + b.set_num_threads.set_text(str(num_threads)) + b.set_num_threads.set_tooltip("Number of threads to use for mosaicing") + b.set_num_threads.add_callback('activated', self.set_num_threads_cb) + + fr.set_widget(w) + vbox.add_widget(fr, stretch=0) + + vbox2 = Widgets.VBox() + # Collage evaluation status + hbox = Widgets.HBox() + hbox.set_spacing(4) + hbox.set_border_width(4) + label = Widgets.Label() + self.w.eval_status = label + hbox.add_widget(self.w.eval_status, stretch=0) + hbox.add_widget(Widgets.Label(''), stretch=1) + vbox2.add_widget(hbox, stretch=0) + + # Collage evaluation progress bar and stop button + hbox = Widgets.HBox() + hbox.set_spacing(4) + hbox.set_border_width(4) + btn = Widgets.Button("Stop") + btn.add_callback('activated', lambda w: self.eval_intr()) + btn.set_enabled(False) + self.w.btn_intr_eval = btn + hbox.add_widget(btn, stretch=0) + + self.w.eval_pgs = Widgets.ProgressBar() + hbox.add_widget(self.w.eval_pgs, stretch=1) + + vbox2.add_widget(hbox, stretch=0) + vbox2.add_widget(Widgets.Label(''), stretch=1) + vbox.add_widget(vbox2, stretch=1) + + self.w.vbox = Widgets.VBox() + vbox.add_widget(self.w.vbox, stretch=0) + + spacer = Widgets.Label('') + vbox.add_widget(spacer, stretch=1) + + top.add_widget(sw, stretch=1) + + btns = Widgets.HBox() + btns.set_spacing(3) + + btn = Widgets.Button("Close") + btn.add_callback('activated', lambda w: self.close()) + btns.add_widget(btn, stretch=0) + btn = Widgets.Button("Help") + btn.add_callback('activated', lambda w: self.help()) + btns.add_widget(btn, stretch=0) + btns.add_widget(Widgets.Label(''), stretch=1) + top.add_widget(btns, stretch=0) + + container.add_widget(top, stretch=1) + self.gui_up = True + + def set_preprocess(self, fn): + if fn is None: + fn = lambda x: x # noqa + self.preprocess = fn + + def close(self): + self.canvas.delete_all_objects() + self.mosaicer.reset() + self.fv.stop_local_plugin(self.chname, str(self)) + self.gui_up = False + return True + + def start(self): + # insert layer if it is not already + p_canvas = self.fitsimage.get_canvas() + try: + p_canvas.get_object_by_tag(self.layertag) + + except KeyError: + # Add canvas layer + p_canvas.add(self.canvas, tag=self.layertag) + + # image loaded in channel is used as initial reference image, + # if there is one + ref_image = self.fitsimage.get_image() + if ref_image is not None: + self.mosaicer.prepare_mosaic(ref_image) + else: + self.mosaicer.reset() + self.resume() + + def stop(self): + self.canvas.ui_set_active(False) + p_canvas = self.fitsimage.get_canvas() + try: + p_canvas.delete_object_by_tag(self.layertag) + except Exception: + pass + self.fitsimage.clear() + self.fitsimage.reset_limits() + self.mosaicer.reset() + self.fv.show_status("") + + def pause(self): + # comment this to NOT disable the UI for this plugin + # when it loses focus + #self.canvas.ui_set_active(False) + pass + + def resume(self): + self.canvas.ui_set_active(True, viewer=self.fitsimage) + + def new_collage_cb(self): + self.mosaicer.reset() + self.canvas.delete_all_objects() + self.fitsimage.clear() + self.fitsimage.onscreen_message("Drag new files...", + delay=2.0) + + def drop_cb(self, canvas, paths, *args): + self.logger.info("files dropped: %s" % str(paths)) + self.fv.gui_do(self.fv.error_wrap, self.collage, paths) + return True + + def annotate_cb(self, widget, tf): + self.settings.set(annotate_images=tf) + + def load_tiles(self, paths, image_loader=None): + # NOTE: this runs in a gui thread + self.fv.assert_nongui_thread() + + if image_loader is None: + image_loader = self.fv.load_image + + try: + for url in paths: + if self.ev_intr.is_set(): + break + collage_hdus = self.settings.get('collage_hdus', False) + if collage_hdus: + self.logger.debug("loading hdus") + opener = io_fits.get_fitsloader(logger=self.logger) + # User wants us to collage HDUs + opener.open_file(url, memmap=False) + try: + for i in range(len(opener)): + self.logger.debug("ingesting hdu #%d" % (i)) + try: + image = opener.load_idx(i) + + except Exception as e: + self.logger.error( + "Failed to open HDU #%d: %s" % (i, str(e))) + continue + + if not isinstance(image, AstroImage): + self.logger.debug( + "HDU #%d is not an image; skipping..." % (i)) + continue + + data = image.get_data() + if data is None: + # skip blank data + continue + + if image.ndim != 2: + # skip images without 2 dimensions + continue + + image.set(name='hdu%d' % (i)) + + image = self.preprocess(image) + + with self.lock: + self.images.append(image) + + finally: + opener.close() + opener = None + + else: + image = image_loader(url) + + image = self.preprocess(image) + + with self.lock: + self.images.append(image) + + with self.lock: + self.ingest_count += 1 + self.update_progress(self.ingest_count / self.total_files) + + finally: + with self.lock: + self.num_groups -= 1 + if self.num_groups <= 0: + self.fv.gui_do(self.finish_collage) + + def collage(self, paths, image_loader=None): + if image_loader is None: + image_loader = self.fv.load_image + + self.fv.assert_gui_thread() + + self.ingest_count = 0 + self.total_files = len(paths) + if self.total_files == 0: + return + + self.images = [] + self.ev_intr.clear() + # Initialize progress bar + self.update_status("Loading files...") + self.init_progress() + self.start_time = time.time() + + num_threads = self.settings.get('num_threads', 4) + groups = dp.split_n(paths, num_threads) + self.num_groups = len(groups) + self.logger.info("num groups=%d" % (self.num_groups)) + + for group in groups: + self.fv.nongui_do(self.load_tiles, group, + image_loader=image_loader) + + def finish_collage(self): + self.fv.assert_gui_thread() + + self.load_time = time.time() - self.start_time + images, self.images = self.images, [] + self.logger.info("num images={}".format(len(images))) + + if self.ev_intr.is_set(): + self.update_status("collage cancelled!") + self.end_progress() + return + + self.w.eval_pgs.set_value(0.0) + self.w.btn_intr_eval.set_enabled(False) + + # set options + self.mosaicer.annotate = self.settings.get('annotate_images', False) + self.mosaicer.match_bg = self.settings.get('match_bg', False) + + self.mosaicer.mosaic(self.fitsimage, images, canvas=self.canvas,) + + def match_bg_cb(self, w, tf): + self.settings.set(match_bg=tf) + + def collage_hdus_cb(self, w, tf): + self.settings.set(collage_hdus=tf) + + def set_num_threads_cb(self, w): + num_threads = int(w.get_text()) + self.w.num_threads.set_text(str(num_threads)) + self.settings.set(num_threads=num_threads) + + def update_status(self, text): + if self.gui_up: + self.fv.gui_do(self.w.eval_status.set_text, text) + self.fv.gui_do(self.fv.update_pending) + + def _plot_progress_cb(self, mosaicer, category, pct): + self.w.eval_status.set_text(category + '...') + self.w.eval_pgs.set_value(pct) + + def _plot_finished_cb(self, mosaicer, t_sec): + total = self.load_time + t_sec + msg = "done. load: %.4f collage: %.4f total: %.4f sec" % ( + self.load_time, t_sec, total) + self.update_status(msg) + + def init_progress(self): + def _foo(): + self.w.btn_intr_eval.set_enabled(True) + self.w.eval_pgs.set_value(0.0) + if self.gui_up: + self.fv.gui_do(_foo) + + def update_progress(self, pct): + def _foo(): + self.w.eval_pgs.set_value(pct) + if self.gui_up: + self.fv.gui_do(_foo) + + def end_progress(self): + if self.gui_up: + self.fv.gui_do(self.w.btn_intr_eval.set_enabled, False) + + def eval_intr(self): + self.ev_intr.set() + + def __str__(self): + return 'collage' + + +# Append module docstring with config doc for auto insert by Sphinx. +from ginga.util.toolbox import generate_cfg_example # noqa +if __doc__ is not None: + __doc__ += generate_cfg_example('plugin_Collage', package='ginga') diff -Nru ginga-3.0.0/ginga/rv/plugins/Compose.py ginga-3.1.0/ginga/rv/plugins/Compose.py --- ginga-3.0.0/ginga/rv/plugins/Compose.py 2019-03-14 19:49:55.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Compose.py 2020-07-08 20:09:29.000000000 +0000 @@ -181,7 +181,7 @@ zi.get_canvas().add(self.canvas) self.canvas.set_surface(zi) - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=zi) fr = Widgets.Frame("Preview") fr.set_widget(iw) diff -Nru ginga-3.0.0/ginga/rv/plugins/Crosshair.py ginga-3.1.0/ginga/rv/plugins/Crosshair.py --- ginga-3.0.0/ginga/rv/plugins/Crosshair.py 2019-03-14 19:49:55.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Crosshair.py 2020-07-08 20:09:29.000000000 +0000 @@ -124,7 +124,7 @@ self.canvas.ui_set_active(False) def resume(self): - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status("Click and drag to position crosshair") def stop(self): diff -Nru ginga-3.0.0/ginga/rv/plugins/Cursor.py ginga-3.1.0/ginga/rv/plugins/Cursor.py --- ginga-3.0.0/ginga/rv/plugins/Cursor.py 2019-08-24 00:57:36.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Cursor.py 2020-07-08 20:09:29.000000000 +0000 @@ -27,8 +27,6 @@ """ import platform -import numpy - from ginga import GingaPlugin, toolkit from ginga.gw import Readout from ginga.ImageView import ImageViewNoDataError @@ -154,7 +152,7 @@ return True def field_info_cb(self, viewer, channel, info): - if not self.gui_up: + if not self.gui_up or channel is None: return readout = self.readout fitsimage = channel.fitsimage @@ -162,12 +160,7 @@ if readout.fitsimage != fitsimage: self.change_readout(channel, fitsimage) - # If this is a multiband image, then average the values - # for the readout value = info.value - if isinstance(value, numpy.ndarray): - avg = numpy.average(value) - value = avg # Update the readout px_x = "%.3f" % info.x diff -Nru ginga-3.0.0/ginga/rv/plugins/Cuts.py ginga-3.1.0/ginga/rv/plugins/Cuts.py --- ginga-3.0.0/ginga/rv/plugins/Cuts.py 2019-09-03 23:54:06.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Cuts.py 2020-07-08 20:09:29.000000000 +0000 @@ -517,7 +517,7 @@ # turn off any mode user may be in self.modes_off() - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status("Draw a line with the right mouse button") self.replot_all() if self.use_slit: @@ -576,7 +576,7 @@ def _plotpoints(self, obj, color): - image = self.fitsimage.get_image() + image = self.fitsimage.get_vip() # Get points on the line if obj.kind == 'line': @@ -606,7 +606,7 @@ x1, y1 = x2, y2 elif obj.kind == 'beziercurve': - points = obj.get_pixels_on_curve(image) + points = image.get_pixels_on_curve(obj) points = np.array(points) @@ -922,9 +922,9 @@ coords = [] if cuttype == 'horizontal': - coords.append((0, data_y, wd, data_y)) + coords.append((0, data_y, wd - 1, data_y)) elif cuttype == 'vertical': - coords.append((data_x, 0, data_x, ht)) + coords.append((data_x, 0, data_x, ht - 1)) count = self._get_cut_index() tag = "cuts%d" % (count) diff -Nru ginga-3.0.0/ginga/rv/plugins/Drawing.py ginga-3.1.0/ginga/rv/plugins/Drawing.py --- ginga-3.0.0/ginga/rv/plugins/Drawing.py 2019-03-14 19:49:55.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Drawing.py 2020-07-08 20:09:29.000000000 +0000 @@ -206,7 +206,7 @@ self.canvas.ui_set_active(False) def resume(self): - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status("Draw a figure with the right mouse button") def stop(self): diff -Nru ginga-3.0.0/ginga/rv/plugins/Header.py ginga-3.1.0/ginga/rv/plugins/Header.py --- ginga-3.0.0/ginga/rv/plugins/Header.py 2019-08-03 02:47:48.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Header.py 2020-07-20 21:06:00.000000000 +0000 @@ -47,11 +47,14 @@ ('Comment', 'comment'), ] + spec = self.fv.get_plugin_spec(str(self)) + prefs = self.fv.get_preferences() self.settings = prefs.create_category('plugin_Header') self.settings.add_defaults(sortable=False, color_alternate_rows=True, - max_rows_for_col_resize=5000) + max_rows_for_col_resize=5000, + closeable=not spec.get('hidden', False)) self.settings.load(onError='silent') self.flg_sort = self.settings.get('sortable', False) @@ -86,18 +89,19 @@ hbox.add_widget(Widgets.Label(''), stretch=1) vbox.add_widget(hbox, stretch=0) - btns = Widgets.HBox() - btns.set_border_width(4) - btns.set_spacing(4) - - btn = Widgets.Button("Close") - btn.add_callback('activated', lambda w: self.close()) - btns.add_widget(btn) - btn = Widgets.Button("Help") - btn.add_callback('activated', lambda w: self.help()) - btns.add_widget(btn, stretch=0) - btns.add_widget(Widgets.Label(''), stretch=1) - vbox.add_widget(btns, stretch=0) + if self.settings.get('closeable', False): + btns = Widgets.HBox() + btns.set_border_width(4) + btns.set_spacing(4) + + btn = Widgets.Button("Close") + btn.add_callback('activated', lambda w: self.close()) + btns.add_widget(btn) + btn = Widgets.Button("Help") + btn.add_callback('activated', lambda w: self.help()) + btns.add_widget(btn, stretch=0) + btns.add_widget(Widgets.Label(''), stretch=1) + vbox.add_widget(btns, stretch=0) container.add_widget(vbox, stretch=1) self.gui_up = True diff -Nru ginga-3.0.0/ginga/rv/plugins/Histogram.py ginga-3.1.0/ginga/rv/plugins/Histogram.py --- ginga-3.0.0/ginga/rv/plugins/Histogram.py 2019-09-03 23:54:06.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Histogram.py 2020-07-08 20:09:29.000000000 +0000 @@ -271,7 +271,7 @@ # turn off any mode user may be in self.modes_off() - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status("Draw a rectangle with the right mouse button") def stop(self): @@ -299,7 +299,7 @@ except Exception: pass - image = self.fitsimage.get_image() + image = self.fitsimage.get_vip() width, height = image.get_size() x1, y1, x2, y2 = 0, 0, width - 1, height - 1 tag = canvas.add(self.dc.Rectangle(x1, y1, x2, y2, @@ -307,15 +307,9 @@ linestyle='dash')) self.draw_cb(canvas, tag) - def get_data(self, image, x1, y1, x2, y2, z=None): - if z is not None: - data = image.get_data() - data = data[y1:y2, x1:x2, z] - else: - tup = image.cutout_adjust(x1, y1, x2, y2) - data = tup[0] - - return data + def get_data(self, image, x1, y1, x2, y2, z=0): + tup = image.cutout_adjust(x1, y1, x2 + 1, y2 + 1, z=z) + return tup[0] def histogram(self, image, x1, y1, x2, y2, z=None, pct=1.0, numbins=2048): self.logger.warning("This call will be deprecated soon. " @@ -336,7 +330,7 @@ bbox = obj.objects[0] # Do histogram on the points within the rect - image = self.fitsimage.get_image() + image = self.fitsimage.get_vip() self.plot.clear() numbins = self.numbins diff -Nru ginga-3.0.0/ginga/rv/plugins/Info.py ginga-3.1.0/ginga/rv/plugins/Info.py --- ginga-3.0.0/ginga/rv/plugins/Info.py 2019-08-03 02:47:48.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Info.py 2020-07-08 20:09:29.000000000 +0000 @@ -112,6 +112,8 @@ ('Object:', 'label', 'Object', 'llabel'), ('X:', 'label', 'X', 'llabel'), ('Y:', 'label', 'Y', 'llabel'), + ('Image-X:', 'label', 'Image_X', 'llabel'), + ('Image-Y:', 'label', 'Image_Y', 'llabel'), ('Value:', 'label', 'Value', 'llabel'), ('RA:', 'label', 'RA', 'llabel'), ('DEC:', 'label', 'DEC', 'llabel'), @@ -485,7 +487,7 @@ info.winfo.dimensions.set_text(dim_txt) def field_info(self, viewer, channel, info): - if not self.gui_up: + if not self.gui_up or channel is None: return if '_info_info' not in channel.extdata: return @@ -493,6 +495,14 @@ obj.winfo.x.set_text("%.3f" % info.x) obj.winfo.y.set_text("%.3f" % info.y) + if 'image_x' in info: + obj.winfo.image_x.set_text("%.3f" % info.image_x) + else: + obj.winfo.image_x.set_text("") + if 'image_y' in info: + obj.winfo.image_y.set_text("%.3f" % info.image_y) + else: + obj.winfo.image_y.set_text("") obj.winfo.value.set_text(str(info.value)) if 'ra_txt' in info: obj.winfo.ra.set_text(info.ra_txt) diff -Nru ginga-3.0.0/ginga/rv/plugins/LineProfile.py ginga-3.1.0/ginga/rv/plugins/LineProfile.py --- ginga-3.0.0/ginga/rv/plugins/LineProfile.py 2019-03-14 19:49:55.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/LineProfile.py 2020-07-08 20:09:29.000000000 +0000 @@ -688,7 +688,7 @@ # turn off any mode user may be in self.modes_off() - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status("Mark a point or region and choose axis") self.redo() diff -Nru ginga-3.0.0/ginga/rv/plugins/Mosaic.py ginga-3.1.0/ginga/rv/plugins/Mosaic.py --- ginga-3.0.0/ginga/rv/plugins/Mosaic.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Mosaic.py 2020-07-08 20:09:29.000000000 +0000 @@ -1,7 +1,7 @@ # This is open-source software licensed under a BSD license. # Please see the file LICENSE.txt for details. """ -Plugin to create image mosaic. +Plugin to create an image mosaic by constructing a composite image. **Plugin Type: Local** @@ -12,8 +12,8 @@ .. warning:: This can be very memory intensive. -This plugin is used to automatically create a mosaic in the channel using -images provided by the user (e.g., using ``FBrowser``). +This plugin is used to automatically build a mosaic image in the channel +using images provided by the user (e.g., using ``FBrowser``). The position of an image on the mosaic is determined by its WCS without distortion correction. This is meant as a quick-look tool, not a replacement for image drizzling that takes account of image distortion, etc. @@ -25,7 +25,15 @@ is sufficiently large (see "Customizing Ginga" in the manual). To create a new mosaic, set the FOV and drag files onto the display window. -Images must have a working WCS. +Images must have a working WCS. The first image's WCS will be used to orient +the other tiles. + +**Difference from `Collage` plugin** + +- Allocates a single large array to hold all the mosaic contents +- Slower to build, but can be quicker to manipulate large resultant images +- Can save the mosaic as a new data file +- Fills in values between tiles with a fill value (can be `NaN`) """ import math @@ -649,5 +657,3 @@ from ginga.util.toolbox import generate_cfg_example # noqa if __doc__ is not None: __doc__ += generate_cfg_example('plugin_Mosaic', package='ginga') - -# END diff -Nru ginga-3.0.0/ginga/rv/plugins/MultiDim.py ginga-3.1.0/ginga/rv/plugins/MultiDim.py --- ginga-3.0.0/ginga/rv/plugins/MultiDim.py 2019-08-29 00:23:59.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/MultiDim.py 2020-07-08 20:09:29.000000000 +0000 @@ -102,7 +102,8 @@ top.set_border_width(4) vbox, sw, orientation = Widgets.get_oriented_box(container, - scrolled=True) + scrolled=True, + aspect=3.0) self.orientation = orientation vbox.set_border_width(4) vbox.set_spacing(2) @@ -651,11 +652,12 @@ self.w.hdu.show_text(toc_ent) # rebuild the NAXIS controls, if necessary + wd, ht = image.get_size() # No two images in the same channel can have the same name. # Here we keep track of the name to decide if we need to rebuild if self.img_name != name: self.img_name = name - dims = [0, 0] + dims = [wd, ht] data = image.get_data() if data is None: # <- empty data part to this HDU @@ -669,10 +671,8 @@ str(idx), htype)) else: - mddata = image.get_mddata() - if mddata is not None: - dims = list(mddata.shape) - dims.reverse() + dims = list(image.axisdim) + dims.reverse() self.build_naxis(dims, image) diff -Nru ginga-3.0.0/ginga/rv/plugins/Overlays.py ginga-3.1.0/ginga/rv/plugins/Overlays.py --- ginga-3.0.0/ginga/rv/plugins/Overlays.py 2019-08-29 00:23:59.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Overlays.py 2020-07-08 20:09:29.000000000 +0000 @@ -155,7 +155,7 @@ self.canvas.ui_set_active(False) def resume(self): - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status("Enter a value for saturation limit") def stop(self): @@ -202,12 +202,16 @@ except KeyError: self.fv.show_error("No such color found: '%s'" % (self.lo_color)) - image = self.fitsimage.get_image() + image = self.fitsimage.get_vip() if image is None: return + (x1, y1), (x2, y2) = self.fitsimage.get_limits() + data = image.cutout_data(x1, y1, x2, y2) + self.logger.debug("preparing RGB image") - wd, ht = image.get_size() + #wd, ht = image.get_size() + ht, wd = data.shape[:2] if (wd, ht) != self.arrsize: rgbarr = np.zeros((ht, wd, 4), dtype=np.uint8) self.arrsize = (wd, ht) @@ -225,7 +229,7 @@ self.logger.debug("Calculating alpha channel") # set alpha channel according to saturation limit try: - data = image.get_data() + #data = image.get_data() ac[:] = 0 if self.hi_value is not None: idx = data >= self.hi_value @@ -244,14 +248,14 @@ if self.canvas_img is None: self.logger.debug("Adding image to canvas") - self.canvas_img = self.dc.Image(0, 0, self.rgbobj) + self.canvas_img = self.dc.Image(x1, y1, self.rgbobj) self.canvas.add(self.canvas_img) else: self.logger.debug("Updating canvas image") self.canvas_img.set_image(self.rgbobj) self.logger.debug("redrawing canvas") - self.canvas.update_canvas() + self.canvas.update_canvas(whence=0) self.logger.debug("redo completed") diff -Nru ginga-3.0.0/ginga/rv/plugins/Pan.py ginga-3.1.0/ginga/rv/plugins/Pan.py --- ginga-3.0.0/ginga/rv/plugins/Pan.py 2019-08-29 00:23:59.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Pan.py 2020-07-08 20:09:29.000000000 +0000 @@ -66,19 +66,17 @@ prefs = self.fv.get_preferences() self.settings = prefs.create_category('plugin_Pan') - self.settings.add_defaults(use_shared_canvas=False, - closeable=not spec.get('hidden', False), + self.settings.add_defaults(closeable=not spec.get('hidden', False), pan_position_color='yellow', pan_rectangle_color='red', compass_color='skyblue', rotate_pan_image=True) self.settings.load(onError='silent') - # share canvas with channel viewer? - self.use_shared_canvas = self.settings.get('use_shared_canvas', False) self._wd = 200 self._ht = 200 - self.copy_attrs = ['transforms', 'cutlevels', 'rotation', 'rgbmap'] + self.copy_attrs = ['transforms', 'cutlevels', 'rotation', 'rgbmap', + 'limits', 'icc', 'interpolation'] self.gui_up = False def build_gui(self, container): @@ -125,15 +123,13 @@ # for debugging pi.set_name('panimage') - my_canvas = pi.get_canvas() + my_canvas = pi.get_private_canvas() my_canvas.enable_draw(True) my_canvas.set_drawtype('rectangle', linestyle='dash', color='green') my_canvas.set_callback('draw-event', self.draw_cb) - my_canvas.ui_set_active(True) - if self.use_shared_canvas: - canvas = fitsimage.get_canvas() - pi.set_canvas(canvas) + canvas = fitsimage.get_canvas() + pi.set_canvas(canvas) bd = pi.get_bindings() bd.enable_pan(False) @@ -146,19 +142,31 @@ return fitsimage = channel.fitsimage panimage = self._create_pan_viewer(fitsimage) + p_canvas = panimage.get_private_canvas() + + # add X/Y compass + x, y = 0.5, 0.5 + radius = 0.1 + + compass_xy = p_canvas.add(self.dc.Compass( + x, y, radius, + color=self.settings.get('xy_compass_color', 'yellow'), + fontsize=14, ctype='pixel', coord='percentage')) iw = Viewers.GingaViewerWidget(panimage) iw.resize(self._wd, self._ht) self.nb.add_widget(iw) #index = self.nb.index_of(iw) paninfo = Bunch.Bunch(panimage=panimage, widget=iw, - compass_wcs=None, compass_xy=None, + compass_wcs=None, compass_xy=compass_xy, panrect=None) channel.extdata._pan_info = paninfo fitsimage.copy_attributes(panimage, self.copy_attrs) fitsimage.add_callback('redraw', self.redraw_cb, channel) + fitsimage.add_callback('image-set', + lambda viewer, image: self._redo(channel, image)) self.logger.debug("channel '%s' added." % (channel.name)) @@ -206,7 +214,10 @@ # CALLBACKS - def redo(self, channel, image): + def _redo(self, channel, image): + """NOTE: this plugin is triggered not by a CHANNEL getting a new + image, but by the VIEWER getting a new image, OR the viewer redrawing. + """ if not self.gui_up: return self.logger.debug("redo") @@ -263,9 +274,12 @@ return paninfo = channel.extdata._pan_info if paninfo is not None: - fitsimage.copy_attributes(paninfo.panimage, self.copy_attrs) - if whence == 0: + if whence < 3: + fitsimage.copy_attributes(paninfo.panimage, self.copy_attrs, + whence=whence) + paninfo.panimage.zoom_fit() self.panset(channel.fitsimage, channel, paninfo) + pass return True # LOGIC @@ -280,11 +294,7 @@ paninfo.panimage.clear() return - if not self.use_shared_canvas: - self.logger.debug("setting Pan viewer image") - paninfo.panimage.set_image(image) - else: - paninfo.panimage.zoom_fit() + paninfo.panimage.zoom_fit() p_canvas = paninfo.panimage.get_private_canvas() # remove old compasses @@ -292,22 +302,11 @@ p_canvas.delete_object_by_tag(paninfo.compass_wcs) except Exception: pass - try: - p_canvas.delete_object_by_tag(paninfo.compass_xy) - except Exception: - pass - - x, y = 0.5, 0.5 - radius = 0.1 - - paninfo.compass_xy = p_canvas.add(self.dc.Compass( - x, y, radius, - color=self.settings.get('xy_compass_color', 'yellow'), - fontsize=14, ctype='pixel', coord='percentage')) # create compass if image.has_valid_wcs(): try: + x, y = 0.5, 0.5 # HACK: force a wcs error here if one is going to happen wcs.add_offset_xy(image, x, y, 1.0, 1.0) @@ -319,16 +318,15 @@ except Exception as e: paninfo.compass_wcs = None - self.logger.warning("Can't calculate wcs compass: %s" % ( - str(e))) + self.logger.warning("Can't calculate wcs compass: {}".format(e)) try: # log traceback, if possible (type_, value_, tb) = sys.exc_info() tb_str = "".join(traceback.format_tb(tb)) - self.logger.error("Traceback:\n%s" % (tb_str)) + self.logger.debug("Traceback:\n%s" % (tb_str)) except Exception: tb_str = "Traceback information unavailable." - self.logger.error(tb_str) + self.logger.debug(tb_str) self.panset(channel.fitsimage, channel, paninfo) @@ -336,19 +334,13 @@ image = fitsimage.get_image() if image is None or not paninfo.panimage.viewable(image): paninfo.panimage.clear() - return x, y = fitsimage.get_pan() points = fitsimage.get_pan_rect() - # calculate pan position point radius - p_image = paninfo.panimage.get_image() # noqa - try: - obj = paninfo.panimage.canvas.get_object_by_tag('__image') - except KeyError: - obj = None - - width, height = image.get_size() + limits = fitsimage.get_limits() + width = limits[1][0] - limits[0][0] + height = limits[1][1] - limits[0][1] edgew = math.sqrt(width**2 + height**2) radius = int(0.015 * edgew) @@ -363,18 +355,18 @@ point.x, point.y = x, y point.radius = radius bbox.points = points - p_canvas.update_canvas(whence=0) + p_canvas.update_canvas(whence=3) except KeyError: paninfo.panrect = p_canvas.add(self.dc.CompoundObject( self.dc.Point( - x, y, radius=radius, style='plus', + x, y, radius, style='plus', color=self.settings.get('pan_position_color', 'yellow')), self.dc.Polygon( points, color=self.settings.get('pan_rectangle_color', 'red')))) - paninfo.panimage.zoom_fit() + #paninfo.panimage.zoom_fit() return True def motion_cb(self, fitsimage, event, data_x, data_y): diff -Nru ginga-3.0.0/ginga/rv/plugins/Pick.py ginga-3.1.0/ginga/rv/plugins/Pick.py --- ginga-3.0.0/ginga/rv/plugins/Pick.py 2019-09-10 04:03:28.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Pick.py 2020-07-08 20:09:29.000000000 +0000 @@ -363,7 +363,7 @@ 30.0) self.iqcalc = iqcalc.IQCalc(self.logger) - self.copy_attrs = ['transforms', 'cutlevels', 'autocuts'] + self.copy_attrs = ['transforms', 'cutlevels'] if (self.settings.get('pick_cmap_name', None) is None and self.settings.get('pick_imap_name', None) is None): self.copy_attrs.append('rgbmap') @@ -1254,7 +1254,7 @@ # turn off any mode user may be in self.modes_off() - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status("Draw a shape with the right mouse button") def stop(self): @@ -1304,7 +1304,7 @@ text.x, text.y = x1, y2 try: - image = self.fitsimage.get_image() + image = self.fitsimage.get_vip() # sanity check on size of region width, height = abs(x2 - x1), abs(y2 - y1) @@ -1419,7 +1419,7 @@ def _make_report_header(self): return self.rpt_header + '\n' - def _make_report(self, image, qs): + def _make_report(self, vip_img, qs): d = Bunch.Bunch() try: x, y = qs.objx, qs.objy @@ -1427,6 +1427,7 @@ # user wants RA/DEC calculated by centroid instead of fwhm x, y = qs.oid_x, qs.oid_y + image, pt2 = vip_img.get_image_at_pt((x, y)) equinox = float(image.get_keyword('EQUINOX', 2000.0)) try: ra_deg, dec_deg = image.pixtoradec(x, y, coords='data') @@ -1486,7 +1487,7 @@ objs = self.canvas.get_objects_by_tag_pfx('peak') self.canvas.delete_objects(objs) - image = self.fitsimage.get_image() + vip_img = self.fitsimage.get_vip() shape_obj = pickobj.objects[0] point = pickobj.objects[1] text = pickobj.objects[2] @@ -1499,14 +1500,13 @@ # Mark new peaks, if desired if self.show_candidates: - reports = list(map(lambda x: self._make_report(image, x), - objlist)) + reports = [self._make_report(vip_img, x) for x in objlist] for obj in objlist: self.canvas.add(self.dc.Point( obj.objx, obj.objy, 5, linewidth=1, color=self.candidate_color), tagpfx='peak') else: - reports = [self._make_report(image, qs)] + reports = [self._make_report(vip_img, qs)] # Calculate X/Y of center of star obj_x = qs.objx @@ -1565,9 +1565,9 @@ self.plot_panx = float(i1) / wd self.plot_pany = float(j1) / ht if self.have_mpl: - self.plot_contours(image) - self.plot_fwhm(qs, image) - self.plot_radial(qs, image) + self.plot_contours(vip_img) + self.plot_fwhm(qs, vip_img) + self.plot_radial(qs, vip_img) except Exception as e: errmsg = "Error calculating quality metrics: %s" % ( @@ -1596,7 +1596,7 @@ self.pickimage.center_image() self.plot_panx = self.plot_pany = 0.5 if self.have_mpl: - self.plot_contours(image) + self.plot_contours(vip_img) # TODO: could calc background based on numpy calc self.clear_fwhm() self.clear_radial() @@ -1612,9 +1612,7 @@ self.ev_intr.set() def redo_quick(self): - image = self.fitsimage.get_image() - if image is None: - return + vip_img = self.fitsimage.get_vip() self.cuts_plot.clear() @@ -1623,7 +1621,7 @@ return shape = obj.objects[0] - x1, y1, x2, y2, data = self.cutdetail(image, shape) + x1, y1, x2, y2, data = self.cutdetail(vip_img, shape) self.pick_x1, self.pick_y1 = x1, y1 self.pick_data = data @@ -1831,27 +1829,23 @@ text = self.fv.scale2text(scalefactor) self.wdetail.zoom.set_text(text) - def detailxy(self, canvas, button, data_x, data_y): + def detailxy(self, canvas, event, data_x, data_y): """Motion event in the pick fits window. Show the pointing information under the cursor. """ - if button == 0: - # TODO: we could track the focus changes to make this check - # more efficient - chviewer = self.fv.getfocus_viewer() - # Don't update global information if our chviewer isn't focused - if chviewer != self.fitsimage: - return True - - # Add offsets from cutout - data_x = data_x + self.pick_x1 - data_y = data_y + self.pick_y1 + chviewer = self.fv.getfocus_viewer() + # Don't update global information if our chviewer isn't focused + if chviewer != self.fitsimage: + return True - return self.fv.showxy(chviewer, data_x, data_y) + # Add offsets from cutout + data_x = data_x + self.pick_x1 + data_y = data_y + self.pick_y1 + + return self.fv.showxy(chviewer, data_x, data_y) def cutdetail(self, image, shape_obj): view, mask = image.get_shape_view(shape_obj) - data = image._slice(view) y1, y2 = view[0].start, view[0].stop diff -Nru ginga-3.0.0/ginga/rv/plugins/PixTable.py ginga-3.1.0/ginga/rv/plugins/PixTable.py --- ginga-3.0.0/ginga/rv/plugins/PixTable.py 2019-03-14 19:49:55.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/PixTable.py 2020-07-08 20:09:29.000000000 +0000 @@ -119,7 +119,7 @@ self.dc = self.fv.get_draw_classes() canvas = self.dc.DrawingCanvas() - canvas.set_callback('cursor-changed', self.cursor_cb) + self.fitsimage.set_callback('cursor-changed', self.cursor_cb) canvas.enable_draw(True) canvas.set_drawtype('point', color='cyan', linestyle='dash') canvas.set_callback('draw-event', self.draw_cb) @@ -402,6 +402,8 @@ width, height = self.fitsimage.get_dims(data) if self.txt_arr is None: return + if data.shape != self.txt_arr.shape: + return maxval = np.nanmax(data) minval = np.nanmin(data) @@ -463,25 +465,28 @@ # turn off any mode user may be in self.modes_off() - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.redo() def redo(self): if self.pixview is None: return # cut out and set the pixel table data - image = self.fitsimage.get_image() + image = self.fitsimage.get_vip() if image is None: return # We report the value across the pixel, even though the coords # change halfway across the pixel - data_x, data_y = int(self.lastx + 0.5), int(self.lasty + 0.5) + px_off = self.fitsimage.data_off + data_x, data_y = (int(np.floor(self.lastx + px_off)), + int(np.floor(self.lasty + px_off))) # cutout image data data, x1, y1, x2, y2 = image.cutout_radius(data_x, data_y, self.pixtbl_radius) + self.fv.error_wrap(self.plot, data, x1, y1, x2, y2, self.lastx, self.lasty, self.pixtbl_radius, maxv=9) @@ -546,6 +551,8 @@ self._rebuild_table() def cursor_cb(self, canvas, junk, data_x, data_y): + if not self.gui_up: + return if self.mark_selected is not None: return False if self.pixview is None: diff -Nru ginga-3.0.0/ginga/rv/plugins/Preferences.py ginga-3.1.0/ginga/rv/plugins/Preferences.py --- ginga-3.0.0/ginga/rv/plugins/Preferences.py 2019-03-14 19:49:55.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Preferences.py 2020-07-20 21:06:00.000000000 +0000 @@ -170,14 +170,22 @@ "Auto Cuts" preferences. -The "Auto Cuts" preferences control the calculation of auto cut levels for +The "Auto Cuts" preferences control the calculation of cut levels for the view when the auto cut levels button or key is pressed, or when -loading a new image with auto cuts enabled. +loading a new image with auto cuts enabled. You can also set the cut +levels manually from here. +The "Cut Low" and "Cut High" fields can be used to manually specify lower +and upper cut levels. Pressing "Cut Levels" will set the levels to these +values manually. If a value is missing, it is assumed to default to the +whatever the current value is. + +Pressing "Auto Levels" will calculate the levels according to an algorithm. The "Auto Method" control is used to choose which auto cuts algorithm -used: "minmax" (minimum maximum values), "histogram" (based on an image -histogram), "stddev" (based on the standard deviation of pixel values), or -"zscale" (based on the ZSCALE algorithm popularized by IRAF). +used: "minmax" (minimum maximum values), "median" (based on median +filtering), "histogram" (based on an image histogram), "stddev" (based on +the standard deviation of pixel values), or "zscale" (based on the ZSCALE +algorithm popularized by IRAF). As the algorithm is changed, the boxes under it may also change to allow changes to parameters particular to each algorithm. @@ -373,12 +381,12 @@ self.t_.get_setting(name).add_callback( 'set', self.set_transform_ext_cb) - # TODO: assigning a callback for "autocut_method" results in a bad - # feedback loop, at least under Qt - ## self.t_.get_setting('autocut_method').add_callback('set', - ## self.set_autocut_method_ext_cb) + self.t_.get_setting('autocut_method').add_callback('set', + self.set_autocut_method_ext_cb) self.t_.get_setting('autocut_params').add_callback('set', self.set_autocut_params_ext_cb) + self.t_.get_setting('cuts').add_callback( + 'set', self.cutset_cb) self.t_.setdefault('wcs_coords', 'icrs') self.t_.setdefault('wcs_display', 'sexagesimal') @@ -495,11 +503,32 @@ vbox2 = Widgets.VBox() fr.set_widget(vbox2) - captions = (('Auto Method:', 'label', 'Auto Method', 'combobox'), - ) + captions = (('Cut Low:', 'label', 'Cut Low Value', 'llabel', + 'Cut Low', 'entry'), + ('Cut High:', 'label', 'Cut High Value', 'llabel', + 'Cut High', 'entry'), + ('spacer_1', 'spacer', 'spacer_2', 'spacer', + 'Cut Levels', 'button'), + ('Auto Method:', 'label', 'Auto Method', 'combobox', + 'Auto Levels', 'button'),) w, b = Widgets.build_info(captions, orientation=orientation) self.w.update(b) + loval, hival = self.t_['cuts'] + b.cut_levels.set_tooltip("Set cut levels manually") + b.auto_levels.set_tooltip("Set cut levels by algorithm") + b.cut_low.set_tooltip("Set low cut level (press Enter)") + b.cut_low.set_length(9) + b.cut_low_value.set_text('%.4g' % (loval)) + b.cut_high.set_tooltip("Set high cut level (press Enter)") + b.cut_high.set_length(9) + b.cut_high_value.set_text('%.4g' % (hival)) + + b.cut_low.add_callback('activated', self.cut_levels) + b.cut_high.add_callback('activated', self.cut_levels) + b.cut_levels.add_callback('activated', self.cut_levels) + b.auto_levels.add_callback('activated', self.auto_levels) + # Setup auto cuts method choice combobox = b.auto_method index = 0 @@ -1124,6 +1153,35 @@ index = self.autozoom_options.index(option) self.w.zoom_new.set_index(index) + def cut_levels(self, w): + fitsimage = self.fitsimage + loval, hival = fitsimage.get_cut_levels() + try: + lostr = self.w.cut_low.get_text().strip() + if lostr != '': + loval = float(lostr) + + histr = self.w.cut_high.get_text().strip() + if histr != '': + hival = float(histr) + self.logger.debug("locut=%f hicut=%f" % (loval, hival)) + + return fitsimage.cut_levels(loval, hival) + except Exception as e: + self.fv.show_error("Error cutting levels: %s" % (str(e))) + + return True + + def auto_levels(self, w): + self.fitsimage.auto_levels() + + def cutset_cb(self, setting, value): + if not self.gui_up: + return + loval, hival = value + self.w.cut_low_value.set_text('%.4g' % (loval)) + self.w.cut_high_value.set_text('%.4g' % (hival)) + def config_autocut_params(self, method): try: index = self.autocut_methods.index(method) diff -Nru ginga-3.0.0/ginga/rv/plugins/Ruler.py ginga-3.1.0/ginga/rv/plugins/Ruler.py --- ginga-3.0.0/ginga/rv/plugins/Ruler.py 2019-03-14 19:49:55.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Ruler.py 2020-07-08 20:09:29.000000000 +0000 @@ -332,7 +332,7 @@ self.canvas.ui_set_active(False) def resume(self): - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status("Draw a ruler with the right mouse button") def stop(self): diff -Nru ginga-3.0.0/ginga/rv/plugins/ScreenShot.py ginga-3.1.0/ginga/rv/plugins/ScreenShot.py --- ginga-3.0.0/ginga/rv/plugins/ScreenShot.py 2019-08-29 00:23:59.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/ScreenShot.py 2020-07-08 20:09:29.000000000 +0000 @@ -330,6 +330,13 @@ # load the snapped image into the screenshot viewer self.scrnimage.set_image(img) + image = self.fitsimage.get_image() + name = image.get('name', 'NoName') + name, _ext = os.path.splitext(name) + wd, ht = img.get_size() + save_name = name + '_{}x{}'.format(wd, ht) + '.' + format + self.w.name.set_text(save_name) + def _center_cb(self, w): """This function is called when the user clicks the 'Center' button. """ diff -Nru ginga-3.0.0/ginga/rv/plugins/Thumbs.py ginga-3.1.0/ginga/rv/plugins/Thumbs.py --- ginga-3.0.0/ginga/rv/plugins/Thumbs.py 2019-08-29 00:23:59.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Thumbs.py 2020-07-20 21:06:00.000000000 +0000 @@ -32,6 +32,7 @@ import math import time import threading +import bisect from ginga import GingaPlugin from ginga import RGBImage @@ -52,11 +53,10 @@ super(Thumbs, self).__init__(fv) # For thumbnail pane + self.thumblock = threading.RLock() self.thumb_dict = {} self.thumb_list = [] self.thumb_num_cols = 1 - self.thumb_row_count = 0 - self.thumb_col_count = 0 self._wd = 300 self._ht = 400 self._cmxoff = 0 @@ -84,11 +84,13 @@ label_font_color='white', label_font_size=10, label_bg_color='lightgreen', - autoload_visible_thumbs=True, - autoload_interval=1.0, + autoload_visible_thumbs=False, + autoload_interval=0.25, + update_interval=0.25, closeable=not spec.get('hidden', False), - transfer_attrs=['transforms', - 'cutlevels', 'rgbmap']) + transfer_attrs=['transforms', 'autocuts', + 'cutlevels', 'rgbmap', + 'icc', 'interpolation']) self.settings.load(onError='silent') # max length of thumb on the long side self.thumb_width = self.settings.get('thumb_length', 180) @@ -99,29 +101,36 @@ # used to open thumbnails on disk self.rgb_opener = io_rgb.RGBFileHandler(self.logger) + tmp_path = os.path.join(icondir, 'fits.png') + self.placeholder_image = self.rgb_opener.load_file(tmp_path) # Build our thumb generator - tg = CanvasView(logger=self.logger) - tg.configure_surface(self.thumb_width, self.thumb_width) - tg.enable_autozoom('on') - tg.set_autocut_params('histogram') - tg.enable_autocuts('on') - tg.enable_auto_orient(True) - tg.defer_redraw = False - tg.set_bg(0.7, 0.7, 0.7) - self.thumb_generator = tg + self.thumb_generator = self.get_thumb_generator() - self.thmbtask = fv.get_timer() - self.thmbtask.set_callback('expired', self.redo_delay_timer) + # a timer that controls how fast we attempt to update a thumbnail + # after its associated full image has been modified + self.timer_redo = self.fv.get_backend_timer() + self.timer_redo.set_callback('expired', self.timer_redo_cb) self.lagtime = self.settings.get('rebuild_wait', 0.5) - self.thmblock = threading.RLock() - self.timer_autoload = fv.get_timer() + + # a timer that controls how quickly we attempt to autoload missing + # thumbnails + self.timer_autoload = self.fv.get_backend_timer() self.timer_autoload.set_callback('expired', self.timer_autoload_cb) self.autoload_interval = self.settings.get('autoload_interval', - 1.0) + 0.25) self.autoload_visible = self.settings.get('autoload_visible_thumbs', False) + self.autoload_serial = time.time() + + # timer that controls how quickly we attempt to rebuild thumbs after + # a pan/scroll operation + self.timer_update = self.fv.get_backend_timer() + self.timer_update.set_callback('expired', self.timer_update_cb) + self.update_interval = 0.25 self._to_build = set([]) + self._latest_thumb = None + self.save_thumbs = self.settings.get('cache_thumbs', False) # this will hold the thumbnails pane viewer self.c_view = None @@ -131,6 +140,7 @@ self.keywords = self.settings.get('tt_keywords', tt_keywords) self.keywords.insert(0, self.settings.get('mouseover_name_key', 'NAME')) + self.re_hilite_set = set([]) self.highlight_tracks_keyboard_focus = self.settings.get( 'highlight_tracks_keyboard_focus', True) self._tkf_highlight = set([]) @@ -171,7 +181,7 @@ canvas = c_v.get_canvas() canvas.register_for_cursor_drawing(c_v) canvas.set_draw_mode('pick') - canvas.ui_set_active(True) + canvas.ui_set_active(True, viewer=c_v) self.canvas = canvas bd = c_v.get_bindings() @@ -224,6 +234,17 @@ self.gui_up = True + def get_thumb_generator(self): + tg = CanvasView(logger=self.logger) + tg.configure_surface(self.thumb_width, self.thumb_width) + tg.enable_autozoom('on') + tg.set_autocut_params('histogram') + tg.enable_autocuts('on') + tg.enable_auto_orient(True) + tg.defer_redraw = False + tg.set_bg(0.7, 0.7, 0.7) + return tg + def drag_drop_cb(self, viewer, urls): """Punt drag-drops to the ginga shell. """ @@ -259,71 +280,100 @@ thumbkey = (chname, imname, path) return thumbkey - def add_image_cb(self, viewer, chname, image, info): + def add_image_cb(self, shell, chname, image, info): + """This callback happens when an image is loaded into one of the + channels in the Ginga shell. + """ + channel = self.fv.get_channel(chname) + genthumb = channel.settings.get('genthumb', True) + if not genthumb: + return False + + self.fv.gui_do(self._add_image, shell, chname, image, info) + + def _add_image(self, shell, chname, image, info): + # invoked via add_image_cb() + self.fv.assert_gui_thread() + + channel = self.fv.get_channel(chname) + if info is None: + try: + imname = image.get('name', None) + info = channel.get_image_info(imname) + except KeyError: + self.logger.warn("no information in channel about image '%s'" % ( + imname)) + return False # Get any previously stored thumb information in the image info - thumb_extra = info.setdefault('thumb_extras', Bunch.Bunch()) + extras = info.setdefault('thumb_extras', Bunch.Bunch()) # Get metadata for mouse-over tooltip metadata = self._get_tooltip_metadata(info, image) # Update the tooltip, in case of new or changed metadata text = self._mk_tooltip_text(metadata) - thumb_extra.tooltip = text + extras.tooltip = text - if not self.gui_up: - return False + if 'rgbimg' not in extras: + # since we have full size image in hand, generate a thumbnail + # now, and cache it for when the thumb is added to the canvas + thumb_image = self._regen_thumb_image(self.thumb_generator, + image, extras, channel.fitsimage) + extras.rgbimg = thumb_image - channel = self.fv.get_channel(chname) - - if thumb_extra.get('time_update', None) is None: - self.fv.gui_do(self.redo_thumbnail_image, channel, image, info) + self._add_image_info(shell, channel, info) + return True - def add_image_info_cb(self, viewer, channel, info): + def add_image_info_cb(self, shell, channel, info): + """This callback happens when an image is loaded into one of the + channels in the Ginga shell OR information about an image (without) + the actual image data is loaded into the channel (a lazy load). + NOTE: in the case where image data is loaded into the channel, BOTH + `add_image_cb` and `add_image_info_cb` will be called. + """ genthumb = channel.settings.get('genthumb', True) if not genthumb: return False - save_thumb = self.settings.get('cache_thumbs', False) + + self.fv.gui_do(self._add_image_info, shell, channel, info) + + def _add_image_info(self, shell, channel, info): + # invoked via add_image_info_cb() + self.fv.assert_gui_thread() # Do we already have this thumb loaded? chname = channel.name thumbkey = self.get_thumb_key(chname, info.name, info.path) thumbpath = self.get_thumbpath(info.path) - with self.thmblock: + with self.thumblock: try: bnch = self.thumb_dict[thumbkey] - # if these are not equal then the mtime must have - # changed on the file, better reload and regenerate + if bnch.thumbpath == thumbpath: self.logger.debug("we have this thumb--skipping regeneration") - return + return False + + # if these are not equal then the mtime must have + # changed on the file, better reload and regenerate self.logger.debug("we have this thumb, but thumbpath is different--regenerating thumb") + bnch.thumbpath = thumbpath + #bnch.extras.setvals(placeholder=True) + return + except KeyError: self.logger.debug("we don't seem to have this thumb--generating thumb") - thmb_image = self._get_thumb_image(channel, info, None) - - self.fv.gui_do(self._make_thumb, chname, thmb_image, info, thumbkey, - save_thumb=save_thumb, thumbpath=thumbpath) - - def _add_image(self, viewer, chname, image): - channel = self.fv.get_channel(chname) - try: - imname = image.get('name', None) - info = channel.get_image_info(imname) - except KeyError: - self.logger.warn("no information in channel about image '%s'" % ( - imname)) - return - - self.add_image_cb(viewer, chname, image, info) + self._insert_lazy_thumb(thumbkey, chname, info, thumbpath) - def remove_image_info_cb(self, viewer, channel, image_info): - if not self.gui_up: - return + return True + def remove_image_info_cb(self, shell, channel, image_info): + """This callback is called when an image is removed from a channel + in the Ginga shell. + """ chname, imname, impath = channel.name, image_info.name, image_info.path try: thumbkey = self.get_thumb_key(chname, imname, impath) @@ -333,7 +383,9 @@ imname, str(e))) def remove_thumb(self, thumbkey): - with self.thmblock: + """Remove the thumb indicated by `thumbkey`. + """ + with self.thumblock: self.logger.debug("Removing thumb %s" % (str(thumbkey))) if thumbkey in self.thumb_dict: del self.thumb_dict[thumbkey] @@ -346,46 +398,42 @@ self._tkf_highlight.discard(thumbkey) channel.extdata.thumbs_old_highlight.discard(thumbkey) - self.fv.gui_do_oneshot('thumbs-reorder', self.reorder_thumbs) - - def update_thumbs(self, name_list): - - # Remove old thumbs that are not in the dataset - invalid = set(self.thumb_list) - set(name_list) - if len(invalid) > 0: - with self.thmblock: - for thumbkey in invalid: - if thumbkey in self.thumb_list: - self.thumb_list.remove(thumbkey) - if thumbkey in self.thumb_dict: - del self.thumb_dict[thumbkey] - self._tkf_highlight.discard(thumbkey) - - self.fv.gui_do_oneshot('thumbs-reorder', self.reorder_thumbs) + self.timer_update.set(self.update_interval) - def thumbpane_resized_cb(self, thumbvw, width, height): + def thumbpane_resized_cb(self, thumb_viewer, width, height): + """This callback is called when the Thumbs pane is resized. + """ self.fv.gui_do_oneshot('thumbs-resized', self._resized, width, height) def _resized(self, width, height): + # invoked via thumbpane_resized_cb() + self.fv.assert_gui_thread() self.logger.debug("thumbs resized, width=%d" % (width)) - with self.thmblock: + with self.thumblock: self._cmxoff = -width // 2 + 10 cols = max(1, width // (self.thumb_width + self.thumb_hsep)) self.logger.debug("column count is now %d" % (cols)) self.thumb_num_cols = cols - self.fv.gui_do_oneshot('thumbs-reorder', self.reorder_thumbs) + self.timer_update.set(self.update_interval) return False def load_file(self, thumbkey, chname, info): + """Called when a thumbnail is clicked (denoted by `thumbkey`) + to load the thumbnail. + """ + self.fv.assert_gui_thread() self.logger.debug("loading image: %s" % (str(thumbkey))) # TODO: deal with channel object directly? self.fv.switch_name(chname, info.name, path=info.path, image_future=info.image_future) def clear(self): - with self.thmblock: + """Called when "Clear" button is clicked to clear the pane + of thumbnails. + """ + with self.thumblock: self.thumb_list = [] self.thumb_dict = {} self._displayed_thumb_dict = {} @@ -393,11 +441,12 @@ self.canvas.delete_all_objects(redraw=False) self.canvas.update_canvas(whence=0) - self.fv.gui_do_oneshot('thumbs-reorder', self.reorder_thumbs) + self.timer_update.set(self.update_interval) - def add_channel_cb(self, viewer, channel): + def add_channel_cb(self, shell, channel): """Called when a channel is added from the main interface. - Parameter is channel (a bunch).""" + Parameter is channel (a bunch). + """ fitsimage = channel.fitsimage fitssettings = fitsimage.get_settings() for name in ['cuts']: @@ -411,7 +460,10 @@ # add old highlight set to channel external data channel.extdata.setdefault('thumbs_old_highlight', set([])) - def focus_cb(self, viewer, channel): + def focus_cb(self, shell, channel): + """This callback is called when a channel viewer is focused. + We use this to highlight the proper thumbs in the Thumbs pane. + """ # Reflect transforms, colormap, etc. fitsimage = channel.fitsimage image = channel.get_current_image() @@ -432,89 +484,153 @@ self._tkf_highlight = new_highlight def transform_cb(self, fitsimage): + """Called when a channel viewer has a transform event. + Used to transform the corresponding thumbnail. + """ self.redo_delay(fitsimage) return True def cutset_cb(self, setting, value, fitsimage): + """Called when a channel viewer has a cut levels event. + Used to adjust cuts on the corresponding thumbnail. + """ self.redo_delay(fitsimage) return True def rgbmap_cb(self, rgbmap, fitsimage): + """Called when a channel viewer has an RGB mapper event. + Used to make color and contrast adjustments on the corresponding + thumbnail. + """ # color mapping has changed in some way self.redo_delay(fitsimage) return True def redo_delay(self, fitsimage): # Delay regeneration of thumbnail until most changes have propagated - self.thmbtask.data.setvals(fitsimage=fitsimage) - self.thmbtask.set(self.lagtime) + self.timer_redo.data.setvals(fitsimage=fitsimage) + self.timer_redo.set(self.lagtime) return True - def redo_delay_timer(self, timer): - self.fv.gui_do(self.redo_thumbnail, timer.data.fitsimage) + def timer_redo_cb(self, timer): + """Called when the redo timer expires; used to rebuild the thumbnail + corresponding to changes in the viewer. + """ + self.fv.assert_gui_thread() + self.redo_thumbnail(timer.data.fitsimage) def timer_autoload_cb(self, timer): + """Called when the autoload timer expires; used to expand placeholder + thumbnails. + """ + self.fv.assert_gui_thread() + self.fv.nongui_do(self._autoload_thumbs) + + def _autoload_thumbs(self): + # invoked via timer_autoload_cb() self.logger.debug("autoload missing thumbs") + self.fv.assert_nongui_thread() + + with self.thumblock: + to_build = self._to_build.copy() - with self.thmblock: - if len(self._to_build) == 0: + serial = self.autoload_serial + for thumbkey in to_build: + if serial != self.autoload_serial or len(to_build) == 0: + # cancel this autoload session if autoload set has changed return - thumbkey = self._to_build.pop() - bnch = self.thumb_dict[thumbkey] - self.fv.nongui_do(self.force_load_for_thumb, thumbkey, bnch, timer) + with self.thumblock: + bnch = self.thumb_dict.get(thumbkey, None) + if bnch is None: + return - def force_load_for_thumb(self, thumbkey, bnch, timer): - placeholder = bnch.image.get_image().get('placeholder', False) - path = bnch.info.path - if placeholder and (path is not None): - self.logger.debug("autoload missing [%s]" % (path)) - info = bnch.info - chname = thumbkey[0] - channel = self.fv.get_channel(chname) - try: - image = self.fv.load_image(path, show_error=False) - self.logger.debug("loaded [%s]" % (path)) - self.fv.gui_do(channel.add_image_update, image, info, - update_viewer=False) - except Exception as e: - # load errors will be reported in self.fv.load_image() - # Just ignore autoload errors for now... - pass + # Get any previously stored thumb information in the image info + extras = bnch.extras - timer.cond_set(0.10) + placeholder = extras.get('placeholder', True) + ignore = extras.get('ignore', False) + path = bnch.info.path + if placeholder and path is not None and not ignore: + self.force_load_for_thumb(thumbkey, path, bnch, extras) + + def force_load_for_thumb(self, thumbkey, path, bnch, extras): + """Called by _autoload_thumbs() to load a file if the pane is + currently showing a placeholder for a thumb. + """ + self.logger.debug("autoload missing [%s]" % (path)) + info = bnch.info + chname = thumbkey[0] + channel = self.fv.get_channel(chname) + try: + thumb_image = self._get_thumb_image(channel, info, None, extras, + no_placeholder=True) + if thumb_image is not None: + self.fv.gui_do(self.update_thumbnail, thumbkey, thumb_image, + None) + return + + # <-- No easy thumb to load. Forced to load the full image + # if we want a thumbnail + if not self.autoload_visible: + return + + image = self.fv.load_image(path, show_error=False) + self.logger.debug("loaded [%s]" % (path)) + + ## self.fv.gui_do(channel.add_image_update, image, info, + ## update_viewer=False) + + self.fv.gui_do(self.redo_thumbnail_image, channel, image, bnch, + save_thumb=self.save_thumbs) + + except Exception as e: + self.logger.error("autoload missing [%s] failed:" % (path)) + # load errors will be reported in self.fv.load_image() + # Just ignore autoload errors for now... + extras.ignore = True def update_highlights(self, old_highlight_set, new_highlight_set): """Unhighlight the thumbnails represented by `old_highlight_set` and highlight the ones represented by new_highlight_set. Both are sets of thumbkeys. - """ - with self.thmblock: - un_hilite_set = old_highlight_set - new_highlight_set - re_hilite_set = new_highlight_set - old_highlight_set + with self.thumblock: + common = old_highlight_set & new_highlight_set + un_hilite_set = old_highlight_set - common + re_hilite_set = new_highlight_set | common - # highlight new labels that should be bg = self.settings.get('label_bg_color', 'lightgreen') - fg = self.settings.get('label_font_color', 'black') + fg = self.settings.get('label_font_color', 'white') # unhighlight thumb labels that should NOT be highlighted any more for thumbkey in un_hilite_set: if thumbkey in self.thumb_dict: - namelbl = self.thumb_dict[thumbkey].get('namelbl') - namelbl.color = fg + bnch = self.thumb_dict[thumbkey] + namelbl = bnch.get('namelbl', None) + if namelbl is not None: + namelbl.color = fg + # highlight new labels that should be for thumbkey in re_hilite_set: if thumbkey in self.thumb_dict: - namelbl = self.thumb_dict[thumbkey].get('namelbl') - namelbl.color = bg + bnch = self.thumb_dict[thumbkey] + namelbl = bnch.get('namelbl', None) + if namelbl is not None: + namelbl.color = bg + + self.re_hilite_set = re_hilite_set if self.gui_up: self.c_view.redraw(whence=3) def redo(self, channel, image): - """This method is called when an image is set in a channel.""" + """This method is called when an image is set in a channel. + In this plugin it mostly serves to cause a different thumbnail + label to be highlighted. + """ + self.fv.assert_gui_thread() self.logger.debug("image set") chname = channel.name @@ -531,10 +647,16 @@ # no image has the focus new_highlight = set([]) - # TODO: already calculated thumbkey, use simpler test - if not self.have_thumbnail(channel.fitsimage, image): - # No memory of this thumbnail, so regenerate it - self._add_image(self.fv, chname, image) + with self.thumblock: + #if not self.have_thumbnail(channel.fitsimage, image): + if thumbkey not in self.thumb_dict: + # No memory of this thumbnail, so regenerate it + if not self._add_image(self.fv, chname, image, None): + return + + # this would have auto scroll feature pan to most recent image + # loaded in channel + #self.auto_scroll(thumbkey) self.logger.debug("highlighting") # Only highlights active image in the current channel @@ -547,6 +669,8 @@ self.update_highlights(old_highlight, new_highlight) channel.extdata.thumbs_old_highlight = new_highlight + self.redo_delay(channel.fitsimage) + def have_thumbnail(self, fitsimage, image): """Returns True if we already have a thumbnail version of this image cached, False otherwise. @@ -566,10 +690,26 @@ name = image.get('name', name) thumbkey = self.get_thumb_key(chname, name, path) - with self.thmblock: + with self.thumblock: return thumbkey in self.thumb_dict + def _save_thumb(self, thumb_image, bnch): + extras = bnch.extras + if extras.get('ignore', False) or extras.get('placeholder', False): + # don't save placeholders, or thumbs we are instructed to ignore + return + + thumbpath = self.get_thumbpath(bnch.info.path) + if thumbpath is not None: + if os.path.exists(thumbpath): + os.remove(thumbpath) + thumb_image.save_as_file(thumbpath) + def redo_thumbnail(self, viewer, save_thumb=None): + """Regenerate the thumbnail for the image in the channel viewer + (`viewer`). If `save_thumb` is `True`, then save a copy of the + thumbnail if the user has allowed it. + """ # Get the thumbnail image image = viewer.get_image() if image is None: @@ -587,9 +727,22 @@ # don't generate a thumbnail without info return - self.redo_thumbnail_image(channel, image, info, save_thumb=save_thumb) + thumbkey = self.get_thumb_key(chname, imname, info.path) + with self.thumblock: + if thumbkey not in self.thumb_dict: + # No memory of this thumbnail + return + + bnch = self.thumb_dict[thumbkey] + self.redo_thumbnail_image(channel, image, bnch, + save_thumb=save_thumb) - def redo_thumbnail_image(self, channel, image, info, save_thumb=None): + def redo_thumbnail_image(self, channel, image, bnch, save_thumb=None): + """Regenerate the thumbnail for image `image`, in the channel + `channel` and whose entry in the thumb_dict is `bnch`. + If `save_thumb` is `True`, then save a copy of the thumbnail if + the user has allowed it. + """ # image is flagged not to make a thumbnail? nothumb = (image.get('nothumb', False) or not channel.settings.get('genthumb', True)) @@ -598,46 +751,46 @@ self.logger.debug("redoing thumbnail ...") if save_thumb is None: - save_thumb = self.settings.get('cache_thumbs', False) + save_thumb = self.save_thumbs # Get any previously stored thumb information in the image info - thumb_extra = info.setdefault('thumb_extras', Bunch.Bunch()) + extras = bnch.extras # Get metadata for mouse-over tooltip + info = bnch.info metadata = self._get_tooltip_metadata(info, image) chname = channel.name thumbkey = self.get_thumb_key(chname, info.name, info.path) - with self.thmblock: + with self.thumblock: if thumbkey not in self.thumb_dict: - # No memory of this thumbnail, so regenerate it - self.logger.debug("No memory of %s, adding..." % (str(thumbkey))) - self._add_image(self.fv, chname, image) + # This shouldn't happen, but if it does, ignore the thumbkey + self.logger.warning("No memory of %s..." % (str(thumbkey))) return + bnch = self.thumb_dict[thumbkey] + # Generate new thumbnail self.logger.debug("generating new thumbnail") - thmb_image = self._regen_thumb_image(image, channel.fitsimage) - thumb_extra.time_update = time.time() + thumb_image = self._regen_thumb_image(self.thumb_generator, + image, extras, channel.fitsimage) # Save a thumbnail for future browsing if save_thumb and info.path is not None: - thumbpath = self.get_thumbpath(info.path) - if thumbpath is not None: - if os.path.exists(thumbpath): - os.remove(thumbpath) - thmb_image.save_as_file(thumbpath) + self._save_thumb(thumb_image, bnch) + + self.update_thumbnail(thumbkey, thumb_image, metadata) - self.update_thumbnail(thumbkey, thmb_image, metadata) self.fv.update_pending() - def delete_channel_cb(self, viewer, channel): + def delete_channel_cb(self, shell, channel): """Called when a channel is deleted from the main interface. - Parameter is channel (a bunch).""" + Parameter is channel (a bunch). + """ chname_del = channel.name # TODO: delete thumbs for this channel! self.logger.debug("deleting thumbs for channel '%s'" % (chname_del)) - with self.thmblock: + with self.thumblock: new_thumb_list = [] un_hilite_set = set([]) for thumbkey in self.thumb_list: @@ -652,9 +805,13 @@ self.thumb_list = new_thumb_list self._tkf_highlight -= un_hilite_set # Unhighlight - self.fv.gui_do_oneshot('thumbs-reorder', self.reorder_thumbs) + self.timer_update.set(self.update_interval) def _get_tooltip_metadata(self, info, image, keywords=None): + """Construct a metadata dict containing values for selected + `keywords` (defaults to `self.keywords`) for the image `image` + (can be `None`) whose info dict is `info`. + """ # Get metadata for mouse-over tooltip header = {} if image is not None: @@ -672,20 +829,28 @@ return metadata - def make_tt(self, viewer, canvas, text, pt, obj, fontsize=10): - x1, y1, x2, y2 = obj.get_llur() + def make_tt(self, thumbs_viewer, canvas, text, pt, obj, fontsize=10): + """Create a tooltip pop-up for a thumbnail to be displayed in + `thumbs_viewer` on its `canvas` based on `text` where the cursor + is at point `pt` and on the thumbnail object `obj`. This object + is created but not added to the canvas. + NOTE: currently `pt` is not used, but kept for possible future use. + """ # Determine pop-up position on canvas. Try to align a little below # the thumbnail image and offset a bit + x1, y1, x2, y2 = obj.get_llur() + x = x1 + 10 y = y1 + 10 mxwd = 0 lines = text.split('\n') + # create the canvas object representing the tooltip point = canvas.dc.Point(x, y, radius=0, color='black', alpha=0.0) rect = canvas.dc.Rectangle(x, y, x, y, color='black', fill=True, fillcolor='lightyellow') - crdmap = viewer.get_coordmap('offset') + crdmap = thumbs_viewer.get_coordmap('offset') crdmap.refobj = point l = [point, rect] @@ -695,7 +860,7 @@ fontsize=fontsize) text.crdmap = crdmap l.append(text) - txt_wd, txt_ht = viewer.renderer.get_dimensions(text) + txt_wd, txt_ht = thumbs_viewer.renderer.get_dimensions(text) b += txt_ht + 2 text.y = b mxwd = max(mxwd, txt_wd) @@ -706,7 +871,7 @@ # sanity check and adjustment so that popup will be minimally obscured # by a window edge - x3, y3, x4, y4 = viewer.get_datarect() + x3, y3, x4, y4 = thumbs_viewer.get_datarect() if rect.x2 > x4: off = rect.x2 - x4 rect.x1 -= off @@ -717,7 +882,13 @@ def show_tt(self, obj, canvas, event, pt, thumbkey, chname, info, tf): - + """Removing any previous tooltip, and if `tf` is `True`, pop up a + new tooltip on the thumbnail viewer window for the thumb denoted + by `thumbkey`. `obj` is the thumbnail compound object, `canvas` + if the thumbnail viewer canvas, `event` is the cursor event that + caused the popup. The cursor is at point `pt`. The channel for this + image has name `chname` and has image info `info`. + """ text = info.thumb_extras.tooltip tag = '_$tooltip' @@ -729,35 +900,43 @@ tt = self.make_tt(self.c_view, canvas, text, pt, obj) canvas.add(tt, tag=tag) - def _regen_thumb_image(self, image, viewer): + def _regen_thumb_image(self, tg, image, extras, viewer): self.logger.debug("generating new thumbnail") - if not self.thumb_generator.viewable(image): - # this is not a regular image type - tmp_path = os.path.join(icondir, 'fits.png') - image = self.rgb_opener.load_file(tmp_path) + if not tg.viewable(image): + # this is not something we know how to open + # TODO: other viewers might be able to open it, need to check + # with them + image = self.placeholder_image + extras.setvals(rgbimg=image, placeholder=False, + ignore=True, time_update=time.time()) + return image - self.thumb_generator.set_image(image) + tg.set_image(image) if viewer is not None: + # if a viewer was passed, and there is an image loaded there, + # then copy the viewer attributes to teh thumbnail generator v_img = viewer.get_image() if v_img is not None: - viewer.copy_attributes(self.thumb_generator, - self.transfer_attrs) + viewer.copy_attributes(tg, self.transfer_attrs) - rgb_img = self.thumb_generator.get_image_as_array() - thmb_image = RGBImage.RGBImage(rgb_img) - thmb_image.set(placeholder=False) - return thmb_image - - def _get_thumb_image(self, channel, info, image): - - # Get any previously stored thumb information in the image info - thumb_extra = info.setdefault('thumb_extras', Bunch.Bunch()) + order = tg.rgb_order + rgb_img = tg.get_image_as_array(order=order) + thumb_image = RGBImage.RGBImage(rgb_img, order=order) + extras.setvals(rgbimg=thumb_image, placeholder=False, + time_update=time.time()) + return thumb_image + + def _get_thumb_image(self, channel, info, image, extras, + no_placeholder=False): + """Get a thumb image for the image `image` (can be `None`) that + is associated with channel `channel` and image information `info`. + """ # Choice [A]: is there a thumb image attached to the image info? - if 'rgbimg' in thumb_extra: + if 'rgbimg' in extras: # yes - return thumb_extra.rgbimg + return extras.rgbimg thumbpath = self.get_thumbpath(info.path) @@ -771,10 +950,12 @@ if image is not None: try: - thmb_image = self._regen_thumb_image(image, None) - thumb_extra.rgbimg = thmb_image - thumb_extra.time_update = time.time() - return thmb_image + thumb_image = self._regen_thumb_image(self.thumb_generator, + image, extras, + channel.fitsimage) + extras.setvals(rgbimg=thumb_image, placeholder=False, + time_update=None) + return thumb_image except Exception as e: self.logger.warning("Error generating thumbnail: %s" % (str(e))) @@ -783,39 +964,38 @@ if (thumbpath is not None) and os.path.exists(thumbpath): try: # try to load the thumbnail image - thmb_image = self.rgb_opener.load_file(thumbpath) - thmb_image.set(name=info.name, placeholder=False) - thmb_image = self._regen_thumb_image(thmb_image, None) - thumb_extra.rgbimg = thmb_image - return thmb_image + thumb_image = self.rgb_opener.load_file(thumbpath) + wd, ht = thumb_image.get_size()[:2] + if max(wd, ht) > self.thumb_width: + # <-- thumb size does not match our expected size + thumb_image = self._regen_thumb_image(self.thumb_generator, + thumb_image, extras, + None) + thumb_image.set(name=info.name) + extras.setvals(rgbimg=thumb_image, placeholder=False, + time_update=None) + return thumb_image except Exception as e: self.logger.warning("Error loading thumbnail: %s" % (str(e))) - # Choice [D]: load a placeholder image - tmp_path = os.path.join(icondir, 'fits.png') - thmb_image = self.rgb_opener.load_file(tmp_path) - thmb_image.set(name=info.name, path=None, placeholder=True) - - return thmb_image - - def _make_thumb(self, chname, thmb_image, info, thumbkey, - save_thumb=False, thumbpath=None): - - # This is called by the plugin FBrowser.make_thumbs() as - # a gui thread - with self.thmblock: - # Save a thumbnail for future browsing - if save_thumb and (thumbpath is not None): - thmb_image.save_as_file(thumbpath) + if no_placeholder: + return None - # Get metadata for mouse-over tooltip - metadata = self._get_tooltip_metadata(info, None) + # Choice [D]: use a placeholder image + data_np = self.placeholder_image.get_data() + thumb_image = RGBImage.RGBImage(data_np=data_np) + thumb_image.set(name=info.name, path=None) + extras.setvals(placeholder=True, time_update=None) - self.insert_thumbnail(thmb_image, thumbkey, chname, - thumbpath, metadata, info) + return thumb_image def get_thumbpath(self, path, makedir=True): + """Return the path for the thumbnail location on disk based on the + path of the original. Can return `None` if there is no suitable path. + The preference for where to store thumbnails is set in the settings + for this plugin. + """ if path is None: return None @@ -826,8 +1006,8 @@ if cache_location == 'ginga': # thumbs in .ginga cache prefs = self.fv.get_preferences() - thumbDir = os.path.join(prefs.get_baseFolder(), 'thumbs') - thumbdir = os.path.join(thumbDir, iohelper.gethex(dirpath)) + thumbdir = os.path.join(prefs.get_baseFolder(), 'thumbs') + thumbdir = os.path.join(thumbdir, iohelper.gethex(dirpath)) else: # thumbs in .thumbs subdirectory of image folder thumbdir = os.path.join(dirpath, '.thumbs') @@ -844,6 +1024,10 @@ return thumbpath def _calc_thumb_pos(self, row, col): + """Calculate the thumb data coordinates on the thumbs canvas for + a thumb at row `row` and column `col`. Returns a 4-tuple of x/y + positions for the text item and the image item. + """ self.logger.debug("row, col = %d, %d" % (row, col)) # TODO: should really be text ht text_ht = self.thumb_vsep @@ -862,9 +1046,10 @@ return (xt, yt, xi, yi) - def get_visible_thumbs(self): - if not self.gui_up: - return [] + def get_visible_rows(self): + """Return the list of thumbkeys for the thumbs that should be + visible in the Thumbs pane. + """ x1, y1, x2, y2 = self.c_view.get_datarect() self.logger.debug("datarect=(%f, %f, %f, %f)", x1, y1, x2, y2) @@ -877,6 +1062,13 @@ row1 = int(math.floor(abs(y1) / twd_vplus) - 2) row2 = int(math.ceil(abs(y2) / twd_vplus) + 2) self.logger.debug("row1, row2 = %d, %d" % (row1, row2)) + return row1, row2 + + def get_visible_thumbs(self): + """Return the list of thumbkeys for the thumbs that should be + visible in the Thumbs pane. + """ + row1, row2 = self.get_visible_rows() i = max(0, row1 * self.thumb_num_cols) j = min(len(self.thumb_list) - 1, row2 * self.thumb_num_cols) self.logger.debug("i, j = %d, %d" % (i, j)) @@ -884,11 +1076,16 @@ return thumbs def add_visible_thumbs(self): + """Add the thumbs to the canvas that should be visible in the + Thumbs pane and remove the others. + """ if not self.gui_up: return + self.fv.assert_gui_thread() + canvas = self.c_view.get_canvas() - with self.thmblock: + with self.thumblock: thumb_keys = set(self.get_visible_thumbs()) old_thumb_keys = set(self._displayed_thumb_dict.keys()) if old_thumb_keys == thumb_keys: @@ -903,49 +1100,151 @@ self._to_build = set(thumb_keys) # delete thumbs from canvas that are no longer visible - for thumbkey in to_delete: - bnch = self._displayed_thumb_dict[thumbkey] - canvas.delete_object(bnch.widget, redraw=False) + canvas.delete_all_objects(redraw=False) + ## for thumbkey in to_delete: + ## bnch = self._displayed_thumb_dict[thumbkey] + ## if 'widget' in bnch and canvas.has_object(bnch.widget): + ## canvas.delete_object(bnch.widget, redraw=False) # update displayed thumbs dict self._displayed_thumb_dict = {thumbkey: self.thumb_dict[thumbkey] for thumbkey in thumb_keys} # add newly-visible thumbs to canvas - for thumbkey in to_add: - bnch = self.thumb_dict[thumbkey] - canvas.add(bnch.widget, redraw=False) + row1, row2 = self.get_visible_rows() + row, col = row1, 0 + + while row <= row2: + i = row * self.thumb_num_cols + col + if 0 <= i < len(self.thumb_list): + thumbkey = self.thumb_list[i] + bnch = self.thumb_dict[thumbkey] + bnch.setvals(row=row, col=col) + + if 'widget' not in bnch: + # lazily make a canvas object for "empty" thumb entries + self._make_thumb(bnch, thumbkey) + else: + # update object positions for existing entries + xt, yt, xi, yi = self._calc_thumb_pos(row, col) + bnch.namelbl.x, bnch.namelbl.y = xt, yt + bnch.image.x, bnch.image.y = xi, yi + + if not canvas.has_object(bnch.widget): + canvas.add(bnch.widget, redraw=False) + + col = (col + 1) % self.thumb_num_cols + if col == 0: + row += 1 - self.c_view.redraw(whence=0) + self.c_view.redraw(whence=0) self.fv.update_pending() - if self.autoload_visible: - # load and create thumbnails for any placeholder icons - self.timer_autoload.set(self.autoload_interval) - def thumbs_pan_cb(self, viewer, pan_vec): - self.fv.gui_do_oneshot('thumbs_pan', self.add_visible_thumbs) + # load and create thumbnails for any placeholder icons + self.autoload_serial = time.time() + #self.fv.gui_do_oneshot('thumbs-autoload', self._autoload_thumbs) + self.timer_autoload.set(self.autoload_interval) - def insert_thumbnail(self, thumb_img, thumbkey, chname, - thumbpath, metadata, info): + def update_thumbs(self): + """This is called whenever the thumb list has changed. + """ + self.fv.assert_gui_thread() + + with self.thumblock: + n = len(self.thumb_list) + row = n // self.thumb_num_cols + col = n % self.thumb_num_cols + + thumbkey = None + if self._latest_thumb in self.thumb_dict: + thumbkey = self._latest_thumb + else: + self._latest_thumb = None + + # update the visible limits (e.g. scroll bars) + xm, ym, x_, y_ = self._calc_thumb_pos(0, 0) + xt, yt, xi, yi = self._calc_thumb_pos(row, col) + xi += self.thumb_width * 2 + self.c_view.set_limits([(xm, ym), (xi, yi)], coord='data') + + if thumbkey is not None: + self.auto_scroll(thumbkey) + + # update the thumbs pane in the case that thumbs visibility + # has changed + self.add_visible_thumbs() + def thumbs_pan_cb(self, thumbs_viewer, pan_vec): + """This callback is called when the Thumbs pane is panned/scrolled. + """ + self.fv.gui_do_oneshot('thumbs-pan', self.add_visible_thumbs) + + def _insert_lazy_thumb(self, thumbkey, chname, info, thumbpath): + """This function gets called to create an initial entry for a + thumb. + """ thumbname = info.name - self.logger.debug("inserting thumb %s" % (thumbname)) - # make a context menu - menu = self._mk_context_menu(thumbkey, chname, info) # noqa + self.logger.debug("inserting an empty thumb %s" % (thumbname)) + + # Make an initial entry for the thumbs in the tracking dict. + # Nothing is actually plotted, because the thumb may be in a region + # that is not visible. + n = len(self.thumb_list) + row = n // self.thumb_num_cols + col = n % self.thumb_num_cols + + extras = info.setdefault('thumb_extras', Bunch.Bunch()) + bnch = Bunch.Bunch(info=info, extras=extras, + thumbname=thumbname, chname=chname, + thumbpath=thumbpath, row=row, col=col) + + self.thumb_dict[thumbkey] = bnch + if thumbkey not in self.thumb_list: + sort_order = self.settings.get('sort_order', None) + if sort_order is not None: + bisect.insort(self.thumb_list, thumbkey) + else: + self.thumb_list.append(thumbkey) + + self._latest_thumb = thumbkey + + self.timer_update.cond_set(self.update_interval) + #self.timer_update.set(self.update_interval) + + def _make_thumb(self, bnch, thumbkey): + """Called from inside add_visible_thumbs() to generate a placeholder + thumb for thumbs that are not yet made manifest. + """ + channel = self.fv.get_channel(bnch.chname) + info = bnch.info + image = None # Get any previously stored thumb information in the image info - thumb_extra = info.setdefault('thumb_extras', Bunch.Bunch()) + extras = bnch.extras + + thumbpath = self.get_thumbpath(info.path) + thumb_image = extras.get('rgbimg', None) + if thumb_image is None: + thumb_image = self._get_thumb_image(channel, info, image, extras) + + # Get metadata for mouse-over tooltip + metadata = self._get_tooltip_metadata(info, None) + + thumbname = info.name + self.logger.debug("inserting thumb %s" % (thumbname)) + chname = bnch.chname # If there is no previously made tooltip, then generate one - if 'tooltip' in thumb_extra: - text = thumb_extra.tooltip + if 'tooltip' in extras: + text = extras.tooltip else: text = self._mk_tooltip_text(metadata) - thumb_extra.tooltip = text + extras.tooltip = text - canvas = self.thumb_generator.get_canvas() - fg = self.settings.get('label_font_color', 'black') + canvas = self.canvas + fg = self.settings.get('label_font_color', 'white') + bg = self.settings.get('label_bg_color', 'lightgreen') fontsize = self.settings.get('label_font_size', 10) # Shorten thumbnail label, if requested @@ -959,58 +1258,39 @@ else: thumbname = thumbname[:label_length] - with self.thmblock: - row, col = self.thumb_row_count, self.thumb_col_count - self.thumb_col_count = (self.thumb_col_count + 1) % self.thumb_num_cols - if self.thumb_col_count == 0: - self.thumb_row_count += 1 - - xt, yt, xi, yi = self._calc_thumb_pos(row, col) - l2 = [] - namelbl = canvas.dc.Text(xt, yt, thumbname, color=fg, - fontsize=fontsize, coord='data') - l2.append(namelbl) - - image = canvas.dc.Image(xi, yi, thumb_img, alpha=1.0, - linewidth=1, color='black', coord='data') - l2.append(image) - - obj = canvas.dc.CompoundObject(*l2, coord='data') - obj.pickable = True - obj.opaque = True - obj.set_data(row=row, col=col) - - bnch = Bunch.Bunch(widget=obj, image=image, info=info, - namelbl=namelbl, - chname=chname, - thumbpath=thumbpath) - - self.thumb_dict[thumbkey] = bnch - if thumbkey not in self.thumb_list: - self.thumb_list.append(thumbkey) + row, col = bnch.row, bnch.col - # set the load callback - obj.add_callback('pick-down', - lambda *args: self.load_file(thumbkey, chname, - info)) - # set callbacks for tool tips - obj.add_callback('pick-enter', self.show_tt, - thumbkey, chname, info, True) - obj.add_callback('pick-leave', self.show_tt, - thumbkey, chname, info, False) - - # thumb will be added to canvas later in reorder_thumbs() - - sort_order = self.settings.get('sort_order', None) - if sort_order: - self.thumb_list.sort() - self.logger.debug("added thumb for %s" % (info.name)) - - self.fv.gui_do_oneshot('thumbs-reorder', self.reorder_thumbs, - new_thumbkey=thumbkey) + xt, yt, xi, yi = self._calc_thumb_pos(row, col) + l2 = [] + color = bg if thumbkey in self.re_hilite_set else fg + namelbl = canvas.dc.Text(xt, yt, text=thumbname, color=color, + fontsize=fontsize, coord='data') + l2.append(namelbl) + + image = canvas.dc.Image(xi, yi, thumb_image, alpha=1.0, + linewidth=1, color='black', coord='data') + l2.append(image) + + obj = canvas.dc.CompoundObject(*l2, coord='data') + obj.pickable = True + obj.opaque = True + + bnch.setvals(widget=obj, image=image, namelbl=namelbl, + thumbpath=thumbpath) + + # set the load callback + obj.add_callback('pick-down', + lambda *args: self.load_file(thumbkey, chname, + info)) + # set callbacks for tool tips + obj.add_callback('pick-enter', self.show_tt, + thumbkey, chname, info, True) + obj.add_callback('pick-leave', self.show_tt, + thumbkey, chname, info, False) def auto_scroll(self, thumbkey): - """Scroll the window to the thumb.""" + """Scroll the Thumbs viewer to the thumb denoted by `thumbkey`. + """ if not self.gui_up: return # force scroll to bottom of thumbs, if checkbox is set @@ -1018,15 +1298,19 @@ if not scrollp: return - bnch = self.thumb_dict[thumbkey] + with self.thumblock: + i = self.thumb_list.index(thumbkey) + + row = i // self.thumb_num_cols + col = i % self.thumb_num_cols # override X parameter because we only want to scroll vertically pan_x, pan_y = self.c_view.get_pan() - self.c_view.panset_xy(pan_x, bnch.image.y) + xt, yt, xi, yi = self._calc_thumb_pos(row, col) + self.c_view.panset_xy(pan_x, yi) def clear_widget(self): - """ - Clears the thumbnail display widget of all thumbnails, but does + """Clears the thumbnail viewer of all thumbnails, but does not remove them from the thumb_dict or thumb_list. """ if not self.gui_up: @@ -1035,41 +1319,15 @@ canvas.delete_all_objects() self.c_view.redraw(whence=0) - def reorder_thumbs(self, new_thumbkey=None): - self.logger.debug("Reordering thumb grid") - xi, yi = None, None - with self.thmblock: - - # Add thumbs back in by rows - self.thumb_col_count = 0 - self.thumb_row_count = 0 - for thumbkey in self.thumb_list: - bnch = self.thumb_dict[thumbkey] - - row, col = self.thumb_row_count, self.thumb_col_count - self.thumb_col_count = ((self.thumb_col_count + 1) % - self.thumb_num_cols) - if self.thumb_col_count == 0: - self.thumb_row_count += 1 - - xt, yt, xi, yi = self._calc_thumb_pos(row, col) - bnch.namelbl.x, bnch.namelbl.y = xt, yt - bnch.image.x, bnch.image.y = xi, yi - bnch.widget.set_data(row=row, col=col) - - if xi is not None: - xi += self.thumb_width * 2 - xm, ym, x_, y_ = self._calc_thumb_pos(0, 0) - if self.gui_up: - self.c_view.set_limits([(xm, ym), (xi, yi)], coord='data') - - if new_thumbkey is not None: - self.auto_scroll(new_thumbkey) - - self.add_visible_thumbs() - self.logger.debug("Reordering done") + def timer_update_cb(self, timer): + if not self.gui_up: + return + self.fv.gui_do_oneshot('thumbs-update', self.update_thumbs) def _mk_tooltip_text(self, metadata): + """Make a tooltip text from values related to interesting keywords + in `metadata`. + """ result = [] for kwd in self.keywords: try: @@ -1083,26 +1341,11 @@ return '\n'.join(result) - def _mk_context_menu(self, thumbkey, chname, info): - """NOTE: currently not used, but left here to be reincorporated - at some point. - """ - menu = Widgets.Menu() - item = menu.add_name("Display") - item.add_callback('activated', - lambda w: self.load_file( - thumbkey, chname, info.name, info.path, - info.image_future)) - menu.add_separator() - item = menu.add_name("Remove") - item.add_callback('activated', - lambda w: self.fv.remove_image_by_name( - chname, info.name, impath=info.path)) - - return menu - - def update_thumbnail(self, thumbkey, thmb_image, metadata): - with self.thmblock: + def update_thumbnail(self, thumbkey, thumb_image, metadata): + """Update the thumbnail denoted by `thumbkey` with a new thumbnail + image (`thumb_image`) and new knowledge of dict `metadata`. + """ + with self.thumblock: try: bnch = self.thumb_dict[thumbkey] except KeyError: @@ -1112,20 +1355,26 @@ info = bnch.info # Get any previously stored thumb information in the image info - thumb_extra = info.setdefault('thumb_extras', Bunch.Bunch()) + extras = bnch.extras # Update the tooltip, in case of new or changed metadata - text = self._mk_tooltip_text(metadata) - thumb_extra.tooltip = text + if metadata is not None: + text = self._mk_tooltip_text(metadata) + extras.tooltip = text self.logger.debug("updating thumbnail '%s'" % (info.name)) - # TODO: figure out why set_image() causes corruption of the - # redraw here. Instead we force a manual redraw. - #bnch.image.set_image(thmb_image) - bnch.image.image = thmb_image - thumb_extra.rgbimg = thmb_image - - if self.gui_up: + extras.rgbimg = thumb_image + cvs_img = bnch.get('image', None) + if cvs_img is not None: + # TODO: figure out why set_image() causes corruption of the + # redraw here. Instead we force a manual redraw. + #image.set_image(thumb_image) + cvs_img.image = thumb_image + # TODO: need to set width, height? + + if self.gui_up and thumbkey in self._displayed_thumb_dict: + # redraw the thumbs viewer if the update was to a displayed + # thumb self.c_view.redraw(whence=0) self.logger.debug("update finished.") diff -Nru ginga-3.0.0/ginga/rv/plugins/Toolbar.py ginga-3.1.0/ginga/rv/plugins/Toolbar.py --- ginga-3.0.0/ginga/rv/plugins/Toolbar.py 2019-08-26 19:08:48.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Toolbar.py 2020-07-20 21:06:00.000000000 +0000 @@ -338,6 +338,8 @@ self.update_channel_buttons(channel) def update_channel_buttons(self, channel): + if not self.gui_up: + return # Update toolbar channel buttons enabled = len(channel) > 1 self.w.btn_up.set_enabled(enabled) diff -Nru ginga-3.0.0/ginga/rv/plugins/TVMark.py ginga-3.1.0/ginga/rv/plugins/TVMark.py --- ginga-3.0.0/ginga/rv/plugins/TVMark.py 2019-07-31 01:35:09.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/TVMark.py 2020-07-08 20:09:29.000000000 +0000 @@ -731,7 +731,7 @@ # turn off any mode user may be in self.modes_off() - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status('Press "Help" for instructions') def stop(self): diff -Nru ginga-3.0.0/ginga/rv/plugins/TVMask.py ginga-3.1.0/ginga/rv/plugins/TVMask.py --- ginga-3.0.0/ginga/rv/plugins/TVMask.py 2019-07-31 01:35:09.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/TVMask.py 2020-07-08 20:09:29.000000000 +0000 @@ -469,7 +469,7 @@ # turn off any mode user may be in self.modes_off() - self.canvas.ui_set_active(True) + self.canvas.ui_set_active(True, viewer=self.fitsimage) self.fv.show_status('Press "Help" for instructions') def stop(self): diff -Nru ginga-3.0.0/ginga/rv/plugins/Zoom.py ginga-3.1.0/ginga/rv/plugins/Zoom.py --- ginga-3.0.0/ginga/rv/plugins/Zoom.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/rv/plugins/Zoom.py 2020-07-20 21:06:00.000000000 +0000 @@ -13,10 +13,8 @@ **Usage** -The size of the cutout radius can be adjusted by the slider below the -zoom image labeled "Zoom Radius". The default radius is 30 pixels, -making a 61x61 zoom image. The magnification can be changed by -adjusting the "Zoom Amount" slider. +The magnification of the zoom window can be changed by adjusting the +"Zoom Amount" slider. Two modes of operation are possible -- absolute and relative zoom: @@ -65,30 +63,26 @@ self.zoom_x = 0 self.zoom_y = 0 self.t_abszoom = True - self.zoomtask = fv.get_timer() + self.zoomtask = fv.get_backend_timer() self.zoomtask.set_callback('expired', self.showzoom_timer_cb) self.fitsimage_focus = None + self.layer_tag = 'shared-canvas' self.update_time = time.time() + spec = self.fv.get_plugin_spec(str(self)) + # read preferences for this plugin prefs = self.fv.get_preferences() self.settings = prefs.create_category('plugin_Zoom') - self.settings.add_defaults(zoom_radius=self.default_radius, - zoom_amount=self.default_zoom, - zoom_cmap_name=None, - zoom_imap_name=None, - refresh_interval=0.02, - rotate_zoom_image=True) + self.settings.add_defaults(zoom_amount=self.default_zoom, + closeable=not spec.get('hidden', False), + refresh_interval=0.02) self.settings.load(onError='silent') - self.zoom_radius = self.settings.get('zoom_radius', self.default_radius) self.zoom_amount = self.settings.get('zoom_amount', self.default_zoom) self.refresh_interval = self.settings.get('refresh_interval', 0.02) - self.zoom_rotate = self.settings.get('rotate_zoom_image', False) - self.copy_attrs = ['transforms', 'cutlevels', 'rotation'] - if (self.settings.get('zoom_cmap_name', None) is None and - self.settings.get('zoom_imap_name', None) is None): - self.copy_attrs.append('rgbmap') + self.copy_attrs = ['transforms', 'cutlevels', 'rotation', 'rgbmap', + 'icc'] # , 'interpolation'] self._wd = 300 self._ht = 300 @@ -120,12 +114,6 @@ 'set', self.zoomset, zi) zi.set_bg(0.4, 0.4, 0.4) zi.show_pan_mark(True) - cmname = self.settings.get('zoom_cmap_name', None) - if cmname is not None: - zi.set_color_map(cmname) - imname = self.settings.get('zoom_imap_name', None) - if imname is not None: - zi.set_intensity_map(imname) # for debugging zi.set_name('zoomimage') self.zoomimage = zi @@ -142,18 +130,12 @@ self.w.splitter = paned vbox2 = Widgets.VBox() - captions = (("Zoom Radius:", 'label', 'Zoom Radius', 'hscale'), - ("Zoom Amount:", 'label', 'Zoom Amount', 'hscale'), + captions = (("Zoom Amount:", 'label', 'Zoom Amount', 'hscale'), ) w, b = Widgets.build_info(captions, orientation=orientation) self.w.update(b) vbox2.add_widget(w, stretch=0) - self.w.zoom_radius.set_limits(1, 300, incr_value=1) - self.w.zoom_radius.set_value(self.zoom_radius) - self.w.zoom_radius.add_callback('value-changed', self.set_radius_cb) - self.w.zoom_radius.set_tracking(True) - self.w.zoom_amount.set_limits(-20, 30, incr_value=1) self.w.zoom_amount.set_value(self.zoom_amount) self.w.zoom_amount.add_callback('value-changed', self.set_amount_cb) @@ -192,25 +174,25 @@ vtop.add_widget(paned, stretch=5) - btns = Widgets.HBox() - btns.set_border_width(4) - btns.set_spacing(4) - - btn = Widgets.Button("Close") - btn.add_callback('activated', lambda w: self.close()) - btns.add_widget(btn) - btn = Widgets.Button("Help") - btn.add_callback('activated', lambda w: self.help()) - btns.add_widget(btn, stretch=0) - btns.add_widget(Widgets.Label(''), stretch=1) - vtop.add_widget(btns, stretch=0) + if self.settings.get('closeable', False): + btns = Widgets.HBox() + btns.set_border_width(4) + btns.set_spacing(4) + + btn = Widgets.Button("Close") + btn.add_callback('activated', lambda w: self.close()) + btns.add_widget(btn) + btn = Widgets.Button("Help") + btn.add_callback('activated', lambda w: self.help()) + btns.add_widget(btn, stretch=0) + btns.add_widget(Widgets.Label(''), stretch=1) + vtop.add_widget(btns, stretch=0) container.add_widget(vtop, stretch=5) self.gui_up = True def prepare(self, fitsimage): fitssettings = fitsimage.get_settings() - zoomsettings = self.zoomimage.get_settings() fitsimage.add_callback('cursor-changed', self.motion_cb) fitsimage.add_callback('redraw', self.redraw_cb) fitssettings.get_setting('zoomlevel').add_callback( @@ -245,6 +227,19 @@ # Reflect transforms, colormap, etc. fitsimage.copy_attributes(self.zoomimage, self.copy_attrs) + p_canvas = self.zoomimage.get_private_canvas() + try: + p_canvas.delete_object_by_tag(self.layer_tag) + except Exception: + pass + canvas = fitsimage.get_canvas() + p_canvas.add(canvas, tag=self.layer_tag) + # NOTE: necessary for zoom viewer to correctly handle some settings + # TODO: see if there is a cleaner way to do this + self.zoomimage._imgobj = fitsimage._imgobj + + self.zoomimage.redraw(whence=0) + def redo(self, channel, image): if not self.gui_up: return @@ -273,7 +268,6 @@ self.logger.debug("zoomlevel=%d myzoom=%d" % ( zoomlevel, myzoomlevel)) self.zoomimage.zoom_to(myzoomlevel) - text = self.fv.scale2text(self.zoomimage.get_scale()) # noqa return True def zoomset_cb(self, setting, zoomlevel, fitsimage): @@ -291,26 +285,10 @@ # LOGIC - def set_radius(self, val): - self.logger.debug("Setting radius to %d" % val) - self.zoom_radius = int(val) - fitsimage = self.fitsimage_focus - if fitsimage is None: - return True - image = fitsimage.get_image() - wd, ht = image.get_size() - data_x, data_y = wd // 2, ht // 2 - self.magnify_xy(fitsimage, data_x, data_y) - def magnify_xy(self, fitsimage, data_x, data_y): - # Cut and show zoom image in zoom window + # Show zoom image in zoom window self.zoom_x, self.zoom_y = data_x, data_y - image = fitsimage.get_image() - if image is None: - # No image loaded into this channel - return True - # If this is a new source, then update our widget with the # attributes of the source if self.fitsimage_focus != fitsimage: @@ -325,11 +303,10 @@ if elapsed > self.refresh_interval: # cancel timer self.zoomtask.clear() - self.showzoom(image, data_x, data_y) + self.showzoom(data_x, data_y) else: # store needed data into the timer - self.zoomtask.data.setvals(image=image, - data_x=data_x, data_y=data_y) + self.zoomtask.data.setvals(data_x=data_x, data_y=data_y) # calculate delta until end of refresh interval period = self.refresh_interval - elapsed # set timer @@ -356,23 +333,14 @@ if not self.gui_up: return data = timer.data - self.showzoom(data.image, data.data_x, data.data_y) + self._zoom_data(self.zoomimage, data.data_x, data.data_y) - def _zoom_data(self, fitsimage, data, pan_pos): - with fitsimage.suppress_redraw: - self.update_time = time.time() - self.zoomimage.set_data(data) - pan_x, pan_y = pan_pos[:2] - self.zoomimage.panset_xy(pan_x, pan_y) - - def showzoom(self, image, data_x, data_y): - # cut out detail area and set the zoom image - data, x1, y1, x2, y2 = image.cutout_radius(int(data_x), int(data_y), - self.zoom_radius) - # pan to the exact position of the center of the cutout - pan_pos = (data_x - x1, data_y - y1) + def _zoom_data(self, fitsimage, data_x, data_y): + fitsimage.set_pan(data_x, data_y) - self.fv.gui_do(self._zoom_data, self.zoomimage, data, pan_pos) + def showzoom(self, data_x, data_y): + # set the zoom image + self.fv.gui_do(self._zoom_data, self.zoomimage, data_x, data_y) def set_amount_cb(self, widget, val): """This method is called when 'Zoom Amount' control is adjusted. @@ -389,7 +357,6 @@ def set_defaults(self): self.t_abszoom = True self.w.relative_zoom.set_state(not self.t_abszoom) - self.w.zoom_radius.set_value(self.default_radius) self.w.zoom_amount.set_value(self.default_zoom) self.zoomimage.zoom_to(self.default_zoom) @@ -397,9 +364,6 @@ text = self.fv.scale2text(self.zoomimage.get_scale()) self.w.zoom.set_text(text) - def set_radius_cb(self, w, val): - self.set_radius(val) - def set_refresh_cb(self, w, val): self.refresh_interval = val / 1000.0 self.logger.debug("Setting refresh time to %.4f sec" % ( diff -Nru ginga-3.0.0/ginga/tests/coveragerc ginga-3.1.0/ginga/tests/coveragerc --- ginga-3.0.0/ginga/tests/coveragerc 2016-05-20 20:10:03.000000000 +0000 +++ ginga-3.1.0/ginga/tests/coveragerc 1970-01-01 00:00:00.000000000 +0000 @@ -1,31 +0,0 @@ -[run] -source = {packagename} -omit = - {packagename}/_astropy_init* - {packagename}/conftest* - {packagename}/cython_version* - {packagename}/setup_package* - {packagename}/*/setup_package* - {packagename}/*/*/setup_package* - {packagename}/tests/* - {packagename}/*/tests/* - {packagename}/*/*/tests/* - {packagename}/version* - -[report] -exclude_lines = - # Have to re-enable the standard pragma - pragma: no cover - - # Don't complain about packages we have installed - except ImportError - - # Don't complain if tests don't hit assertions - raise AssertionError - raise NotImplementedError - - # Don't complain about script hooks - def main\(.*\): - - # Ignore branches that don't pertain to this version of Python - pragma: py{ignore_python_version} \ No newline at end of file diff -Nru ginga-3.0.0/ginga/tests/setup_package.py ginga-3.1.0/ginga/tests/setup_package.py --- ginga-3.0.0/ginga/tests/setup_package.py 2017-11-21 03:33:27.000000000 +0000 +++ ginga-3.1.0/ginga/tests/setup_package.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,2 +0,0 @@ -def get_package_data(): - return {'ginga.tests': ['coveragerc']} diff -Nru ginga-3.0.0/ginga/tests/test_colors.py ginga-3.1.0/ginga/tests/test_colors.py --- ginga-3.0.0/ginga/tests/test_colors.py 2017-10-11 22:29:44.000000000 +0000 +++ ginga-3.1.0/ginga/tests/test_colors.py 2020-01-20 03:17:53.000000000 +0000 @@ -49,6 +49,23 @@ with pytest.raises(errtype): ginga.colors.lookup_color(*args) + # Tests for the resolve_color() function + + def test_resolve_color_name(self): + expected = (1.0, 0.0, 0.0) + actual = ginga.colors.resolve_color('red') + assert expected == actual + + def test_resolve_color_hex(self): + expected = (1.0, 0.0, 0.0) + actual = ginga.colors.resolve_color('#FF0000') + assert expected == actual + + def test_resolve_color_tuple(self): + expected = (1.0, 0.0, 0.0) + actual = ginga.colors.resolve_color(expected) + assert expected == actual + # Tests for the get_colors() function def test_get_colors_len(self): expected = self.color_list_length diff -Nru ginga-3.0.0/ginga/tests/test_ImageView.py ginga-3.1.0/ginga/tests/test_ImageView.py --- ginga-3.0.0/ginga/tests/test_ImageView.py 2018-09-11 02:59:36.000000000 +0000 +++ ginga-3.1.0/ginga/tests/test_ImageView.py 2020-07-08 20:09:29.000000000 +0000 @@ -3,14 +3,14 @@ import numpy as np from ginga import AstroImage -from ginga.mockw.ImageViewCanvasMock import ImageViewCanvas +from ginga.mockw.ImageViewMock import CanvasView class TestImageView(object): def setup_class(self): self.logger = logging.getLogger("TestImageView") - self.viewer = ImageViewCanvas(logger=self.logger) + self.viewer = CanvasView(logger=self.logger) self.data = np.identity(2000) self.image = AstroImage.AstroImage(logger=self.logger) self.image.set_data(self.data) @@ -25,29 +25,31 @@ zoomlevel = viewer.get_zoom() assert zoomlevel == zoom - def test_pan(self): + def test_centering(self): viewer = self.viewer viewer.set_window_size(900, 1100) viewer.set_image(self.image) - ctr_x, ctr_y = viewer.get_center() - viewer.set_pan(ctr_x, ctr_y) + viewer.center_image() + ht, wd = self.data.shape[:2] + ctr_x, ctr_y = wd / 2. - viewer.data_off, ht / 2. - viewer.data_off pan_x, pan_y = viewer.get_pan() - assert (pan_x == ctr_x) and (pan_y == ctr_y) + assert np.isclose(pan_x, ctr_x) and np.isclose(pan_y, ctr_y) - ## off_x, off_y = viewer.window_to_offset(200, 200) - ## print "200,200 absolute window_to_offset ->", off_x, off_y - ## data_x, data_y = viewer.get_data_xy(200, 200) - ## print "200,200 data xy ->", data_x, data_y - - ## win_x, win_y = viewer.offset_to_window(200, 200) - ## print "200,200 relative offset_to_window ->", win_x, win_y - ## win_x, win_y = viewer.get_canvas_xy(200, 200) - ## print "200,200 canvas xy ->", win_x, win_y - - ## x1, y1, x2, y2 = viewer.get_datarect() - ## print "getting canvas for %d,%d (%d,%d)" % (x1, y1, x2, y2) - ## dst_x, dst_y = viewer.get_canvas_xy(x1, y2) - ## print (x1, y2) - ## print (dst_x, dst_y) + def test_pan(self): + viewer = self.viewer + viewer.set_window_size(900, 1100) + viewer.set_image(self.image) + viewer.set_pan(401.0, 501.0) + pan_x, pan_y = viewer.get_pan() + assert np.isclose(pan_x, 401.0) and np.isclose(pan_y, 501.0) -# END + def test_pan2(self): + viewer = self.viewer + viewer.set_window_size(400, 300) + viewer.set_image(self.image) + viewer.set_pan(401.0, 501.0) + viewer.scale_to(8.0, 8.0) + x1, y1, x2, y2 = viewer.get_datarect() + result = np.array([(x1, y1), (x2, y2)]) + expected = np.array([[376., 482.25], [426., 519.75]]) + assert np.all(np.isclose(expected, result)) diff -Nru ginga-3.0.0/ginga/tests/test_trcalc.py ginga-3.1.0/ginga/tests/test_trcalc.py --- ginga-3.0.0/ginga/tests/test_trcalc.py 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/tests/test_trcalc.py 2019-11-29 07:54:06.000000000 +0000 @@ -0,0 +1,81 @@ + +import numpy as np + +from ginga import trcalc + + +class TestTrcalc: + + def _2ddata(self): + data = np.zeros((10, 10), dtype=np.int) + for i in range(10): + for j in range(10): + data[i, j] = min(i, j) + return data + + def _3ddata(self): + data = np.zeros((10, 10, 10), dtype=np.int) + for i in range(10): + for j in range(10): + for k in range(10): + data[i, j, k] = min(i, j, k) + return data + + def test_get_scaled_cutout_wdht_view(self): + + data = self._2ddata() + p1 = (2, 2) + p2 = (4, 4) + nd = (8, 10) + + res = np.asarray([[2, 2, 2, 2, 2, 2, 2, 2], + [2, 2, 2, 2, 2, 2, 2, 2], + [2, 2, 2, 2, 2, 2, 2, 2], + [2, 2, 2, 2, 2, 2, 2, 2], + [2, 2, 2, 3, 3, 3, 3, 3], + [2, 2, 2, 3, 3, 3, 3, 3], + [2, 2, 2, 3, 3, 3, 3, 3], + [2, 2, 2, 3, 3, 3, 4, 4], + [2, 2, 2, 3, 3, 3, 4, 4], + [2, 2, 2, 3, 3, 3, 4, 4]]) + + view, scales = trcalc.get_scaled_cutout_wdht_view(data.shape, + p1[0], p1[1], + p2[0], p2[1], + nd[0], nd[1]) + new_data = data[view] + assert new_data.shape == (10, 8) + assert np.allclose(new_data, res) + + def test_get_scaled_cutout_wdhtdp_view(self): + + data = self._3ddata() + p1 = (0, 0, 0) + p2 = (9, 9, 9) + nd = (4, 4, 4) + + res = np.asarray([[[0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0]], + + [[0, 0, 0, 0], + [0, 2, 2, 2], + [0, 2, 2, 2], + [0, 2, 2, 2]], + + [[0, 0, 0, 0], + [0, 2, 2, 2], + [0, 2, 5, 5], + [0, 2, 5, 5]], + + [[0, 0, 0, 0], + [0, 2, 2, 2], + [0, 2, 5, 5], + [0, 2, 5, 7]]]) + + view, scales = trcalc.get_scaled_cutout_wdhtdp_view(data.shape, + p1, p2, nd) + new_data = data[view] + assert new_data.shape == (4, 4, 4) + assert np.allclose(new_data, res) diff -Nru ginga-3.0.0/ginga/tkw/ImageViewTk.py ginga-3.1.0/ginga/tkw/ImageViewTk.py --- ginga-3.0.0/ginga/tkw/ImageViewTk.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/tkw/ImageViewTk.py 2020-07-08 20:09:29.000000000 +0000 @@ -95,7 +95,7 @@ raise ImageViewTkError("No valid renderers available: {}".format(str(self.possible_renderers))) - def update_image(self): + def update_widget(self): if self.tkcanvas is None: return @@ -134,7 +134,7 @@ self._defer_task.start(time_sec) def configure_window(self, width, height): - self.configure_surface(width, height) + self.configure(width, height) def _resize_cb(self, event): self.configure_window(event.width, event.height) @@ -300,7 +300,7 @@ keyname = event.keysym keyname = self.transkey(keyname) self.logger.debug("key press event, key=%s" % (keyname)) - return self.make_ui_callback('key-press', keyname) + return self.make_ui_callback_viewer(self, 'key-press', keyname) def key_release_event(self, event): self.tkcanvas.grab_release() @@ -308,7 +308,7 @@ keyname = event.keysym keyname = self.transkey(keyname) self.logger.debug("key release event, key=%s" % (keyname)) - return self.make_ui_callback('key-release', keyname) + return self.make_ui_callback_viewer(self, 'key-release', keyname) def button_press_event(self, event): x = event.x @@ -331,8 +331,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('scroll', direction, num_degrees, - data_x, data_y) + return self.make_ui_callback_viewer(self, 'scroll', direction, + num_degrees, data_x, data_y) button |= 0x1 << (event.num - 1) self._button = button @@ -340,7 +340,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-press', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-press', button, + data_x, data_y) def button_release_event(self, event): # event.button, event.x, event.y @@ -359,7 +360,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-release', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-release', button, + data_x, data_y) def motion_notify_event(self, event): #button = 0 @@ -378,7 +380,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('motion', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'motion', button, + data_x, data_y) ## def drop_event(self, widget, context, x, y, selection, targetType, ## time): @@ -386,7 +389,7 @@ ## return False ## paths = selection.data.split('\n') ## self.logger.debug("dropped filename(s): %s" % (str(paths))) - ## return self.make_ui_callback('drag-drop', paths) + ## return self.make_ui_callback_viewer(self, 'drag-drop', paths) class ImageViewZoom(Mixins.UIMixin, ImageViewEvent): @@ -409,7 +412,7 @@ settings=settings) Mixins.UIMixin.__init__(self) - self.ui_set_active(True) + self.ui_set_active(True, viewer=self) if bindmap is None: bindmap = ImageViewZoom.bindmapClass(self.logger) diff -Nru ginga-3.0.0/ginga/trcalc.py ginga-3.1.0/ginga/trcalc.py --- ginga-3.0.0/ginga/trcalc.py 2019-08-24 00:57:36.000000000 +0000 +++ ginga-3.1.0/ginga/trcalc.py 2020-07-08 20:09:29.000000000 +0000 @@ -4,36 +4,29 @@ # This is open-source software licensed under a BSD license. # Please see the file LICENSE.txt for details. # +import sys import math import numpy as np interpolation_methods = ['basic'] +_use = None def use(pkgname): - global interpolation_methods - global have_opencv, cv2, cv2_resize global have_opencl, trcalc_cl + global _use if pkgname == 'opencv': - import cv2 - cv2_resize = { - 'nearest': cv2.INTER_NEAREST, - 'linear': cv2.INTER_LINEAR, - 'area': cv2.INTER_AREA, - 'bicubic': cv2.INTER_CUBIC, - 'lanczos': cv2.INTER_LANCZOS4, - } - have_opencv = True - if 'nearest' not in interpolation_methods: - interpolation_methods = list(set(['basic'] + - list(cv2_resize.keys()))) - interpolation_methods.sort() + _use = 'opencv' + + elif pkgname == 'pillow': + _use = 'pillow' elif pkgname == 'opencl': try: from ginga.opencl import CL have_opencl = True + _use = 'opencl' trcalc_cl = CL.CL('trcalc.cl') except Exception as e: @@ -44,7 +37,20 @@ try: # optional opencv package speeds up certain operations, especially # rotation - use('opencv') + import cv2 + cv2_resize = { + 'nearest': cv2.INTER_NEAREST, + 'linear': cv2.INTER_LINEAR, + 'area': cv2.INTER_AREA, + 'bicubic': cv2.INTER_CUBIC, + 'lanczos': cv2.INTER_LANCZOS4, + } + have_opencv = True + + if 'nearest' not in interpolation_methods: + interpolation_methods = list(set(['basic'] + + list(cv2_resize.keys()))) + interpolation_methods.sort() except ImportError: pass @@ -63,10 +69,31 @@ except ImportError: pass +have_pillow = False +try: + # do we have Python Imaging Library available? + import PIL.Image as PILimage + pil_resize = { + 'nearest': PILimage.NEAREST, + 'linear': PILimage.BILINEAR, + 'area': PILimage.HAMMING, + 'bicubic': PILimage.BICUBIC, + 'lanczos': PILimage.LANCZOS, + } + have_pillow = True + + if 'nearest' not in interpolation_methods: + interpolation_methods = list(set(['basic'] + + list(pil_resize.keys()))) + interpolation_methods.sort() + +except ImportError: + pass + have_numexpr = False try: # optional numexpr package speeds up certain combined numpy array - # operations, especially rotation + # operations, useful to have import numexpr as ne have_numexpr = True @@ -77,13 +104,16 @@ #have_numexpr = False #have_opencv = False #have_opencl = False +#have_pillow = False def get_center(data_np): ht, wd = data_np.shape[:2] - ctr_x = wd // 2 - ctr_y = ht // 2 + ctr_x = int(wd // 2) + ctr_y = int(ht // 2) + ## ctr_x = wd * 0.5 + ## ctr_y = ht * 0.5 return (ctr_x, ctr_y) @@ -119,7 +149,7 @@ def rotate_clip(data_np, theta_deg, rotctr_x=None, rotctr_y=None, - out=None, use_opencl=True, logger=None): + out=None, logger=None): """ Rotate numpy array `data_np` by `theta_deg` around rotation center (rotctr_x, rotctr_y). If the rotation center is omitted it defaults @@ -142,17 +172,12 @@ if rotctr_y is None: rotctr_y = ht // 2 - if have_opencv: + if dtype == np.uint8 and have_opencv and _use in (None, 'opencv'): if logger is not None: logger.debug("rotating with OpenCv") # opencv is fastest M = cv2.getRotationMatrix2D((rotctr_y, rotctr_x), theta_deg, 1) - if data_np.dtype == np.dtype('>f8'): - # special hack for OpenCv warpAffine bug on numpy arrays of - # dtype '>f8'-- it corrupts them - data_np = data_np.astype(np.float64) - newdata = cv2.warpAffine(data_np, M, (wd, ht)) new_ht, new_wd = newdata.shape[:2] assert (wd == new_wd) and (ht == new_ht), \ @@ -165,7 +190,19 @@ out[:, :, ...] = newdata newdata = out - elif have_opencl and use_opencl: + elif dtype == np.uint8 and have_pillow and _use in (None, 'pillow'): + if logger is not None: + logger.debug("rotating with pillow") + img = PILimage.fromarray(data_np) + img_rot = img.rotate(theta_deg, resample=False, expand=False, + center=(rotctr_x, rotctr_y)) + newdata = np.array(img_rot, dtype=data_np.dtype) + new_ht, new_wd = newdata.shape[:2] + assert (wd == new_wd) and (ht == new_ht), \ + Exception("rotated cutout is %dx%d original=%dx%d" % ( + new_wd, new_ht, wd, ht)) + + elif dtype == np.uint8 and have_opencl and _use in (None, 'opencl'): if logger is not None: logger.debug("rotating with OpenCL") # opencl is very close, sometimes better, sometimes worse @@ -220,7 +257,7 @@ def rotate(data_np, theta_deg, rotctr_x=None, rotctr_y=None, pad=20, - use_opencl=True, logger=None): + logger=None): # If there is no rotation, then we are done if math.fmod(theta_deg, 360.0) == 0.0: @@ -237,7 +274,7 @@ # Find center of new data array ncx, ncy = new_wd // 2, new_ht // 2 - if have_opencl and use_opencl: + if have_opencl and _use == 'opencl': if logger is not None: logger.debug("rotating with OpenCL") # find offsets of old image in new image @@ -266,44 +303,44 @@ def get_scaled_cutout_wdht_view(shp, x1, y1, x2, y2, new_wd, new_ht): - """ - Like get_scaled_cutout_wdht, but returns the view/slice to extract - from an image instead of the extraction itself. - """ x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) - new_wd, new_ht = int(new_wd), int(new_ht) # calculate dimensions of NON-scaled cutout - old_wd = x2 - x1 + 1 - old_ht = y2 - y1 + 1 - max_x, max_y = shp[1] - 1, shp[0] - 1 + old_wd, old_ht = max(x2 - x1 + 1, 1), max(y2 - y1 + 1, 1) - if (new_wd != old_wd) or (new_ht != old_ht): - # Make indexes and scale them - # Is there a more efficient way to do this? - yi = np.mgrid[0:new_ht].reshape(-1, 1) - xi = np.mgrid[0:new_wd].reshape(1, -1) + if new_wd == 0: + iscale_x = 0.0 + else: iscale_x = float(old_wd) / float(new_wd) - iscale_y = float(old_ht) / float(new_ht) - xi = (x1 + xi * iscale_x).clip(0, max_x).astype(np.int, copy=False) - yi = (y1 + yi * iscale_y).clip(0, max_y).astype(np.int, copy=False) - wd, ht = xi.shape[1], yi.shape[0] + if new_ht == 0: + iscale_y = 0.0 + else: + iscale_y = float(old_ht) / float(new_ht) - # bounds check against shape (to protect future data access) - xi_max, yi_max = xi[0, -1], yi[-1, 0] - assert xi_max <= max_x, ValueError("X index (%d) exceeds shape bounds (%d)" % (xi_max, max_x)) - assert yi_max <= max_y, ValueError("Y index (%d) exceeds shape bounds (%d)" % (yi_max, max_y)) + max_x, max_y = shp[1] - 1, shp[0] - 1 - view = np.s_[yi, xi] + # Make indexes and scale them + # Is there a more efficient way to do this? + xi = np.clip(x1 + np.arange(0, new_wd) * iscale_x, + 0, max_x).astype(np.int, copy=False) + yi = np.clip(y1 + np.arange(0, new_ht) * iscale_y, + 0, max_y).astype(np.int, copy=False) + wd, ht = xi.size, yi.size + + # bounds check against shape (to protect future data access) + if new_wd > 0: + xi_max = xi[-1] + if xi_max > max_x: + raise ValueError("X index (%d) exceeds shape bounds (%d)" % (xi_max, max_x)) + if new_ht > 0: + yi_max = yi[-1] + if yi_max > max_y: + raise ValueError("Y index (%d) exceeds shape bounds (%d)" % (yi_max, max_y)) - else: - # simple stepped view will do, because new view is same as old - wd, ht = old_wd, old_ht - view = np.s_[y1:y2 + 1, x1:x2 + 1] + view = np.ix_(yi, xi) # Calculate actual scale used (vs. desired) - old_wd, old_ht = max(old_wd, 1), max(old_ht, 1) scale_x = float(wd) / old_wd scale_y = float(ht) / old_ht @@ -320,45 +357,59 @@ x2, y2, z2 = p2 new_wd, new_ht, new_dp = new_dims - x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2), int(z1), int(z2) + x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) z1, z2, new_wd, new_ht = int(z1), int(z2), int(new_wd), int(new_ht) # calculate dimensions of NON-scaled cutout - old_wd = x2 - x1 + 1 - old_ht = y2 - y1 + 1 - old_dp = z2 - z1 + 1 + old_wd = max(x2 - x1 + 1, 1) + old_ht = max(y2 - y1 + 1, 1) + old_dp = max(z2 - z1 + 1, 1) max_x, max_y, max_z = shp[1] - 1, shp[0] - 1, shp[2] - 1 - if (new_wd != old_wd) or (new_ht != old_ht) or (new_dp != old_dp): - # Make indexes and scale them - # Is there a more efficient way to do this? - yi = np.mgrid[0:new_ht].reshape(-1, 1, 1) - xi = np.mgrid[0:new_wd].reshape(1, -1, 1) - zi = np.mgrid[0:new_dp].reshape(1, 1, -1) + # Make indexes and scale them + # Is there a more efficient way to do this? + if new_wd == 0: + iscale_x = 0.0 + else: iscale_x = float(old_wd) / float(new_wd) + + if new_ht == 0: + iscale_y = 0.0 + else: iscale_y = float(old_ht) / float(new_ht) + + if new_dp == 0: + iscale_z = 0.0 + else: iscale_z = float(old_dp) / float(new_dp) - xi = (x1 + xi * iscale_x).clip(0, max_x).astype(np.int, copy=False) - yi = (y1 + yi * iscale_y).clip(0, max_y).astype(np.int, copy=False) - zi = (z1 + zi * iscale_z).clip(0, max_z).astype(np.int, copy=False) - wd, ht, dp = xi.shape[1], yi.shape[0], zi.shape[2] - - # bounds check against shape (to protect future data access) - xi_max, yi_max, zi_max = xi[0, -1, 0], yi[-1, 0, 0], zi[0, 0, -1] - assert xi_max <= max_x, ValueError("X index (%d) exceeds shape bounds (%d)" % (xi_max, max_x)) - assert yi_max <= max_y, ValueError("Y index (%d) exceeds shape bounds (%d)" % (yi_max, max_y)) - assert zi_max <= max_z, ValueError("Z index (%d) exceeds shape bounds (%d)" % (zi_max, max_z)) - - view = np.s_[yi, xi, zi] - - else: - # simple stepped view will do, because new view is same as old - wd, ht, dp = old_wd, old_ht, old_dp - view = np.s_[y1:y2 + 1, x1:x2 + 1, z1:z2 + 1] + xi = np.clip(x1 + np.arange(0, new_wd) * iscale_x, + 0, max_x).astype(np.int, copy=False) + yi = np.clip(y1 + np.arange(0, new_ht) * iscale_y, + 0, max_y).astype(np.int, copy=False) + zi = np.clip(z1 + np.arange(0, new_dp) * iscale_z, + 0, max_z).astype(np.int, copy=False) + wd, ht, dp = xi.size, yi.size, zi.size + + # bounds check against shape (to protect future data access) + if new_wd > 0: + xi_max = xi[-1] + if xi_max > max_x: + raise ValueError("X index (%d) exceeds shape bounds (%d)" % (xi_max, max_x)) + + if new_ht > 0: + yi_max = yi[-1] + if yi_max > max_y: + raise ValueError("Y index (%d) exceeds shape bounds (%d)" % (yi_max, max_y)) + + if new_dp > 0: + zi_max = zi[-1] + if zi_max > max_z: + raise ValueError("Z index (%d) exceeds shape bounds (%d)" % (zi_max, max_z)) + + view = np.ix_(yi, xi, zi) # Calculate actual scale used (vs. desired) - old_wd, old_ht, old_dp = max(old_wd, 1), max(old_ht, 1), max(old_dp, 1) scale_x = float(wd) / old_wd scale_y = float(ht) / old_ht scale_z = float(dp) / old_dp @@ -370,7 +421,15 @@ def get_scaled_cutout_wdht(data_np, x1, y1, x2, y2, new_wd, new_ht, interpolation='basic', logger=None, dtype=None): + """Extract a region of the `data_np` defined by corners (x1, y1) and + (x2, y2) and resample it to fit dimensions (new_wd, new_ht). + `interpolation` describes the method of interpolation used, where the + default "basic" is nearest neighbor. If `logger` is not `None` it will + be used for logging messages. If `dtype` is defined then the output + array will be converted to that type; the default is the same as the + input type. + """ x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) new_wd, new_ht = int(new_wd), int(new_ht) @@ -379,7 +438,7 @@ if dtype is None: dtype = data_np.dtype - if have_opencv: + if have_opencv and _use in (None, 'opencv'): if logger is not None: logger.debug("resizing with OpenCv") # opencv is fastest and supports many methods @@ -388,9 +447,8 @@ method = cv2_resize[interpolation] cutout = data_np[y1:y2 + 1, x1:x2 + 1] - if cutout.dtype == np.dtype('>f8'): - # special hack for OpenCv resize bug on numpy arrays of - # dtype '>f8'-- it corrupts them + if cutout.dtype not in (np.uint8, np.uint16): + # special hack for OpenCv resize on certain numpy array types cutout = cutout.astype(np.float64) newdata = cv2.resize(cutout, (new_wd, new_ht), @@ -400,8 +458,23 @@ ht, wd = newdata.shape[:2] scale_x, scale_y = float(wd) / old_wd, float(ht) / old_ht - elif (have_opencl and interpolation in ('basic', 'nearest') and - open_cl_ok): + elif data_np.dtype == np.uint8 and have_pillow and _use in (None, 'pillow'): + if logger is not None: + logger.info("resizing with pillow") + if interpolation == 'basic': + interpolation = 'nearest' + method = pil_resize[interpolation] + img = PILimage.fromarray(data_np[y1:y2 + 1, x1:x2 + 1]) + img_siz = img.resize((new_wd, new_ht), resample=method) + newdata = np.array(img_siz, dtype=dtype) + + old_wd, old_ht = max(x2 - x1 + 1, 1), max(y2 - y1 + 1, 1) + ht, wd = newdata.shape[:2] + scale_x, scale_y = float(wd) / old_wd, float(ht) / old_ht + + elif (data_np.dtype == np.uint8 and have_opencl and + interpolation in ('basic', 'nearest') and + open_cl_ok and _use in (None, 'opencl')): # opencl is almost as fast or sometimes faster, but currently # we only support nearest neighbor if logger is not None: @@ -446,22 +519,19 @@ from an image, instead of the extraction itself """ - x1, y1 = p1[:2] - x2, y2 = p2[:2] + x1, y1, x2, y2 = int(p1[0]), int(p1[1]), int(p2[0]), int(p2[1]) scale_x, scale_y = scales[:2] + # calculate dimensions of NON-scaled cutout - old_wd = x2 - x1 + 1 - old_ht = y2 - y1 + 1 - # calculate dimensions of scaled cutout - new_wd = int(round(scale_x * old_wd)) - new_ht = int(round(scale_y * old_ht)) + old_wd, old_ht = max(x2 - x1 + 1, 1), max(y2 - y1 + 1, 1) + new_wd, new_ht = int(scale_x * old_wd), int(scale_y * old_ht) if len(scales) == 2: return get_scaled_cutout_wdht_view(shp, x1, y1, x2, y2, new_wd, new_ht) z1, z2, scale_z = p1[2], p2[2], scales[2] - old_dp = z2 - z1 + 1 - new_dp = int(round(scale_z * old_dp)) + old_dp = max(z2 - z1 + 1, 1) + new_dp = int(scale_z * old_dp) return get_scaled_cutout_wdhtdp_view(shp, p1, p2, (new_wd, new_ht, new_dp)) @@ -476,7 +546,7 @@ if dtype is None: dtype = data_np.dtype - if have_opencv: + if have_opencv and _use in (None, 'opencv'): if logger is not None: logger.debug("resizing with OpenCv") # opencv is fastest @@ -487,9 +557,8 @@ x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) cutout = data_np[y1:y2 + 1, x1:x2 + 1] - if cutout.dtype == np.dtype('>f8'): - # special hack for OpenCl resize bug on numpy arrays of - # dtype '>f8'-- it corrupts them + if cutout.dtype not in (np.uint8, np.uint16): + # special hack for OpenCv resize on certain numpy array types cutout = cutout.astype(np.float64) newdata = cv2.resize(cutout, None, fx=scale_x, fy=scale_y, @@ -499,8 +568,23 @@ ht, wd = newdata.shape[:2] scale_x, scale_y = float(wd) / old_wd, float(ht) / old_ht + elif data_np.dtype == np.uint8 and have_pillow and _use in (None, 'pillow'): + if logger is not None: + logger.info("resizing with pillow") + if interpolation == 'basic': + interpolation = 'nearest' + method = pil_resize[interpolation] + img = PILimage.fromarray(data_np[y1:y2 + 1, x1:x2 + 1]) + old_wd, old_ht = max(x2 - x1 + 1, 1), max(y2 - y1 + 1, 1) + new_wd, new_ht = int(scale_x * old_wd), int(scale_y * old_ht) + img_siz = img.resize((new_wd, new_ht), resample=method) + newdata = np.array(img_siz, dtype=dtype) + + ht, wd = newdata.shape[:2] + scale_x, scale_y = float(wd) / old_wd, float(ht) / old_ht + elif (have_opencl and interpolation in ('basic', 'nearest') and - open_cl_ok): + open_cl_ok and _use in (None, 'opencl')): if logger is not None: logger.debug("resizing with OpenCL") newdata, (scale_x, scale_y) = trcalc_cl.get_scaled_cutout_basic( @@ -527,17 +611,23 @@ def get_scaled_cutout_basic2(data_np, p1, p2, scales, interpolation='basic', logger=None): - if interpolation not in ('basic', 'nearest'): - raise ValueError("Interpolation method not supported: '%s'" % ( - interpolation)) + if interpolation not in ('basic', 'view'): + if len(scales) != 2: + raise ValueError("Interpolation method not supported: '%s'" % ( + interpolation)) + return get_scaled_cutout_basic(data_np, p1[0], p1[1], + p2[0], p2[1], + scales[0], scales[1], + interpolation=interpolation, + logger=logger) if logger is not None: logger.debug('resizing by slicing') - view, scales = get_scaled_cutout_basic_view(data_np.shape, - p1, p2, scales) + view, oscales = get_scaled_cutout_basic_view(data_np.shape, + p1, p2, scales) newdata = data_np[view] - return newdata, scales + return newdata, oscales def transform(data_np, flip_x=False, flip_y=False, swap_xy=False): @@ -617,9 +707,35 @@ return ((dst_x, dst_y), (a1, b1), (a2, b2)) -def overlay_image_2d(dstarr, pos, srcarr, dst_order='RGBA', - src_order='RGBA', - alpha=1.0, copy=False, fill=False, flipy=False): +def overlay_image_2d_pil(dstarr, pos, srcarr, dst_order='RGBA', + src_order='RGBA', + alpha=1.0, copy=False, fill=False, flipy=False): + + dst_x, dst_y = int(round(pos[0])), int(round(pos[1])) + + if flipy: + srcarr = np.flipud(srcarr) + + if dst_order != src_order: + srcarr = reorder_image(dst_order, srcarr, src_order) + img_dst = PILimage.fromarray(dstarr) + img_src = PILimage.fromarray(srcarr) + + mask = img_src + if 'A' not in src_order: + mask = None + img_dst.paste(img_src, (dst_x, dst_y), mask=mask) + + res_arr = np.array(img_dst, dtype=dstarr.dtype) + if copy: + return res_arr + + dstarr[:, :, :] = res_arr + + +def overlay_image_2d_np(dstarr, pos, srcarr, dst_order='RGBA', + src_order='RGBA', + alpha=1.0, copy=False, fill=False, flipy=False): dst_ht, dst_wd, dst_ch = dstarr.shape dst_type = dstarr.dtype @@ -646,9 +762,6 @@ src_wd -= dx dst_x = 0 - if src_wd <= 0 or src_ht <= 0: - return dstarr - # Trim off parts of srcarr that would be "hidden" # to the right and below the dstarr edge. ex = dst_y + src_ht - dst_ht @@ -661,6 +774,10 @@ srcarr = srcarr[:, :dst_wd, :] src_wd -= ex + if src_wd <= 0 or src_ht <= 0: + # nothing to do + return dstarr + if copy: dstarr = np.copy(dstarr, order='C') @@ -680,45 +797,62 @@ if fill and (da_idx >= 0): dstarr[dst_y:dst_y + src_ht, dst_x:dst_x + src_wd, da_idx] = dst_max_val + # if overlay source contains an alpha channel, extract it + # and use it, otherwise use scalar keyword parameter if (src_ch > 3) and ('A' in src_order): sa_idx = src_order.index('A') - # if overlay source contains an alpha channel, extract it - # and use it, otherwise use scalar keyword parameter - alpha = srcarr[0:src_ht, 0:src_wd, sa_idx] / float(src_max_val) - alpha = np.dstack((alpha, alpha, alpha)) + alpha = srcarr[:src_ht, :src_wd, sa_idx] + if np.all(np.isclose(alpha, src_max_val)): + # optimization to avoid blending if all alpha elements are max + alpha = 1.0 + else: + alpha = alpha / float(src_max_val) + alpha = np.dstack((alpha, alpha, alpha)) # reorder srcarr if necessary to match dstarr for alpha merge get_order = dst_order - if ('A' in dst_order) and not ('A' in src_order): + if ('A' in dst_order) and ('A' not in src_order): get_order = dst_order.replace('A', '') if get_order != src_order: srcarr = reorder_image(get_order, srcarr, src_order) + # define the two subarrays we are blending + _dst = dstarr[dst_y:dst_y + src_ht, dst_x:dst_x + src_wd, slc] + _src = srcarr[:src_ht, :src_wd, slc] + if np.isscalar(alpha) and alpha == 1.0: # optimization to avoid alpha blending # Place our srcarr into this dstarr at dst offsets - dstarr[dst_y:dst_y + src_ht, dst_x:dst_x + src_wd, slc] = ( - srcarr[0:src_ht, 0:src_wd, slc]) + _dst[:, :, :] = _src else: # calculate alpha blending # Co = CaAa + CbAb(1 - Aa) - a_arr = (alpha * srcarr[0:src_ht, 0:src_wd, slc]).astype(dst_type, - copy=False) - b_arr = ((1.0 - alpha) * dstarr[dst_y:dst_y + src_ht, - dst_x:dst_x + src_wd, - slc]).astype(dst_type, copy=False) - - # Place our srcarr into this dstarr at dst offsets - dstarr[dst_y:dst_y + src_ht, dst_x:dst_x + src_wd, slc] = ( - a_arr[0:src_ht, 0:src_wd, slc] + b_arr[0:src_ht, 0:src_wd, slc]) + if have_numexpr: + _dst[:, :, :] = ne.evaluate("(alpha * _src) + (1.0 - alpha) * _dst") + else: + _dst[:, :, :] = (alpha * _src) + (1.0 - alpha) * _dst return dstarr +def overlay_image_2d(dstarr, pos, srcarr, dst_order='RGBA', + src_order='RGBA', + alpha=1.0, copy=False, fill=False, flipy=False): + # NOTE: not tested yet thoroughly enough to use + # if have_pillow: + # return overlay_image_2d_pil(dstarr, pos, srcarr, dst_order=dst_order, + # src_order=src_order, alpha=alpha, + # copy=copy, fill=fill, flipy=flipy) + + return overlay_image_2d_np(dstarr, pos, srcarr, dst_order=dst_order, + src_order=src_order, alpha=alpha, + copy=copy, fill=fill, flipy=flipy) + + def overlay_image_3d(dstarr, pos, srcarr, dst_order='RGBA', src_order='RGBA', alpha=1.0, copy=False, fill=True, flipy=False): - dst_x, dst_y, dst_z = pos + dst_x, dst_y, dst_z = [int(round(pos[n])) for n in range(3)] dst_ht, dst_wd, dst_dp, dst_ch = dstarr.shape dst_type = dstarr.dtype dst_max_val = np.iinfo(dst_type).max @@ -733,43 +867,41 @@ # to the left and above the dstarr edge. if dst_y < 0: dy = abs(dst_y) - srcarr = srcarr[dy:, :, :] + srcarr = srcarr[dy:, :, :, :] src_ht -= dy dst_y = 0 if dst_x < 0: dx = abs(dst_x) - srcarr = srcarr[:, dx:, :] + srcarr = srcarr[:, dx:, :, :] src_wd -= dx dst_x = 0 if dst_z < 0: dz = abs(dst_z) - srcarr = srcarr[:, :, dz:] + srcarr = srcarr[:, :, dz:, :] src_dp -= dz dst_z = 0 - if src_wd <= 0 or src_ht <= 0 or src_dp <= 0: - return dstarr - # Trim off parts of srcarr that would be "hidden" # to the right and below the dstarr edge. ex = dst_y + src_ht - dst_ht if ex > 0: - srcarr = srcarr[:dst_ht, :, :] + srcarr = srcarr[:dst_ht, :, :, :] src_ht -= ex ex = dst_x + src_wd - dst_wd if ex > 0: - srcarr = srcarr[:, :dst_wd, :] + srcarr = srcarr[:, :dst_wd, :, :] src_wd -= ex ex = dst_z + src_dp - dst_dp if ex > 0: - srcarr = srcarr[:, :, :dst_dp] + srcarr = srcarr[:, :, :dst_dp, :] src_dp -= ex if src_wd <= 0 or src_ht <= 0 or src_dp <= 0: + # nothing to do return dstarr if copy: @@ -792,15 +924,20 @@ dstarr[dst_y:dst_y + src_ht, dst_x:dst_x + src_wd, dst_z:dst_z + src_dp, da_idx] = dst_max_val + # if overlay source contains an alpha channel, extract it + # and use it, otherwise use scalar keyword parameter if (src_ch > 3) and ('A' in src_order): sa_idx = src_order.index('A') - # if overlay source contains an alpha channel, extract it - # and use it, otherwise use scalar keyword parameter - alpha = srcarr[0:src_ht, 0:src_wd, 0:src_dp, sa_idx] / float(src_max_val) - alpha = np.concatenate([alpha[..., np.newaxis], - alpha[..., np.newaxis], - alpha[..., np.newaxis]], - axis=-1) + alpha = srcarr[:src_ht, :src_wd, :src_dp, sa_idx] + if np.all(np.isclose(alpha, src_max_val)): + # optimization to avoid blending if all alpha elements are max + alpha = 1.0 + else: + alpha = srcarr[0:src_ht, 0:src_wd, 0:src_dp, sa_idx] / float(src_max_val) + alpha = np.concatenate([alpha[..., np.newaxis], + alpha[..., np.newaxis], + alpha[..., np.newaxis]], + axis=-1) # reorder srcarr if necessary to match dstarr for alpha merge get_order = dst_order @@ -809,27 +946,22 @@ if get_order != src_order: srcarr = reorder_image(get_order, srcarr, src_order) + # define the two subarrays we are blending + _dst = dstarr[dst_y:dst_y + src_ht, dst_x:dst_x + src_wd, + dst_z:dst_z + src_dp, slc] + _src = srcarr[:src_ht, :src_wd, :src_dp, slc] + if np.isscalar(alpha) and alpha == 1.0: # optimization to avoid alpha blending # Place our srcarr into this dstarr at dst offsets - dstarr[dst_y:dst_y + src_ht, dst_x:dst_x + src_wd, - dst_z:dst_z + src_dp, slc] = ( - srcarr[0:src_ht, 0:src_wd, 0:src_dp, slc]) + _dst[:, :, :, :] = _src else: # calculate alpha blending # Co = CaAa + CbAb(1 - Aa) - a_arr = (alpha * srcarr[0:src_ht, 0:src_wd, - 0:src_dp, slc]).astype(dst_type, copy=False) - b_arr = ((1.0 - alpha) * dstarr[dst_y:dst_y + src_ht, - dst_x:dst_x + src_wd, - dst_z:dst_z + src_dp, - slc]).astype(dst_type, copy=False) - - # Place our srcarr into this dstarr at dst offsets - dstarr[dst_y:dst_y + src_ht, dst_x:dst_x + src_wd, - dst_z:dst_z + src_dp, slc] = ( - a_arr[0:src_ht, 0:src_wd, 0:src_dp, slc] + - b_arr[0:src_ht, 0:src_wd, 0:src_dp, slc]) + if have_numexpr: + _dst[:, :, :, :] = ne.evaluate("(alpha * _src) + (1.0 - alpha) * _dst") + else: + _dst[:, :, :, :] = (alpha * _src) + (1.0 - alpha) * _dst return dstarr @@ -881,10 +1013,10 @@ return pts -def pad_z(pts, value=0.0): +def pad_z(pts, value=0.0, dtype=np.float32): """Adds a Z component from `pts` if it is missing. The value defaults to `value` (0.0)""" - pts = np.asarray(pts) + pts = np.asarray(pts, dtype=dtype) if pts.shape[-1] < 3: if len(pts.shape) < 2: return np.asarray((pts[0], pts[1], value), dtype=pts.dtype) @@ -901,6 +1033,12 @@ [np.max(_pts) for _pts in pts_t])) +def sort_xy(x1, y1, x2, y2): + """Sort a set of bounding box parameters.""" + pmn, pmx = get_bounds(((x1, y1), (x2, y2))) + return (pmn[0], pmn[1], pmx[0], pmx[1]) + + def fill_array(dstarr, order, r, g, b, a): """Fill array dstarr with a color value. order defines the color planes in the array. (r, g, b, a) are expected to be in the range 0..1 and @@ -914,7 +1052,7 @@ bgval = dict(A=int(maxv * a), R=int(maxv * r), G=int(maxv * g), B=int(maxv * b)) bgtup = tuple([bgval[order[i]] for i in range(len(order))]) - if dtype is np.uint8 and len(bgtup) == 4: + if dtype == np.uint8 and len(bgtup) == 4: # optimiztion bgtup = np.array(bgtup, dtype=dtype).view(np.uint32)[0] dstarr = dstarr.view(np.uint32) @@ -934,8 +1072,8 @@ bgval = dict(A=int(maxv * a), R=int(maxv * r), G=int(maxv * g), B=int(maxv * b)) bgtup = tuple([bgval[order[i]] for i in range(len(order))]) - if dtype is np.uint8 and len(bgtup) == 4: - # optimiztion when dealing with 32-bit RGBA arrays + if dtype == np.uint8 and len(bgtup) == 4: + # optimization when dealing with 32-bit RGBA arrays fill_val = np.array(bgtup, dtype=dtype).view(np.uint32) rgba = np.zeros(shp, dtype=dtype) rgba_i = rgba.view(np.uint32) @@ -943,4 +1081,33 @@ return rgba return np.full(shp, bgtup, dtype=dtype) -# END + +def add_alpha(arr, alpha=None): + """Takes an array and adds an alpha layer to it if it doesn't already + exist.""" + if len(arr.shape) == 2: + arr = arr[..., np.newaxis] + + if arr.shape[2] in (1, 3): + a_arr = np.zeros(arr.shape[:2], dtype=arr.dtype) + if alpha is not None: + a_arr[:, :] = alpha + arr = np.dstack((arr, a_arr)) + + return arr + + +def get_minmax_dtype(dtype): + if issubclass(dtype.type, np.integer): + info = np.iinfo(dtype) + else: + info = np.finfo(dtype) + + return info.min, info.max + + +def check_native_byteorder(data_np): + dt = str(data_np.dtype) + + return ((dt.startswith('>') and sys.byteorder == 'little') or + (dt.startswith('<') and sys.byteorder == 'big')) diff -Nru ginga-3.0.0/ginga/util/addons.py ginga-3.1.0/ginga/util/addons.py --- ginga-3.0.0/ginga/util/addons.py 2017-11-21 03:33:27.000000000 +0000 +++ ginga-3.1.0/ginga/util/addons.py 2020-07-08 20:09:29.000000000 +0000 @@ -212,7 +212,7 @@ tag = '_$zoom_buttons' if canvas is None: canvas = viewer.get_private_canvas() - canvas.ui_set_active(True) + canvas.ui_set_active(True, viewer) canvas.register_for_cursor_drawing(viewer) canvas.set_draw_mode('pick') viewer.add_callback('configure', zoom_resize, canvas, tag) diff -Nru ginga-3.0.0/ginga/util/heaptimer.py ginga-3.1.0/ginga/util/heaptimer.py --- ginga-3.0.0/ginga/util/heaptimer.py 2017-11-21 03:33:27.000000000 +0000 +++ ginga-3.1.0/ginga/util/heaptimer.py 2020-07-08 20:09:29.000000000 +0000 @@ -36,6 +36,7 @@ self.action = action self.args = args self.kwargs = kwargs + self.start_time = 0.0 self.expire = None self.timer_heap = heap self.logger = heap.logger @@ -85,7 +86,7 @@ def remaining_time(self): """Return the remaining time to the timer expiration. - If the timer has already expired then None is returned. + If the timer has already expired then 0 is returned. """ if self.expire is None: return 0.0 diff -Nru ginga-3.0.0/ginga/util/io_fits.py ginga-3.1.0/ginga/util/io_fits.py --- ginga-3.0.0/ginga/util/io_fits.py 2019-08-29 00:23:59.000000000 +0000 +++ ginga-3.1.0/ginga/util/io_fits.py 2019-11-29 07:54:06.000000000 +0000 @@ -319,7 +319,7 @@ except Exception as e: # Let's hope for the best! - self.logger.warn("Problem verifying fits file '%s': %s" % ( + self.logger.warning("Problem verifying fits file '%s': %s" % ( filepath, str(e))) try: diff -Nru ginga-3.0.0/ginga/util/iohelper.py ginga-3.1.0/ginga/util/iohelper.py --- ginga-3.0.0/ginga/util/iohelper.py 2019-08-29 00:23:59.000000000 +0000 +++ ginga-3.1.0/ginga/util/iohelper.py 2019-11-29 07:54:06.000000000 +0000 @@ -34,6 +34,7 @@ # 'mimetypes' module. You can add an extension here if the loaders # can reliably load it and it is not recognized properly by 'mimetypes' known_types = { + '.fit': 'image/fits', '.fits': 'image/fits', '.fits.gz': 'image/fits', '.fits.fz': 'image/fits', diff -Nru ginga-3.0.0/ginga/util/io_rgb.py ginga-3.1.0/ginga/util/io_rgb.py --- ginga-3.0.0/ginga/util/io_rgb.py 2019-09-10 04:03:28.000000000 +0000 +++ ginga-3.1.0/ginga/util/io_rgb.py 2019-11-29 07:54:06.000000000 +0000 @@ -15,6 +15,7 @@ from ginga.util import iohelper, rgb_cms from ginga.util.io import io_base from ginga.misc import Bunch +from ginga import trcalc try: # do we have opencv available? @@ -79,9 +80,8 @@ header = Header() metadata = {'header': header, 'path': filepath} - data_np = self.imload(filepath, header) + data_np = self.imload(filepath, metadata) - # TODO: set up the channel order correctly dstobj.set_data(data_np, metadata=metadata) if dstobj.name is not None: @@ -90,6 +90,8 @@ name = iohelper.name_image_from_path(filepath, idx=None) dstobj.set(name=name) + if 'order' in metadata: + dstobj.order = metadata['order'] dstobj.set(path=filepath, idx=None, image_loader=self.load_file) return dstobj @@ -122,10 +124,10 @@ # call continuation function loader_cont_fn(data_obj) - def imload(self, filepath, kwds): + def imload(self, filepath, metadata): """Load an image file, guessing the format, and return a numpy array containing an RGB image. If EXIF keywords can be read - they are returned in the dict _kwds_. + they are returned in the metadata. """ start_time = time.time() typ, enc = mimetypes.guess_type(filepath) @@ -134,7 +136,7 @@ typ, subtyp = typ.split('/') self.logger.debug("MIME type is %s/%s" % (typ, subtyp)) - data_np = self._imload(filepath, kwds) + data_np = self._imload(filepath, metadata) end_time = time.time() self.logger.debug("loading time %.4f sec" % (end_time - start_time)) @@ -225,6 +227,7 @@ self._path = filepath self.rgb_f = cv2.VideoCapture(filepath) + # self.rgb_f.set(cv2.CAP_PROP_CONVERT_RGB, False) idx = 0 extver_db = {} @@ -263,42 +266,25 @@ if self.rgb_f is None: raise ValueError("Please call open_file() first!") - # TODO: idx ignored for now for RGB images! + if idx is None: + idx = 0 - #self.rgb_f.seek(0) - #print(dir(self.rgb_f)) + self.rgb_f.set(cv2.CAP_PROP_POS_FRAMES, idx) okay, data_np = self.rgb_f.read() if not okay: raise ValueError("Error reading index {}".format(idx)) - # funky indexing because opencv returns BGR images, - # whereas PIL and others return RGB - if len(data_np.shape) >= 3 and data_np.shape[2] >= 3: - data_np = data_np[..., :: -1] - - # OpenCv doesn't "do" image metadata, so we punt to piexif - # library (if installed) - kwds = {} - self.piexif_getexif(self._path, kwds) - - # OpenCv added a feature to do auto-orientation when loading - # (see https://github.com/opencv/opencv/issues/4344) - # So reset these values to prevent auto-orientation from - # happening later - kwds['Orientation'] = 1 - kwds['Image Orientation'] = 1 - - # convert to working color profile, if can - if self.clr_mgr.can_profile(): - data_np = self.clr_mgr.profile_to_working_numpy(data_np, kwds) + metadata = {} + data_np = self._process_opencv_array(data_np, metadata, + self.fileinfo.filepath) from ginga.RGBImage import RGBImage - data_obj = RGBImage(data_np=data_np, logger=self.logger) + data_obj = RGBImage(data_np=data_np, logger=self.logger, + order=metadata['order'], metadata=metadata) data_obj.io = self name = self.fileinfo.name + '[{}]'.format(idx) - data_obj.set(name=name, path=self.fileinfo.filepath, idx=idx, - header=kwds) + data_obj.set(name=name, path=self.fileinfo.filepath, idx=idx) return data_obj @@ -312,7 +298,7 @@ # multiband images cv2.imwrite(filepath, data_np) - def _imload(self, filepath, kwds): + def _imload(self, filepath, metadata): if not have_opencv: raise ImageError("Install 'opencv' to be able " "to load images") @@ -321,10 +307,26 @@ # multiband images data_np = cv2.imread(filepath, cv2.IMREAD_ANYDEPTH + cv2.IMREAD_ANYCOLOR) - # funky indexing because opencv returns BGR images, - # whereas PIL and others return RGB + + return self._process_opencv_array(data_np, metadata, filepath) + + def _process_opencv_array(self, data_np, metadata, filepath): + # opencv returns BGR images, whereas PIL and others return RGB if len(data_np.shape) >= 3 and data_np.shape[2] >= 3: - data_np = data_np[..., :: -1] + #data_np = data_np[..., :: -1] + if data_np.shape[2] == 3: + order = 'BGR' + dst_order = 'RGB' + else: + order = 'BGRA' + dst_order = 'RGBA' + data_np = trcalc.reorder_image(dst_order, data_np, order) + metadata['order'] = dst_order + + kwds = metadata.get('header', None) + if kwds is None: + kwds = Header() + metadata['header'] = kwds # OpenCv doesn't "do" image metadata, so we punt to piexif # library (if installed) @@ -426,7 +428,6 @@ # "seek" functionality does not seem to be working for all the # versions of Pillow we are encountering - #self.rgb_f.seek(0) #self.rgb_f.seek(idx) image = self.rgb_f @@ -445,7 +446,8 @@ data_np = np.array(image) from ginga.RGBImage import RGBImage - data_obj = RGBImage(data_np=data_np, logger=self.logger) + data_obj = RGBImage(data_np=data_np, logger=self.logger, + order=image.mode) data_obj.io = self name = self.fileinfo.name + '[{}]'.format(idx) @@ -468,18 +470,22 @@ else: raise Exception("Please install 'piexif' module to get image metadata") - def _imload(self, filepath, kwds): + def _imload(self, filepath, metadata): if not have_pil: raise ImageError("Install 'pillow' to be able " "to load RGB images") image = PILimage.open(filepath) - kwds = {} + kwds = metadata.get('header', None) + if kwds is None: + kwds = Header() + metadata['header'] = kwds + try: self._get_header(image, kwds) except Exception as e: - self.logger.warning("Failed to get image metadata: %s" % (str(e))) + self.logger.warning("Failed to get image metadata: {!r}".format(e)) # convert to working color profile, if can if self.clr_mgr.can_profile(): @@ -487,6 +493,7 @@ # convert from PIL to numpy data_np = np.array(image) + metadata['order'] = image.mode return data_np def _imresize(self, data, new_wd, new_ht, method='bilinear'): @@ -507,7 +514,7 @@ name = 'PPM' - def _imload(self, filepath, kwds): + def _imload(self, filepath, metadata): return open_ppm(filepath) diff -Nru ginga-3.0.0/ginga/util/json.py ginga-3.1.0/ginga/util/json.py --- ginga-3.0.0/ginga/util/json.py 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/util/json.py 2020-07-20 21:06:00.000000000 +0000 @@ -0,0 +1,50 @@ +# +# json.py -- augmented JSON parsing +# +# This is open-source software licensed under a BSD license. +# Please see the file LICENSE.txt for details. +# +import json + +from ginga.misc import Bunch + +__all__ = ['BunchEncoder', 'as_bunch', 'dumps', 'loads'] + + +class BunchEncoder(json.JSONEncoder): + """Custom encoder to serialize Ginga's Bunch.Bunch class. + + Usage + ----- + st = json.dumps(objs, indent=2, cls=BunchEncoder) + + """ + def default(self, obj): + if isinstance(obj, Bunch.Bunch): + d = dict(__bunch__=True) + d.update(obj) + return d + return json.JSONEncoder.default(self, obj) + + +def as_bunch(dct): + """If `dct` is a serialized Bunch, return it as a Bunch.""" + if '__bunch__' in dct: + d = dct.copy() + del d['__bunch__'] + return Bunch.Bunch(d) + return dct + + +def dumps(*args, **kwargs): + """Like json.dumps(), but also serializes Ginga Bunch.Bunch type.""" + d = dict(cls=BunchEncoder) + d.update(kwargs) + return json.dumps(*args, **d) + + +def loads(*args, **kwargs): + """Like for json.loads(), but also deserializes Ginga Bunch.Bunch type.""" + d = dict(object_hook=as_bunch) + d.update(kwargs) + return json.loads(*args, **d) diff -Nru ginga-3.0.0/ginga/util/mosaic.py ginga-3.1.0/ginga/util/mosaic.py --- ginga-3.0.0/ginga/util/mosaic.py 2019-09-03 20:32:15.000000000 +0000 +++ ginga-3.1.0/ginga/util/mosaic.py 2020-07-08 20:09:29.000000000 +0000 @@ -10,16 +10,16 @@ $ ./mosaic.py -o output.fits input1.fits input2.fits ... inputN.fits """ -import sys import os import math +import time import numpy as np from ginga import AstroImage, trcalc from ginga.util import wcs, loader, dp, iqcalc from ginga.util import io_fits -from ginga.misc import log +from ginga.misc import log, Callback def mosaic_inline(baseimage, imagelist, bg_ref=None, trim_px=None, @@ -52,7 +52,7 @@ # Calculate sky position at the center of the piece ctr_x, ctr_y = trcalc.get_center(data_np) - ra, dec = image.pixtoradec(ctr_x, ctr_y) + ra, dec = image.pixtoradec(ctr_x, ctr_y, coords='data') # User specified a trim? If so, trim edge pixels from each # side of the array @@ -137,7 +137,7 @@ ctr_x, ctr_y = trcalc.get_center(rotdata) # Find location of image piece (center) in our array - x0, y0 = baseimage.radectopix(ra, dec) + x0, y0 = baseimage.radectopix(ra, dec, coords='data') # Merge piece as closely as possible into our array # Unfortunately we lose a little precision rounding to the @@ -210,7 +210,8 @@ # Adjust our WCS for relocation of the reference pixel crpix1, crpix2 = baseimage.get_keywords_list('CRPIX1', 'CRPIX2') kwds = dict(CRPIX1=crpix1 + nx1_off, - CRPIX2=crpix2 + ny1_off) + CRPIX2=crpix2 + ny1_off, + NAXIS1=new_wd, NAXIS2=new_ht) baseimage.update_keywords(kwds) # fit image piece into our array @@ -325,49 +326,248 @@ img_mosaic.save_as_file(outfile) -if __name__ == "__main__": +class CanvasMosaicer(Callback.Callbacks): - # Parse command line options - from argparse import ArgumentParser + def __init__(self, logger): + super(CanvasMosaicer, self).__init__() - argprs = ArgumentParser() + self.logger = logger - argprs.add_argument("--debug", dest="debug", default=False, - action="store_true", - help="Enter the pdb debugger on main()") - argprs.add_argument("--fov", dest="fov", metavar="DEG", - type=float, - help="Set output field of view") - argprs.add_argument("--log", dest="logfile", metavar="FILE", - help="Write logging output to FILE") - argprs.add_argument("--loglevel", dest="loglevel", metavar="LEVEL", - type=int, - help="Set logging level to LEVEL") - argprs.add_argument("-o", "--outfile", dest="outfile", metavar="FILE", - help="Write mosaic output to FILE") - argprs.add_argument("--stderr", dest="logstderr", default=False, - action="store_true", - help="Copy logging also to stderr") - argprs.add_argument("--profile", dest="profile", action="store_true", - default=False, - help="Run the profiler on main()") - - (options, args) = argprs.parse_known_args(sys.argv[1:]) - - # Are we debugging this? - if options.debug: - import pdb - - pdb.run('main(options, args)') - - # Are we profiling this? - elif options.profile: - import profile + self.ingest_count = 0 + # holds processed images to be inserted into mosaic image + self.total_images = 0 + + # options + self.annotate = False + self.annotate_color = 'pink' + self.annotate_fontsize = 10.0 + self.match_bg = False + self.center_image = True + + # these are updated in prepare_mosaic() and represent measurements + # on the reference image + self.bg_ref = 0.0 + self.xrot_ref, self.yrot_ref = 0.0, 0.0 + self.cdelt1_ref, self.cdelt2_ref = 1.0, 1.0 + self.scale_x = 1.0 + self.scale_y = 1.0 + self.limits = None + self.ref_image = None + + for name in ['progress', 'finished']: + self.enable_callback(name) + + def prepare_mosaic(self, ref_image): + """Prepare a new (blank) mosaic image based on the pointing of + the parameter image + """ + self.ref_image = ref_image + + # if user requesting us to match backgrounds, then calculate + # median of root image and save it + if self.match_bg: + data_np = ref_image.get_data() + self.bg_ref = iqcalc.get_median(data_np) + + header = ref_image.get_header() + + # TODO: handle skew (differing rotation for each axis)? + (rot_xy, cdelt_xy) = wcs.get_xy_rotation_and_scale(header) + self.logger.debug("ref image rot_x=%f rot_y=%f cdelt1=%f cdelt2=%f" % ( + rot_xy[0], rot_xy[1], cdelt_xy[0], cdelt_xy[1])) + + # Store base image rotation and scale + self.xrot_ref, self.yrot_ref = rot_xy + self.cdelt1_ref, self.cdelt2_ref = cdelt_xy + self.scale_x = math.fabs(cdelt_xy[0]) + self.scale_y = math.fabs(cdelt_xy[1]) + self.limits = ((0, 0), (0, 0)) + + def ingest_one(self, canvas, image): + """prepare an image to be dropped in the right place in the canvas. + """ + self.ingest_count += 1 + count = self.ingest_count + name = image.get('name', 'image{}'.format(count)) - print("%s profile:" % sys.argv[0]) - profile.run('main(options, args)') + data_np = image._get_data() + if 0 in data_np.shape: + self.logger.info("Skipping image with zero length axis") + return - else: - main(options, args) + ht, wd = data_np.shape + + # If caller asked us to match background of pieces then + # fix up this data + if self.match_bg: + bg = iqcalc.get_median(data_np) + bg_inc = self.bg_ref - bg + data_np = data_np + bg_inc + + # Calculate sky position at the center of the piece + ctr_x, ctr_y = trcalc.get_center(data_np) + ra, dec = image.pixtoradec(ctr_x, ctr_y, coords='data') + + # Get rotation and scale of piece + header = image.get_header() + ((xrot, yrot), + (cdelt1, cdelt2)) = wcs.get_xy_rotation_and_scale(header) + self.logger.debug("image(%s) xrot=%f yrot=%f cdelt1=%f " + "cdelt2=%f" % (name, xrot, yrot, cdelt1, cdelt2)) + + # scale if necessary to scale of reference image + if (not np.isclose(math.fabs(cdelt1), self.scale_x) or + not np.isclose(math.fabs(cdelt2), self.scale_y)): + nscale_x = math.fabs(cdelt1) / self.scale_x + nscale_y = math.fabs(cdelt2) / self.scale_y + self.logger.debug("scaling piece by x(%f), y(%f)" % ( + nscale_x, nscale_y)) + data_np, (ascale_x, ascale_y) = trcalc.get_scaled_cutout_basic( + #data_np, 0, 0, wd - 1, ht - 1, nscale_x, nscale_y, + data_np, 0, 0, wd, ht, nscale_x, nscale_y, + logger=self.logger) + + # Rotate piece into our orientation, according to wcs + rot_dx, rot_dy = xrot - self.xrot_ref, yrot - self.yrot_ref + + flip_x = False + flip_y = False + + # Optomization for 180 rotations + if (np.isclose(math.fabs(rot_dx), 180.0) or + np.isclose(math.fabs(rot_dy), 180.0)): + rotdata = trcalc.transform(data_np, + flip_x=True, flip_y=True) + rot_dx = 0.0 + rot_dy = 0.0 + else: + rotdata = data_np + + # Finish with any necessary rotation of piece + ignore_alpha = False + if not np.isclose(rot_dy, 0.0): + rot_deg = rot_dy + minv, maxv = trcalc.get_minmax_dtype(rotdata.dtype) + rotdata = trcalc.add_alpha(rotdata, alpha=maxv) + self.logger.debug("rotating %s by %f deg" % (name, rot_deg)) + rotdata = trcalc.rotate(rotdata, rot_deg, + #rotctr_x=ctr_x, rotctr_y=ctr_y, + logger=self.logger, pad=0) + ignore_alpha = True + + # Flip X due to negative CDELT1 + if np.sign(cdelt1) != np.sign(self.cdelt1_ref): + flip_x = True + + # Flip Y due to negative CDELT2 + if np.sign(cdelt2) != np.sign(self.cdelt2_ref): + flip_y = True + + if flip_x or flip_y: + rotdata = trcalc.transform(rotdata, + flip_x=flip_x, flip_y=flip_y) + + # new wrapper for transformed image + metadata = dict(header=header, ignore_alpha=ignore_alpha) + new_image = AstroImage.AstroImage(data_np=rotdata, metadata=metadata) + + # Get size and data of new image + ht, wd = rotdata.shape[:2] + ctr_x, ctr_y = trcalc.get_center(rotdata) + + # Find location of image piece (center) in our array + x0, y0 = self.ref_image.radectopix(ra, dec, coords='data') + + # Merge piece as closely as possible into our array + # Unfortunately we lose a little precision rounding to the + # nearest pixel--can't be helped with this approach + x0, y0 = int(np.round(x0)), int(np.round(y0)) + self.logger.debug("Fitting image '%s' into mosaic at %f,%f" % ( + name, x0, y0)) + + # update limits + xlo, xhi = x0 - ctr_x, x0 + ctr_x + ylo, yhi = y0 - ctr_y, y0 + ctr_y + + new_image.set(xpos=xlo, ypos=ylo, name=name) + + _xlo, _ylo, = self.limits[0] + _xhi, _yhi, = self.limits[1] + _xlo, _ylo = min(_xlo, xlo), min(_ylo, ylo) + _xhi, _yhi = max(_xhi, xhi), max(_yhi, yhi) + self.limits = ((_xlo, _ylo), (_xhi, _yhi)) + + self.plot_image(canvas, new_image) + + self.make_callback('progress', 'fitting', + float(count) / self.total_images) + + def plot_image(self, canvas, image): + name = image.get('name', 'noname'), + # TODO: figure out where/why name gets encased in a tuple + name = name[0] + + dc = canvas.get_draw_classes() + xpos, ypos = image.get_list('xpos', 'ypos') + img = dc.NormImage(xpos, ypos, image) + img.is_data = True + canvas.add(img, redraw=False) + + if self.annotate: + wd, ht = image.get_size() + ## pts = [(xpos, ypos), (xpos + wd, ypos), + ## (xpos + wd, ypos + ht), (xpos, ypos + ht)] + ## box = self.dc.Polygon(pts, color='pink') + text = dc.Text(xpos + 10, ypos + ht * 0.5, name, + color=self.annotate_color, + fontsize=self.annotate_fontsize, + fontscale=True) + canvas.add(text, redraw=False) + + def reset(self): + self.ref_image = None + + def mosaic(self, viewer, images, canvas=None): + self.total_images = len(images) + self.ingest_count = 0 + if self.total_images == 0: + return + + self.make_callback('progress', 'fitting', 0.0) + t1 = time.time() + + if canvas is None: + canvas = viewer.get_canvas() + + with viewer.suppress_redraw: + # If there is no current mosaic then prepare a new one + if self.ref_image is None: + ref_image = images.pop(0) + self.ingest_count += 1 + self.prepare_mosaic(ref_image) + + canvas.delete_all_objects(redraw=False) + # first image is loaded in the usual way + viewer.set_image(ref_image) + self.limits = viewer.get_limits() + + self.logger.info("fitting tiles...") + + for image in images: + self.ingest_one(canvas, image) + pct = self.ingest_count / self.total_images + self.make_callback('progress', 'fitting', pct) + + self.logger.info("finishing...") + self.make_callback('progress', 'finishing', 0.0) + + viewer.set_limits(self.limits) + if self.center_image: + viewer.center_image() + canvas.update_canvas(whence=0) + + self.process_elapsed = time.time() - t1 + self.logger.info("mosaic done. process=%.4f (sec)" % ( + self.process_elapsed)) -# END + self.make_callback('finished', self.process_elapsed) diff -Nru ginga-3.0.0/ginga/util/rgb_cms.py ginga-3.1.0/ginga/util/rgb_cms.py --- ginga-3.0.0/ginga/util/rgb_cms.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/util/rgb_cms.py 2020-07-20 21:06:00.000000000 +0000 @@ -8,6 +8,7 @@ import os import glob import hashlib +import numpy as np from ginga.misc import Bunch @@ -16,17 +17,11 @@ # How about color management (ICC profile) support? try: import PIL.ImageCms as ImageCms + from PIL import Image have_cms = True except ImportError: have_cms = False -have_pilutil = False -try: - from scipy.misc import toimage, fromimage - have_pilutil = True -except ImportError: - pass - basedir = paths.ginga_home # Holds profiles @@ -41,6 +36,10 @@ icc_transform = {} +class ColorManagerError(Exception): + pass + + class ColorManager(object): def __init__(self, logger): @@ -56,19 +55,19 @@ # If we have a working color profile then handle any embedded # profile or color space information, if possible if not have_cms: - self.logger.warning( + self.logger.info( "No CMS is installed; leaving image unprofiled.") return image if not have_profile(working_profile): - self.logger.warning( + self.logger.info( "No working profile defined; leaving image unprofiled.") return image out_profile = profile[working_profile].name if not os.path.exists(profile[out_profile].path): - self.logger.warning( + self.logger.info( "Working profile '%s' (%s) not found; leaving image " "unprofiled." % (out_profile, profile[out_profile].path)) return image @@ -130,21 +129,35 @@ in_profile, profile[out_profile].name)) except Exception as e: - self.logger.error("Error converting from embedded color profile: %s" % (str(e))) + self.logger.error("Error converting from embedded color profile: {!r}".format(e), + exc_info=True) self.logger.warning("Leaving image unprofiled.") return image def profile_to_working_numpy(self, image_np, kwds, intent=None): - image_in = toimage(image_np) + image_in = to_image(image_np) image_out = self.profile_to_working_pil(image_in, kwds, intent=intent) - return fromimage(image_out) + return from_image(image_out) # --- Color Management conversion functions --- +def to_image(image_np, flip_y=True): + if flip_y: + image_np = np.flipud(image_np) + return Image.fromarray(image_np) + + +def from_image(image_pil, flip_y=True): + image_np = np.array(image_pil) + if flip_y: + image_np = np.flipud(image_np) + return image_np + + def convert_profile_pil(image_pil, inprof_path, outprof_path, intent_name, inPlace=False): if not have_cms: @@ -171,23 +184,23 @@ def convert_profile_numpy(image_np, inprof_path, outprof_path, intent_name): - if (not have_pilutil) or (not have_cms): + if not have_cms: return image_np - in_image_pil = toimage(image_np) + in_image_pil = to_image(image_np) out_image_pil = convert_profile_pil(in_image_pil, inprof_path, outprof_path, intent_name) - image_out = fromimage(out_image_pil) + image_out = from_image(out_image_pil) return image_out def convert_profile_numpy_transform(image_np, transform): - if (not have_pilutil) or (not have_cms): + if not have_cms: return image_np - in_image_pil = toimage(image_np) + in_image_pil = to_image(image_np) convert_profile_pil_transform(in_image_pil, transform, inPlace=True) - image_out = fromimage(in_image_pil) + image_out = from_image(in_image_pil) return image_out @@ -201,6 +214,10 @@ use_black_pt=False): global icc_transform + if not have_cms: + return ColorManagerError("Either pillow is not installed, or there is " + "no ICC support in this version of pillow") + flags = 0 if proof_name is not None: if hasattr(ImageCms, 'FLAGS'): @@ -223,7 +240,7 @@ except KeyError: # try to build transform on the fly try: - if not (proof_name is None): + if proof_name is not None: output_transform = ImageCms.buildProofTransform( profile[from_name].path, profile[to_name].path, @@ -243,7 +260,7 @@ icc_transform[key] = output_transform except Exception as e: - raise Exception("Failed to build profile transform: %s" % (str(e))) + raise ColorManagerError("Failed to build profile transform: {!r}".format(e)) return output_transform @@ -265,9 +282,9 @@ except Exception as e: if logger is not None: - logger.warn("Error converting profile from '%s' to '%s': %s" % ( - from_name, to_name, str(e))) - logger.warn("Leaving image unprofiled") + logger.warning("Error converting profile from '{}' to '{}': {!r}".format( + from_name, to_name, e)) + logger.warning("Leaving image unprofiled") return image_np diff -Nru ginga-3.0.0/ginga/util/toolbox.py ginga-3.1.0/ginga/util/toolbox.py --- ginga-3.0.0/ginga/util/toolbox.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/util/toolbox.py 2020-01-20 03:17:53.000000000 +0000 @@ -74,7 +74,7 @@ color = 'cyan' if mode == 'meta' else 'yellow' - o1 = Text(0, 0, text, + o1 = Text(0, 0, text=text, fontsize=self.fontsize, color=color, coord='window') txt_wd, txt_ht = self.viewer.renderer.get_dimensions(o1) diff -Nru ginga-3.0.0/ginga/util/vip.py ginga-3.1.0/ginga/util/vip.py --- ginga-3.0.0/ginga/util/vip.py 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/util/vip.py 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,561 @@ +# +# vip.py -- data extraction operations on mixed, plotted images. +# +# This is open-source software licensed under a BSD license. +# Please see the file LICENSE.txt for details. +# +import time +import numpy as np +# for debugging +#np.set_printoptions(threshold=np.inf) + +from ginga import trcalc +from ginga.canvas.types.image import ImageP +from ginga.canvas.types.layer import Canvas +from ginga.misc import Bunch + + +class ViewerImageProxy: + """This class can be used in lieu of a `~ginga.BaseImage`-subclassed + object (such as `~ginga.AstroImage` to handle cases where either an + image has been loaded into a viewer in the conventional way, OR multiple + images have been plotted onto the viewer canvas. It has a subset of the + API for an image wrapper object, and can be substituted for that in cases + that only use the subset. + + Every `ImageView`-based viewer has one already constructed inside it. + + Example. Previously, one might do: + + image = viewer.get_image() + data, x1, y1, x2, y2 = image.cutout_radius(data_x, data_y, radius) + + Alternative, using the already built vip: + + image = viewer.get_vip() + data, x1, y1, x2, y2 = image.cutout_radius(data_x, data_y, radius) + + Here are the methods that are supported: + * cutout_data + * cutout_adjust + * cutout_radius + * cutout_cross + * get_pixels_on_line + * get_pixels_on_curve + * get_shape_view + * cutout_shape + * get_size + * get_depth + * get_center + * get_data_xy + * info_xy + * pixtoradec + * has_valid_wcs + * _slice + + Supported properties: + * shape + * width + * height + * depth + * ndim + """ + + def __init__(self, viewer): + """Constructor for a ViewerImageProxy object. + + Parameters + ---------- + viewer : `~ginga.ImageView` (or subclass thereof) + Ginga viewer object + + """ + self.viewer = viewer + self.logger = viewer.get_logger() + + self.limit_cutout = 5000 + + def get_canvas_images_at_pt(self, pt): + """Extract the canvas Image-based objects under the point. + + Parameters + ---------- + pt : tuple of int + Point in data coordinates; e.g. (data_x, data_y) + + Returns + ------- + obj : `~ginga.canvas.types.image.Image` (or subclass thereof) + The top canvas image object found under this point + + """ + # get first canvas image object found under the cursor + data_x, data_y = pt[:2] + canvas = self.viewer.get_canvas() + objs = canvas.get_items_at(pt) + objs = list(filter(lambda obj: isinstance(obj, ImageP), objs)) + # top most objects are farther down + objs.reverse() + return objs + + def get_image_at_pt(self, pt): + """Extract the image wrapper object under the point. + + Parameters + ---------- + pt : tuple of int + Point in data coordinates; e.g. (data_x, data_y) + + Returns + ------- + (image, pt2) : tuple + `image` is a `~ginga.BaseImage.BaseImage` (or subclass thereof) + and `pt2` is a modified version of `pt` with the plotted location + subtracted. + + """ + objs = self.get_canvas_images_at_pt(pt) + data_x, data_y = pt[:2] + off = self.viewer.data_off + + for obj in objs: + image = obj.get_image() + # adjust data coords for where this image is plotted + _x, _y = data_x - obj.x, data_y - obj.y + order = image.get_order() + if 'A' not in order: + # no alpha channel, so this image's data is valid + return (image, (_x, _y)) + + aix = order.index('A') + data = image.get_data() + _d_x, _d_y = int(np.floor(_x + off)), int(np.floor(_y + off)) + val = data[_d_y, _d_x] + if np.isclose(val[aix], 0.0): + # alpha value is 0 + continue + + return (image, (_x, _y)) + + return None, pt + + def getval_pt(self, pt): + """Extract the data value from an image under the point. + + The value will be NaN if the point does not refer to a valid + location within a plotted image. + + Parameters + ---------- + pt : tuple of int + Point in data coordinates; e.g. (data_x, data_y) + + Returns + ------- + val : `numpy` value + The value for the image object found under this point + + """ + val = self.get_data_xy(pt[0], pt[1]) + if val is None: + return np.NaN + return val + + ## def extend_view(self, image, view): + ## if len(image.shape) <= 2: + ## return view + ## order = image.get_order() + ## if 'M' in order: + ## idx = order.index('M') + ## else: + ## idx = 0 + ## return view + (idx:idx+1,) + + def get_images(self, res, canvas): + for obj in canvas.objects: + if isinstance(obj, ImageP) and obj.is_data: + res.append(obj) + elif isinstance(obj, Canvas): + self.get_images(res, obj) + return res + + # ----- for compatibility with BaseImage objects ----- + + def cutout_data(self, x1, y1, x2, y2, xstep=1, ystep=1, z=0, + astype=np.float, fill_value=np.NaN): + """Cut out data area based on rectangular coordinates. + + Parameters + ---------- + x1 : int + Starting X coordinate + + y1 : int + Starting Y coordinate + + x2 : int + (One more than) Ending X coordinate + + y2 : int + (one more than) Ending Y coordinate + + xstep : int (optional, default: 1) + Step in X direction + + ystep : int (optional, default: 1) + Step in Y direction + + z : None or int (optional, default: 0) + Index of a Z slice, if cutout array has three dimensions + + astype : None, str or `numpy.dtype` (optional, default: None) + Optional type to coerce the result array to + + Returns + ------- + arr : `numpy.ndarray` + The cut out array + + """ + t1 = time.time() + + if astype is None: + astype = np.float + if fill_value is None: + fill_value = np.NaN + + # coerce to int values, just in case + x1, y1, x2, y2 = trcalc.sort_xy(x1, y1, x2, y2) + x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) + xstep, ystep = int(xstep), int(ystep) + + # output size + x_len = len(range(x1, x2, xstep)) + y_len = len(range(y1, y2, ystep)) + + # create result array, filled with fill value + data_np = np.full((y_len, x_len), fill_value, dtype=astype) + + # calculate pixel containment indexes in cutout bbox + yi, xi = np.mgrid[y1:y2, x1:x2] + pts = np.asarray((xi, yi)).T + + canvas = self.viewer.get_canvas() + images = self.get_images([], canvas) + + # iterate through images on this canvas, filling the result + # array with pixels that overlap in each image + for cv_img in images: + # quick check for images overlapping our bbox; + # skip those that do not overlap + _x1, _y1, _x2, _y2 = cv_img.get_llur() + dx = min(x2, _x2) - max(x1, _x1) + dy = min(y2, _y2) - max(y1, _y1) + if not (dx >= 0 and dy >= 0): + continue + + # make a dst mask to assign only those pixels in this image + # overlapping the cutout bbox. + mask = cv_img.contains_pts(pts) + # now form indices into this image's array to extract + # the overlapping pixels + xpos, ypos = cv_img.crdmap.to_data((cv_img.x, cv_img.y)) + xpos, ypos = int(xpos), int(ypos) + xstart, ystart = x1 - xpos, y1 - ypos + xstop, ystop = x2 - xpos, y2 - ypos + yii, xii = np.mgrid[ystart:ystop:ystep, xstart:xstop:xstep] + yi, xi = yii[mask].ravel(), xii[mask].ravel() + + image = cv_img.get_image() + src_data = image.get_data() + # check if this image has an alpha mask; if so, then we need + # to further modify the dst mask to ignore the alpha masked pixels + # NOTE: currently treatment of alpha channel is binary-- + # you get it or you don't; but it may be possible to do some + # kind of blending in the future + if len(src_data.shape) > 2: + img_data = src_data[..., z][yi, xi] + order = image.get_order() + if 'A' in order: + ai = order.index('A') + a_arr = src_data[..., ai][yi, xi] + amask = np.logical_not(np.isclose(a_arr, 0)) + mask[np.nonzero(mask)] = amask + data_np[mask] = img_data[amask] + else: + data_np[mask] = img_data + else: + data_np[mask] = src_data[yi, xi] + + self.logger.debug("time to cutout_data %.4f sec" % (time.time() - t1)) + return data_np + + def cutout_adjust(self, x1, y1, x2, y2, xstep=1, ystep=1, z=0, + astype=None): + """Like cutout_data(), but modifies the coordinates to remain within + the boundaries of plotted data. + + Returns the cutout data slice and (possibly modified) bounding box. + """ + dx = x2 - x1 + dy = y2 - y1 + + xy_mn, xy_mx = self.viewer.get_limits() + + if x2 >= xy_mx[0]: + x2 = xy_mx[0] + x1 = x2 - dx + + if x1 < xy_mn[0]: + x1 = xy_mn[0] + + if y2 >= xy_mx[1]: + y2 = xy_mx[1] + y1 = y2 - dy + + if y1 < xy_mn[1]: + y1 = xy_mn[1] + + data = self.cutout_data(x1, y1, x2, y2, xstep=xstep, ystep=ystep, + z=z, astype=astype) + return (data, x1, y1, x2, y2) + + def cutout_radius(self, x, y, radius, xstep=1, ystep=1, astype=None): + return self.cutout_adjust(x - radius, y - radius, + x + radius + 1, y + radius + 1, + xstep=xstep, ystep=ystep, z=0, + astype=astype) + + def cutout_cross(self, x, y, radius): + """Cut two data subarrays that have a center at (x, y) and with + radius (radius) from (image). Returns the starting pixel (x0, y0) + of each cut and the respective arrays (xarr, yarr). + """ + x, y, radius = int(x), int(y), int(radius) + x1, x2 = x - radius, x + radius + y1, y2 = y - radius, y + radius + data_np = self.cutout_data(x1, y1, x2, y2) + + xarr = data_np[y - y1, :] + yarr = data_np[:, x - x1] + + return (x1, y1, xarr, yarr) + + ## def get_shape_mask(self, shape_obj): + ## """ + ## Return full mask where True marks pixels within the given shape. + ## """ + ## xy_mn, xy_mx = self.viewer.get_limits() + ## yi = np.mgrid[xy_mn[1]:xy_mx[1]].reshape(-1, 1) + ## xi = np.mgrid[xy_mn[0]:xy_mx[0]].reshape(1, -1) + ## pts = np.asarray((xi, yi)).T + ## contains = shape_obj.contains_pts(pts) + ## return contains + + def cutout_shape(self, shape_obj): + view, mask = self.get_shape_view(shape_obj) + + data = self._slice(view) + + # mask non-containing members + mdata = np.ma.array(data, mask=np.logical_not(mask)) + return mdata + + def get_shape_view(self, shape_obj, avoid_oob=True): + """ + Calculate a bounding box in the data enclosing `shape_obj` and + return a view that accesses it and a mask that is True only for + pixels enclosed in the region. + + If `avoid_oob` is True (default) then the bounding box is clipped + to avoid coordinates outside of the actual data. + """ + x1, y1, x2, y2 = [int(np.round(n)) for n in shape_obj.get_llur()] + + if avoid_oob: + # avoid out of bounds indexes + xy_mn, xy_mx = self.viewer.get_limits() + x1, x2 = max(xy_mn[0], x1), min(x2, xy_mx[0] - 1) + y1, y2 = max(xy_mn[1], y1), min(y2, xy_mx[1] - 1) + + # calculate pixel containment mask in bbox + yi = np.mgrid[y1:y2 + 1].reshape(-1, 1) + xi = np.mgrid[x1:x2 + 1].reshape(1, -1) + pts = np.asarray((xi, yi)).T + contains = shape_obj.contains_pts(pts) + + view = np.s_[y1:y2 + 1, x1:x2 + 1] + return (view, contains) + + def get_pixels_on_line(self, x1, y1, x2, y2, getvalues=True): + """Uses Bresenham's line algorithm to enumerate the pixels along + a line. + (see http://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm) + + If `getvalues`==False then it will return tuples of (x, y) coordinates + instead of pixel values. + """ + x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) + + dx = abs(x2 - x1) + dy = abs(y2 - y1) + if x1 < x2: + sx = 1 + else: + sx = -1 + if y1 < y2: + sy = 1 + else: + sy = -1 + err = dx - dy + + res = [] + x, y = x1, y1 + while True: + res.append((x, y)) + if (x == x2) and (y == y2): + break + e2 = 2 * err + if e2 > -dy: + err = err - dy + x += sx + if e2 < dx: + err = err + dx + y += sy + + if getvalues: + if np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) > self.limit_cutout: + return [self.getval_pt((x, y)) for x, y in res] + + x1, y1, x2, y2 = trcalc.sort_xy(x1, y1, x2, y2) + data_np = self.cutout_data(x1, y1, x2 + 1, y2 + 1) + res = [data_np[y - y1, x - x1] for x, y in res] + + return res + + def get_pixels_on_curve(self, curve_obj, getvalues=True): + res = [(int(x), int(y)) + for x, y in curve_obj.get_points_on_curve(None)] + + if getvalues: + ## x1, y1, x2, y2 = curve_obj.get_llur() + ## x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) + ## data_np = self.cutout_data(x1, y1, x2 + 1, y2 + 1) + ## return [data_np[y - y1, x - x1] for x, y in res] + return [self.getval_pt((x, y)) for x, y in res] + + return res + + def _slice(self, view): + # cutout our enclosing (possibly shortened) bbox + x1, x2 = view[1].start, view[1].stop + y1, y2 = view[0].start, view[0].stop + xs = view[1].step + if xs is None: + xs = 1 + ys = view[0].step + if ys is None: + ys = 1 + + data = self.cutout_data(x1, y1, x2, y2, xstep=xs, ystep=ys) + return data + + @property + def shape(self): + wd, ht = self.get_size()[:2] + return (ht, wd) + + @property + def width(self): + return self.get_size()[0] + + @property + def height(self): + return self.get_size()[1] + + @property + def depth(self): + return self.get_depth() + + @property + def ndim(self): + return len(self.shape) + + def get_size(self): + xy_mn, xy_mx = self.viewer.get_limits() + wd = int(abs(xy_mx[0] - xy_mn[0])) + ht = int(abs(xy_mx[1] - xy_mn[1])) + return (wd, ht) + + def get_depth(self): + image = self.viewer.get_image() + if image is not None: + return image.get_depth() + shape = self.shape + if len(shape) > 2: + return shape[-1] + return 1 + + def get_shape(self): + return self.shape + + def get_center(self): + wd, ht = self.get_size() + ctr_x, ctr_y = wd // 2, ht // 2 + return (ctr_x, ctr_y) + + def get_data_xy(self, x, y): + x1, y1 = int(x), int(y) + data = self.cutout_data(x1, y1, x1 + 1, y1 + 1) + val = data[0, 0] + if np.isnan(val): + return None + return val + + def info_xy(self, data_x, data_y, settings): + + image, pt = self.get_image_at_pt((data_x, data_y)) + ld_image = self.viewer.get_image() + data_off = self.viewer.data_off + + if ld_image is not None: + info = ld_image.info_xy(data_x, data_y, settings) + + if image is not None and image is not ld_image: + info.image_x, info.image_y = pt + _b_x, _b_y = pt[:2] + _d_x, _d_y = (int(np.floor(_b_x + data_off)), + int(np.floor(_b_y + data_off))) + info.value = image.get_data_xy(_d_x, _d_y) + + elif image is not None: + info = image.info_xy(pt[0], pt[1], settings) + info.x, info.y = data_x, data_y + info.data_x, info.data_y = data_x, data_y + + else: + info = Bunch.Bunch(itype='base', data_x=data_x, data_y=data_y, + x=data_x, y=data_y, value=None) + + return info + + def pixtoradec(self, data_x, data_y, format='deg', coords='data'): + ld_image = self.viewer.get_image() + if ld_image is not None: + # if there is a loaded image, then use it for WCS + return ld_image.pixtoradec(data_x, data_y, format=format, + coords=coords) + + # otherwise, look to see if there is an image under the data + # point and use it's WCS if it has one + image, pt = self.get_image_at_pt((data_x, data_y)) + if image is not None: + return image.pixtoradec(pt[0], pt[1], format=format, coords=coords) + + raise ValueError("No image found for WCS conversion") + + def has_valid_wcs(self): + ld_image = self.viewer.get_image() + if ld_image is not None: + return ld_image.has_valid_wcs() + return False diff -Nru ginga-3.0.0/ginga/util/wcs.py ginga-3.1.0/ginga/util/wcs.py --- ginga-3.0.0/ginga/util/wcs.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/util/wcs.py 2020-04-10 20:19:26.000000000 +0000 @@ -275,11 +275,11 @@ cdelt1 = float(header['CDELT1']) cdelt2 = float(header['CDELT2']) - cd1_1, cd1_2 = pc1_1 * cdelt1, pc1_2 * cdelt1 - cd2_1, cd2_2 = pc2_1 * cdelt2, pc2_2 * cdelt2 + cd1_1, cd1_2 = cdelt1 * pc1_1, cdelt1 * pc1_2 + cd2_1, cd2_2 = cdelt2 * pc2_1, cdelt2 * pc2_2 - xrot, yrot, cdelt1p, cdelt2p = calc_from_cd(pc1_1, pc1_2, - pc2_1, pc2_2) + xrot, yrot, cdelt1, cdelt2 = calc_from_cd(cd1_1, cd1_2, + cd2_1, cd2_2) except KeyError: # 2nd, check for presence of CD matrix @@ -356,26 +356,8 @@ # Figure out rotation relative to our orientation rrot_dx, rrot_dy = xrot - xrot_ref, yrot - yrot_ref - # flip_x = False - # flip_y = False - - # ## # Flip X due to negative CDELT1 - # ## if np.sign(cdelt1) < 0: - # ## flip_x = True - - # ## # Flip Y due to negative CDELT2 - # ## if np.sign(cdelt2) < 0: - # ## flip_y = True - - # # Optomization for 180 rotations - # if np.isclose(math.fabs(rrot_dx), 180.0): - # flip_x = not flip_x - # rrot_dx = 0.0 - # if np.isclose(math.fabs(rrot_dy), 180.0): - # flip_y = not flip_y - # rrot_dy = 0.0 - - rrot_deg = max(rrot_dx, rrot_dy) + # Choose Y rotation as default + rrot_deg = rrot_dy res = Bunch.Bunch(rscale_x=rscale_x, rscale_y=rscale_y, rrot_deg=rrot_deg) diff -Nru ginga-3.0.0/ginga/vec/CanvasRenderVec.py ginga-3.1.0/ginga/vec/CanvasRenderVec.py --- ginga-3.0.0/ginga/vec/CanvasRenderVec.py 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/vec/CanvasRenderVec.py 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,183 @@ +# +# CanvasRenderVec.py -- for rendering into a vector of drawing operations +# +# This is open-source software licensed under a BSD license. +# Please see the file LICENSE.txt for details. + +from .VecHelp import (IMAGE, LINE, CIRCLE, BEZIER, ELLIPSE_BEZIER, POLYGON, + PATH, TEXT) +from .VecHelp import Pen, Brush, Font + +from ginga.canvas import render + + +class RenderContext(render.RenderContextBase): + + def __init__(self, renderer, viewer, surface): + render.RenderContextBase.__init__(self, renderer, viewer) + + self.pen = None + self.brush = None + self.font = None + + def set_line_from_shape(self, shape): + alpha = getattr(shape, 'alpha', 1.0) + linewidth = getattr(shape, 'linewidth', 1.0) + linestyle = getattr(shape, 'linestyle', 'solid') + self.pen = Pen(shape.color, linewidth=linewidth, + linestyle=linestyle, alpha=alpha) + + def set_fill_from_shape(self, shape): + fill = getattr(shape, 'fill', False) + if fill: + if hasattr(shape, 'fillcolor') and shape.fillcolor: + color = shape.fillcolor + else: + color = shape.color + alpha = getattr(shape, 'alpha', 1.0) + alpha = getattr(shape, 'fillalpha', alpha) + self.brush = Brush(color, alpha=alpha) + else: + self.brush = None + + def set_font_from_shape(self, shape): + if hasattr(shape, 'font'): + if hasattr(shape, 'fontsize') and shape.fontsize is not None: + fontsize = shape.fontsize + else: + fontsize = shape.scale_font(self.viewer) + fontsize = self.scale_fontsize(fontsize) + alpha = getattr(shape, 'alpha', 1.0) + self.font = Font(shape.font, fontsize, shape.color, alpha=alpha) + else: + self.font = None + + def initialize_from_shape(self, shape, line=True, fill=True, font=True): + if line: + self.set_line_from_shape(shape) + if fill: + self.set_fill_from_shape(shape) + if font: + self.set_font_from_shape(shape) + + def set_line(self, color, alpha=1.0, linewidth=1, style='solid'): + # TODO: support line width and style + self.pen = Pen(color, alpha=alpha) + + def set_fill(self, color, alpha=1.0): + if color is None: + self.brush = None + else: + self.brush = Brush(color, alpha=alpha) + + def set_font(self, fontname, fontsize, color='black', alpha=1.0): + fontsize = self.scale_fontsize(fontsize) + self.font = Font(fontname, fontsize, color, alpha=alpha) + + def text_extents(self, text): + return self.renderer.text_extents(text, self.font) + + ##### DRAWING OPERATIONS ##### + + def draw_image(self, image_id, cpoints, rgb_arr, whence, order='RGB'): + self.renderer.rl.append((IMAGE, (image_id, cpoints, rgb_arr, whence, + order))) + + def draw_text(self, cx, cy, text, rot_deg=0.0): + self.renderer.rl.append((TEXT, (cx, cy, text, rot_deg), + self.pen, self.brush, self.font)) + + def draw_polygon(self, cpoints): + self.renderer.rl.append((POLYGON, cpoints, self.pen, self.brush)) + + def draw_circle(self, cx, cy, cradius): + self.renderer.rl.append((CIRCLE, (cx, cy, cradius), + self.pen, self.brush)) + + ## def draw_bezier_curve(self, cpoints): + ## self.renderer.rl.append((BEZIER, cpoints, self.pen, self.brush)) + + ## def draw_ellipse_bezier(self, cpoints): + ## # draw 4 bezier curves to make the ellipse + ## self.renderer.rl.append((ELLIPSE_BEZIER, cpoints, self.pen, self.brush)) + + ## def draw_ellipse(self, cx, cy, cxradius, cyradius, rot_deg): + ## self.renderer.rl.append((ELLIPSE, + ## (cx, cy, cxradius, cyradius, rot_deg), + ## self.pen, self.brush)) + + def draw_line(self, cx1, cy1, cx2, cy2): + self.renderer.rl.append((LINE, (cx1, cy1, cx2, cy2), + self.pen, self.brush)) + + def draw_path(self, cpoints): + self.renderer.rl.append((PATH, cpoints, self.pen, self.brush)) + + +class VectorRenderMixin: + + def __init__(self): + # the render list + self.rl = [] + + def initialize(self): + wd, ht = self.dims + cpoints = ((0, 0), (wd, 0), (wd, ht), (ht, 0)) + bg = self.viewer.get_bg() + pen = Pen(color=bg) + brush = Brush(color=bg, fill=True) + self.rl = [(POLYGON, cpoints, pen, brush)] + + def draw_vector(self, cr): + for tup in self.rl: + dtyp, font = None, None + try: + dtyp = tup[0] + if dtyp == IMAGE: + (image_id, cpoints, rgb_arr, whence, order) = tup[1] + cr.draw_image(image_id, cpoints, rgb_arr, whence, + order=self.rgb_order) + + elif dtyp == LINE: + (cx1, cy1, cx2, cy2) = tup[1] + cr.setup_pen_brush(*tup[2:4]) + cr.draw_line(cx1, cy1, cx2, cy2) + + elif dtyp == CIRCLE: + (cx, cy, cradius) = tup[1] + cr.setup_pen_brush(*tup[2:4]) + cr.draw_circle(cx, cy, cradius) + + elif dtyp == BEZIER: + cpoints = tup[1] + cr.setup_pen_brush(*tup[2:4]) + cr.draw_bezier_curve(cpoints) + + elif dtyp == ELLIPSE_BEZIER: + cpoints = tup[1] + cr.setup_pen_brush(*tup[2:4]) + cr.draw_ellipse_bezier(cpoints) + + elif dtyp == POLYGON: + cpoints = tup[1] + cr.setup_pen_brush(*tup[2:4]) + cr.draw_polygon(cpoints) + + elif dtyp == PATH: + cpoints = tup[1] + cr.setup_pen_brush(*tup[2:4]) + cr.draw_path(cpoints) + + elif dtyp == TEXT: + (cx, cy, text, rot_deg) = tup[1] + cr.setup_pen_brush(*tup[2:4]) + font = tup[4] + cr.set_font(font.fontname, font.fontsize, + color=font.color, alpha=font.alpha) + cr.draw_text(cx, cy, text, rot_deg=rot_deg) + + except Exception as e: + self.logger.error("Error drawing '{}': {}".format(dtyp, e), + exc_info=True) + +#END diff -Nru ginga-3.0.0/ginga/vec/VecHelp.py ginga-3.1.0/ginga/vec/VecHelp.py --- ginga-3.0.0/ginga/vec/VecHelp.py 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/vec/VecHelp.py 2020-07-08 20:09:29.000000000 +0000 @@ -0,0 +1,46 @@ +# +# VecHelp.py -- help classes for vector drawing +# +# This is open-source software licensed under a BSD license. +# Please see the file LICENSE.txt for details. + +from ginga.fonts import font_asst + +IMAGE = 0 +LINE = 1 +CIRCLE = 2 +BEZIER = 3 +ELLIPSE_BEZIER = 4 +POLYGON = 5 +PATH = 6 +TEXT = 7 + + +class Pen(object): + def __init__(self, color='black', linewidth=1, linestyle='solid', + alpha=1.0): + self.color = color + self.linewidth = linewidth + self.linestyle = linestyle + self.alpha = alpha + + +class Brush(object): + def __init__(self, color='black', fill=False, alpha=1.0): + self.color = color + self.fill = fill + self.alpha = alpha + + +class Font(object): + def __init__(self, fontname='Roboto', fontsize=12.0, color='black', + linewidth=1, alpha=1.0): + fontname = font_asst.resolve_alias(fontname, fontname) + self.fontname = fontname + self.fontsize = float(fontsize) + self.color = color + self.linewidth = linewidth + self.alpha = alpha + + +#END diff -Nru ginga-3.0.0/ginga/version.py ginga-3.1.0/ginga/version.py --- ginga-3.0.0/ginga/version.py 2019-09-21 03:32:45.000000000 +0000 +++ ginga-3.1.0/ginga/version.py 2020-07-20 22:24:24.000000000 +0000 @@ -1,19 +1,4 @@ -# Autogenerated by Astropy-affiliated package ginga's setup.py on 2019-09-21 03:32:45 UTC -from __future__ import unicode_literals -import datetime - -version = "3.0.0" -githash = "23f8d97a9b5490057035509062f7f179957aa126" - - -major = 3 -minor = 0 -bugfix = 0 - -version_info = (major, minor, bugfix) - -release = True -timestamp = datetime.datetime(2019, 9, 21, 3, 32, 45) -debug = False - -astropy_helpers_version = "1.2.dev1156" +# coding: utf-8 +# file generated by setuptools_scm +# don't change, don't track in version control +version = '3.1.0' diff -Nru ginga-3.0.0/ginga/web/bokehw/ImageViewBokeh.py ginga-3.1.0/ginga/web/bokehw/ImageViewBokeh.py --- ginga-3.0.0/ginga/web/bokehw/ImageViewBokeh.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/web/bokehw/ImageViewBokeh.py 2020-07-08 20:09:29.000000000 +0000 @@ -106,7 +106,7 @@ def get_figure(self): return self.figure - def update_image(self): + def update_widget(self): if self.figure is None: return @@ -266,7 +266,7 @@ self.figure.write_to_png(ibuf) return ibuf - def update_image(self): + def update_widget(self): self.logger.debug("redraw surface") if self.figure is None: return @@ -478,14 +478,14 @@ keyname = self.transkey(keyname) if keyname is not None: self.logger.debug("key press event, key=%s" % (keyname)) - return self.make_ui_callback('key-press', keyname) + return self.make_ui_callback_viewer(self, 'key-press', keyname) def key_release_event(self, event): keyname = event.key keyname = self.transkey(keyname) if keyname is not None: self.logger.debug("key release event, key=%s" % (keyname)) - return self.make_ui_callback('key-release', keyname) + return self.make_ui_callback_viewer(self, 'key-release', keyname) def button_press_event(self, event): x, y = int(event.x), int(event.y) @@ -494,7 +494,9 @@ ## button |= 0x1 << (event.button - 1) self.logger.debug("button event at %dx%d, button=%x" % (x, y, button)) - data_x, data_y = self.get_data_xy(x, y) + self.last_win_x, self.last_win_y = x, y + data_x, data_y = self.check_cursor_location() + return self.make_ui_callback('button-press', button, data_x, data_y) def button_release_event(self, event): @@ -504,7 +506,9 @@ button |= 0x1 << (event.button - 1) self.logger.debug("button release at %dx%d button=%x" % (x, y, button)) - data_x, data_y = self.get_data_xy(x, y) + self.last_win_x, self.last_win_y = x, y + data_x, data_y = self.check_cursor_location() + return self.make_ui_callback('button-release', button, data_x, data_y) def select_event_cb(self, attrname, old_val, new_val): @@ -526,11 +530,14 @@ ## button |= 0x1 << (event.button - 1) self.logger.debug("motion event at %dx%d, button=%x" % (x, y, button)) - data_x, data_y = self.get_data_xy(x, y) + self.last_win_x, self.last_win_y = x, y + data_x, data_y = self.check_cursor_location() + self.last_data_x, self.last_data_y = data_x, data_y self.logger.info("motion event at DATA %dx%d" % (data_x, data_y)) - return self.make_ui_callback('motion', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'motion', button, + data_x, data_y) def scroll_event(self, event): x, y = int(event.x), int(event.y) @@ -549,11 +556,13 @@ self.logger.info("scroll deg=%f direction=%f" % ( amount, direction)) - data_x, data_y = self.get_data_xy(x, y) + self.last_win_x, self.last_win_y = x, y + data_x, data_y = self.check_cursor_location() + self.last_data_x, self.last_data_y = data_x, data_y - return self.make_ui_callback('scroll', direction, amount, - data_x, data_y) + return self.make_ui_callback_viewer(self, 'scroll', direction, amount, + data_x, data_y) def pinch_event(self, event): # no rotation (seemingly) in the Bokeh pinch event @@ -561,19 +570,19 @@ scale = event.scale self.logger.debug("pinch gesture rot=%f scale=%f" % (rot, scale)) - return self.make_ui_callback('pinch', 'move', rot, scale) + return self.make_ui_callback_viewer(self, 'pinch', 'move', rot, scale) def pan_start_event(self, event): dx, dy = int(event.delta_x), int(event.delta_y) self.logger.debug("pan gesture dx=%f dy=%f" % (dx, dy)) - return self.make_ui_callback('pan', 'start', dx, dy) + return self.make_ui_callback_viewer(self, 'pan', 'start', dx, dy) def pan_event(self, event): dx, dy = int(event.delta_x), int(event.delta_y) self.logger.debug("pan gesture dx=%f dy=%f" % (dx, dy)) - return self.make_ui_callback('pan', 'move', dx, dy) + return self.make_ui_callback_viewer(self, 'pan', 'move', dx, dy) def tap_event(self, event): x, y = int(event.x), int(event.y) @@ -582,7 +591,9 @@ ## button |= 0x1 << (event.button - 1) self.logger.debug("tap event at %dx%d, button=%x" % (x, y, button)) - data_x, data_y = self.get_data_xy(x, y) + self.last_win_x, self.last_win_y = x, y + data_x, data_y = self.check_cursor_location() + return self.make_ui_callback('button-press', button, data_x, data_y) def press_event(self, event): @@ -610,7 +621,7 @@ settings=settings) Mixins.UIMixin.__init__(self) - self.ui_set_active(True) + self.ui_set_active(True, viewer=self) if bindmap is None: bindmap = ImageViewZoom.bindmapClass(self.logger) diff -Nru ginga-3.0.0/ginga/web/jupyterw/ImageViewJpw.py ginga-3.1.0/ginga/web/jupyterw/ImageViewJpw.py --- ginga-3.0.0/ginga/web/jupyterw/ImageViewJpw.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/web/jupyterw/ImageViewJpw.py 2020-07-08 20:09:29.000000000 +0000 @@ -126,7 +126,7 @@ raise ImageViewJpwError("No valid renderers available: {}".format(str(self.possible_renderers))) - def update_image(self): + def update_widget(self): fmt = self.jp_img.format web_img = self.renderer.get_surface_as_rgb_format_bytes( format=fmt) @@ -138,7 +138,7 @@ self._defer_task.start(time_sec) def configure_window(self, width, height): - self.configure_surface(width, height) + self.configure(width, height) def _resize_cb(self, event): self.configure_window(event.width, event.height) @@ -289,12 +289,12 @@ def key_press_event(self, event): keyname = self.transkey(event['code'], keyname=event['key']) self.logger.debug("key press event, key=%s" % (keyname)) - return self.make_ui_callback('key-press', keyname) + return self.make_ui_callback_viewer(self, 'key-press', keyname) def key_release_event(self, event): keyname = self.transkey(event['code'], keyname=event['key']) self.logger.debug("key release event, key=%s" % (keyname)) - return self.make_ui_callback('key-release', keyname) + return self.make_ui_callback_viewer(self, 'key-release', keyname) def button_press_event(self, event): x, y = event['dataX'], event['dataY'] @@ -307,7 +307,7 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-press', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-press', button, data_x, data_y) def button_release_event(self, event): x, y = event['dataX'], event['dataY'] @@ -320,7 +320,7 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-release', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-release', button, data_x, data_y) def motion_notify_event(self, event): button = self._button @@ -331,7 +331,7 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('motion', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'motion', button, data_x, data_y) def scroll_event(self, event): x, y = event['dataX'], event['dataY'] @@ -341,9 +341,9 @@ if (dx != 0 or dy != 0): # <= This browser gives us deltas for x and y # Synthesize this as a pan gesture event - self.make_ui_callback('pan', 'start', 0, 0) - self.make_ui_callback('pan', 'move', -dx, -dy) - return self.make_ui_callback('pan', 'stop', 0, 0) + self.make_ui_callback_viewer(self, 'pan', 'start', 0, 0) + self.make_ui_callback_viewer(self, 'pan', 'move', -dx, -dy) + return self.make_ui_callback_viewer(self, 'pan', 'stop', 0, 0) # <= This code path should not be followed under normal # circumstances. @@ -365,8 +365,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('scroll', direction, num_deg, - data_x, data_y) + return self.make_ui_callback_viewer(self, 'scroll', direction, num_deg, + data_x, data_y) class ImageViewZoom(Mixins.UIMixin, ImageViewEvent): @@ -389,7 +389,7 @@ settings=settings) Mixins.UIMixin.__init__(self) - self.ui_set_active(True) + self.ui_set_active(True, viewer=self) if bindmap is None: bindmap = ImageViewZoom.bindmapClass(self.logger) @@ -505,7 +505,7 @@ canvas.enable_draw(True) canvas.enable_edit(True) canvas.set_drawtype(None) - canvas.ui_set_active(True) + canvas.ui_set_active(True, viewer=self) canvas.set_surface(self) canvas.register_for_cursor_drawing(self) # add the canvas to the view. diff -Nru ginga-3.0.0/ginga/web/pgw/ImageViewPg.py ginga-3.1.0/ginga/web/pgw/ImageViewPg.py --- ginga-3.0.0/ginga/web/pgw/ImageViewPg.py 2019-08-31 02:43:11.000000000 +0000 +++ ginga-3.1.0/ginga/web/pgw/ImageViewPg.py 2020-07-08 20:09:29.000000000 +0000 @@ -91,8 +91,8 @@ raise ImageViewPgError("No valid renderers available: {}".format(str(self.possible_renderers))) - def update_image(self): - self.logger.debug("update_image pgcanvas=%s" % self.pgcanvas) + def update_widget(self): + self.logger.debug("update_widget pgcanvas=%s" % self.pgcanvas) if self.pgcanvas is None: return @@ -118,16 +118,12 @@ self.delayed_redraw() def get_plain_image_as_widget(self): - """Used for generating thumbnails. Does not include overlaid - graphics. - """ + """Does not include overlaid graphics.""" image_buf = self.renderer.get_surface_as_rgb_format_buffer() return image_buf.getvalue() def save_plain_image_as_file(self, filepath, format='png', quality=90): - """Used for generating thumbnails. Does not include overlaid - graphics. - """ + """Does not include overlaid graphics.""" pass def set_cursor(self, cursor): @@ -148,7 +144,7 @@ self.onscreen_message(None) def configure_window(self, width, height): - self.configure_surface(width, height) + self.configure(width, height) def map_event(self, event): self.logger.info("window mapped to %dx%d" % ( @@ -422,7 +418,7 @@ if keyname in self._keytbl3: keyname = self._keytbl3[keyname] self.logger.debug("making key-press cb, key=%s" % (keyname)) - return self.make_ui_callback('key-press', keyname) + return self.make_ui_callback_viewer(self, 'key-press', keyname) def key_down_event(self, event): # For key down events, javascript only validly reports a key code. @@ -438,7 +434,7 @@ # JS doesn't report key press callbacks for certain keys # so we synthesize one here for those self.logger.debug("making key-press cb, key=%s" % (keyname)) - return self.make_ui_callback('key-press', keyname) + return self.make_ui_callback_viewer(self, 'key-press', keyname) return False def key_up_event(self, event): @@ -450,7 +446,7 @@ self._shifted = False self.logger.debug("making key-release cb, key=%s" % (keyname)) - return self.make_ui_callback('key-release', keyname) + return self.make_ui_callback_viewer(self, 'key-release', keyname) def button_press_event(self, event): x = event.x @@ -462,7 +458,8 @@ self.logger.debug("button event at %dx%d, button=%x" % (x, y, button)) data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-press', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-press', button, + data_x, data_y) def button_release_event(self, event): # event.button, event.x, event.y @@ -475,7 +472,8 @@ self.logger.debug("button release at %dx%d button=%x" % (x, y, button)) data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('button-release', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'button-release', button, + data_x, data_y) def motion_notify_event(self, event): #button = 0 @@ -487,7 +485,8 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('motion', button, data_x, data_y) + return self.make_ui_callback_viewer(self, 'motion', button, + data_x, data_y) def scroll_event(self, event): x, y = event.x, event.y @@ -498,9 +497,9 @@ if (dx != 0 or dy != 0): # <= This browser gives us deltas for x and y # Synthesize this as a pan gesture event - self.make_ui_callback('pan', 'start', 0, 0) - self.make_ui_callback('pan', 'move', dx, dy) - return self.make_ui_callback('pan', 'stop', 0, 0) + self.make_ui_callback_viewer(self, 'pan', 'start', 0, 0) + self.make_ui_callback_viewer(self, 'pan', 'move', dx, dy) + return self.make_ui_callback_viewer(self, 'pan', 'stop', 0, 0) # 15 deg is standard 1-click turn for a wheel mouse # delta usually returns +/- 1.0 @@ -516,15 +515,15 @@ data_x, data_y = self.check_cursor_location() - return self.make_ui_callback('scroll', direction, num_degrees, - data_x, data_y) + return self.make_ui_callback_viewer(self, 'scroll', direction, + num_degrees, data_x, data_y) def drop_event(self, event): data = event.delta self.logger.debug("data=%s" % (str(data))) paths = data.split('\n') self.logger.debug("dropped text(s): %s" % (str(paths))) - return self.make_ui_callback('drag-drop', paths) + return self.make_ui_callback_viewer(self, 'drag-drop', paths) def pinch_event(self, event): self.logger.debug("pinch: event=%s" % (str(event))) @@ -538,7 +537,7 @@ self.logger.debug("pinch gesture rot=%f scale=%f state=%s" % ( rot, scale, state)) - return self.make_ui_callback('pinch', state, rot, scale) + return self.make_ui_callback_viewer(self, 'pinch', state, rot, scale) def rotate_event(self, event): state = 'move' @@ -550,7 +549,7 @@ self.logger.debug("rotate gesture rot=%f state=%s" % ( rot, state)) - return self.make_ui_callback('rotate', state, rot) + return self.make_ui_callback_viewer(self, 'rotate', state, rot) def pan_event(self, event): state = 'move' @@ -563,7 +562,7 @@ self.logger.debug("pan gesture dx=%f dy=%f state=%s" % ( dx, dy, state)) - return self.make_ui_callback('pan', state, dx, dy) + return self.make_ui_callback_viewer(self, 'pan', state, dx, dy) def swipe_event(self, event): if event.isfinal: @@ -571,7 +570,8 @@ self.logger.debug("swipe gesture event=%s" % (str(event))) ## self.logger.debug("swipe gesture hdir=%s vdir=%s" % ( ## hdir, vdir)) - ## return self.make_ui_callback('swipe', state, hdir, vdir) + ## return self.make_ui_callback_viewer(self, 'swipe', state, + ## hdir, vdir) def tap_event(self, event): if event.isfinal: @@ -599,7 +599,7 @@ settings=settings) Mixins.UIMixin.__init__(self) - self.ui_set_active(True) + self.ui_set_active(True, viewer=self) if bindmap is None: bindmap = ImageViewZoom.bindmapClass(self.logger) diff -Nru ginga-3.0.0/ginga/web/pgw/js/Hammer_LICENSE.md ginga-3.1.0/ginga/web/pgw/js/Hammer_LICENSE.md --- ginga-3.0.0/ginga/web/pgw/js/Hammer_LICENSE.md 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/web/pgw/js/Hammer_LICENSE.md 2018-07-03 04:54:43.000000000 +0000 @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (C) 2011-2014 by Jorik Tangelder (Eight Media) + +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 -Nru ginga-3.0.0/ginga/web/pgw/PgHelp.py ginga-3.1.0/ginga/web/pgw/PgHelp.py --- ginga-3.0.0/ginga/web/pgw/PgHelp.py 2019-08-24 00:57:36.000000000 +0000 +++ ginga-3.1.0/ginga/web/pgw/PgHelp.py 2020-07-08 20:09:29.000000000 +0000 @@ -191,6 +191,8 @@ # For storing aritrary data with timers self.data = Bunch.Bunch() self.deadline = None + self.start_time = 0.0 + self.end_time = 0.0 for name in ('expired', 'canceled'): self.enable_callback(name) @@ -206,10 +208,32 @@ def set(self, duration): self.stop() - self.deadline = time.time() + duration + + self.start_time = time.time() + # this attribute is used externally to manage the timer + self.deadline = self.start_time + duration + self.end_time = self.deadline self.app.add_timer(self) + def is_set(self): + return self.deadline is not None + + def cond_set(self, time_sec): + if not self.is_set(): + # TODO: probably a race condition here + self.set(time_sec) + + def elapsed_time(self): + return time.time() - self.start_time + + def time_left(self): + return max(0.0, self.time_end - time.time()) + + def get_deadline(self): + return self.time_end + def expire(self): + """This method is called externally to expire the timer.""" self.stop() self.make_callback('expired') diff -Nru ginga-3.0.0/ginga/web/pgw/setup_package.py ginga-3.1.0/ginga/web/pgw/setup_package.py --- ginga-3.0.0/ginga/web/pgw/setup_package.py 2019-03-08 03:17:36.000000000 +0000 +++ ginga-3.1.0/ginga/web/pgw/setup_package.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst - - -def get_package_data(): - return {'ginga.web.pgw': ['templates/*.html', 'js/*.js']} diff -Nru ginga-3.0.0/ginga/web/pgw/Widgets.py ginga-3.1.0/ginga/web/pgw/Widgets.py --- ginga-3.0.0/ginga/web/pgw/Widgets.py 2019-08-27 00:54:47.000000000 +0000 +++ ginga-3.1.0/ginga/web/pgw/Widgets.py 2020-07-08 20:09:29.000000000 +0000 @@ -10,7 +10,7 @@ import json from functools import reduce -from ginga.misc import Callback, Bunch, LineHistory +from ginga.misc import Callback, Bunch, Settings, LineHistory from ginga.web.pgw import PgHelp # For future support of WebView widget @@ -2360,12 +2360,18 @@ } def __init__(self, logger=None, base_url=None, - host='localhost', port=9909): + host='localhost', port=9909, settings=None): + # NOTE: base_url parameter not used, but here for backward compatibility global _app, widget_dict super(Application, self).__init__() self.logger = logger - self.base_url = base_url + if settings is None: + settings = Settings.SettingGroup(logger=self.logger) + self.settings = settings + self.settings.add_defaults(host=host, port=port) + + self.base_url = self.settings.get('base_url', None) self.window_dict = {} self.wincnt = 0 # list of web socket handlers connected to this application @@ -2380,8 +2386,8 @@ self._timer_lock = threading.RLock() self._timers = [] - self.host = host - self.port = port + self.host = self.settings.get('host', 'localhost') + self.port = self.settings.get('port', 9909) self.base_url = "http://%s:%d/app" % (self.host, self.port) # Get screen size diff -Nru ginga-3.0.0/ginga/web/README.md ginga-3.1.0/ginga/web/README.md --- ginga-3.0.0/ginga/web/README.md 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga/web/README.md 2018-07-13 22:23:16.000000000 +0000 @@ -0,0 +1,5 @@ +## Web-based viewers ## + +* `bokehw` is for [Bokeh](https://bokeh.pydata.org/). This is still experimental and does not yet support event handling. +* `jupyterw` is for Jupyter notebook using [ipywidgets](https://ipywidgets.readthedocs.io/). +* `pgw` is for Jupyter notebook using HTML5. diff -Nru ginga-3.0.0/ginga.egg-info/dependency_links.txt ginga-3.1.0/ginga.egg-info/dependency_links.txt --- ginga-3.0.0/ginga.egg-info/dependency_links.txt 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga.egg-info/dependency_links.txt 2020-07-20 22:24:24.000000000 +0000 @@ -0,0 +1 @@ + diff -Nru ginga-3.0.0/ginga.egg-info/entry_points.txt ginga-3.1.0/ginga.egg-info/entry_points.txt --- ginga-3.0.0/ginga.egg-info/entry_points.txt 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga.egg-info/entry_points.txt 2020-07-20 22:24:24.000000000 +0000 @@ -0,0 +1,4 @@ +[console_scripts] +ggrc = ginga.misc.grc:_main +ginga = ginga.rv.main:_main + diff -Nru ginga-3.0.0/ginga.egg-info/not-zip-safe ginga-3.1.0/ginga.egg-info/not-zip-safe --- ginga-3.0.0/ginga.egg-info/not-zip-safe 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga.egg-info/not-zip-safe 2020-07-20 22:24:24.000000000 +0000 @@ -0,0 +1 @@ + diff -Nru ginga-3.0.0/ginga.egg-info/PKG-INFO ginga-3.1.0/ginga.egg-info/PKG-INFO --- ginga-3.0.0/ginga.egg-info/PKG-INFO 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga.egg-info/PKG-INFO 2020-07-20 22:24:24.000000000 +0000 @@ -0,0 +1,44 @@ +Metadata-Version: 2.1 +Name: ginga +Version: 3.1.0 +Summary: A scientific image viewer and toolkit +Home-page: https://ejeschke.github.io/ginga/ +Author: Ginga Maintainers +Author-email: eric@naoj.org +License: BSD +Description: Ginga is a toolkit designed for building viewers for scientific image + data in Python, visualizing 2D pixel data in numpy arrays. + The Ginga toolkit centers around an image display class which supports + zooming and panning, color and intensity mapping, a choice of several + automatic cut levels algorithms and canvases for plotting scalable + geometric forms. In addition to this widget, a general purpose + 'reference' FITS viewer is provided, based on a plugin framework. + + Ginga is distributed under an open-source BSD licence. Please see the + file LICENSE.txt in the top-level directory for details. + +Keywords: scientific,image,viewer,numpy,toolkit,astronomy,FITS +Platform: UNKNOWN +Classifier: Intended Audience :: Science/Research +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: MacOS :: MacOS X +Classifier: Operating System :: Microsoft :: Windows +Classifier: Operating System :: POSIX +Classifier: Programming Language :: C +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Topic :: Scientific/Engineering :: Astronomy +Classifier: Topic :: Scientific/Engineering :: Physics +Requires-Python: >=3.5 +Description-Content-Type: text/plain +Provides-Extra: recommended +Provides-Extra: test +Provides-Extra: docs +Provides-Extra: gtk3 +Provides-Extra: qt5 +Provides-Extra: tk +Provides-Extra: web diff -Nru ginga-3.0.0/ginga.egg-info/requires.txt ginga-3.1.0/ginga.egg-info/requires.txt --- ginga-3.0.0/ginga.egg-info/requires.txt 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga.egg-info/requires.txt 2020-07-20 22:24:24.000000000 +0000 @@ -0,0 +1,35 @@ +numpy>=1.13 +qtpy>=1.1 +astropy>=3 + +[docs] +sphinx-astropy +sphinx_rtd_theme + +[gtk3] +pycairo +pygobject + +[qt5] +PyQt5 +QtPy>=1.1 + +[recommended] +pillow>=3.2.0 +scipy>=0.18.1 +matplotlib>=1.5.1 +opencv-python>=3.4.1 +piexif>=1.0.13 +beautifulsoup4>=4.3.2 +docutils + +[test] +attrs>=19.2.0 +pytest-astropy + +[tk] +aggdraw + +[web] +tornado +aggdraw diff -Nru ginga-3.0.0/ginga.egg-info/SOURCES.txt ginga-3.1.0/ginga.egg-info/SOURCES.txt --- ginga-3.0.0/ginga.egg-info/SOURCES.txt 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga.egg-info/SOURCES.txt 2020-07-20 22:24:24.000000000 +0000 @@ -0,0 +1,597 @@ +.gitignore +.readthedocs.yml +.travis.yml +CHANGES.rst +CITATION +CODE_OF_CONDUCT.md +LICENSE.rst +LICENSE.txt +LONG_DESC.txt +MANIFEST.in +README.md +ROADMAP.rst +ginga.desktop +pyproject.toml +setup.cfg +setup.py +doc/FAQ.rst +doc/LICENSE.rst +doc/Makefile +doc/WhatsNew.rst +doc/conf.py +doc/index.rst +doc/install.rst +doc/make.bat +doc/optimizing.rst +doc/quickref.rst +doc/ref_api.rst +doc/_static/.gitignore +doc/_static/ginga-128x128.png +doc/_templates/autosummary/base.rst +doc/_templates/autosummary/class.rst +doc/_templates/autosummary/module.rst +doc/dev_manual/canvas.rst +doc/dev_manual/developers.rst +doc/dev_manual/image_viewer.rst +doc/dev_manual/image_wrappers.rst +doc/dev_manual/index.rst +doc/dev_manual/internals.rst +doc/dev_manual/jupnotebooks.rst +doc/dev_manual/viewer.rst +doc/dev_manual/code/io_hdf5.py +doc/dev_manual/figures/barebonesviewer_qt.png +doc/dev_manual/figures/class_structure_astroimage.png +doc/dev_manual/figures/class_structure_drawingcanvas.png +doc/dev_manual/figures/class_structure_viewer.png +doc/dev_manual/figures/example2_screenshot.png +doc/dev_manual/figures/global_plugin1.png +doc/dev_manual/figures/local_plugin1.png +doc/manual/concepts.rst +doc/manual/customizing.rst +doc/manual/index.rst +doc/manual/introduction.rst +doc/manual/operation.rst +doc/manual/plugins.rst +doc/manual/figures/channel_selector.png +doc/manual/figures/channels.png +doc/manual/figures/gingadefault.png +doc/manual/figures/mode_indicator.png +doc/manual/figures/nested_workspaces.png +doc/manual/figures/wstype_selector.png +doc/manual/plugins_global/changehistory.rst +doc/manual/plugins_global/colorbar.rst +doc/manual/plugins_global/colormappicker.rst +doc/manual/plugins_global/command.rst +doc/manual/plugins_global/contents.rst +doc/manual/plugins_global/cursor.rst +doc/manual/plugins_global/downloads.rst +doc/manual/plugins_global/errors.rst +doc/manual/plugins_global/fbrowser.rst +doc/manual/plugins_global/header.rst +doc/manual/plugins_global/info.rst +doc/manual/plugins_global/log.rst +doc/manual/plugins_global/operations.rst +doc/manual/plugins_global/pan.rst +doc/manual/plugins_global/rc.rst +doc/manual/plugins_global/samp.rst +doc/manual/plugins_global/saveimage.rst +doc/manual/plugins_global/thumbs.rst +doc/manual/plugins_global/toolbar.rst +doc/manual/plugins_global/wbrowser.rst +doc/manual/plugins_global/wcsmatch.rst +doc/manual/plugins_global/zoom.rst +doc/manual/plugins_global/figures/SAMP-plugin.png +doc/manual/plugins_global/figures/changehistory-plugin.png +doc/manual/plugins_global/figures/colorbar-plugin.png +doc/manual/plugins_global/figures/colormappicker.png +doc/manual/plugins_global/figures/contents-plugin.png +doc/manual/plugins_global/figures/cursor-plugin.png +doc/manual/plugins_global/figures/downloads-plugin.png +doc/manual/plugins_global/figures/header-plugin.png +doc/manual/plugins_global/figures/info-plugin.png +doc/manual/plugins_global/figures/pan-plugin.png +doc/manual/plugins_global/figures/plugin_log.png +doc/manual/plugins_global/figures/saveimage_screenshot.png +doc/manual/plugins_global/figures/thumbs-plugin.png +doc/manual/plugins_global/figures/wcsmatch-plugin.png +doc/manual/plugins_global/figures/zoom-plugin.png +doc/manual/plugins_local/blink.rst +doc/manual/plugins_local/catalogs.rst +doc/manual/plugins_local/collage.rst +doc/manual/plugins_local/compose.rst +doc/manual/plugins_local/crosshair.rst +doc/manual/plugins_local/cuts.rst +doc/manual/plugins_local/drawing.rst +doc/manual/plugins_local/fbrowser.rst +doc/manual/plugins_local/histogram.rst +doc/manual/plugins_local/lineprofile.rst +doc/manual/plugins_local/mosaic.rst +doc/manual/plugins_local/multidim.rst +doc/manual/plugins_local/overlays.rst +doc/manual/plugins_local/pick.rst +doc/manual/plugins_local/pipeline.rst +doc/manual/plugins_local/pixtable.rst +doc/manual/plugins_local/plottable.rst +doc/manual/plugins_local/preferences.rst +doc/manual/plugins_local/ruler.rst +doc/manual/plugins_local/screenshot.rst +doc/manual/plugins_local/tvmark.rst +doc/manual/plugins_local/tvmask.rst +doc/manual/plugins_local/wcsaxes.rst +doc/manual/plugins_local/figures/autocuts-prefs.png +doc/manual/plugins_local/figures/blink-plugin.png +doc/manual/plugins_local/figures/cdist-prefs.png +doc/manual/plugins_local/figures/cmap-prefs.png +doc/manual/plugins_local/figures/collage-plugin.png +doc/manual/plugins_local/figures/compose-alpha.png +doc/manual/plugins_local/figures/compose-rgb.png +doc/manual/plugins_local/figures/crosshair-plugin.png +doc/manual/plugins_local/figures/cuts-plugin.png +doc/manual/plugins_local/figures/histogram-plugin.png +doc/manual/plugins_local/figures/lineprofile-plugin.png +doc/manual/plugins_local/figures/mosaic-plugin.png +doc/manual/plugins_local/figures/multidim-plugin-table.png +doc/manual/plugins_local/figures/multidim-plugin.png +doc/manual/plugins_local/figures/newimages-prefs.png +doc/manual/plugins_local/figures/overlays-plugin.png +doc/manual/plugins_local/figures/pan-prefs.png +doc/manual/plugins_local/figures/pick-candidates.png +doc/manual/plugins_local/figures/pick-contour-no-candidate.png +doc/manual/plugins_local/figures/pick-contour.png +doc/manual/plugins_local/figures/pick-controls.png +doc/manual/plugins_local/figures/pick-cutout.png +doc/manual/plugins_local/figures/pick-cuts.png +doc/manual/plugins_local/figures/pick-fwhm.png +doc/manual/plugins_local/figures/pick-move-draw-edit.png +doc/manual/plugins_local/figures/pick-no-candidate.png +doc/manual/plugins_local/figures/pick-radial.png +doc/manual/plugins_local/figures/pick-readout.png +doc/manual/plugins_local/figures/pick-report.png +doc/manual/plugins_local/figures/pick-sc1.png +doc/manual/plugins_local/figures/pick-settings.png +doc/manual/plugins_local/figures/pixtable-plugin.png +doc/manual/plugins_local/figures/plottable-plugin.png +doc/manual/plugins_local/figures/ruler_plugin.png +doc/manual/plugins_local/figures/screenshot-plugin.png +doc/manual/plugins_local/figures/transform-prefs.png +doc/manual/plugins_local/figures/tvmark_screenshot.png +doc/manual/plugins_local/figures/tvmask_screenshot.png +doc/manual/plugins_local/figures/wcs-prefs.png +doc/manual/plugins_local/figures/wcsaxes-plugin.png +doc/manual/plugins_local/figures/zoom-prefs.png +experimental/plugins/IIS_DataListener.py +experimental/plugins/IRAF.py +experimental/plugins/Imexam.py +experimental/remote_image/gris +experimental/remote_image/plugin/RemoteData.py +experimental/remote_image/plugin/RemoteImage.py +ginga/AstroImage.py +ginga/AutoCuts.py +ginga/BaseImage.py +ginga/Bindings.py +ginga/ColorDist.py +ginga/GingaPlugin.py +ginga/ImageView.py +ginga/LayerImage.py +ginga/Mixins.py +ginga/RGBImage.py +ginga/RGBMap.py +ginga/__init__.py +ginga/_astropy_init.py +ginga/cmap.py +ginga/colors.py +ginga/conftest.py +ginga/imap.py +ginga/toolkit.py +ginga/trcalc.py +ginga/version.py +ginga/zoom.py +ginga.egg-info/PKG-INFO +ginga.egg-info/SOURCES.txt +ginga.egg-info/dependency_links.txt +ginga.egg-info/entry_points.txt +ginga.egg-info/not-zip-safe +ginga.egg-info/requires.txt +ginga.egg-info/top_level.txt +ginga/aggw/AggHelp.py +ginga/aggw/CanvasRenderAgg.py +ginga/aggw/ImageViewAgg.py +ginga/aggw/ImageViewCanvasAgg.py +ginga/aggw/Plot.py +ginga/aggw/Widgets.py +ginga/aggw/__init__.py +ginga/cairow/CairoHelp.py +ginga/cairow/CanvasRenderCairo.py +ginga/cairow/ImageViewCairo.py +ginga/cairow/__init__.py +ginga/canvas/CanvasMixin.py +ginga/canvas/CanvasObject.py +ginga/canvas/CompoundMixin.py +ginga/canvas/DrawingMixin.py +ginga/canvas/__init__.py +ginga/canvas/coordmap.py +ginga/canvas/mixins.py +ginga/canvas/render.py +ginga/canvas/transform.py +ginga/canvas/types/__init__.py +ginga/canvas/types/all.py +ginga/canvas/types/astro.py +ginga/canvas/types/basic.py +ginga/canvas/types/image.py +ginga/canvas/types/layer.py +ginga/canvas/types/mixins.py +ginga/canvas/types/utils.py +ginga/cvw/CanvasRenderCv.py +ginga/cvw/CvHelp.py +ginga/cvw/ImageViewCanvasCv.py +ginga/cvw/ImageViewCv.py +ginga/cvw/__init__.py +ginga/doc/__init__.py +ginga/doc/download_doc.py +ginga/doc/help.html +ginga/examples/bindings/bindings.cfg.ds9 +ginga/examples/bindings/bindings.cfg.standard +ginga/examples/bindings/bindings.cfg.trackpad +ginga/examples/bokeh/example1.py +ginga/examples/bokeh/example2.py +ginga/examples/configs/channel_Image.cfg +ginga/examples/configs/general.cfg +ginga/examples/configs/plugin_Blink.cfg +ginga/examples/configs/plugin_Catalogs.cfg +ginga/examples/configs/plugin_ChangeHistory.cfg +ginga/examples/configs/plugin_Collage.cfg +ginga/examples/configs/plugin_ColorMapPicker.cfg +ginga/examples/configs/plugin_Colorbar.cfg +ginga/examples/configs/plugin_Contents.cfg +ginga/examples/configs/plugin_Cursor.cfg +ginga/examples/configs/plugin_Cuts.cfg +ginga/examples/configs/plugin_Downloads.cfg +ginga/examples/configs/plugin_Errors.cfg +ginga/examples/configs/plugin_FBrowser.cfg +ginga/examples/configs/plugin_Header.cfg +ginga/examples/configs/plugin_Histogram.cfg +ginga/examples/configs/plugin_Info.cfg +ginga/examples/configs/plugin_LineProfile.cfg +ginga/examples/configs/plugin_Mosaic.cfg +ginga/examples/configs/plugin_MultiDim.cfg +ginga/examples/configs/plugin_Operations.cfg +ginga/examples/configs/plugin_Pan.cfg +ginga/examples/configs/plugin_Pick.cfg +ginga/examples/configs/plugin_Pipeline.cfg +ginga/examples/configs/plugin_PixTable.cfg +ginga/examples/configs/plugin_PlotTable.cfg +ginga/examples/configs/plugin_Ruler.cfg +ginga/examples/configs/plugin_SAMP.cfg +ginga/examples/configs/plugin_SaveImage.cfg +ginga/examples/configs/plugin_TVMark.cfg +ginga/examples/configs/plugin_TVMask.cfg +ginga/examples/configs/plugin_Thumbs.cfg +ginga/examples/configs/plugin_Toolbar.cfg +ginga/examples/configs/plugin_WBrowser.cfg +ginga/examples/configs/plugin_WCSAxes.cfg +ginga/examples/configs/plugin_Zoom.cfg +ginga/examples/gl/example_wireframe.py +ginga/examples/gtk3/example1.py +ginga/examples/gtk3/example2.py +ginga/examples/gw/README.rst +ginga/examples/gw/clocks.py +ginga/examples/gw/example1_video.py +ginga/examples/gw/example2.py +ginga/examples/gw/plot_ds9_regions.py +ginga/examples/gw/shared_canvas.py +ginga/examples/gw/widgets.py +ginga/examples/jupyter-notebook/Jupyter Widget Ideas.ipynb +ginga/examples/jupyter-notebook/MEF_slider_demo.ipynb +ginga/examples/jupyter-notebook/Remote_control_of_a_Ginga_reference_viewer.ipynb +ginga/examples/jupyter-notebook/ginga_ipython_demo.ipynb +ginga/examples/layouts/ds9ish/README.md +ginga/examples/layouts/ds9ish/bindings.cfg +ginga/examples/layouts/ds9ish/general.cfg +ginga/examples/layouts/ds9ish/layout +ginga/examples/layouts/ds9ish/plugins.json +ginga/examples/layouts/ds9ish/screenshot.png +ginga/examples/layouts/standard/README.md +ginga/examples/layouts/standard/bindings.cfg +ginga/examples/layouts/standard/general.cfg +ginga/examples/layouts/standard/layout +ginga/examples/layouts/standard/plugins.json +ginga/examples/layouts/standard/screenshot.png +ginga/examples/layouts/twofer/README.md +ginga/examples/layouts/twofer/general.cfg +ginga/examples/layouts/twofer/layout.json +ginga/examples/layouts/twofer/plugins.json +ginga/examples/layouts/twofer/screenshot.png +ginga/examples/matplotlib/example1_mpl.py +ginga/examples/matplotlib/example2_mpl.py +ginga/examples/matplotlib/example3_mpl.py +ginga/examples/matplotlib/example4_mpl.py +ginga/examples/matplotlib/example5_mpl.py +ginga/examples/pg/example2_pg.py +ginga/examples/qt/example1_qt.py +ginga/examples/qt/example2_qt.py +ginga/examples/qt/example_asdf.py +ginga/examples/reference-viewer/MyGlobalPlugin.py +ginga/examples/reference-viewer/MyLocalPlugin.py +ginga/examples/tk/example1_tk.py +ginga/examples/tk/example2_tk.py +ginga/fonts/__init__.py +ginga/fonts/font_asst.py +ginga/fonts/Roboto/LICENSE.txt +ginga/fonts/Roboto/Roboto-Black.ttf +ginga/fonts/Roboto/Roboto-Bold.ttf +ginga/fonts/Roboto/Roboto-Light.ttf +ginga/fonts/Roboto/Roboto-Medium.ttf +ginga/fonts/Roboto/Roboto-Regular.ttf +ginga/fonts/Roboto/Roboto-Thin.ttf +ginga/gtk3w/GtkHelp.py +ginga/gtk3w/ImageViewCanvasGtk.py +ginga/gtk3w/ImageViewGtk.py +ginga/gtk3w/Plot.py +ginga/gtk3w/Viewers.py +ginga/gtk3w/Widgets.py +ginga/gtk3w/__init__.py +ginga/gtk3w/gtk_css +ginga/gw/ColorBar.py +ginga/gw/Desktop.py +ginga/gw/GwHelp.py +ginga/gw/GwMain.py +ginga/gw/Plot.py +ginga/gw/PlotView.py +ginga/gw/PluginManager.py +ginga/gw/Readout.py +ginga/gw/Viewers.py +ginga/gw/Widgets.py +ginga/gw/__init__.py +ginga/gw/sv.py +ginga/icons/Ginga.icns +ginga/icons/__init__.py +ginga/icons/auto_cuts_48.png +ginga/icons/center_image_48.png +ginga/icons/color_48.png +ginga/icons/contrast_48.png +ginga/icons/cuts_48.png +ginga/icons/distribute_48.png +ginga/icons/down_48.png +ginga/icons/file.png +ginga/icons/first_48.png +ginga/icons/fits.png +ginga/icons/flipx_48.png +ginga/icons/flipy_48.png +ginga/icons/folder.png +ginga/icons/forward_48.png +ginga/icons/ginga-512x512.png +ginga/icons/ginga-splash.ppm +ginga/icons/ginga.fits +ginga/icons/hand_48.png +ginga/icons/histogram_48.png +ginga/icons/inbox_minus_48.png +ginga/icons/inbox_plus_48.png +ginga/icons/last_48.png +ginga/icons/layers_48.png +ginga/icons/levels_48.png +ginga/icons/lock_48.png +ginga/icons/microscope_48.png +ginga/icons/minus_48.png +ginga/icons/next_48.png +ginga/icons/openHandCursor.png +ginga/icons/open_48.png +ginga/icons/orient_ne_48.png +ginga/icons/orient_nw_48.png +ginga/icons/pan_48.png +ginga/icons/pause_48.png +ginga/icons/play_48.png +ginga/icons/plus_48.png +ginga/icons/prev_48.png +ginga/icons/record_48.png +ginga/icons/reset_contrast_48.png +ginga/icons/reset_rotation_48.png +ginga/icons/reverse_48.png +ginga/icons/rot90ccw_48.png +ginga/icons/rot90cw_48.png +ginga/icons/rotate_48.png +ginga/icons/settings_48.png +ginga/icons/stop_48.png +ginga/icons/swapxy_48.png +ginga/icons/tags_48.png +ginga/icons/thinCrossCursor.png +ginga/icons/triangle-down-48.png +ginga/icons/triangle-right-48.png +ginga/icons/up_48.png +ginga/icons/zoom_100_48.png +ginga/icons/zoom_fit_48.png +ginga/icons/zoom_in_48.png +ginga/icons/zoom_out_48.png +ginga/misc/Bunch.py +ginga/misc/Callback.py +ginga/misc/CanvasTypes.py +ginga/misc/Datasrc.py +ginga/misc/Future.py +ginga/misc/LineHistory.py +ginga/misc/ModuleManager.py +ginga/misc/ParamSet.py +ginga/misc/Settings.py +ginga/misc/Task.py +ginga/misc/Timer.py +ginga/misc/__init__.py +ginga/misc/grc.py +ginga/misc/log.py +ginga/misc/tests/__init__.py +ginga/misc/tests/test_Callback.py +ginga/misc/tests/test_Future.py +ginga/misc/tests/test_Task.py +ginga/mockw/CanvasRenderMock.py +ginga/mockw/ImageViewCanvasMock.py +ginga/mockw/ImageViewMock.py +ginga/mockw/__init__.py +ginga/mplw/CanvasRenderMpl.py +ginga/mplw/FigureCanvasQt.py +ginga/mplw/ImageViewCanvasMpl.py +ginga/mplw/ImageViewMpl.py +ginga/mplw/MplHelp.py +ginga/mplw/__init__.py +ginga/mplw/ipg.py +ginga/mplw/transform.py +ginga/opencl/CL.py +ginga/opencl/__init__.py +ginga/opencl/trcalc.cl +ginga/opengl/Camera.py +ginga/opengl/CanvasRenderGL.py +ginga/opengl/GlHelp.py +ginga/opengl/__init__.py +ginga/opengl/geometry_helper.py +ginga/opengl/glsl/__init__.py +ginga/opengl/glsl/image.frag +ginga/opengl/glsl/image.vert +ginga/opengl/glsl/req.py +ginga/opengl/glsl/shape.frag +ginga/opengl/glsl/shape.vert +ginga/pilw/CanvasRenderPil.py +ginga/pilw/ImageViewPil.py +ginga/pilw/PilHelp.py +ginga/pilw/__init__.py +ginga/qtw/CanvasRenderQt.py +ginga/qtw/ImageViewCanvasQt.py +ginga/qtw/ImageViewQt.py +ginga/qtw/Plot.py +ginga/qtw/QtHelp.py +ginga/qtw/Viewers.py +ginga/qtw/Widgets.py +ginga/qtw/__init__.py +ginga/qtw/tests/__init__.py +ginga/qtw/tests/cbar.py +ginga/rv/Channel.py +ginga/rv/Control.py +ginga/rv/__init__.py +ginga/rv/main.py +ginga/rv/plugins/Blink.py +ginga/rv/plugins/Catalogs.py +ginga/rv/plugins/ChangeHistory.py +ginga/rv/plugins/Collage.py +ginga/rv/plugins/ColorMapPicker.py +ginga/rv/plugins/Colorbar.py +ginga/rv/plugins/Command.py +ginga/rv/plugins/Compose.py +ginga/rv/plugins/Contents.py +ginga/rv/plugins/Crosshair.py +ginga/rv/plugins/Cursor.py +ginga/rv/plugins/Cuts.py +ginga/rv/plugins/Downloads.py +ginga/rv/plugins/Drawing.py +ginga/rv/plugins/Errors.py +ginga/rv/plugins/FBrowser.py +ginga/rv/plugins/Header.py +ginga/rv/plugins/Histogram.py +ginga/rv/plugins/Info.py +ginga/rv/plugins/LineProfile.py +ginga/rv/plugins/Log.py +ginga/rv/plugins/Mosaic.py +ginga/rv/plugins/MultiDim.py +ginga/rv/plugins/Operations.py +ginga/rv/plugins/Overlays.py +ginga/rv/plugins/Pan.py +ginga/rv/plugins/Pick.py +ginga/rv/plugins/Pipeline.py +ginga/rv/plugins/PixTable.py +ginga/rv/plugins/PlotTable.py +ginga/rv/plugins/Preferences.py +ginga/rv/plugins/RC.py +ginga/rv/plugins/Ruler.py +ginga/rv/plugins/SAMP.py +ginga/rv/plugins/SaveImage.py +ginga/rv/plugins/ScreenShot.py +ginga/rv/plugins/TVMark.py +ginga/rv/plugins/TVMask.py +ginga/rv/plugins/Thumbs.py +ginga/rv/plugins/Toolbar.py +ginga/rv/plugins/WBrowser.py +ginga/rv/plugins/WCSAxes.py +ginga/rv/plugins/WCSMatch.py +ginga/rv/plugins/Zoom.py +ginga/rv/plugins/__init__.py +ginga/table/AstroTable.py +ginga/table/TableView.py +ginga/table/__init__.py +ginga/tests/__init__.py +ginga/tests/test_ImageView.py +ginga/tests/test_aimg.py +ginga/tests/test_ap_regions.py +ginga/tests/test_cmap.py +ginga/tests/test_colordist.py +ginga/tests/test_colors.py +ginga/tests/test_imap.py +ginga/tests/test_iohelper.py +ginga/tests/test_iqcalc.py +ginga/tests/test_trcalc.py +ginga/tests/test_wcs.py +ginga/tkw/ImageViewTk.py +ginga/tkw/TkHelp.py +ginga/tkw/__init__.py +ginga/util/__init__.py +ginga/util/addons.py +ginga/util/ap_region.py +ginga/util/bezier.py +ginga/util/catalog.py +ginga/util/colorramp.py +ginga/util/contour.py +ginga/util/dp.py +ginga/util/grc.py +ginga/util/heaptimer.py +ginga/util/io_fits.py +ginga/util/io_rgb.py +ginga/util/iohelper.py +ginga/util/iqcalc.py +ginga/util/json.py +ginga/util/loader.py +ginga/util/make_release.py +ginga/util/mosaic.py +ginga/util/paths.py +ginga/util/plots.py +ginga/util/rgb_cms.py +ginga/util/toolbox.py +ginga/util/videosink.py +ginga/util/viewer.py +ginga/util/vip.py +ginga/util/wcs.py +ginga/util/zscale.py +ginga/util/io/__init__.py +ginga/util/io/io_asdf.py +ginga/util/io/io_base.py +ginga/util/wcsmod/__init__.py +ginga/util/wcsmod/common.py +ginga/util/wcsmod/wcs_astlib.py +ginga/util/wcsmod/wcs_astropy.py +ginga/util/wcsmod/wcs_astropy_ape14.py +ginga/util/wcsmod/wcs_barebones.py +ginga/util/wcsmod/wcs_kapteyn.py +ginga/util/wcsmod/wcs_starlink.py +ginga/vec/CanvasRenderVec.py +ginga/vec/VecHelp.py +ginga/vec/__init__.py +ginga/web/README.md +ginga/web/__init__.py +ginga/web/bokehw/BokehHelp.py +ginga/web/bokehw/CanvasRenderBokeh.py +ginga/web/bokehw/ImageViewBokeh.py +ginga/web/bokehw/__init__.py +ginga/web/jupyterw/ImageViewJpw.py +ginga/web/jupyterw/JpHelp.py +ginga/web/jupyterw/__init__.py +ginga/web/pgw/ImageViewPg.py +ginga/web/pgw/PgHelp.py +ginga/web/pgw/Plot.py +ginga/web/pgw/Viewers.py +ginga/web/pgw/Widgets.py +ginga/web/pgw/__init__.py +ginga/web/pgw/ipg.py +ginga/web/pgw/js/Hammer_LICENSE.md +ginga/web/pgw/js/__init__.py +ginga/web/pgw/js/ginga_pg.css +ginga/web/pgw/js/ginga_pg.js +ginga/web/pgw/js/hammer.js +ginga/web/pgw/templates/__init__.py +ginga/web/pgw/templates/index.html +licenses/README.rst +mkapp/Ginga.icns +mkapp/Ginga.py +mkapp/Makefile +mkapp/setup.py \ No newline at end of file diff -Nru ginga-3.0.0/ginga.egg-info/top_level.txt ginga-3.1.0/ginga.egg-info/top_level.txt --- ginga-3.0.0/ginga.egg-info/top_level.txt 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/ginga.egg-info/top_level.txt 2020-07-20 22:24:24.000000000 +0000 @@ -0,0 +1 @@ +ginga diff -Nru ginga-3.0.0/.gitignore ginga-3.1.0/.gitignore --- ginga-3.0.0/.gitignore 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/.gitignore 2020-07-20 21:18:19.000000000 +0000 @@ -0,0 +1,67 @@ +# Compiled files +*.py[cod] +*.a +*.o +*.so +__pycache__ + +# Ignore .c files by default to avoid including generated code. If you want to +# add a non-generated .c extension, use `git add -f filename.c`. +*.c + +# Other generated files +*/version.py +*/cython_version.py +htmlcov +.coverage +MANIFEST +ginga/icons/.thumbs/ + +# Sphinx +doc/api +doc/manual/api/ +doc/manual/plugins_global/api/ +doc/manual/plugins_local/api/ +doc/_build + +# TeX products +*.aux +*.log +*.pdf +*.toc + +# Eclipse editor project files +.project +.pydevproject +.settings + +# Pycharm editor project files +.idea + +# Packages/installer info +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +distribute-*.tar.gz +pip-wheel-metadata/ + +# IPython notebook (Jupyter) checkpoints +.ipynb_checkpoints + +# Other +.cache +.tox +.*.sw[op] +*~ +\#*\# + +# Mac OSX +.DS_Store diff -Nru ginga-3.0.0/LICENSE.rst ginga-3.1.0/LICENSE.rst --- ginga-3.0.0/LICENSE.rst 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/LICENSE.rst 2020-01-20 03:17:53.000000000 +0000 @@ -1,4 +1,4 @@ -Copyright (c) 2011-2019, Ginga Maintainers +Copyright (c) 2011-2020, Ginga Maintainers All rights reserved. diff -Nru ginga-3.0.0/LICENSE.txt ginga-3.1.0/LICENSE.txt --- ginga-3.0.0/LICENSE.txt 2019-03-08 03:17:35.000000000 +0000 +++ ginga-3.1.0/LICENSE.txt 2020-01-20 03:17:53.000000000 +0000 @@ -1,4 +1,4 @@ -Copyright (c) 2011-2019, Ginga Maintainers +Copyright (c) 2011-2020, Ginga Maintainers All rights reserved. diff -Nru ginga-3.0.0/LONG_DESC.txt ginga-3.1.0/LONG_DESC.txt --- ginga-3.0.0/LONG_DESC.txt 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/LONG_DESC.txt 2020-01-20 03:17:53.000000000 +0000 @@ -0,0 +1,10 @@ +Ginga is a toolkit designed for building viewers for scientific image +data in Python, visualizing 2D pixel data in numpy arrays. +The Ginga toolkit centers around an image display class which supports +zooming and panning, color and intensity mapping, a choice of several +automatic cut levels algorithms and canvases for plotting scalable +geometric forms. In addition to this widget, a general purpose +'reference' FITS viewer is provided, based on a plugin framework. + +Ginga is distributed under an open-source BSD licence. Please see the +file LICENSE.txt in the top-level directory for details. diff -Nru ginga-3.0.0/MANIFEST.in ginga-3.1.0/MANIFEST.in --- ginga-3.0.0/MANIFEST.in 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/MANIFEST.in 2020-01-20 03:17:53.000000000 +0000 @@ -0,0 +1,23 @@ +include README.md +include LONG_DESC.txt +include CHANGES.rst +include LICENSE.* +include ROADMAP.rst +include CITATION +include ginga.desktop + +include setup.cfg +include pyproject.toml + +recursive-include *.pyx *.c *.pxd +recursive-include doc * +recursive-include licenses * +recursive-include cextern * +recursive-include scripts * +recursive-include experimental * + +prune build +prune doc/_build +prune doc/api + +global-exclude *.pyc *.o Binary files /tmp/tmpzTZlGu/kTeH6Y7wol/ginga-3.0.0/mkapp/Ginga.icns and /tmp/tmpzTZlGu/IL06UTbPyz/ginga-3.1.0/mkapp/Ginga.icns differ diff -Nru ginga-3.0.0/mkapp/Ginga.py ginga-3.1.0/mkapp/Ginga.py --- ginga-3.0.0/mkapp/Ginga.py 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/mkapp/Ginga.py 2018-07-03 04:54:43.000000000 +0000 @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# +# ginga -- astronomical image viewer and toolkit +# +""" +Usage: + ginga --help + ginga [options] [fitsfile] ... +""" +import sys +from ginga import main, trcalc +try: + trcalc.use('opencv') +except ImportError: + pass + +if __name__ == "__main__": + main.reference_viewer(sys.argv) diff -Nru ginga-3.0.0/mkapp/Makefile ginga-3.1.0/mkapp/Makefile --- ginga-3.0.0/mkapp/Makefile 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/mkapp/Makefile 2018-07-03 04:54:43.000000000 +0000 @@ -0,0 +1,6 @@ + + +Ginga.app: setup.py + python setup.py py2app + #zip -r Ginga.app.zip dist/Ginga.app + diff -Nru ginga-3.0.0/mkapp/setup.py ginga-3.1.0/mkapp/setup.py --- ginga-3.0.0/mkapp/setup.py 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/mkapp/setup.py 2018-11-15 00:46:14.000000000 +0000 @@ -0,0 +1,99 @@ +# -*- coding: iso-8859-1 -*- +""" +Build a standalone application for Mac OS X and MS Windows platforms + + Usage (Mac OS X): + python setup.py py2app + + Usage (Windows): + python setup.py py2exe +""" +import sys +from setuptools import setup + +info_plist_template = u""" + + + + CFBundleName + Ginga + CFBundlePackageType + APPL + CFBundleGetInfoString + Copyright © 2010-2016, Eric Jeschke (eric@naoj.org) + CFBundleIconFile + Ginga.icns + + CFBundleShortVersionString + %(version)s + + CFBundleVersion + %(build)s + + NSHumanReadableCopyright + Copyright © 2010-2016, Eric Jeschke (eric@naoj.org) + + CFBundleIdentifier + org.naoj.Ginga + CFBundleDevelopmentRegion + English + CFBundleExecutable + Ginga + CFBundleDisplayName + Ginga + + +""" + +from ginga import __version__ + +d = dict(version=__version__, build=__version__.replace('.', '')) +plist = info_plist_template % d + +with open('Info.plist', 'w') as out_f: + out_f.write(plist) + + +APP = ['Ginga.py'] +DATA_FILES = [] + +OPTIONS = {'argv_emulation': True, + 'compressed': True, + #'packages': 'ginga,scipy,numpy,kapteyn,astropy,PIL,matplotlib', + 'packages': 'ginga,scipy,numpy,astropy,PIL,matplotlib', + 'includes': ['sip', 'PyQt4._qt',], + # currently creating some problems with the app build on mac os x + # so exclude + 'excludes': ['cv2',], + 'matplotlib_backends': 'Qt4Agg', + } + +if sys.platform == 'darwin': + # mac-specific options + OPTIONS['plist'] = 'Info.plist' + OPTIONS['iconfile'] = 'Ginga.icns' + extra_options = dict( + setup_requires=['py2app'], + options={'py2app': OPTIONS}, + ) + +elif sys.platform == 'win32': + extra_options = dict( + setup_requires=['py2exe'], + options={'py2exe': OPTIONS}, + ) +else: + extra_options = dict( + # Normally unix-like platforms will use "setup.py install" + # and install the main script as such + scripts=["ginga"], + ) + + setup_requires=['py2app'], + +setup( + name="Ginga", + app=APP, + data_files=DATA_FILES, + **extra_options +) diff -Nru ginga-3.0.0/PKG-INFO ginga-3.1.0/PKG-INFO --- ginga-3.0.0/PKG-INFO 2019-09-21 03:32:48.000000000 +0000 +++ ginga-3.1.0/PKG-INFO 2020-07-20 22:24:25.000000000 +0000 @@ -1,13 +1,12 @@ Metadata-Version: 2.1 Name: ginga -Version: 3.0.0 +Version: 3.1.0 Summary: A scientific image viewer and toolkit -Home-page: http://ejeschke.github.com/ginga +Home-page: https://ejeschke.github.io/ginga/ Author: Ginga Maintainers Author-email: eric@naoj.org License: BSD -Description: - Ginga is a toolkit designed for building viewers for scientific image +Description: Ginga is a toolkit designed for building viewers for scientific image data in Python, visualizing 2D pixel data in numpy arrays. The Ginga toolkit centers around an image display class which supports zooming and panning, color and intensity mapping, a choice of several @@ -26,14 +25,20 @@ Classifier: Operating System :: Microsoft :: Windows Classifier: Operating System :: POSIX Classifier: Programming Language :: C +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Topic :: Scientific/Engineering :: Astronomy Classifier: Topic :: Scientific/Engineering :: Physics Requires-Python: >=3.5 -Provides-Extra: tk -Provides-Extra: web -Provides-Extra: qt5 +Description-Content-Type: text/plain +Provides-Extra: recommended +Provides-Extra: test Provides-Extra: docs Provides-Extra: gtk3 -Provides-Extra: recommended +Provides-Extra: qt5 +Provides-Extra: tk +Provides-Extra: web diff -Nru ginga-3.0.0/pyproject.toml ginga-3.1.0/pyproject.toml --- ginga-3.0.0/pyproject.toml 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/pyproject.toml 2020-01-20 03:17:53.000000000 +0000 @@ -0,0 +1,5 @@ +[build-system] +requires = ["setuptools>=30.3.0", + "setuptools_scm", + "wheel"] +build-backend = "setuptools.build_meta" diff -Nru ginga-3.0.0/README.md ginga-3.1.0/README.md --- ginga-3.0.0/README.md 2019-08-03 02:20:19.000000000 +0000 +++ ginga-3.1.0/README.md 2020-04-10 20:19:26.000000000 +0000 @@ -24,19 +24,20 @@ ## COPYRIGHT AND LICENSE -Copyright (c) 2011-2019 Ginga Maintainers. All rights reserved. +Copyright (c) 2011-2020 Ginga Maintainers. All rights reserved. Ginga is distributed under an open-source BSD licence. Please see the file `LICENSE.txt` in the top-level directory for details. ## BUILDING AND INSTALLATION -Ginga uses a standard `distutils`-based install, e.g.: +The current release of Ginga can be downloaded and installed from `pip` using: - $ python setup.py build + $ pip install ginga -or +From source code, you should also use `pip`, e.g.: - $ python setup.py install + $ cd ginga + $ pip install -e . The program can then be run using the command "ginga". diff -Nru ginga-3.0.0/.readthedocs.yml ginga-3.1.0/.readthedocs.yml --- ginga-3.0.0/.readthedocs.yml 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/.readthedocs.yml 2020-01-20 03:17:53.000000000 +0000 @@ -0,0 +1,24 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +version: 2 + +sphinx: + builder: html + configuration: doc/conf.py + fail_on_warning: true + +# Set the version of Python and requirements required to build your docs +python: + version: 3.7 + system_packages: true + install: + - method: pip + path: . + extra_requirements: + - docs + - recommended + +# Build PDF & ePub +formats: + - epub + - pdf diff -Nru ginga-3.0.0/setup.cfg ginga-3.1.0/setup.cfg --- ginga-3.0.0/setup.cfg 2019-09-21 03:11:37.000000000 +0000 +++ ginga-3.1.0/setup.cfg 2020-07-20 22:24:25.000000000 +0000 @@ -1,19 +1,9 @@ -[build_docs] -source-dir = doc -build-dir = doc/_build -all_files = 1 - -[upload_docs] -upload-dir = doc/_build/html -show-response = 1 - [tool:pytest] minversion = 3 +testpaths = "ginga" "doc" norecursedirs = build doc/_build -addopts = -p no:warnings - -[ah_bootstrap] -auto_use = True +astropy_header = true +addopts = -p no:warnings --doctest-ignore-import-errors [bdist_wheel] universal = 1 @@ -21,57 +11,101 @@ [metadata] name = ginga description = A scientific image viewer and toolkit +long_description = file: LONG_DESC.txt +long_description_content_type = text/plain author = Ginga Maintainers author_email = eric@naoj.org license = BSD -url = http://ejeschke.github.com/ginga +license_file = LICENSE.txt +url = https://ejeschke.github.io/ginga/ edit_on_github = False -github_project = ejeschke/ginga/ -version = 3.0.0 +github_project = ejeschke/ginga keywords = scientific, image, viewer, numpy, toolkit, astronomy, FITS -classifiers = - Intended Audience :: Science/Research - License :: OSI Approved :: BSD License - Operating System :: MacOS :: MacOS X - Operating System :: Microsoft :: Windows - Operating System :: POSIX - Programming Language :: C - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3 - Topic :: Scientific/Engineering :: Astronomy - Topic :: Scientific/Engineering :: Physics +classifiers = + Intended Audience :: Science/Research + License :: OSI Approved :: BSD License + Operating System :: MacOS :: MacOS X + Operating System :: Microsoft :: Windows + Operating System :: POSIX + Programming Language :: C + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3 + Programming Language :: Python :: Implementation :: CPython + Topic :: Scientific/Engineering :: Astronomy + Topic :: Scientific/Engineering :: Physics [options] zip_safe = False packages = find: python_requires = >=3.5 -setup_requires = numpy>=1.13 install_requires = numpy>=1.13; qtpy>=1.1; astropy>=3 -tests_require = pytest +setup_requires = setuptools_scm [options.extras_require] -recommended = pillow>=3.2.0; scipy>=0.18.1; matplotlib>=1.5.1; opencv-python>=3.4.1; piexif>=1.0.13; beautifulsoup4>=4.3.2 -docs = sphinx-astropy +recommended = pillow>=3.2.0; scipy>=0.18.1; matplotlib>=1.5.1; opencv-python>=3.4.1; piexif>=1.0.13; beautifulsoup4>=4.3.2; docutils +test = attrs>=19.2.0; pytest-astropy +docs = sphinx-astropy; sphinx_rtd_theme gtk3 = pycairo; pygobject qt5 = PyQt5; QtPy>=1.1 tk = aggdraw web = tornado; aggdraw -[entry_points] -ginga = ginga.rv.main:_main -ggrc = ginga.misc.grc:_main +[options.package_data] +ginga = examples/*/*/*, web/pgw/js/*.js, web/pgw/js/*.css +ginga.doc = *.html +ginga.fonts = */*.ttf, */*.txt +ginga.gtk3w = gtk_css +ginga.icons = *.ppm, *.png +ginga.opencl = trcalc.cl +ginga.opengl = glsl/*.vert, glsl/*.frag +ginga.web.pgw = templates/*.html, js/*.js + +[options.entry_points] +console_scripts = + ginga = ginga.rv.main:_main + ggrc = ginga.misc.grc:_main [flake8] -# Ignoring these for now: -# E129: visually indented line with same indent as next logical line -# E265: block comment should start with '#' -# E266: too many leading '#' for block comment -# E402: module level import not at top of file -# E501: line too long -# E741: ambiguous variable name 'l' -# I100: import statements are in the wrong order -# I101: imported names are in the wrong order -# W504: line break after binary operator -# F841: local variable '%s' is assigned to but never used ignore = E129,E265,E266,E402,E501,E741,I100,I101,W504,F841 -exclude = conftest.py + +[coverage:run] +source = ginga +omit = + ginga/_astropy_init* + ginga/conftest* + ginga/cython_version* + ginga/setup_package* + ginga/*/setup_package* + ginga/*/*/setup_package* + ginga/tests/* + ginga/*/tests/* + ginga/*/*/tests/* + ginga/version* + */ginga/_astropy_init* + */ginga/conftest* + */ginga/cython_version* + */ginga/setup_package* + */ginga/*/setup_package* + */ginga/*/*/setup_package* + */ginga/tests/* + */ginga/*/tests/* + */ginga/*/*/tests/* + */ginga/version* + +[coverage:report] +exclude_lines = + pragma: no cover + except ImportError + raise AssertionError + raise NotImplementedError + def main\(.*\): + pragma: py{ignore_python_version} + def _ipython_key_completions_ + +[egg_info] +tag_build = +tag_date = 0 + diff -Nru ginga-3.0.0/setup.py ginga-3.1.0/setup.py --- ginga-3.0.0/setup.py 2019-07-31 04:01:11.000000000 +0000 +++ ginga-3.1.0/setup.py 2020-01-20 03:17:53.000000000 +0000 @@ -2,96 +2,32 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst import sys - -import glob -import os - -import ah_bootstrap # noqa from setuptools import setup -import builtins -builtins._ASTROPY_SETUP_ = True +TEST_HELP = """ +Note: running tests is no longer done using 'python setup.py test'. Instead +you will need to run: + pip install -e . + pytest +For more information, see: + https://docs.astropy.org/en/latest/development/testguide.html#running-tests +""" + +if 'test' in sys.argv: + print(TEST_HELP) + sys.exit(1) + +DOCS_HELP = """ +Note: building the documentation is no longer done using +'python setup.py build_docs'. Instead you will need to run: + cd docs + make html +For more information, see: + https://docs.astropy.org/en/latest/install.html#builddocs +""" + +if 'build_docs' in sys.argv or 'build_sphinx' in sys.argv: + print(DOCS_HELP) + sys.exit(1) -from astropy_helpers.setup_helpers import register_commands, get_package_info -from astropy_helpers.git_helpers import get_git_devstr -from astropy_helpers.version_helpers import generate_version_py - -# Get some values from the setup.cfg -from configparser import ConfigParser - -conf = ConfigParser() - -conf.read(['setup.cfg']) -metadata = dict(conf.items('metadata')) - -PACKAGENAME = metadata.get('name', 'ginga') - -# Get the long description from the package's docstring -__import__(PACKAGENAME) -package = sys.modules[PACKAGENAME] -LONG_DESCRIPTION = package.__doc__ - -# Store the package name in a built-in variable so it's easy -# to get from other parts of the setup infrastructure -builtins._ASTROPY_PACKAGE_NAME_ = PACKAGENAME - -# VERSION should be PEP386 compatible (http://www.python.org/dev/peps/pep-0386) -VERSION = metadata.get('version', '0.0.dev') - -# Indicates if this version is a release version -RELEASE = 'dev' not in VERSION - -if not RELEASE: - VERSION += get_git_devstr(False) - -# Populate the dict of setup command overrides; this should be done before -# invoking any other functionality from distutils since it can potentially -# modify distutils' behavior. -cmdclassd = register_commands() - -# Freeze build information in version.py -generate_version_py() - -# Treat everything in scripts except README.rst and fits2pdf.py -# as a script to be installed -scripts = [fname for fname in glob.glob(os.path.join('scripts', '*')) - if (os.path.basename(fname) != 'README.rst' and - os.path.basename(fname) != 'fits2pdf.py')] - -# Get configuration information from all of the various subpackages. -# See the docstring for setup_helpers.update_package_files for more -# details. -package_info = get_package_info() - -# Add the project-global data -package_info['package_data'].setdefault(PACKAGENAME, []) -package_info['package_data'][PACKAGENAME].append('examples/*/*') -package_info['package_data'][PACKAGENAME].append('web/pgw/js/*.js') -package_info['package_data'][PACKAGENAME].append('web/pgw/js/*.css') - -# Define entry points for command-line scripts -entry_points = {'console_scripts': []} - -entry_point_list = conf.items('entry_points') -for entry_point in entry_point_list: - entry_points['console_scripts'].append('{0} = {1}'.format(entry_point[0], - entry_point[1])) - -# Include all .c files, recursively, including those generated by -# Cython, since we can not do this in MANIFEST.in with a "dynamic" -# directory name. -c_files = [] -for root, dirs, files in os.walk(PACKAGENAME): - for filename in files: - if filename.endswith('.c'): - c_files.append( - os.path.join( - os.path.relpath(root, PACKAGENAME), filename)) -package_info['package_data'][PACKAGENAME].extend(c_files) - -setup(version=VERSION, - scripts=scripts, - long_description=LONG_DESCRIPTION, - cmdclass=cmdclassd, - entry_points=entry_points, - **package_info) +setup(use_scm_version={'write_to': 'ginga/version.py'}) diff -Nru ginga-3.0.0/.travis.yml ginga-3.1.0/.travis.yml --- ginga-3.0.0/.travis.yml 1970-01-01 00:00:00.000000000 +0000 +++ ginga-3.1.0/.travis.yml 2020-01-20 03:17:53.000000000 +0000 @@ -0,0 +1,98 @@ +# We set the language to c because python isn't supported on the MacOS X nodes +# on Travis. However, the language ends up being irrelevant anyway, since we +# install Python ourselves using conda. +language: c + +compiler: gcc + +os: + - linux + +# Setting sudo to false opts in to Travis-CI container-based builds. +sudo: false + +env: + global: + # The following versions are the 'default' for tests, unless + # overidden underneath. They are defined here in order to save having + # to repeat them for all configurations. + - PYTHON_VERSION=3.7 + - NUMPY_VERSION=stable + - ASTROPY_VERSION=stable + - PIP_DEPENDENCIES='' + - INSTALL_CMD='pip install -e .[test]' + - MAIN_CMD='pytest --pyargs ginga doc' + # *** TODO: We should test the various GUI toolkits that ginga supports + # on travis-ci ... + # https://ginga.readthedocs.io/en/latest/install.html#dependences + - CONDA_DEPENDENCIES='pyqt' + +matrix: + + # Don't wait for allowed failures + fast_finish: true + + include: + + # Try with the latest Numpy + - os: linux + + # Try older Numpy version(s) + # NOTE: Need NO_HELPERS and extra incantations to prevent conda solver timeout. + - os: linux + language: python + python: 3.6 + env: PYTHON_VERSION=3.6 NUMPY_VERSION=1.15 NO_HELPERS=true + + # Try with Astropy dev and matplotlib installed + - os: linux + env: ASTROPY_VERSION=development CONDA_DEPENDENCIES='pyqt matplotlib' + + # Try on Windows + - os: windows + + # Do a PEP 8 test with flake8 (white-list in setup.cfg) + - os: linux + env: MAIN_CMD='flake8 ginga --count' INSTALL_CMD='' + + # Do a PEP 517 test with twine check + - os: linux + env: CONDA_DEPENDENCIES='' PIP_DEPENDENCIES='pep517 twine' + INSTALL_CMD='python -m pep517.build --source .' + MAIN_CMD='twine check dist/*' + +before_install: + + # If there are matplotlib tests, comment these out to + # Make sure that interactive matplotlib backends work + # - export DISPLAY=:99.0 + # - sh -e /etc/init.d/xvfb start + +install: + + # We now use the ci-helpers package to set up our testing environment. + # This is done by using Miniconda and then using conda and pip to install + # dependencies. Which dependencies are installed using conda and pip is + # determined by the CONDA_DEPENDENCIES and PIP_DEPENDENCIES variables, + # which should be space-delimited lists of package names. See the README + # in https://github.com/astropy/ci-helpers for information about the full + # list of environment variables that can be used to customize your + # environment. In some cases, ci-helpers may not offer enough flexibility + # in how to install a package, in which case you can have additional + # commands in the install: section below. + + - git clone git://github.com/astropy/ci-helpers.git + - if [[ -z $NO_HELPERS ]]; then source ci-helpers/travis/setup_conda.sh; fi + + # As described above, using ci-helpers, you should be able to set up an + # environment with dependencies installed using conda and pip, but in some + # cases this may not provide enough flexibility in how to install a + # specific dependency (and it will not be able to install non-Python + # dependencies). Therefore, you can also include commands below (as + # well as at the start of the install section or in the before_install + # section if they are needed before setting up conda) to install any + # other dependencies. + +script: + - $INSTALL_CMD + - $MAIN_CMD