├── astro3d ├── core │ ├── __init__.py │ ├── texture_examples.py │ ├── region_mask.py │ ├── image_utils.py │ └── meshes.py ├── external │ ├── __init__.py │ └── qt.py ├── tests │ ├── __init__.py │ └── test_model3d.py ├── util │ ├── tests │ │ ├── __init__.py │ │ └── test_text_catalog.py │ ├── __init__.py │ ├── text_catalog.py │ ├── register_leaf_classes.py │ ├── logger.py │ └── signal_slot.py ├── gui │ ├── controller.py │ ├── qt │ │ ├── __init__.py │ │ ├── info_box.py │ │ ├── view_instruction.py │ │ ├── preferences.py │ │ ├── image_view.py │ │ ├── pyqt_nonblock.py │ │ ├── util.py │ │ ├── layer_manager.py │ │ ├── view_mesh.py │ │ ├── process.py │ │ ├── parameters.py │ │ ├── overlay.py │ │ └── shape_editor.py │ ├── start_ui_app.py │ ├── __init__.py │ ├── textures.py │ ├── config.py │ ├── helps.py │ ├── signals.py │ ├── util.py │ ├── store_widgets.py │ ├── model.py │ └── viewer.py ├── __init__.py ├── data │ ├── astro3d.cfg │ └── text_catalog.yaml ├── conftest.py └── app.py ├── codecov.yml ├── docs ├── rtd-pip-requirements ├── _templates │ └── autosummary │ │ ├── base.rst │ │ ├── class.rst │ │ └── module.rst ├── high-level_API.rst ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── pyproject.toml ├── MANIFEST.in ├── .gitignore ├── LICENSE.rst ├── setup.cfg ├── setup.py ├── .github └── workflows │ └── ci_tests.yml ├── tox.ini └── README.rst /astro3d/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /astro3d/external/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /astro3d/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /astro3d/util/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | -------------------------------------------------------------------------------- /astro3d/util/__init__.py: -------------------------------------------------------------------------------- 1 | from .signal_slot import * 2 | -------------------------------------------------------------------------------- /astro3d/external/qt.py: -------------------------------------------------------------------------------- 1 | from qtpy import ( 2 | QtCore, 3 | QtGui 4 | ) 5 | -------------------------------------------------------------------------------- /docs/rtd-pip-requirements: -------------------------------------------------------------------------------- 1 | numpy 2 | matplotlib 3 | Cython 4 | astropy-helpers 5 | astropy 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['setuptools', 3 | 'setuptools_scm', 4 | 'wheel'] 5 | build-backend = 'setuptools.build_meta' 6 | -------------------------------------------------------------------------------- /astro3d/gui/controller.py: -------------------------------------------------------------------------------- 1 | """UI Controller 2 | """ 3 | 4 | 5 | __all__ = ['Controller'] 6 | 7 | 8 | class Controller(object): 9 | """UI Controller 10 | """ 11 | -------------------------------------------------------------------------------- /astro3d/gui/qt/__init__.py: -------------------------------------------------------------------------------- 1 | from .image_view import * 2 | from .info_box import * 3 | from .view_instruction import * 4 | from .layer_manager import * 5 | from .view_mesh import * 6 | from .overlay import * 7 | from .parameters import * 8 | from .shape_editor import * 9 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/base.rst: -------------------------------------------------------------------------------- 1 | {% extends "autosummary_core/base.rst" %} 2 | {# The template this is inherited from is in astropy/sphinx/ext/templates/autosummary_core. If you want to modify this template, it is strongly recommended that you still inherit from the astropy template. #} -------------------------------------------------------------------------------- /docs/_templates/autosummary/class.rst: -------------------------------------------------------------------------------- 1 | {% extends "autosummary_core/class.rst" %} 2 | {# The template this is inherited from is in astropy/sphinx/ext/templates/autosummary_core. If you want to modify this template, it is strongly recommended that you still inherit from the astropy template. #} -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst 2 | include README.rst 3 | include pyproject.toml 4 | include setup.cfg 5 | 6 | recursive-include astro3d *.pyx *.c *.pxd 7 | recursive-include docs * 8 | 9 | prune build 10 | prune docs/_build 11 | prune docs/api 12 | 13 | global-exclude *.pyc *.o 14 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/module.rst: -------------------------------------------------------------------------------- 1 | {% extends "autosummary_core/module.rst" %} 2 | {# The template this is inherited from is in astropy/sphinx/ext/templates/autosummary_core. If you want to modify this template, it is strongly recommended that you still inherit from the astropy template. #} -------------------------------------------------------------------------------- /astro3d/__init__.py: -------------------------------------------------------------------------------- 1 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 2 | """ 3 | A package to create a 3D model from an astronomical image. 4 | """ 5 | 6 | from .core.model3d import * # noqa 7 | 8 | try: 9 | from .version import version as __version__ 10 | except ImportError: 11 | __version__ = '' 12 | -------------------------------------------------------------------------------- /astro3d/gui/start_ui_app.py: -------------------------------------------------------------------------------- 1 | """Start the UI application 2 | """ 3 | from .qt.pyqt_nonblock import pyqtapplication 4 | 5 | 6 | def start_ui_app(argv=None): 7 | """Start the appropriate UI application 8 | 9 | Parameters 10 | ---------- 11 | argv: str 12 | The argument string 13 | 14 | Returns 15 | ------- 16 | UI application 17 | """ 18 | 19 | return pyqtapplication(argv) 20 | -------------------------------------------------------------------------------- /astro3d/util/tests/test_text_catalog.py: -------------------------------------------------------------------------------- 1 | """Text catalog tests""" 2 | 3 | 4 | def test_import_text_catalog(): 5 | """Test the the catalog is importable""" 6 | import astro3d.util.text_catalog 7 | 8 | 9 | def test_basic_info(): 10 | """Ensure the text catalog has some basic information""" 11 | from astro3d.util.text_catalog import TEXT_CATALOG 12 | 13 | basic_keys = set(['instructions_default', 'shape_editor']) 14 | assert basic_keys.issubset(TEXT_CATALOG.keys()) 15 | -------------------------------------------------------------------------------- /astro3d/util/text_catalog.py: -------------------------------------------------------------------------------- 1 | """Load the text catalog 2 | 3 | The text catalog is a dictionary of all text, such as instructions, 4 | that are displayed by the application. 5 | """ 6 | from pathlib import Path 7 | import yaml 8 | 9 | # If the text catalog has not been loaded, load now 10 | try: 11 | TEXT_CATALOG 12 | except NameError: 13 | _catalog_path = Path(__file__).parents[1] / 'data' / 'text_catalog.yaml' 14 | with open(_catalog_path, 'r') as fh: 15 | TEXT_CATALOG = yaml.safe_load(fh) 16 | -------------------------------------------------------------------------------- /astro3d/gui/qt/info_box.py: -------------------------------------------------------------------------------- 1 | """Info Box""" 2 | from qtpy import QtWidgets 3 | 4 | __all__ = ['InfoBox'] 5 | 6 | 7 | class InfoBox(QtWidgets.QMessageBox): 8 | 9 | def show_error(self, label, error): 10 | """ Show error 11 | 12 | Parameters 13 | ---------- 14 | label: str 15 | The label of the message. 16 | 17 | error: str 18 | Detailed error message 19 | """ 20 | self.setText(str(label)) 21 | self.setInformativeText(str(error)) 22 | self.exec_() 23 | -------------------------------------------------------------------------------- /docs/high-level_API.rst: -------------------------------------------------------------------------------- 1 | Reference/API 2 | ============= 3 | 4 | ``astro3d.model3d`` 5 | ------------------- 6 | 7 | .. automodule:: astro3d.core.model3d 8 | :members: 9 | 10 | 11 | ``astro3d.meshes`` 12 | ------------------ 13 | 14 | .. automodule:: astro3d.core.meshes 15 | :members: 16 | 17 | 18 | ``astro3d.textures`` 19 | -------------------- 20 | 21 | .. automodule:: astro3d.core.textures 22 | :members: 23 | 24 | 25 | ``astro3d.image_utils`` 26 | ----------------------- 27 | 28 | .. automodule:: astro3d.core.image_utils 29 | :members: 30 | -------------------------------------------------------------------------------- /astro3d/gui/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | # Force the loading of Qt 4 | # First let the qtpy compatibility library 5 | # force the appropriate version of Qt. 6 | # Then force ginga to use that version. 7 | try: 8 | from qtpy import QtCore 9 | except ImportError: 10 | raise RuntimeError('Cannot import Qt toolkit') 11 | try: 12 | from ginga.qtw import QtHelp 13 | except ImportError: 14 | raise RuntimeError('Cannot import Qt toolkit') 15 | 16 | from .signals import Signals as _Signals 17 | signaldb = _Signals() 18 | 19 | from .model import * 20 | from .viewer import * 21 | from .controller import * 22 | -------------------------------------------------------------------------------- /astro3d/util/register_leaf_classes.py: -------------------------------------------------------------------------------- 1 | class RegisterLeafClasses(type): 2 | def __init__(cls, name, bases, nmspc): 3 | super(RegisterLeafClasses, cls).__init__(name, bases, nmspc) 4 | if not hasattr(cls, 'registry'): 5 | cls.registry = set() 6 | cls.registry.add(cls) 7 | cls.registry -= set(bases) # Remove base classes 8 | 9 | # Metamethods, called on class objects: 10 | def __iter__(cls): 11 | return iter(cls.registry) 12 | def __str__(cls): 13 | if cls in cls.registry: 14 | return cls.__name__ 15 | return cls.__name__ + ": " + ", ".join([sc.__name__ for sc in cls]) 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled files 2 | *.py[cod] 3 | *.a 4 | *.o 5 | *.so 6 | __pycache__ 7 | 8 | # Ignore .c files by default to avoid including generated code. If you want to 9 | # add a non-generated .c extension, use `git add -f filename.c`. 10 | *.c 11 | 12 | # Other generated files 13 | */version.py 14 | */cython_version.py 15 | MANIFEST 16 | htmlcov 17 | .coverage 18 | .ipynb_checkpoints 19 | .pytest_cache 20 | 21 | # Sphinx 22 | docs/_build 23 | docs/api 24 | 25 | # Packages/installer info 26 | *.egg 27 | *.egg-info 28 | dist 29 | build 30 | eggs 31 | .eggs 32 | parts 33 | bin 34 | var 35 | sdist 36 | develop-eggs 37 | .installed.cfg 38 | distribute-*.tar.gz 39 | pip-wheel-metadata 40 | 41 | # Other 42 | .cache 43 | .tox 44 | .*.sw[op] 45 | *~ 46 | 47 | # Eclipse editor project files 48 | .project 49 | .pydevproject 50 | .settings 51 | 52 | # PyCharm editor project files 53 | .idea 54 | 55 | # Visual Studio Code project files 56 | .vscode 57 | 58 | # Mac OSX 59 | .DS_Store 60 | -------------------------------------------------------------------------------- /astro3d/gui/qt/view_instruction.py: -------------------------------------------------------------------------------- 1 | """View the basic instructions""" 2 | from qtpy import (QtCore, QtWidgets) 3 | from qtpy.QtCore import Signal as pyqtSignal 4 | 5 | from ...util.text_catalog import TEXT_CATALOG 6 | 7 | __all__ = ['InstructionViewer'] 8 | 9 | 10 | class InstructionViewer(QtWidgets.QTextBrowser): 11 | """View the basic instructions 12 | 13 | Parameters 14 | ---------- 15 | qt_args, qt_kwargs: argument expansions 16 | General Qt arguments 17 | """ 18 | 19 | closed = pyqtSignal(bool) 20 | 21 | def __init__(self, *qt_args, **qt_kwargs): 22 | super(InstructionViewer, self).__init__(*qt_args, **qt_kwargs) 23 | self.setHtml(TEXT_CATALOG['instructions_default']) 24 | 25 | self.setOpenExternalLinks(True) 26 | 27 | def toggle_view(self): 28 | """Toggle visibility""" 29 | sender = self.sender() 30 | self.setVisible(sender.isChecked()) 31 | 32 | def closeEvent(self, event): 33 | """Close the window""" 34 | self.closed.emit(False) 35 | event.accept() 36 | -------------------------------------------------------------------------------- /astro3d/gui/qt/preferences.py: -------------------------------------------------------------------------------- 1 | """Preferences""" 2 | from functools import partial 3 | 4 | from qtpy import (QtGui, QtCore) 5 | 6 | 7 | class Preferences(QtGui.QMenu): 8 | 9 | def __init__(self, *args, **kwargs): 10 | super(Preferences, self).__init__(*args, **kwargs) 11 | self.mutex = QtCore.QMutex() 12 | 13 | def exec_(self, pos, checked): 14 | """Called by the Prefences QAction to bring up the Preferences panel""" 15 | if not self.mutex.tryLock(): 16 | return 17 | try: 18 | pos = pos() 19 | except TypeError: 20 | """pos is already a value, ignore""" 21 | if pos is None: 22 | pos = QtGui.QCursor.pos() 23 | try: 24 | result = super(Preferences, self).exec_(pos) 25 | finally: 26 | self.mutex.unlock() 27 | return result 28 | 29 | def for_menubar(self, pos=None, parent=None): 30 | """Create the dummy menu to encorporate into system menubar""" 31 | act = QtGui.QAction('Preferences', parent) 32 | act.triggered.connect(partial(self.exec_, pos)) 33 | #act.setMenuRole(QtGui.QAction.PreferencesRole) 34 | 35 | menu = QtGui.QMenu('Preferences') 36 | menu.addAction(act) 37 | return menu 38 | -------------------------------------------------------------------------------- /astro3d/gui/textures.py: -------------------------------------------------------------------------------- 1 | """Get Texture info from user config""" 2 | from __future__ import (absolute_import, division, print_function, 3 | unicode_literals) 4 | 5 | from ..core.textures import * 6 | 7 | __all__ = ['TextureConfig'] 8 | 9 | 10 | class TextureConfig(object): 11 | """User Texture configuration""" 12 | 13 | def __init__(self, config): 14 | self.params = { 15 | 'texture_info': {}, 16 | 'textures': {}, 17 | 'texture_mappings': {}, 18 | 'catalog_textures': {} 19 | } 20 | params = self.params 21 | for section in params: 22 | params[section].update({ 23 | p: config.get(section, p) 24 | for p in config.options(section) 25 | }) 26 | 27 | # Acquire the region textures 28 | self.translate_texture = params['texture_mappings'] 29 | self.texture_order = params['texture_info']['texture_order'] 30 | self.textures = { 31 | name: eval(pars) 32 | for name, pars in params['textures'].items() 33 | } 34 | 35 | # Acquire the catalog textures 36 | self.catalog_textures = { 37 | name: eval(pars) 38 | for name, pars in params['catalog_textures'].items() 39 | } 40 | -------------------------------------------------------------------------------- /astro3d/util/logger.py: -------------------------------------------------------------------------------- 1 | """Logging convenience functions""" 2 | import logging 3 | 4 | 5 | # Default logging format 6 | DEFAULT_FORMAT = ( 7 | '%(levelname)s:' 8 | ' %(filename)s:%(lineno)d (%(funcName)s)' 9 | ' | %(message)s' 10 | ) 11 | 12 | 13 | def make_logger(name, level=logging.INFO, log_format=None): 14 | """Configure a basic logger 15 | 16 | Parameters 17 | ---------- 18 | name: str 19 | The name of the logger. Usually `__name__` from the caller. 20 | 21 | level: int 22 | The logging level of the logger 23 | 24 | log_format: str or None 25 | The logging format 26 | """ 27 | 28 | logger = logging.getLogger(name) 29 | logger.setLevel(level) 30 | 31 | log_format = log_format or DEFAULT_FORMAT 32 | formatter = logging.Formatter(log_format) 33 | handler = logging.StreamHandler() 34 | handler.setFormatter(formatter) 35 | logger.addHandler(handler) 36 | 37 | return logger 38 | 39 | 40 | def make_null_logger(name): 41 | """Create a null logger for the given name 42 | 43 | Parameters 44 | ---------- 45 | name: str 46 | The name of the logger. Usually `__name__` from the caller. 47 | 48 | """ 49 | logger = logging.getLogger(name) 50 | logger.addHandler(logging.NullHandler()) 51 | return logger 52 | -------------------------------------------------------------------------------- /astro3d/gui/qt/image_view.py: -------------------------------------------------------------------------------- 1 | from ginga.gw.Viewers import CanvasView 2 | from ginga.canvas.CanvasObject import get_canvas_types 3 | 4 | from ...util.logger import make_null_logger 5 | from ...core.region_mask import RegionMask 6 | 7 | # Configure logging 8 | logger = make_null_logger(__name__) 9 | 10 | __all__ = ['ImageView'] 11 | 12 | 13 | class ImageView(CanvasView): 14 | """The image view""" 15 | 16 | def __init__(self): 17 | super(ImageView, self).__init__(logger) 18 | 19 | # Enable the image viewing functions. 20 | self.enable_autocuts('on') 21 | self.set_autocut_params('zscale') 22 | self.enable_autozoom('on') 23 | self.set_zoom_algorithm('rate') 24 | self.set_zoomrate(1.4) 25 | self.set_bg(0.2, 0.2, 0.2) 26 | self.ui_set_active(True) 27 | 28 | bd = self.get_bindings() 29 | bd.enable_all(True) 30 | 31 | # Show the mode. 32 | dc = get_canvas_types() 33 | self.private_canvas.add(dc.ModeIndicator(corner='ur', fontsize=14)) 34 | bm = self.get_bindmap() 35 | bm.add_callback('mode-set', lambda *args: self.redraw(whence=3)) 36 | 37 | def get_shape_mask(self, mask_type, shape): 38 | """Return the RegionMask representing the shape""" 39 | data = self.get_image() 40 | shape_mask = data.get_shape_mask(shape) 41 | region_mask = RegionMask(shape_mask, mask_type) 42 | return region_mask 43 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2024, Association of Universities for Research in Astronomy (AURA) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Astro3d 2 | ======= 3 | 4 | Astro3d provides a GUI and engine to create a `3D model`_ from an 5 | astronomical image. The 3D model is saved in a `STL`_ file, which can 6 | then be sent to a 3D printer. 7 | 8 | .. _3D Model: https://en.wikipedia.org/wiki/3D_modeling 9 | .. _STL: https://en.wikipedia.org/wiki/STL_(file_format) 10 | 11 | 12 | Installation 13 | ============ 14 | 15 | Requirements 16 | ------------ 17 | 18 | Astro3d requires: 19 | 20 | * `Python `_ 3.7 or later 21 | 22 | * `Numpy `_ 1.17 or later 23 | 24 | * `Scipy `_ 25 | 26 | * `matplotlib `_ 27 | 28 | * `Astropy`_ 4.0 or later 29 | 30 | * `Pillow `_ 31 | 32 | * `PyQt5 `_ 33 | 34 | * `photutils `_ 35 | 36 | 37 | Obtaining the Source Package 38 | ---------------------------- 39 | 40 | The latest development version of ``astro3d`` can be cloned from 41 | github using this command:: 42 | 43 | git clone git@github.com:spacetelescope/astro3d.git 44 | 45 | 46 | Installing from the Source Package 47 | ---------------------------------- 48 | 49 | To install from the root of the source package:: 50 | 51 | pip install . 52 | 53 | 54 | Installing using pip 55 | -------------------- 56 | 57 | To install the current *development* version using `pip 58 | `_:: 59 | 60 | pip install git+https://github.com/spacetelescope/astro3d 61 | 62 | 63 | Using ``astro3d`` 64 | ================= 65 | 66 | .. toctree:: 67 | :maxdepth: 2 68 | 69 | high-level_API 70 | -------------------------------------------------------------------------------- /astro3d/gui/config.py: -------------------------------------------------------------------------------- 1 | """GUI global configuration 2 | """ 3 | from __future__ import print_function 4 | 5 | from ast import literal_eval 6 | 7 | from configparser import ConfigParser as SystemConfigParser 8 | from os.path import (abspath, dirname, expanduser) 9 | 10 | CONFIG_NAME = 'astro3d.cfg' 11 | 12 | __all__ = ['config'] 13 | 14 | 15 | class Config(SystemConfigParser): 16 | 17 | def __init__(self): 18 | SystemConfigParser.__init__(self) 19 | builtin_config = _default_config() 20 | home_config = expanduser('/'.join(['~', CONFIG_NAME])) 21 | 22 | # List of configuration files. 23 | # The most specific should be last in the list. 24 | config_sources = [ 25 | builtin_config, 26 | home_config, 27 | '/'.join(['.', CONFIG_NAME]), 28 | ] 29 | 30 | used = self.read(config_sources) 31 | if len(used) <= 1: 32 | self.save_config = home_config 33 | else: 34 | self.save_config = used[-1] 35 | 36 | def save(self): 37 | with open(self.save_config, 'w') as save_config: 38 | self.write(save_config) 39 | 40 | def get(self, *args, **kwargs): 41 | """Get option with guessing at value type""" 42 | value = SystemConfigParser.get(self, *args, **kwargs) 43 | try: 44 | evalue = literal_eval(value) 45 | except (SyntaxError, ValueError): 46 | evalue = value 47 | return evalue 48 | 49 | 50 | def _default_config(): 51 | # Strip off the '/gui' part. 52 | exec_dir = dirname(abspath(__file__))[:-4] 53 | exec_dir = '/'.join([exec_dir, 'data']) 54 | return '/'.join([exec_dir, CONFIG_NAME]) 55 | 56 | 57 | config = Config() 58 | -------------------------------------------------------------------------------- /astro3d/gui/qt/pyqt_nonblock.py: -------------------------------------------------------------------------------- 1 | '''Setup for non-blocking PyQt apps 2 | 3 | Routine Listings 4 | ---------------- 5 | There are two interfaces: 6 | pyqtapplication: Functional interface that returns the QApplication instance 7 | PyQtNonblock: Class that ensures the QApplication instance never goes out-of-scope 8 | 9 | Usage 10 | ----- 11 | The main class inherits PyQtNonblock, ensuring it is calling super().__init__ 12 | The module, if called as __main__, should, after instantiation of the main class, 13 | explicitly execute the QApplication with instance.qapplication.exec_() 14 | ''' 15 | 16 | 17 | class PyQtNonblock(object): 18 | '''Provide a standard interface for non-blocking Qt apps. 19 | 20 | Parameters 21 | ---------- 22 | argv: list 23 | The command line arguments to pass to QApplication 24 | 25 | Attributes 26 | ---------- 27 | qapplication: QApplication (class attribute only) 28 | The instance of the QApplication 29 | ''' 30 | 31 | qapplication = None 32 | 33 | def __init__(self, argv=None): 34 | self.__class__.qapplication = pyqtapplication(argv) 35 | 36 | 37 | def pyqtapplication(argv=None): 38 | '''Return the current QApplication instance. If there is not one, one will be started 39 | 40 | Parameters 41 | ---------- 42 | argv: list 43 | The command line arguments to pass to QApplication 44 | 45 | Returns 46 | ------- 47 | QApplication: 48 | The current instance QApplication instance. If None, something 49 | didn't work 50 | ''' 51 | from qtpy import QtWidgets 52 | 53 | qapplication = QtWidgets.QApplication.instance() 54 | if not qapplication: 55 | argv = argv if argv else [] 56 | qapplication = QtWidgets.QApplication(argv) 57 | return qapplication 58 | -------------------------------------------------------------------------------- /astro3d/gui/qt/util.py: -------------------------------------------------------------------------------- 1 | """Qt Utilities""" 2 | 3 | from functools import (partial, wraps) 4 | 5 | from qtpy import QtCore 6 | 7 | # Shortcuts 8 | QTimer = QtCore.QTimer 9 | 10 | 11 | class DecorMethod(object): 12 | def __init__(self, decor, instance): 13 | self.decor = decor 14 | self.instance = instance 15 | 16 | def __call__(self, *args, **kw): 17 | return self.decor(self.instance, *args, **kw) 18 | 19 | def __getattr__(self, name): 20 | return getattr(self.decor, name) 21 | 22 | def __repr__(self): 23 | return ''.format(self.decor, type(self)) 24 | 25 | 26 | def event_deferred(func): 27 | timer = QTimer() 28 | timer.setSingleShot(True) 29 | f_args = () 30 | f_kwargs = {} 31 | f_func = func 32 | 33 | def _exec(): 34 | global f_args, f_kwargs 35 | f_func(*f_args, **f_kwargs) 36 | 37 | @wraps(func) 38 | def wrapper(*args, **kwargs): 39 | global f_args, f_kwargs 40 | timer.stop 41 | f_args = args 42 | f_kwargs = kwargs 43 | timer.start(0) 44 | 45 | timer.timeout.connect(_exec) 46 | 47 | return wrapper 48 | 49 | 50 | class EventDeferred(QTimer): 51 | """Defer execution until no events""" 52 | def __init__(self, func): 53 | super(EventDeferred, self).__init__() 54 | 55 | self.setSingleShot(True) 56 | self.timeout.connect(self._exec) 57 | self.func = func 58 | 59 | def __get__(self, instance, owner): 60 | if instance is None: 61 | return self 62 | return DecorMethod(self, instance) 63 | 64 | def __call__(self, instance, *args, **kwargs): 65 | self.stop() 66 | self.instance = instance 67 | self.args = args 68 | self.kwargs = kwargs 69 | self.start(0) 70 | 71 | def _exec(self): 72 | partial(self.func, self.instance, *self.args, **self.kwargs)() 73 | -------------------------------------------------------------------------------- /astro3d/gui/helps.py: -------------------------------------------------------------------------------- 1 | """Helper texts""" 2 | 3 | from collections import defaultdict 4 | 5 | __all__ = ['instructions'] 6 | 7 | INSTRUCTIONS_DEFAULT = ( 8 | 'Use File menu to read in the image and various region' 9 | ' definitions which define what to render in 3D.\n' 10 | '\n' 11 | 'Use View-Mesh View to show/hide the 3D renderered view.\n' 12 | '\n' 13 | 'Stages toggle the various processing stages.\n' 14 | '\n' 15 | 'By default, processing occurs automatically.' 16 | ' This can be toggled on/off through Preferences->Auto Reprocess\n' 17 | '\n' 18 | 'When the mouse is on the image, type "H" for a cheat sheat on' 19 | ' shortcut keys to change the display.\n' 20 | '\n' 21 | 'To create new regions, right-click on "Regions".' 22 | ' To delete, hide, or edit existing regions,' 23 | ' click on or right-click on the desired region.\n' 24 | '\n' 25 | 'When complete, use File->Save to save all regions and the' 26 | ' STL model files.' 27 | ) 28 | 29 | INSTRUCTIONS_DRAW = ( 30 | 'Select the shape desired.\n' 31 | '\n' 32 | 'To create/modify a pixel mask, select "paint".' 33 | ) 34 | 35 | INSTRUCTIONS_EDIT = ( 36 | 'Click-drag to move the region.\n' 37 | '\n' 38 | 'Click-drag any of the "control points" to' 39 | ' to change the size or shape of the region.\n' 40 | '\n' 41 | 'Enter an angle and hit RETURN to change region rotation.\n' 42 | '\n' 43 | 'Enter a scale factor and hit RETURN to change region size.' 44 | ) 45 | 46 | INSTRUCTIONS_PAINT = ( 47 | 'Click-drag to either add to or erase a mask.\n' 48 | '\n' 49 | 'Choose whether you are "Paint"ing or "Erase"ing.\n' 50 | '\n' 51 | 'Brush size determines how much area is affected while painting.' 52 | ) 53 | 54 | instructions = defaultdict( 55 | lambda: INSTRUCTIONS_DEFAULT, 56 | { 57 | 'draw': INSTRUCTIONS_DRAW, 58 | 'edit': INSTRUCTIONS_EDIT, 59 | 'edit_select': INSTRUCTIONS_EDIT, 60 | 'paint': INSTRUCTIONS_PAINT, 61 | 'paint_edit': INSTRUCTIONS_PAINT, 62 | } 63 | ) 64 | -------------------------------------------------------------------------------- /astro3d/gui/signals.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import six 3 | import warnings 4 | 5 | from ..util import signal_slot 6 | from ..util.register_leaf_classes import (RegisterLeafClasses) 7 | 8 | @six.add_metaclass(RegisterLeafClasses) 9 | class Signal(signal_slot.Signal): 10 | """astro3d signals""" 11 | 12 | 13 | class Signals(signal_slot.Signals): 14 | '''The signal container that allows autoregistring of a 15 | set of predefined signals. 16 | ''' 17 | def __init__(self, signal_class=Signal): 18 | super(Signals, self).__init__() 19 | if signal_class is not None: 20 | with warnings.catch_warnings(): 21 | warnings.simplefilter('ignore') 22 | for signal in signal_class: 23 | self.add(signal) 24 | 25 | 26 | # Specific Signal Definitions 27 | # Signals can proliferate without bound. 28 | # So, define all possible signals here. 29 | 30 | # General application events 31 | class Quit(Signal): 32 | """Quit the application""" 33 | 34 | 35 | class ProcessStart(Signal): 36 | """Process new mesh""" 37 | 38 | 39 | class ProcessFinish(Signal): 40 | """Mesh processing complete""" 41 | 42 | 43 | class ProcessForceQuit(Signal): 44 | """Force quit mesh processing""" 45 | 46 | 47 | class ProcessFail(Signal): 48 | """Process has failed, expect no result""" 49 | 50 | # Data Manipulation 51 | class CreateGasSpiralMasks(Signal): 52 | """Auto-create gas and spiral masks""" 53 | 54 | 55 | class ModelUpdate(Signal): 56 | """Update mesh model""" 57 | 58 | 59 | class NewImage(Signal): 60 | """New Image is available""" 61 | 62 | 63 | class OpenFile(Signal): 64 | """Open a new data file""" 65 | 66 | 67 | class StageChange(Signal): 68 | """A stage has changed state""" 69 | 70 | 71 | # GUI events 72 | class CatalogFromFile(Signal): 73 | """Initiate a catalog from file read""" 74 | 75 | 76 | class LayerSelected(Signal): 77 | """Layers have been selected/deselected""" 78 | 79 | 80 | class NewRegion(Signal): 81 | """New region being created""" 82 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = astro3d 3 | author = STScI 4 | url = https://github.com/spacetelescope/astro3d 5 | github_project = spacetelescope/astro3d 6 | edit_on_github = False 7 | description = Create a 3D model from an astronomical image. 8 | long_description = file: README.rst 9 | long_description_content_type = text/x-rst 10 | keywords = astronomy, astrophysics, 3D printing 11 | 12 | [options] 13 | zip_safe = False 14 | packages = find: 15 | python_requires = >=3.7 16 | setup_requires = setuptools_scm 17 | install_requires = 18 | astropy>=4.0 19 | attrdict3 20 | ginga 21 | numpy>=1.17 22 | photutils 23 | Pillow 24 | pyqt5 25 | PyYAML 26 | qtpy 27 | scipy 28 | six 29 | vispy 30 | 31 | [options.entry_points] 32 | console_scripts = 33 | astro3d = astro3d.app:main 34 | 35 | [options.extras_require] 36 | test = 37 | pytest-astropy 38 | docs = 39 | sphinx-astropy 40 | 41 | [options.package_data] 42 | astro3d = data/* 43 | 44 | [tool:pytest] 45 | testpaths = "astro3d" "docs" 46 | astropy_header = true 47 | # doctest_plus = enabled 48 | text_file_format = rst 49 | addopts = --doctest-rst 50 | 51 | [coverage:run] 52 | omit = 53 | astro3d/_astropy_init* 54 | astro3d/conftest.py 55 | astro3d/*setup_package* 56 | astro3d/tests/* 57 | astro3d/*/tests/* 58 | astro3d/extern/* 59 | astro3d/version* 60 | */astro3d/_astropy_init* 61 | */astro3d/conftest.py 62 | */astro3d/*setup_package* 63 | */astro3d/tests/* 64 | */astro3d/*/tests/* 65 | */astro3d/extern/* 66 | */astro3d/version* 67 | 68 | [coverage:report] 69 | exclude_lines = 70 | # Have to re-enable the standard pragma 71 | pragma: no cover 72 | # Don't complain about packages we have installed 73 | except ImportError 74 | # Don't complain if tests don't hit assertions 75 | raise AssertionError 76 | raise NotImplementedError 77 | # Don't complain about script hooks 78 | def main\(.*\): 79 | # Ignore branches that don't pertain to this version of Python 80 | pragma: py{ignore_python_version} 81 | # Don't complain about IPython completion helper 82 | def _ipython_key_completions_ 83 | -------------------------------------------------------------------------------- /astro3d/data/astro3d.cfg: -------------------------------------------------------------------------------- 1 | # astro3d configuration 2 | [stages] 3 | intensity = True 4 | textures = True 5 | spiral_galaxy = True 6 | double_sided = True 7 | 8 | [model] 9 | image_size = 1000 10 | mm_per_pixel = 0.24224 11 | 12 | [model_make] 13 | compress_bulge = True 14 | compress_bulge_factor = 0.05 15 | compress_bulge_percentile = 0. 16 | crop_data_pad_width = 20 17 | crop_data_threshold = 0. 18 | intensity_height = 27.5 19 | minvalue_to_zero = 0.02 20 | model_base_fill_holes = True 21 | model_base_filter_size = 10 22 | model_base_height = 5. 23 | model_base_min_thickness = 0.5 24 | smooth_size1 = 11 25 | smooth_size2 = 15 26 | star_radius_a = 10. 27 | star_radius_b = 5. 28 | cluster_radius_a = 10. 29 | cluster_radius_b = 5. 30 | star_texture_depth = 3. 31 | suppress_background_factor = 0.2 32 | suppress_background_percentile = 90. 33 | split_model = True 34 | split_model_axis = 0 35 | 36 | [gui] 37 | folder_image = ./ 38 | folder_regions = ./ 39 | folder_textures = ./ 40 | folder_save = ./ 41 | 42 | [texture_info] 43 | texture_order = ['small_dots', 'dots', 'lines'] 44 | 45 | [textures] 46 | small_dots = { 47 | 'model': DotsTexture( 48 | profile='spherical', 49 | diameter=9.0, 50 | height=4.0, 51 | grid=HexagonalGrid(spacing=7.0) 52 | ), 53 | 'color': '#cc00cc' 54 | } 55 | dots = { 56 | 'model': DotsTexture( 57 | profile='spherical', 58 | diameter=9.0, 59 | height=4.0, 60 | grid=HexagonalGrid(spacing=11.0) 61 | ), 62 | 'color': '#6600ff' 63 | } 64 | lines = { 65 | 'model': LinesTexture( 66 | profile='linear', 67 | thickness=13, 68 | height=7.8, 69 | spacing=20, 70 | orientation=0 71 | ), 72 | 'color': '#ff3399' 73 | } 74 | 75 | [texture_mappings] 76 | small_dots = ['gas'] 77 | dots = ['spiral', 'dust'] 78 | lines = ['bulge', 'disk', 'filament'] 79 | 80 | [catalog_textures] 81 | central_star = { 82 | 'model': InvertedStarTexture, 83 | 'color': '#9900cc', 84 | 'radius_a': 10, 85 | 'radius_b': 5, 86 | 'depth': 5, 87 | 'slope': 1.0 88 | } 89 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 3 | 4 | # NOTE: The configuration for the package, including the name, version, and 5 | # other information are set in the setup.cfg file. 6 | 7 | import os 8 | import sys 9 | 10 | from setuptools import setup 11 | 12 | 13 | # First provide helpful messages if contributors try and run legacy commands 14 | # for tests or docs. 15 | 16 | TEST_HELP = """ 17 | Note: running tests is no longer done using 'python setup.py test'. Instead 18 | you will need to run: 19 | 20 | tox -e test 21 | 22 | If you don't already have tox installed, you can install it with: 23 | 24 | pip install tox 25 | 26 | If you only want to run part of the test suite, you can also use pytest 27 | directly with:: 28 | 29 | pip install -e .[test] 30 | pytest 31 | 32 | For more information, see: 33 | 34 | http://docs.astropy.org/en/latest/development/testguide.html#running-tests 35 | """ 36 | 37 | if 'test' in sys.argv: 38 | print(TEST_HELP) 39 | sys.exit(1) 40 | 41 | DOCS_HELP = """ 42 | Note: building the documentation is no longer done using 43 | 'python setup.py build_docs'. Instead you will need to run: 44 | 45 | tox -e build_docs 46 | 47 | If you don't already have tox installed, you can install it with: 48 | 49 | pip install tox 50 | 51 | You can also build the documentation with Sphinx directly using:: 52 | 53 | pip install -e .[docs] 54 | cd docs 55 | make html 56 | 57 | For more information, see: 58 | 59 | http://docs.astropy.org/en/latest/install.html#builddocs 60 | """ 61 | 62 | if 'build_docs' in sys.argv or 'build_sphinx' in sys.argv: 63 | print(DOCS_HELP) 64 | sys.exit(1) 65 | 66 | VERSION_TEMPLATE = """ 67 | # Note that we need to fall back to the hard-coded version if either 68 | # setuptools_scm can't be imported or setuptools_scm can't determine the 69 | # version, so we catch the generic 'Exception'. 70 | try: 71 | from setuptools_scm import get_version 72 | version = get_version(root='..', relative_to=__file__) 73 | except Exception: 74 | version = '{version}' 75 | """.lstrip() 76 | 77 | setup(use_scm_version={'write_to': os.path.join('astro3d', 'version.py'), 78 | 'write_to_template': VERSION_TEMPLATE}) 79 | -------------------------------------------------------------------------------- /astro3d/conftest.py: -------------------------------------------------------------------------------- 1 | # This file is used to configure the behavior of pytest when using the Astropy 2 | # test infrastructure. It needs to live inside the package in order for it to 3 | # get picked up when running the tests inside an interpreter using 4 | # packagename.test 5 | 6 | import os 7 | 8 | try: 9 | from pytest_astropy_header.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS 10 | ASTROPY_HEADER = True 11 | except ImportError: 12 | ASTROPY_HEADER = False 13 | 14 | 15 | def pytest_configure(config): 16 | if ASTROPY_HEADER: 17 | config.option.astropy_header = True 18 | 19 | # Customize the following lines to add/remove entries from the 20 | # list of packages for which version numbers are displayed when 21 | # running the tests. 22 | PYTEST_HEADER_MODULES['Cython'] = 'Cython' # noqa 23 | PYTEST_HEADER_MODULES['Numpy'] = 'numpy' # noqa 24 | PYTEST_HEADER_MODULES['Astropy'] = 'astropy' # noqa 25 | PYTEST_HEADER_MODULES['Scipy'] = 'scipy' # noqa 26 | PYTEST_HEADER_MODULES['Matplotlib'] = 'matplotlib' # noqa 27 | PYTEST_HEADER_MODULES['scikit-image'] = 'skimage' # noqa 28 | PYTEST_HEADER_MODULES['scikit-learn'] = 'sklearn' # noqa 29 | PYTEST_HEADER_MODULES.pop('Pandas', None) # noqa 30 | PYTEST_HEADER_MODULES.pop('h5py', None) # noqa 31 | 32 | from . import __version__ 33 | packagename = os.path.basename(os.path.dirname(__file__)) 34 | TESTED_VERSIONS[packagename] = __version__ 35 | 36 | # Uncomment the last two lines in this block to treat all DeprecationWarnings as 37 | # exceptions. For Astropy v2.0 or later, there are 2 additional keywords, 38 | # as follow (although default should work for most cases). 39 | # To ignore some packages that produce deprecation warnings on import 40 | # (in addition to 'compiler', 'scipy', 'pygments', 'ipykernel', and 41 | # 'setuptools'), add: 42 | # modules_to_ignore_on_import=['module_1', 'module_2'] 43 | # To ignore some specific deprecation warning messages for Python version 44 | # MAJOR.MINOR or later, add: 45 | # warnings_to_ignore_by_pyver={(MAJOR, MINOR): ['Message to ignore']} 46 | from astropy.tests.helper import enable_deprecations_as_exceptions # noqa 47 | enable_deprecations_as_exceptions() 48 | -------------------------------------------------------------------------------- /.github/workflows/ci_tests.yml: -------------------------------------------------------------------------------- 1 | name: CI Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | schedule: 11 | # run every Monday at 6am UTC 12 | - cron: '0 6 * * 1' 13 | workflow_dispatch: 14 | 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | env: 21 | TOXARGS: '-v' 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | ci-tests: 28 | name: ${{ matrix.os }}, ${{ matrix.tox_env }} 29 | runs-on: ${{ matrix.os }} 30 | strategy: 31 | matrix: 32 | include: 33 | - os: ubuntu-latest 34 | python: '3.7' 35 | tox_env: 'py37-test-alldeps' 36 | - os: ubuntu-latest 37 | python: '3.8' 38 | tox_env: 'py38-test-alldeps' 39 | toxposargs: --remote-data=any 40 | - os: ubuntu-latest 41 | python: '3.9' 42 | tox_env: 'py39-test-alldeps' 43 | - os: macos-latest 44 | python: '3.9' 45 | tox_env: 'py39-test-alldeps' 46 | - os: windows-latest 47 | python: '3.9' 48 | tox_env: 'py39-test-alldeps' 49 | - os: ubuntu-latest 50 | python: '3.9' 51 | tox_env: 'py39-test' 52 | - os: ubuntu-latest 53 | python: '3.7' 54 | tox_env: 'py37-test-alldeps-astropylts-numpy118' 55 | 56 | steps: 57 | - name: Check out repository 58 | uses: actions/checkout@v3 59 | with: 60 | fetch-depth: 0 61 | - name: Set up Python ${{ matrix.python }} 62 | uses: actions/setup-python@v4 63 | with: 64 | python-version: ${{ matrix.python }} 65 | - name: Install base dependencies 66 | run: | 67 | python -m pip install --upgrade pip 68 | python -m pip install tox 69 | - name: Print Python, pip, setuptools, and tox versions 70 | run: | 71 | python -c "import sys; print(f'Python {sys.version}')" 72 | python -c "import pip; print(f'pip {pip.__version__}')" 73 | python -c "import setuptools; print(f'setuptools {setuptools.__version__}')" 74 | python -c "import tox; print(f'tox {tox.__version__}')" 75 | - name: Run tests 76 | run: tox -e ${{ matrix.tox_env }} -- ${{ matrix.toxposargs }} 77 | -------------------------------------------------------------------------------- /astro3d/data/text_catalog.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Informational text catalog 3 | # 4 | 5 | # Instructions 6 | instructions_default: | 7 |
8 |

Basic Work Flow

9 |

Use File menu to read in the image and various region definitions which define what to render in 3D.

10 | 11 |

Use View-Mesh View to show/hide the 3D renderered view.

12 | 13 |

Stages toggle the various processing stages.

14 | 15 |

By default, processing occurs automatically. This can be toggled on/off through Preferences->Auto Reprocess

16 | 17 |

There are a number of basic image manipulation key commands available. The image display is based on ginga. 18 | The key commands can be found in the ginga documentation 19 | 20 |

To create new regions, right-click on "Regions". 21 | To delete, hide, or edit existing regions, click on or right-click on the desired region. 22 |

23 | 24 |

When complete, use File->Save to save all regions and the STL model files.

25 |
26 | # 27 | # Shape editor 28 | # 29 | shape_editor: 30 | default: | 31 | To create new regions, right-click on "Regions". 32 | 33 | To delete, hide, or edit existing regions, click on or right-click on the desired region. 34 | draw: | 35 | Select the shape desired. 36 | 37 | To create/modify a pixel mask, select "paint". 38 | edit: | 39 | Click-drag to move the region. 40 | 41 | Click-drag any of the "control points" to to change the size or shape of the region. 42 | 43 | Enter an angle and hit RETURN to change region rotation. 44 | 45 | Enter a scale factor and hit RETURN to change region size. 46 | edit_select: | 47 | Click-drag to move the region. 48 | 49 | Click-drag any of the "control points" to change the size or shape of the region. 50 | 51 | Enter an angle and hit RETURN to change region rotation. 52 | 53 | Enter a scale factor and hit RETURN to change region size. 54 | paint: | 55 | Click-drag to either add to or erase a mask. 56 | 57 | Choose whether you are "Paint"ing or "Erase"ing. 58 | 59 | Brush size determines how much area is affected while painting. 60 | paint_edit: | 61 | Click-drag to either add to or erase a mask. 62 | 63 | Choose whether you are "Paint"ing or "Erase"ing. 64 | 65 | Brush size determines how much area is affected while painting. 66 | -------------------------------------------------------------------------------- /astro3d/core/texture_examples.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides tools to produce texture samples. 3 | """ 4 | from __future__ import (absolute_import, division, print_function, 5 | unicode_literals) 6 | import numpy as np 7 | from astropy.io import fits 8 | from astropy import log 9 | from . import textures 10 | 11 | 12 | __doctest_skip__ = ['textures_to_jpeg'] 13 | 14 | 15 | def save_image(data, filename): 16 | """ 17 | Save an image to a bitmap (e.g. TIFF, JPEG, PNG) or a FITS file. 18 | 19 | The type of file is determined by the filename suffix. 20 | 21 | Parameters 22 | ---------- 23 | data : `~numpy.ndarray` 24 | The data array to save. 25 | 26 | filename : str 27 | The output filename. 28 | """ 29 | 30 | if isinstance(data, np.ma.core.MaskedArray): 31 | data = data.data 32 | 33 | suffix = filename.split('.')[-1].lower() 34 | if suffix in ('fit', 'fits'): 35 | fits.writeto(filename, data, clobber=True) 36 | else: 37 | from scipy.misc import imsave # uses PIL/Pillow 38 | imsave(filename, np.flipud(data)) # flip to make origin lower-left 39 | log.info('Saved {0}.'.format(filename)) 40 | 41 | 42 | def textures_to_jpeg(): 43 | """ 44 | Generate some textures and save them to JPEG images. 45 | 46 | Examples 47 | -------- 48 | >>> from astro3d import texture_examples 49 | >>> texture_examples.textures_to_jpeg() 50 | """ 51 | 52 | shape = (200, 200) 53 | size = [15, 10, 6, 3] # line thickness or dot diameter 54 | spacing = [25, 15, 10, 5] # line spacing or dot grid spacing 55 | 56 | for sz, sp in zip(size, spacing): 57 | log.info('{0} {1}'.format(sz, sp)) 58 | for profile in ['spherical', 'linear']: 59 | log.info('\t{0}'.format(profile)) 60 | 61 | lim = textures.lines_texture(shape, profile, sz, 1., sp, 62 | orientation=0.) 63 | fn = ('lines_{0}_thickness{1}_spacing{2}' 64 | '.jpg'.format(profile, sz, sp)) 65 | save_image(lim, fn) 66 | 67 | rlim = lim.transpose() 68 | lim[rlim > lim] = rlim[rlim > lim] 69 | fn = ('hatch_{0}_thickness{1}_spacing{2}' 70 | '.jpg'.format(profile, sz, sp)) 71 | save_image(lim, fn) 72 | 73 | sdim = textures.dots_texture(shape, profile, sz, 1., 74 | textures.square_grid(shape, sp)) 75 | fn = ('dots_squaregrid_{0}_diameter{1}_spacing{2}' 76 | '.jpg'.format(profile, sz, sp)) 77 | save_image(sdim, fn) 78 | 79 | hdim = textures.dots_texture(shape, profile, sz, 1, 80 | textures.hexagonal_grid(shape, sp)) 81 | fn = ('dots_hexagonalgrid_{0}_diameter{1}_spacing{2}' 82 | '.jpg'.format(profile, sz, sp)) 83 | save_image(hdim, fn) 84 | -------------------------------------------------------------------------------- /astro3d/gui/qt/layer_manager.py: -------------------------------------------------------------------------------- 1 | """Layer Manager""" 2 | 3 | from qtpy import QtWidgets 4 | 5 | from ...util.logger import make_null_logger 6 | from .items import LayerItem, Action 7 | from .. import signaldb 8 | 9 | # Configure logging 10 | logger = make_null_logger(__name__) 11 | 12 | __all__ = ['LayerManager'] 13 | 14 | 15 | class LayerManager(QtWidgets.QTreeView): 16 | """Manager the various layers""" 17 | 18 | def __init__(self, *args, **kwargs): 19 | super(LayerManager, self).__init__(*args, **kwargs) 20 | self.setHeaderHidden(True) 21 | 22 | def selectionChanged(self, selected, deselected): 23 | """QT builtin slot called when a selection is changed""" 24 | 25 | def get_selected_item(itemselection): 26 | try: 27 | index = itemselection.indexes()[0] 28 | item = self.model().itemFromIndex(index) 29 | if not item.is_available: 30 | item = None 31 | except IndexError: 32 | item = None 33 | return item 34 | 35 | selected_layer = get_selected_item(selected) 36 | deselected_layer = get_selected_item(deselected) 37 | logger.debug('selected="{}" deselected="{}"'.format( 38 | selected_layer, 39 | deselected_layer 40 | )) 41 | signaldb.LayerSelected( 42 | selected_item=selected_layer, 43 | deselected_item=deselected_layer, 44 | source='layermanager') 45 | 46 | def select_from_object(self, 47 | selected_item=None, 48 | deselected_item=None, 49 | source=None): 50 | # If from the layer manager, there is nothing that need be 51 | # done. 52 | if source == 'layermanager': 53 | return 54 | 55 | try: 56 | self.setCurrentIndex(selected_item.index()) 57 | except AttributeError: 58 | """IF cannot select, doesn't matter""" 59 | pass 60 | 61 | def contextMenuEvent(self, event): 62 | logger.debug('event = "{}"'.format(event)) 63 | 64 | indexes = self.selectedIndexes() 65 | if len(indexes) > 0: 66 | index = indexes[0] 67 | item = self.model().itemFromIndex(index) 68 | menu = QtWidgets.QMenu() 69 | for action_def in item._actions: 70 | if isinstance(action_def, Action): 71 | action = menu.addAction(action_def.text) 72 | action.setData(action_def) 73 | else: 74 | menu.addAction(action_def) 75 | 76 | taken = menu.exec_(event.globalPos()) 77 | if taken: 78 | logger.debug( 79 | 'taken action = "{}", data="{}"'.format( 80 | taken, 81 | taken.data() 82 | ) 83 | ) 84 | action_def = taken.data() 85 | action_def.func(*action_def.args) 86 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{37,38,39}-test{,-alldeps,-devdeps}{,-cov} 4 | py{37,38,39}-test-numpy{117,118,119,120} 5 | py{37,38,39}-test-astropy{40,lts} 6 | build_docs 7 | linkcheck 8 | codestyle 9 | requires = 10 | setuptools >= 30.3.0 11 | pip >= 19.3.1 12 | isolated_build = true 13 | 14 | [testenv] 15 | # Suppress display of matplotlib plots generated during docs build 16 | setenv = 17 | MPLBACKEND=agg 18 | devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/scientific-python-nightly-wheels/simple 19 | 20 | # Pass through the following environment variables which may be needed 21 | # for the CI 22 | passenv = HOME,WINDIR,LC_ALL,LC_CTYPE,CC,CI 23 | 24 | # Run the tests in a temporary directory to make sure that we don't import 25 | # this package from the source tree 26 | changedir = .tmp/{envname} 27 | 28 | # tox environments are constructed with so-called 'factors' (or terms) 29 | # separated by hyphens, e.g., test-devdeps-cov. Lines below starting 30 | # with factor: will only take effect if that factor is included in the 31 | # environment name. To see a list of example environments that can be run, 32 | # along with a description, run: 33 | # 34 | # tox -l -v 35 | # 36 | description = 37 | run tests 38 | alldeps: with all optional dependencies 39 | devdeps: with the latest developer version of key dependencies 40 | oldestdeps: with the oldest supported version of key dependencies 41 | cov: and test coverage 42 | numpy116: with numpy 1.16.* 43 | numpy117: with numpy 1.17.* 44 | numpy118: with numpy 1.18.* 45 | numpy119: with numpy 1.19.* 46 | numpy120: with numpy 1.20.* 47 | astropy40: with astropy 4.0.* 48 | astropylts: with the latest astropy LTS 49 | 50 | # The following provides some specific pinnings for key packages 51 | deps = 52 | numpy117: numpy==1.17.* 53 | numpy118: numpy==1.18.* 54 | numpy119: numpy==1.19.* 55 | numpy120: numpy==1.20.* 56 | 57 | astropy40: astropy==4.0.* 58 | astropylts: astropy==4.0.* 59 | 60 | devdeps: numpy>=0.0.dev0 61 | 62 | # The following indicates which extras_require from setup.cfg will be installed 63 | extras = 64 | test 65 | alldeps: all 66 | 67 | commands = 68 | devdeps: pip install -U -i https://pypi.anaconda.org/astropy/simple astropy --pre 69 | pip freeze 70 | 71 | pytest --pyargs astro3d {toxinidir}/docs \ 72 | cov: --cov astro3d --cov-config={toxinidir}/setup.cfg --cov-report xml:{toxinidir}/coverage.xml \ 73 | {posargs} 74 | 75 | [testenv:build_docs] 76 | changedir = docs 77 | description = invoke sphinx-build to build the HTML docs 78 | extras = docs 79 | commands = 80 | pip freeze 81 | sphinx-build -W -b html . _build/html 82 | 83 | [testenv:linkcheck] 84 | changedir = docs 85 | description = check the links in the HTML docs 86 | extras = docs 87 | commands = 88 | pip freeze 89 | sphinx-build -W -b linkcheck . _build/html 90 | 91 | [testenv:codestyle] 92 | skip_install = true 93 | changedir = . 94 | description = check code style with flake8 95 | deps = flake8 96 | commands = flake8 astro3d --count --max-line-length=100 97 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Astro3d 2 | ======= 3 | 4 | Astro3d provides a GUI and engine to create a 3D model from an 5 | astronomical image. The 3D model is saved in a `STL`_ file, which can 6 | then be sent to a 3D printer. 7 | 8 | .. _STL: https://en.wikipedia.org/wiki/STL_(file_format) 9 | 10 | Requirements 11 | ------------ 12 | - Python 3 13 | - Qt5 14 | 15 | Installation 16 | ------------ 17 | 18 | Start a terminal window 19 | ^^^^^^^^^^^^^^^^^^^^^^^ 20 | 21 | In whatever system you have, start a terminal window that will get you 22 | to the command line. 23 | 24 | Remember, you need to be in the ``bash`` shell. If not sure, start 25 | ``bash``:: 26 | 27 | $ bash 28 | 29 | Install Anaconda 30 | ^^^^^^^^^^^^^^^^ 31 | If you have never installed anaconda before, or want to start fresh, 32 | this is the place to start. First, remove any previous instance of 33 | anaconda:: 34 | 35 | $ cd 36 | $ rm -rf anaconda3 37 | 38 | Go to the Anaconda download site, https://www.anaconda.com/download/, 39 | and download the command line installer appropriate for your system. 40 | Choose the Python3.x version. 41 | 42 | After downloading, perform the installation:: 43 | 44 | $ cd 45 | $ bash 46 | 47 | Accept all the defaults for any questions asked. 48 | 49 | Now, quit the terminal window you were in and start another one. 50 | Remember to start `bash` if necessary. 51 | 52 | Create environment 53 | ^^^^^^^^^^^^^^^^^^ 54 | 55 | Now we can create the ``astro3d`` Python environment. We will use the 56 | `stenv` environment, which is a conda environment that contains all the 57 | necessary packages for ``astro3d``. 58 | 59 | Create the ``astro3d`` conda environment:: 60 | 61 | $ conda env create -n astro3d -f https://raw.githubusercontent.com/spacetelescope/stenv/main/environment.yaml 62 | 63 | The ``astro3d`` environment is now ready. To activate, do the following:: 64 | 65 | $ conda activate astro3d 66 | 67 | Always Do 68 | ^^^^^^^^^ 69 | 70 | At this point, the environment is setup. From now on, always make sure 71 | you are running ``bash`` and in the ``astro3d`` environment whenever you 72 | need to start a new terminal window:: 73 | 74 | $ bash 75 | $ conda activate astro3d 76 | 77 | ``astro3d`` code install 78 | ^^^^^^^^^^^^^^^^^^^^^^^^ 79 | 80 | If one has never installed ``astro3d`` or one wishes to get a fresh 81 | install, do the following. Remember to start bash and activate the 82 | environment before doing this:: 83 | 84 | $ cd 85 | $ rm -rf astro3d 86 | $ git clone git@github.com:spacetelescope/astro3d.git 87 | $ cd astro3d 88 | $ pip install . 89 | 90 | The application should now be runnable. Note, you can run the 91 | application from whatever directory you wish:: 92 | 93 | $ astro3d 94 | 95 | Updating astro3d 96 | ^^^^^^^^^^^^^^^^ 97 | 98 | To update the code, do the following. Remember to start bash and 99 | activate the environment:: 100 | 101 | $ cd 102 | $ cd astro3d 103 | $ git pull 104 | $ pip install . 105 | 106 | This should do it. 107 | -------------------------------------------------------------------------------- /astro3d/gui/util.py: -------------------------------------------------------------------------------- 1 | """General GUI utilities""" 2 | from __future__ import print_function 3 | 4 | from ast import literal_eval 5 | 6 | from ginga.gw.Widgets import build_info 7 | 8 | from . import signaldb 9 | 10 | 11 | __all__ = ['build_widgets'] 12 | 13 | 14 | def build_widgets(store, callback=None, extra=None): 15 | """Build Ginga widgets from simple captions 16 | 17 | Based off of ginga.gw.Widgets.build_info, 18 | adds to extdata the following: 19 | 'store': The given dict 20 | 'index': The index into the store 21 | 22 | Parameters 23 | ---------- 24 | store: dict-like 25 | The dictionary of items to create widgets from. 26 | 27 | callback: callable 28 | The callback to attach to the widgets. 29 | If None, a built in callback is used which 30 | takes the new value and places it back 31 | into the store. 32 | 33 | extra: (name, gui_type, ...) 34 | Extra widgets defined in the caption 35 | format. No special processing is 36 | done beyond the creation of the widgets. 37 | 38 | Returns 39 | ------- 40 | widget: The Ginga widget 41 | The Ginga widget to use in GUI construction 42 | 43 | bunch: ginga.misc.Bunch.bunch 44 | The dict of all the individual widgets. 45 | """ 46 | if callback is None: 47 | callback = value_update 48 | if extra is None: 49 | extra = [] 50 | 51 | captions = [] 52 | captions_notbool = [] 53 | for idx in store: 54 | value = store[idx] 55 | if isinstance(value, bool): 56 | captions.append((idx, 'checkbutton')) 57 | else: 58 | captions_notbool.append(( 59 | idx, 'label', 60 | idx, 'entryset' 61 | )) 62 | captions = captions + captions_notbool + extra 63 | container, widget_list = build_info(captions) 64 | for idx in store: 65 | value = store[idx] 66 | widget = widget_list[idx] 67 | widget.extdata.update({ 68 | 'store': store, 69 | 'index': idx 70 | }) 71 | if isinstance(value, bool): 72 | widget.set_state(value) 73 | else: 74 | widget.set_text(str(value)) 75 | widget.add_callback( 76 | 'activated', 77 | callback 78 | ) 79 | 80 | return container, widget_list 81 | 82 | 83 | def value_update(widget, *args, **kwargs): 84 | """Update the internal store from the widget 85 | 86 | Parameters 87 | ---------- 88 | widget: GUI widget 89 | The widget that initiated the callback. 90 | 91 | args, kwargs: 0 or more objects 92 | Depending on the widget, there may be extra 93 | information generated. 94 | """ 95 | get_value_funcs = [ 96 | lambda: args[0], 97 | lambda: literal_eval(widget.get_text()), 98 | lambda: widget.get_text() 99 | ] 100 | idx = widget.extdata['index'] 101 | store = widget.extdata['store'] 102 | for get_value in get_value_funcs: 103 | try: 104 | store[idx] = get_value() 105 | except: 106 | continue 107 | else: 108 | break 109 | else: 110 | raise RuntimeError( 111 | 'Cannot retrieve widget value, widget="{}"'.format(widget) 112 | ) 113 | 114 | signaldb.ModelUpdate() 115 | -------------------------------------------------------------------------------- /astro3d/app.py: -------------------------------------------------------------------------------- 1 | """astro3d UI Application 2 | """ 3 | from __future__ import absolute_import 4 | 5 | import sys 6 | import logging 7 | from argparse import ArgumentParser 8 | 9 | from astropy import log as astropy_log 10 | 11 | from .util.logger import make_logger 12 | from .gui.qt.process import MeshThread 13 | from .gui import (Controller, MainWindow, Model, signaldb) 14 | from .gui.start_ui_app import start_ui_app 15 | 16 | # Configure logging 17 | logger = make_logger(__package__) 18 | logger.setLevel(logging.CRITICAL) 19 | 20 | 21 | class Application(Controller): 22 | """Start the astro3d Qt application 23 | 24 | Parameters 25 | ---------- 26 | argv: list or None 27 | Argument list to pass to `argparse.ArgumentParser`. 28 | If None, use `sys.argv`. 29 | """ 30 | 31 | # The UI event loop. 32 | ui_app = None 33 | 34 | def __init__(self, argv=None): 35 | 36 | self.mesh_thread = None 37 | 38 | self.parse_command_line(argv) 39 | 40 | self._create_signals() 41 | self.model = Model() 42 | 43 | if self.__class__.ui_app is None: 44 | self.__class__.ui_app = start_ui_app(argv) 45 | self.viewer = MainWindow(model=self.model) 46 | self.viewer.show() 47 | self.__class__.ui_app.setActiveWindow(self.viewer) 48 | self.viewer.raise_() 49 | self.viewer.activateWindow() 50 | 51 | def quit(self, *args, **kwargs): 52 | self.process_force_quit() 53 | logger.debug("Attempting to shut down the application...") 54 | 55 | def process(self, *args, **kwargs): 56 | """Do the processing.""" 57 | logger.debug('Starting processing...') 58 | self.process_force_quit() 59 | signaldb.ProcessStart() 60 | self.model.process() 61 | 62 | def process_force_quit(self, *args, **kwargs): 63 | """Force quit a process""" 64 | signaldb.ProcessForceQuit() 65 | 66 | def process_finish(self, mesh, model3d): 67 | logger.debug('3D generation completed.') 68 | 69 | def parse_command_line(self, argv): 70 | """Parse command line arguments 71 | 72 | Parameters 73 | ---------- 74 | argv: list or None 75 | Argument list to pass to `argparse.ArgumentParser`. 76 | If None, use `sys.argv`. 77 | """ 78 | parser = ArgumentParser( 79 | description='astro3d: Create 3D models of astronomical images' 80 | ) 81 | parser.add_argument( 82 | '-D', '--debug', 83 | help='Turn on debugging information', 84 | action='store_true' 85 | ) 86 | parser.add_argument( 87 | '-v', '--verbose', 88 | help='Turn on basic informational messages', 89 | action='store_true' 90 | ) 91 | args = parser.parse_args(argv) 92 | 93 | if args.verbose: 94 | logger.setLevel(logging.INFO) 95 | elif args.debug: 96 | logger.setLevel(logging.DEBUG) 97 | astropy_log.setLevel(logger.level) 98 | 99 | def _create_signals(self): 100 | signaldb.logger = logger 101 | signaldb.Quit.connect(self.quit) 102 | signaldb.ModelUpdate.connect(self.process) 103 | signaldb.ProcessFinish.connect(self.process_finish) 104 | 105 | 106 | def main(): 107 | app = Application() 108 | sys.exit(app.ui_app.exec_()) 109 | 110 | 111 | if __name__ == '__main__': 112 | main() 113 | -------------------------------------------------------------------------------- /astro3d/gui/qt/view_mesh.py: -------------------------------------------------------------------------------- 1 | """View the 3D Model""" 2 | 3 | import numpy as np 4 | from qtpy import (QtCore, QtWidgets) 5 | from qtpy.QtCore import Signal as pyqtSignal 6 | from vispy import scene 7 | 8 | from ...util.logger import make_null_logger 9 | 10 | # Configure logging 11 | logger = make_null_logger(__name__) 12 | 13 | __all__ = ['ViewMesh'] 14 | 15 | 16 | class ViewMesh(QtWidgets.QWidget): 17 | """View the 3D Mesh""" 18 | 19 | closed = pyqtSignal(bool) 20 | 21 | def __init__(self, *args, **kwargs): 22 | super(ViewMesh, self).__init__(*args, **kwargs) 23 | self.setLayout(QtWidgets.QVBoxLayout()) 24 | 25 | # --------------------------------------------- 26 | # Need to implement the following at some point 27 | # --------------------------------------------- 28 | def process(self): 29 | """Display while new mesh is processing""" 30 | self.remove_mesh() 31 | 32 | def update_mesh(self, mesh, model3d): 33 | logger.debug('Called.') 34 | 35 | # Get the vertices and scale to unit level. 36 | mesh = mesh[:, 1:, :] 37 | scaled = ((mesh - mesh.min()) / mesh.max()) - 1.0 38 | 39 | # Color the mesh. 40 | nf = scaled.shape[0] 41 | fcolor = np.ones((nf, 3, 4), dtype=np.float32) 42 | 43 | # Show it. 44 | _ = scene.visuals.Mesh( 45 | parent=self.viewbox.scene, 46 | face_colors=fcolor, 47 | vertices=scaled, 48 | shading='flat', 49 | ) 50 | 51 | def remove_mesh(self): 52 | """Remove the current mesh from display""" 53 | self.viewbox = None 54 | 55 | def toggle_view(self): 56 | """Toggle this view""" 57 | sender = self.sender() 58 | self.setVisible(sender.isChecked()) 59 | 60 | def sizeHint(self): 61 | return QtCore.QSize(800, 800) 62 | 63 | def closeEvent(self, event): 64 | self.closed.emit(False) 65 | event.accept() 66 | 67 | @property 68 | def viewbox(self): 69 | try: 70 | viewbox = self._viewbox 71 | except AttributeError: 72 | viewbox = None 73 | 74 | if viewbox: 75 | return viewbox 76 | 77 | # Create the scene to view in. 78 | viewbox = scene.widgets.ViewBox(parent=self.canvas.scene) 79 | self.canvas.central_widget.add_widget(viewbox) 80 | viewbox.camera = scene.TurntableCamera() 81 | self._viewbox = viewbox 82 | 83 | return viewbox 84 | 85 | @viewbox.setter 86 | def viewbox(self, viewbox): 87 | if viewbox: 88 | viewbox.add_parent(self.canvas.scene) 89 | self._viewbox = viewbox 90 | else: 91 | self.canvas = None 92 | self._viewbox = None 93 | 94 | @property 95 | def canvas(self): 96 | try: 97 | canvas = self._canvas 98 | except AttributeError: 99 | canvas = None 100 | 101 | if canvas: 102 | return canvas 103 | 104 | # Create the canvas. 105 | canvas = scene.SceneCanvas(keys='interactive') 106 | canvas.size = 800, 800 107 | self.canvas = canvas 108 | 109 | return canvas 110 | 111 | @canvas.setter 112 | def canvas(self, canvas): 113 | if canvas: 114 | self.layout().addWidget(canvas.native) 115 | self._canvas = canvas 116 | else: 117 | try: 118 | self.layout().removeWidget(self._canvas.native) 119 | except (AttributeError, ValueError): 120 | pass 121 | finally: 122 | self._canvas = None 123 | -------------------------------------------------------------------------------- /astro3d/gui/qt/process.py: -------------------------------------------------------------------------------- 1 | """Thread to process 3D model""" 2 | import traceback 3 | 4 | from astropy import log 5 | from numpy import concatenate 6 | from qtpy import QtCore 7 | from qtpy.QtCore import Signal as pyqtSignal 8 | 9 | from ...core.meshes import (make_triangles, reflect_triangles) 10 | 11 | from ...gui import signaldb 12 | 13 | __all__ = ['MeshThread'] 14 | 15 | # Configure logging 16 | log.setLevel('DEBUG') 17 | 18 | 19 | class MeshWorker(QtCore.QObject): 20 | """The mesh thread 21 | 22 | Parameters 23 | ---------- 24 | model: astro3d.Model3d 25 | The model to process. 26 | 27 | make_params: dict 28 | Other parameters to create the model 29 | 30 | Signals 31 | ------- 32 | finished(results): pyqtSignal emitted 33 | Emitted when processing is successfully 34 | completed. The results is a tuple with 35 | triset: 3D mesh for display 36 | model3d: astro3d.core.Model3d of the 37 | computed model. 38 | 39 | aborted: pyqtSignal emitted 40 | Emitted when the processing has been aborted. 41 | Usually this is a result of another thread 42 | emitting the abort signal. 43 | 44 | exception: pyqtSignal emitted 45 | If an exception or other error condition 46 | occurs, this will be emitted with an 47 | Exception as argument 48 | 49 | abort: pyqtSignal received 50 | If received, processing will stop and the 51 | aborted signal will be emitted 52 | 53 | """ 54 | abort = pyqtSignal() 55 | aborted = pyqtSignal() 56 | finished = pyqtSignal(tuple) 57 | exception = pyqtSignal(Exception) 58 | 59 | def __init__(self, model3d, make_params): 60 | super(MeshWorker, self).__init__() 61 | self.model3d = model3d 62 | self.make_params = make_params 63 | self._aborted = False 64 | self.abort.connect(self._abort) 65 | 66 | def run(self): 67 | try: 68 | self.model3d.make(**self.make_params) 69 | triset = make_triangles(self.model3d.data) 70 | 71 | if self.make_params['double_sided']: 72 | triset = concatenate((triset, reflect_triangles(triset))) 73 | except Exception as e: 74 | log.debug(traceback.format_exc()) 75 | self.exception.emit(e) 76 | return 77 | 78 | if not self._aborted: 79 | self.finished.emit((triset, self.model3d)) 80 | 81 | def _abort(self): 82 | self._aborted = True 83 | self.aborted.emit() 84 | 85 | 86 | class MeshThread(object): 87 | def __init__(self, model3d, make_params): 88 | mesh_worker = MeshWorker(model3d, make_params) 89 | self.mesh_worker = mesh_worker 90 | worker_thread = QtCore.QThread() 91 | self.worker_thread = worker_thread 92 | mesh_worker.moveToThread(worker_thread) 93 | 94 | worker_thread.started.connect(mesh_worker.run) 95 | worker_thread.finished.connect(self.cleanup) 96 | 97 | mesh_worker.finished.connect(self.finished) 98 | mesh_worker.finished.connect( 99 | lambda x: worker_thread.quit() 100 | ) 101 | mesh_worker.aborted.connect( 102 | lambda: self.mesh_worker_fail('Processing aborted.') 103 | ) 104 | mesh_worker.exception.connect( 105 | lambda e: self.mesh_worker_fail('Processing error', e) 106 | ) 107 | signaldb.ProcessForceQuit.connect( 108 | self.mesh_worker.abort.emit, 109 | single_shot=True 110 | ) 111 | 112 | worker_thread.start() 113 | 114 | def finished(self, results): 115 | triset, model3d = results 116 | signaldb.ProcessFinish(triset, model3d) 117 | 118 | def cleanup(self): 119 | signaldb.ProcessForceQuit.clear(single_shot=True) 120 | self.worker_thread.deleteLater() 121 | self.mesh_worker.deleteLater() 122 | 123 | def mesh_worker_fail(self, message='', error_text=''): 124 | self.worker_thread.quit() 125 | signaldb.ProcessFail(message, error_text) 126 | -------------------------------------------------------------------------------- /astro3d/core/region_mask.py: -------------------------------------------------------------------------------- 1 | """This module defines a RegionMask.""" 2 | from __future__ import (absolute_import, division, print_function, 3 | unicode_literals) 4 | import numpy as np 5 | from astropy import log 6 | from astropy.io import fits 7 | from .image_utils import resize_image_absolute 8 | 9 | 10 | class RegionMask(object): 11 | """ 12 | This class defines a region mask. 13 | """ 14 | 15 | def __init__(self, mask, mask_type, required_shape=None, shape=None): 16 | """ 17 | Parameters 18 | ---------- 19 | mask : array-like (bool) 20 | A 2D boolean image defining the region mask. For texture 21 | masks, the texture will be applied where the ``mask`` is 22 | `True`. 23 | 24 | mask_type : str 25 | The type of mask. Some examples include 'dots', 'small_dots', 26 | 'lines', 'gas', and 'spiral'. 27 | 28 | required_shape : tuple, optional 29 | If not `None`, then the ``(ny, nx)`` shape required for the 30 | input mask. 31 | 32 | shape : tuple, optional 33 | If not `None`, then the input mask will be resized to 34 | ``shape``. 35 | """ 36 | 37 | self.mask = np.asanyarray(mask) 38 | self.mask_type = mask_type 39 | 40 | if required_shape is not None: 41 | if self.mask.shape != required_shape: 42 | raise ValueError('Input mask does not have the correct ' 43 | 'shape.') 44 | 45 | if shape is not None: 46 | self.mask = resize_image_absolute(self.mask, x_size=shape[1], 47 | y_size=shape[0]) 48 | 49 | def write(self, filename, shape=None): 50 | """ 51 | Write the region mask to a FITS file. 52 | 53 | Mask `True` and `False` values will be saved as 1 and 0, 54 | respectively. 55 | 56 | Parameters 57 | ---------- 58 | filename : str 59 | The output filename. 60 | 61 | shape : tuple 62 | If not `None`, then the region mask will be resized to 63 | ``shape``. This is used to save the mask with the same size 64 | as the original input image. 65 | """ 66 | 67 | mask = self.mask 68 | if shape is not None: 69 | mask = resize_image_absolute(self.mask, x_size=shape[1], 70 | y_size=shape[0]) 71 | 72 | header = fits.Header() 73 | header['MASKTYPE'] = self.mask_type 74 | hdu = fits.PrimaryHDU(data=mask.astype(np.int32), header=header) 75 | hdu.writeto(filename) 76 | log.info('Saved {0} (mask type="{1}").'.format(filename, 77 | self.mask_type)) 78 | 79 | @classmethod 80 | def from_fits(cls, filename, required_shape=None, shape=None): 81 | """ 82 | Create a `RegionMask` instance from a FITS file. 83 | 84 | The FITS file must have a 'MASKTYPE' header keyword defining the 85 | mask type. This keyword must be in the primary extension. 86 | 87 | The mask data should contain only ones or zeros, which will be 88 | converted to `True` and `False` values, respectively. 89 | 90 | Parameters 91 | ---------- 92 | filename : str 93 | The input FITS filename. 94 | 95 | required_shape : tuple, optional 96 | If not `None`, then the ``(ny, nx)`` shape required for the 97 | input mask. 98 | 99 | shape : tuple, optional 100 | If not `None`, then the input mask will be resized to 101 | ``shape``. 102 | 103 | Returns 104 | ------- 105 | result : `RegionMask` 106 | A `RegionMask` instance. 107 | """ 108 | 109 | fobj = fits.open(filename) 110 | header = fobj[0].header 111 | mask = fobj[0].data.astype(bool) 112 | mask_type = header['MASKTYPE'] 113 | region_mask = cls(mask, mask_type, required_shape=required_shape, 114 | shape=shape) 115 | return region_mask 116 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | #This is needed with git because git doesn't create a dir if it's empty 18 | $(shell [ -d "_static" ] || mkdir -p _static) 19 | 20 | help: 21 | @echo "Please use \`make ' where is one of" 22 | @echo " html to make standalone HTML files" 23 | @echo " dirhtml to make HTML files named index.html in directories" 24 | @echo " singlehtml to make a single large HTML file" 25 | @echo " pickle to make pickle files" 26 | @echo " json to make JSON files" 27 | @echo " htmlhelp to make HTML files and a HTML help project" 28 | @echo " qthelp to make HTML files and a qthelp project" 29 | @echo " devhelp to make HTML files and a Devhelp project" 30 | @echo " epub to make an epub" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " text to make text files" 34 | @echo " man to make manual pages" 35 | @echo " changes to make an overview of all changed/added/deprecated items" 36 | @echo " linkcheck to check all external links for integrity" 37 | 38 | clean: 39 | -rm -rf $(BUILDDIR) 40 | -rm -rf api 41 | -rm -rf generated 42 | 43 | html: 44 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 45 | @echo 46 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 47 | 48 | dirhtml: 49 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 50 | @echo 51 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 52 | 53 | singlehtml: 54 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 55 | @echo 56 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 57 | 58 | pickle: 59 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 60 | @echo 61 | @echo "Build finished; now you can process the pickle files." 62 | 63 | json: 64 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 65 | @echo 66 | @echo "Build finished; now you can process the JSON files." 67 | 68 | htmlhelp: 69 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 70 | @echo 71 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 72 | ".hhp project file in $(BUILDDIR)/htmlhelp." 73 | 74 | qthelp: 75 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 76 | @echo 77 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 78 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 79 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Astropy.qhcp" 80 | @echo "To view the help file:" 81 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Astropy.qhc" 82 | 83 | devhelp: 84 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 85 | @echo 86 | @echo "Build finished." 87 | @echo "To view the help file:" 88 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Astropy" 89 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Astropy" 90 | @echo "# devhelp" 91 | 92 | epub: 93 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 94 | @echo 95 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 96 | 97 | latex: 98 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 99 | @echo 100 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 101 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 102 | "(use \`make latexpdf' here to do that automatically)." 103 | 104 | latexpdf: 105 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 106 | @echo "Running LaTeX files through pdflatex..." 107 | make -C $(BUILDDIR)/latex all-pdf 108 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 109 | 110 | text: 111 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 112 | @echo 113 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 114 | 115 | man: 116 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 117 | @echo 118 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 119 | 120 | changes: 121 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 122 | @echo 123 | @echo "The overview file is in $(BUILDDIR)/changes." 124 | 125 | linkcheck: 126 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 127 | @echo 128 | @echo "Link check complete; look for any errors in the above output " \ 129 | "or in $(BUILDDIR)/linkcheck/output.txt." 130 | 131 | doctest: 132 | @echo "Run 'python setup.py test' in the root directory to run doctests " \ 133 | @echo "in the documentation." 134 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | if errorlevel 1 exit /b 1 46 | echo. 47 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 48 | goto end 49 | ) 50 | 51 | if "%1" == "dirhtml" ( 52 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 53 | if errorlevel 1 exit /b 1 54 | echo. 55 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 56 | goto end 57 | ) 58 | 59 | if "%1" == "singlehtml" ( 60 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 61 | if errorlevel 1 exit /b 1 62 | echo. 63 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 64 | goto end 65 | ) 66 | 67 | if "%1" == "pickle" ( 68 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 69 | if errorlevel 1 exit /b 1 70 | echo. 71 | echo.Build finished; now you can process the pickle files. 72 | goto end 73 | ) 74 | 75 | if "%1" == "json" ( 76 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished; now you can process the JSON files. 80 | goto end 81 | ) 82 | 83 | if "%1" == "htmlhelp" ( 84 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished; now you can run HTML Help Workshop with the ^ 88 | .hhp project file in %BUILDDIR%/htmlhelp. 89 | goto end 90 | ) 91 | 92 | if "%1" == "qthelp" ( 93 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 97 | .qhcp project file in %BUILDDIR%/qthelp, like this: 98 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Astropy.qhcp 99 | echo.To view the help file: 100 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Astropy.ghc 101 | goto end 102 | ) 103 | 104 | if "%1" == "devhelp" ( 105 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 106 | if errorlevel 1 exit /b 1 107 | echo. 108 | echo.Build finished. 109 | goto end 110 | ) 111 | 112 | if "%1" == "epub" ( 113 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 117 | goto end 118 | ) 119 | 120 | if "%1" == "latex" ( 121 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 122 | if errorlevel 1 exit /b 1 123 | echo. 124 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 125 | goto end 126 | ) 127 | 128 | if "%1" == "text" ( 129 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 130 | if errorlevel 1 exit /b 1 131 | echo. 132 | echo.Build finished. The text files are in %BUILDDIR%/text. 133 | goto end 134 | ) 135 | 136 | if "%1" == "man" ( 137 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 141 | goto end 142 | ) 143 | 144 | if "%1" == "changes" ( 145 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.The overview file is in %BUILDDIR%/changes. 149 | goto end 150 | ) 151 | 152 | if "%1" == "linkcheck" ( 153 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Link check complete; look for any errors in the above output ^ 157 | or in %BUILDDIR%/linkcheck/output.txt. 158 | goto end 159 | ) 160 | 161 | if "%1" == "doctest" ( 162 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Testing of doctests in the sources finished, look at the ^ 166 | results in %BUILDDIR%/doctest/output.txt. 167 | goto end 168 | ) 169 | 170 | :end 171 | -------------------------------------------------------------------------------- /astro3d/tests/test_model3d.py: -------------------------------------------------------------------------------- 1 | """Test the core operations of model3d""" 2 | 3 | from filecmp import cmp 4 | from os import environ 5 | from pathlib import Path 6 | import pytest 7 | 8 | from astro3d.core.model3d import Model3D 9 | from astro3d.core.model3d import read_stellar_table 10 | 11 | pytestmark = pytest.mark.skipif( 12 | environ.get('ASTRO3D_TESTDATA') is None, 13 | reason=( 14 | 'Test requires environmental ASTRO3D_TESTDATA' 15 | ' pointing to the test data set.' 16 | ) 17 | ) 18 | 19 | 20 | @pytest.mark.usefixtures('jail') 21 | def test_remove_stars(): 22 | """Test star removal handling 23 | """ 24 | 25 | # Get the data 26 | data_path = Path(environ['ASTRO3D_TESTDATA']) 27 | 28 | model = Model3D.from_fits(data_path / 'ngc3344_crop.fits') 29 | model.read_all_masks(str(data_path / 'features' / 'ngc3344_remove_star_*.fits')) 30 | 31 | # Create and save the model 32 | model.make( 33 | intensity=True, textures=True, double_sided=False, spiral_galaxy=False 34 | ) 35 | model.write_stl('model3d', split_model=False) 36 | 37 | # Check against truth 38 | truth_path = data_path / 'truth' / 'model3d_make_remove_stars' / 'model3d.stl' 39 | assert cmp('model3d.stl', truth_path) 40 | 41 | 42 | def test_catalog_read(caplog): 43 | """test catalog reading 44 | 45 | Parameters 46 | ---------- 47 | caplog: log from the test, provided by the pytest 48 | `caplog` fixture 49 | """ 50 | 51 | # Get the data 52 | data_path = Path(environ['ASTRO3D_TESTDATA']) 53 | catalog = data_path / 'special_features' / 'catalog_uppercase_names.txt' 54 | 55 | # Execute 56 | table = read_stellar_table(str(catalog), 'stars') 57 | 58 | # Ensure no warnings 59 | assert 'Cannot find required column names' not in caplog.text 60 | 61 | # Ensure other table characteristics 62 | assert len(table.colnames) >= 3 63 | assert set(('xcentroid', 'ycentroid', 'flux')).issubset(table.colnames) 64 | 65 | 66 | def test_catalog_read_badnames(caplog): 67 | """test catalog reading 68 | 69 | Parameters 70 | ---------- 71 | caplog: log from the test, provided by the pytest 72 | `caplog` fixture 73 | """ 74 | 75 | # Get the data 76 | data_path = Path(environ['ASTRO3D_TESTDATA']) 77 | catalog = data_path / 'special_features' / 'catalog_bad_names.txt' 78 | 79 | # Execute 80 | table = read_stellar_table(str(catalog), 'stars') 81 | 82 | # Ensure warnings 83 | assert 'Cannot find required column names' in caplog.text 84 | 85 | # Ensure other table characteristics 86 | assert len(table.colnames) >= 2 87 | assert set(('xcentroid', 'ycentroid')).issubset(table.colnames) 88 | 89 | 90 | @pytest.mark.usefixtures('jail') 91 | @pytest.mark.parametrize( 92 | 'id, make_kwargs, use_bulge_mask', 93 | [ 94 | ('full', {'spiral_galaxy': True, 'compress_bulge': True}, True), 95 | ('none', {'spiral_galaxy': False, 'compress_bulge': False}, True), 96 | ('spiral_only', {'spiral_galaxy': True, 'compress_bulge': False}, True), 97 | ('compress_only', {'spiral_galaxy': False, 'compress_bulge': True}, True), 98 | ('full_nomask', {'spiral_galaxy': True, 'compress_bulge': True}, False), 99 | ('none_nomask', {'spiral_galaxy': False, 'compress_bulge': False}, False), 100 | ('spiral_only_nomask', {'spiral_galaxy': True, 'compress_bulge': False}, False), 101 | ('compress_only_nomask', {'spiral_galaxy': False, 'compress_bulge': True}, False), 102 | ] 103 | ) 104 | def test_bulge_handling(id, make_kwargs, use_bulge_mask, caplog): 105 | """Test bulge/spiral model handling 106 | 107 | Parameters 108 | ---------- 109 | id: str 110 | Test identifier used to pick input and truth data. 111 | 112 | make_kwargs: dict 113 | The `Astro3d.make` keyword arguments. 114 | 115 | use_bulge_mask: bool 116 | Use a bulge mask. 117 | 118 | caplog: fixture 119 | The magical `pytest.caplog` fixture that encapsulates the log output. 120 | """ 121 | 122 | # Get the data 123 | data_path = Path(environ['ASTRO3D_TESTDATA']) 124 | 125 | model = Model3D.from_fits(data_path / 'ngc3344_crop.fits') 126 | if use_bulge_mask: 127 | model.read_all_masks(str(data_path / 'features' / 'ngc3344_bulge.fits')) 128 | 129 | # Create and save the model 130 | model.make( 131 | intensity=True, textures=True, double_sided=False, **make_kwargs 132 | ) 133 | model.write_stl('model3d', split_model=False) 134 | 135 | # Check against truth 136 | if use_bulge_mask: 137 | truth_path = data_path / 'truth' / 'model3d_make_bulge' / id / 'model3d.stl' 138 | else: 139 | truth_path = data_path / 'truth' / 'model3d_make_bulge' / 'none_nomask' / 'model3d.stl' 140 | assert cmp('model3d.stl', truth_path) 141 | 142 | if not use_bulge_mask and id is not 'none_nomask': 143 | assert 'A "bulge" mask must be defined.' in caplog.text 144 | 145 | 146 | @pytest.mark.usefixtures('jail') 147 | @pytest.mark.parametrize( 148 | 'id, make_kwargs', 149 | [ 150 | ('full', {'intensity': True, 'textures': True, 'double_sided': True, 'spiral_galaxy': True}), 151 | ('nospiral', {'intensity': True, 'textures': True, 'double_sided': True, 'spiral_galaxy': False}), 152 | ('nospiralnocompress', 153 | {'intensity': True, 'textures': True, 'double_sided': True, 'spiral_galaxy': False, 'compress_bulge': False} 154 | ), 155 | ('nodouble', {'intensity': True, 'textures': True, 'double_sided': False, 'spiral_galaxy': False}), 156 | ('intensity', {'intensity': True, 'textures': False, 'double_sided': False, 'spiral_galaxy': False}), 157 | ('texture', {'intensity': False, 'textures': True, 'double_sided': False, 'spiral_galaxy': False}), 158 | ] 159 | ) 160 | def test_make(id, make_kwargs): 161 | """Test a full run of a spiral model""" 162 | 163 | # Get the data 164 | data_path = Path(environ['ASTRO3D_TESTDATA']) 165 | 166 | model = Model3D.from_fits(data_path / 'ngc3344_crop.fits') 167 | model.read_all_masks(str(data_path / 'features' / '*.fits')) 168 | 169 | # Create and save the model 170 | model.make(**make_kwargs) 171 | model.write_stl('model3d', split_model=False) 172 | 173 | # Check for file existence 174 | assert cmp('model3d.stl', data_path / 'truth' / 'model3d_make' / id / 'model3d.stl') 175 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Licensed under a 3-clause BSD style license - see LICENSE.rst 3 | # 4 | # Astropy documentation build configuration file. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this file. 9 | # 10 | # All configuration values have a default. Some values are defined in 11 | # the global Astropy configuration which is loaded here before anything else. 12 | # See astropy.sphinx.conf for which values are set there. 13 | 14 | # If extensions (or modules to document with autodoc) are in another directory, 15 | # add these directories to sys.path here. If the directory is relative to the 16 | # documentation root, use os.path.abspath to make it absolute, like shown here. 17 | # sys.path.insert(0, os.path.abspath('..')) 18 | # IMPORTANT: the above commented section was generated by sphinx-quickstart, but 19 | # is *NOT* appropriate for astropy or Astropy affiliated packages. It is left 20 | # commented out with this explanation to make it clear why this should not be 21 | # done. If the sys.path entry above is added, when the astropy.sphinx.conf 22 | # import occurs, it will import the *source* version of astropy instead of the 23 | # version installed (if invoked as "make html" or directly with sphinx), or the 24 | # version in the build directory (if "python setup.py build_sphinx" is used). 25 | # Thus, any C-extensions that are needed to build the documentation will *not* 26 | # be accessible, and the documentation will not build correctly. 27 | 28 | import os 29 | import sys 30 | import datetime 31 | from importlib import import_module 32 | 33 | try: 34 | from sphinx_astropy.conf.v1 import * # noqa 35 | except ImportError: 36 | print('ERROR: the documentation requires the sphinx-astropy package to be installed') 37 | sys.exit(1) 38 | 39 | # Get configuration information from setup.cfg 40 | from configparser import ConfigParser 41 | conf = ConfigParser() 42 | 43 | conf.read([os.path.join(os.path.dirname(__file__), '..', 'setup.cfg')]) 44 | setup_cfg = dict(conf.items('metadata')) 45 | 46 | # -- General configuration ---------------------------------------------------- 47 | 48 | # By default, highlight as Python 3. 49 | highlight_language = 'python3' 50 | 51 | # If your documentation needs a minimal Sphinx version, state it here. 52 | #needs_sphinx = '1.2' 53 | 54 | # To perform a Sphinx version check that needs to be more specific than 55 | # major.minor, call `check_sphinx_version("X.Y.Z")` here. 56 | # check_sphinx_version("1.2.1") 57 | 58 | # List of patterns, relative to source directory, that match files and 59 | # directories to ignore when looking for source files. 60 | exclude_patterns.append('_templates') 61 | 62 | # This is added to the end of RST files - a good place to put substitutions to 63 | # be used globally. 64 | rst_epilog += """ 65 | """ 66 | 67 | # -- Project information ------------------------------------------------------ 68 | 69 | # This does not *have* to match the package name, but typically does 70 | project = setup_cfg['name'] 71 | author = setup_cfg['author'] 72 | copyright = '{0}, {1}'.format( 73 | datetime.datetime.now().year, setup_cfg['author']) 74 | 75 | # The version info for the project you're documenting, acts as replacement for 76 | # |version| and |release|, also used in various other places throughout the 77 | # built documents. 78 | 79 | import_module(setup_cfg['name']) 80 | package = sys.modules[setup_cfg['name']] 81 | 82 | # The short X.Y version. 83 | version = package.__version__.split('-', 1)[0] 84 | # The full version, including alpha/beta/rc tags. 85 | release = package.__version__ 86 | 87 | 88 | # -- Options for HTML output -------------------------------------------------- 89 | 90 | # A NOTE ON HTML THEMES 91 | # The global astropy configuration uses a custom theme, 'bootstrap-astropy', 92 | # which is installed along with astropy. A different theme can be used or 93 | # the options for this theme can be modified by overriding some of the 94 | # variables set in the global configuration. The variables set in the 95 | # global configuration are listed below, commented out. 96 | 97 | 98 | # Add any paths that contain custom themes here, relative to this directory. 99 | # To use a different custom theme, add the directory containing the theme. 100 | #html_theme_path = [] 101 | 102 | # The theme to use for HTML and HTML Help pages. See the documentation for 103 | # a list of builtin themes. To override the custom theme, set this to the 104 | # name of a builtin theme or the name of a custom theme in html_theme_path. 105 | #html_theme = None 106 | 107 | 108 | html_theme_options = { 109 | 'logotext1': 'astro3d', # white, semi-bold 110 | 'logotext2': '', # orange, light 111 | 'logotext3': ':docs' # white, light 112 | } 113 | 114 | 115 | # Custom sidebar templates, maps document names to template names. 116 | #html_sidebars = {} 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | #html_logo = '' 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | #html_favicon = '' 126 | 127 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 128 | # using the given strftime format. 129 | #html_last_updated_fmt = '' 130 | 131 | # The name for this set of Sphinx documents. If None, it defaults to 132 | # " v documentation". 133 | html_title = '{0} v{1}'.format(project, release) 134 | 135 | # Output file base name for HTML help builder. 136 | htmlhelp_basename = project + 'doc' 137 | 138 | # Prefixes that are ignored for sorting the Python module index 139 | modindex_common_prefix = ["astro3d."] 140 | 141 | 142 | # -- Options for LaTeX output ------------------------------------------------- 143 | 144 | # Grouping the document tree into LaTeX files. List of tuples 145 | # (source start file, target name, title, author, documentclass [howto/manual]). 146 | latex_documents = [('index', project + '.tex', project + u' Documentation', 147 | author, 'manual')] 148 | 149 | 150 | # -- Options for manual page output ------------------------------------------- 151 | 152 | # One entry per manual page. List of tuples 153 | # (source start file, name, description, authors, manual section). 154 | man_pages = [('index', project.lower(), project + u' Documentation', 155 | [author], 1)] 156 | 157 | 158 | # -- Options for the edit_on_github extension --------------------------------- 159 | 160 | if setup_cfg.get('edit_on_github').lower() == 'true': 161 | 162 | extensions += ['sphinx_astropy.ext.edit_on_github'] 163 | 164 | edit_on_github_project = setup_cfg['github_project'] 165 | edit_on_github_branch = "main" 166 | 167 | edit_on_github_source_root = "" 168 | edit_on_github_doc_root = "docs" 169 | 170 | # -- Resolving issue number to links in changelog ----------------------------- 171 | github_issues_url = 'https://github.com/{0}/issues/'.format(setup_cfg['github_project']) 172 | 173 | 174 | # -- Options for linkcheck output ------------------------------------------- 175 | linkcheck_retry = 5 176 | linkcheck_ignore = [ 177 | r'https://github\.com/spacetelescope/astro3d/(?:issues|pull)/\d+', 178 | ] 179 | linkcheck_timeout = 180 180 | linkcheck_anchors = False 181 | 182 | # -- Turn on nitpicky mode for sphinx (to warn about references not found) ---- 183 | # 184 | # nitpicky = True 185 | # nitpick_ignore = [] 186 | # 187 | # Some warnings are impossible to suppress, and you can list specific references 188 | # that should be ignored in a nitpick-exceptions file which should be inside 189 | # the docs/ directory. The format of the file should be: 190 | # 191 | # 192 | # 193 | # for example: 194 | # 195 | # py:class astropy.io.votable.tree.Element 196 | # py:class astropy.io.votable.tree.SimpleElement 197 | # py:class astropy.io.votable.tree.SimpleElementWithContent 198 | # 199 | # Uncomment the following lines to enable the exceptions: 200 | # 201 | # for line in open('nitpick-exceptions'): 202 | # if line.strip() == "" or line.startswith("#"): 203 | # continue 204 | # dtype, target = line.split(None, 1) 205 | # target = target.strip() 206 | # nitpick_ignore.append((dtype, six.u(target))) 207 | -------------------------------------------------------------------------------- /astro3d/gui/qt/parameters.py: -------------------------------------------------------------------------------- 1 | """Parameter Editor""" 2 | 3 | from ginga.misc.Bunch import Bunch 4 | from ginga.gw import Widgets 5 | from qtpy import (QtCore, QtWidgets) 6 | from qtpy.QtCore import Qt 7 | 8 | from ...util.logger import make_null_logger 9 | from ..store_widgets import StoreWidgets 10 | 11 | # Configure logging 12 | logger = make_null_logger(__name__) 13 | 14 | __all__ = ['Parameters'] 15 | 16 | 17 | class Parameters(QtWidgets.QScrollArea): 18 | """Parameters Editor 19 | """ 20 | 21 | def __init__(self, *args, **kwargs): 22 | self.model = kwargs.pop('model') 23 | self.parent = kwargs.pop('parent') 24 | super(Parameters, self).__init__(*args, **kwargs) 25 | 26 | self._build_gui() 27 | 28 | def preview_model(self): 29 | self.parent.mesh_viewer.setVisible(True) 30 | self.parent.force_update() 31 | 32 | def create_gas_spiral_masks(self, *args, **kwargs): 33 | """Set Gas/Spiral arm esitmator parameters""" 34 | self.model.create_gas_spiral_masks( 35 | smooth_size=self.children.smooth_size.get_value(), 36 | gas_percentile=self.children.gas_percentile.get_value(), 37 | spiral_percentile=self.children.spiral_percentile.get_value(), 38 | model_params=self.model.params.model 39 | ) 40 | 41 | def create_gas_dust_masks(self, *args, **kwargs): 42 | """Set Gas/Dust esitmator parameters""" 43 | params = self.children['gasdust'] 44 | self.model.create_gas_dust_masks( 45 | smooth_size=params.smooth_size.get_value(), 46 | gas_percentile=params.gas_percentile.get_value(), 47 | dust_percentile=params.dust_percentile.get_value(), 48 | model_params=self.model.params.model 49 | ) 50 | 51 | def _build_gui(self): 52 | """Build out the GUI""" 53 | logger.debug('Called.') 54 | self.children = Bunch() 55 | spacer = Widgets.Label('') 56 | 57 | # Processing parameters 58 | captions = [('Save Model', 'button'), 59 | ('Preview Model', 'button')] 60 | params_store = StoreWidgets( 61 | self.model.params.stages, 62 | extra=captions 63 | ) 64 | self.model.params_widget_store.stages = params_store 65 | params_widget = params_store.container 66 | params_bunch = params_store.widgets 67 | self.children.update(params_bunch) 68 | 69 | params_bunch.save_model.add_callback( 70 | 'activated', 71 | lambda w: self.parent.save_all_from_dialog() 72 | ) 73 | 74 | params_bunch.preview_model.add_callback( 75 | 'activated', 76 | lambda w: self.preview_model() 77 | 78 | ) 79 | 80 | params_frame = Widgets.Frame('Processing') 81 | params_frame.set_widget(params_widget) 82 | 83 | # Model parameters 84 | model_store = StoreWidgets(self.model.params.model) 85 | self.model.params_widget_store.model = model_store 86 | model_widget = model_store.container 87 | model_bunch = model_store.widgets 88 | self.children.update(model_bunch) 89 | 90 | model_frame = Widgets.Frame() 91 | model_frame.set_widget(model_widget) 92 | model_expander = Widgets.Expander('Model Params') 93 | model_expander.set_widget(model_frame) 94 | self.children['model_expander'] = model_expander 95 | 96 | # Model Making parameters 97 | model_make_store = StoreWidgets(self.model.params.model_make) 98 | self.model.params_widget_store.model_make = model_make_store 99 | model_make_widget = model_make_store.container 100 | model_make_bunch = model_make_store.widgets 101 | self.children.update(model_make_bunch) 102 | 103 | model_make_frame = Widgets.Frame() 104 | model_make_frame.set_widget(model_make_widget) 105 | model_make_expander = Widgets.Expander('Model Making Params') 106 | model_make_expander.set_widget(model_make_frame) 107 | self.children['model_make_expander'] = model_make_expander 108 | 109 | # Gas/Spiral parameters 110 | captions = ( 111 | ('Gas Percentile:', 'label', 'Gas Percentile', 'spinbutton'), 112 | ('Spiral Percentile:', 'label', 'Spiral Percentile', 'spinbutton'), 113 | ('Smooth Size:', 'label', 'Smooth Size', 'spinbutton'), 114 | ('Create masks', 'button'), 115 | ) 116 | gasspiral_widget, gasspiral_bunch = Widgets.build_info(captions) 117 | self.children.update(gasspiral_bunch) 118 | 119 | gasspiral_bunch.gas_percentile.set_limits(0, 100) 120 | gasspiral_bunch.gas_percentile.set_value(55) 121 | gasspiral_bunch.gas_percentile.set_tooltip( 122 | 'The percentile of values above which' 123 | ' are assigned to the Gas mask' 124 | ) 125 | 126 | gasspiral_bunch.spiral_percentile.set_limits(0, 100) 127 | gasspiral_bunch.spiral_percentile.set_value(75) 128 | gasspiral_bunch.spiral_percentile.set_tooltip( 129 | 'The percential of values above which are' 130 | ' assigned to the Spiral Arm mask' 131 | ) 132 | 133 | gasspiral_bunch.smooth_size.set_limits(3, 100) 134 | gasspiral_bunch.smooth_size.set_value(11) 135 | gasspiral_bunch.smooth_size.set_tooltip( 136 | 'Size of the smoothing window' 137 | ) 138 | 139 | gasspiral_bunch.create_masks.add_callback( 140 | 'activated', 141 | self.create_gas_spiral_masks 142 | ) 143 | 144 | gasspiral_frame = Widgets.Frame('Gas/Spiral Arm parameters') 145 | gasspiral_frame.set_widget(gasspiral_widget) 146 | 147 | # Gas/Dust parameters 148 | captions = ( 149 | ('Gas Percentile:', 'label', 'Gas Percentile', 'spinbutton'), 150 | ('Dust Percentile:', 'label', 'Dust Percentile', 'spinbutton'), 151 | ('Smooth Size:', 'label', 'Smooth Size', 'spinbutton'), 152 | ('Create masks', 'button'), 153 | ) 154 | gasdust_widget, gasdust_bunch = Widgets.build_info(captions) 155 | self.children['gasdust'] = gasdust_bunch 156 | 157 | gasdust_bunch.gas_percentile.set_limits(0, 100) 158 | gasdust_bunch.gas_percentile.set_value(75) 159 | gasdust_bunch.gas_percentile.set_tooltip( 160 | 'The percentile of values above which' 161 | ' are assigned to the Gas mask' 162 | ) 163 | 164 | gasdust_bunch.dust_percentile.set_limits(0, 100) 165 | gasdust_bunch.dust_percentile.set_value(55) 166 | gasdust_bunch.dust_percentile.set_tooltip( 167 | 'The percentile of pixel values in the weighted data above' 168 | 'which (and below gas_percentile) to assign to the "dust"' 169 | 'mask. dust_percentile must be lower than' 170 | 'gas_percentile.' 171 | ) 172 | 173 | gasdust_bunch.smooth_size.set_limits(3, 100) 174 | gasdust_bunch.smooth_size.set_value(11) 175 | gasdust_bunch.smooth_size.set_tooltip( 176 | 'Size of the smoothing window' 177 | ) 178 | 179 | gasdust_bunch.create_masks.add_callback( 180 | 'activated', 181 | self.create_gas_dust_masks 182 | ) 183 | 184 | gasdust_frame = Widgets.Frame('Gas/Dust parameters') 185 | gasdust_frame.set_widget(gasdust_widget) 186 | 187 | # Put it together 188 | layout = QtWidgets.QVBoxLayout() 189 | layout.setContentsMargins(QtCore.QMargins(20, 20, 20, 20)) 190 | layout.setSpacing(1) 191 | layout.addWidget(params_frame.get_widget(), stretch=0) 192 | layout.addWidget(spacer.get_widget(), stretch=1) 193 | layout.addWidget(model_expander.get_widget(), stretch=0) 194 | layout.addWidget(spacer.get_widget(), stretch=1) 195 | layout.addWidget(model_make_expander.get_widget(), stretch=0) 196 | layout.addWidget(spacer.get_widget(), stretch=1) 197 | layout.addWidget(gasspiral_frame.get_widget(), stretch=0) 198 | layout.addWidget(gasdust_frame.get_widget(), stretch=0) 199 | layout.addWidget(spacer.get_widget(), stretch=2) 200 | content = QtWidgets.QWidget() 201 | content.setLayout(layout) 202 | 203 | self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) 204 | self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) 205 | self.setWidgetResizable(True) 206 | self.setWidget(content) 207 | -------------------------------------------------------------------------------- /astro3d/gui/store_widgets.py: -------------------------------------------------------------------------------- 1 | """Interface between configuration store and widgets""" 2 | from ast import literal_eval 3 | from collections.abc import MutableMapping 4 | from copy import copy 5 | 6 | from ginga.gw.Widgets import ( 7 | CheckBox, 8 | ComboBox, 9 | build_info 10 | ) 11 | 12 | from . import signaldb 13 | 14 | __all__ = ['StoreWidgets'] 15 | 16 | 17 | class StoreWidget(object): 18 | """Widget representing a configuration parameters 19 | 20 | Parameters 21 | ---------- 22 | widget: UI widget object 23 | The UI widget 24 | 25 | config_store: object 26 | The configuration store from which and too which 27 | widget values will be retrieved/set_index 28 | 29 | key: str 30 | The index stro the store 31 | 32 | callback: function 33 | UI widget callback to use. If not defined, the generic 34 | `value_update` is used. 35 | """ 36 | def __init__(self, widget, config_store, key, callback=None): 37 | self._widget = widget 38 | self._config_store = config_store 39 | self._key = key 40 | 41 | # Determine interface to/from widget and store 42 | if isinstance(widget, CheckBox): 43 | self._widget_get_value = widget.get_state 44 | self._widget_set_value = widget.set_state 45 | self._store_get_value = _store_get_value_direct 46 | self._store_set_value = _store_set_value_direct 47 | self._widget_set_value(self._store_get_value(config_store, key)) 48 | elif isinstance(widget, ComboBox): 49 | combo_items = config_store[key][1] 50 | for item in combo_items: 51 | widget.append_text(item) 52 | self._widget_get_value = widget.get_index 53 | self._widget_set_value = widget.set_index 54 | self._store_get_value = _store_get_value_combo 55 | self._store_set_value = _store_set_value_combo 56 | self._widget_set_value(self._store_get_value(config_store, key)) 57 | else: 58 | self._widget_get_value = widget.get_text 59 | self._widget_set_value = lambda value: widget.set_text(str(value)) 60 | self._store_get_value = _store_get_value_direct 61 | self._store_set_value = _store_set_value_direct 62 | self._widget_set_value( 63 | self._store_get_value(config_store, key) 64 | ) 65 | 66 | if callback is None: 67 | callback = value_update 68 | widget.add_callback( 69 | 'activated', 70 | callback 71 | ) 72 | 73 | @property 74 | def value(self): 75 | value = self._store_get_value(self._config_store, self._key) 76 | self._widget_set_value(value) 77 | return value 78 | 79 | @value.setter 80 | def value(self, value): 81 | self._widget_set_value(value) 82 | self._store_set_value(self._config_store, self._key, value) 83 | 84 | 85 | class StoreWidgets(MutableMapping): 86 | """Interface between configuration store and widgets 87 | """ 88 | 89 | def __init__(self, store=None, callback=None, extra=None): 90 | """Initialize StoreWidgets 91 | 92 | Parameters 93 | ---------- 94 | store: dict-like 95 | The originating store 96 | 97 | callback: callable 98 | The callback to attach to the widgets. 99 | If None, a built in callback is used which 100 | takes the new value and places it back 101 | into the store. 102 | 103 | extra: (name, gui_type, ...) 104 | Extra widgets defined in the caption 105 | format. No special processing is 106 | done beyond the creation of the widgets. 107 | """ 108 | super(StoreWidgets, self).__init__() 109 | 110 | # Initializations 111 | self._originating_store = store 112 | self.container = None 113 | self.widgets = None 114 | self.store_widgets = {} 115 | if store is not None: 116 | self.build_widgets(store, callback=callback, extra=extra) 117 | 118 | def build_widgets(self, store, callback=None, extra=None): 119 | """Build Ginga widgets from simple captions 120 | 121 | Parameters 122 | ---------- 123 | store: dict-like 124 | The dictionary of items to create widgets from. 125 | 126 | callback: callable 127 | The callback to attach to the widgets. 128 | If None, a built in callback is used which 129 | takes the new value and places it back 130 | into the store. 131 | 132 | extra: (name, gui_type, ...) 133 | Extra widgets defined in the caption 134 | format. No special processing is 135 | done beyond the creation of the widgets. 136 | 137 | Returns 138 | ------- 139 | widget: The Ginga widget 140 | The Ginga widget to use in GUI construction 141 | 142 | bunch: ginga.misc.Bunch.bunch 143 | The dict of all the individual widgets. 144 | """ 145 | if extra is None: 146 | extra = [] 147 | 148 | # Build the widgets 149 | captions = [] 150 | captions_notbool = [] 151 | for key in store: 152 | value = store[key] 153 | if isinstance(value, bool): 154 | captions.append((key, 'checkbutton')) 155 | else: 156 | if isinstance(value, list): 157 | captions.extend([ 158 | (key, 'label'), 159 | (key, 'combobox') 160 | ]) 161 | else: 162 | captions_notbool.append(( 163 | key, 'label', 164 | key, 'entryset' 165 | )) 166 | captions = captions + captions_notbool + extra 167 | self.container, self.widgets = build_info(captions) 168 | 169 | # Define widget/store api 170 | for key in store: 171 | widget = self.widgets[key] 172 | store_widget = StoreWidget(widget, store, key) 173 | widget.extdata.update({'store_widget': store_widget}) 174 | self.store_widgets[key] = widget 175 | 176 | # ABC required methods 177 | def update(self, other): 178 | """Merge another StoreWidgets""" 179 | if not isinstance(other, StoreWidgets): 180 | raise TypeError( 181 | 'Cannot update StoreWidgets with a non-StoreWidgets object.' 182 | ) 183 | self.store_widgets.update(other.store_widgets) 184 | self.widgets.update(other.widgets) 185 | 186 | def __copy__(self): 187 | """Produce a shallow copy""" 188 | new_store = type(self)() 189 | new_store._originating_store = self._originating_store 190 | new_store.container = self.container 191 | new_store.widgets = self.widgets.copy() 192 | new_store.store_widgets = self.store_widgets.copy() 193 | return new_store 194 | 195 | def __getitem__(self, key): 196 | """Get value of widget 197 | 198 | Parameters 199 | ---------- 200 | key: str 201 | Index into the list 202 | 203 | Returns 204 | ------- 205 | obj: object 206 | Object at index. This is the value 207 | of the item 208 | """ 209 | widget = self.store_widgets[key] 210 | return widget.extdata.store_widget.value 211 | 212 | def __setitem__(self, key, value): 213 | """Set value for widget 214 | 215 | Parameters 216 | ---------- 217 | key: str 218 | Index of item to set 219 | 220 | value: object 221 | The value to set the item to 222 | """ 223 | widget = self.store_widgets[key] 224 | widget.extdata.store_widget.value = value 225 | 226 | def __delitem__(self, key): 227 | """Delete specified item 228 | """ 229 | del self.store_widgets[key] 230 | del self.widgets[key] 231 | 232 | def __len__(self): 233 | """Return number of widgets specified""" 234 | return len(self.store_widgets) 235 | 236 | def __iter__(self): 237 | for key in self.store_widgets: 238 | yield key 239 | 240 | 241 | # ##################### 242 | # Basic update callback 243 | # ##################### 244 | def value_update(widget, *args, **kwargs): 245 | """Update the internal store from the widget 246 | 247 | Parameters 248 | ---------- 249 | widget: GUI widget 250 | The widget that initiated the callback. 251 | 252 | args, kwargs: 0 or more objects 253 | Depending on the widget, there may be extra 254 | information generated. 255 | """ 256 | get_value_funcs = [ 257 | lambda: args[0], 258 | lambda: widget.get_index(), 259 | lambda: literal_eval(widget.get_text()), 260 | lambda: widget.get_text() 261 | ] 262 | for get_value in get_value_funcs: 263 | try: 264 | widget.extdata.store_widget.value = get_value() 265 | except: 266 | continue 267 | else: 268 | break 269 | else: 270 | raise RuntimeError( 271 | 'Cannot retrieve widget value, widget="{}"'.format(widget) 272 | ) 273 | 274 | signaldb.ModelUpdate() 275 | 276 | 277 | # ###################### 278 | # Store access utilities 279 | # ###################### 280 | def _store_set_value_direct(store, key, value): 281 | """Save the value to the store 282 | 283 | Parameters 284 | ---------- 285 | store: dict-like 286 | The store to place the value 287 | 288 | key: str 289 | The index in the store to access 290 | 291 | value: object 292 | Value to place into the store 293 | """ 294 | store[key] = value 295 | 296 | 297 | def _store_set_value_combo(store, key, value): 298 | """Save the value to the store for combo values 299 | 300 | Parameters 301 | ---------- 302 | store: dict-like 303 | The store to place the value 304 | 305 | key: str 306 | The index in the store to access 307 | 308 | value: object 309 | Value to place into the store 310 | """ 311 | store[key][0] = value 312 | 313 | 314 | def _store_get_value_direct(store, key): 315 | """Save the value to the store for combo values 316 | 317 | Parameters 318 | ---------- 319 | store: dict-like 320 | The store to retreive the value from 321 | 322 | key: str 323 | The index in the store to access 324 | 325 | Returns 326 | ------- 327 | value: object 328 | Value from store 329 | """ 330 | return store[key] 331 | 332 | 333 | def _store_get_value_combo(store, key): 334 | """Save the value to the store for combo values 335 | 336 | Parameters 337 | ---------- 338 | store: dict-like 339 | The store to retreive the value from 340 | 341 | key: str 342 | The index in the store to access 343 | 344 | Returns 345 | ------- 346 | value: object 347 | Value from store 348 | """ 349 | return store[key][0] 350 | -------------------------------------------------------------------------------- /astro3d/core/image_utils.py: -------------------------------------------------------------------------------- 1 | """This module provides image (2D array) utility functions.""" 2 | from __future__ import (absolute_import, division, print_function, 3 | unicode_literals) 4 | from functools import reduce 5 | from copy import deepcopy 6 | from PIL import Image 7 | import numpy as np 8 | from astropy import log 9 | from astropy.convolution import convolve 10 | 11 | 12 | def remove_nonfinite(data): 13 | """ 14 | Remove non-finite values (e.g. NaN, inf, etc.) from an array. 15 | 16 | Non-finite values are replaced by a local mean if possible, 17 | otherwise are set to zero. 18 | 19 | Parameters 20 | ---------- 21 | data : array-like 22 | The input data array. 23 | 24 | Returns 25 | ------- 26 | result : `~numpy.ndarray` 27 | The array with non-finite values removed. 28 | """ 29 | 30 | mask = ~np.isfinite(data) 31 | 32 | if np.any(mask): 33 | # use astropy's convolve as a 5x5 mean filter that ignores nans 34 | # (in regions that are smaller than 5x5) 35 | data_out = deepcopy(np.asanyarray(data)) 36 | data_out[mask] = np.nan 37 | filt = np.ones((5, 5)) 38 | data_conv = convolve(data_out, filt) / filt.sum() 39 | data_out[mask] = data_conv[mask] 40 | 41 | # if there any non-finite values left (e.g. contiguous non-finite 42 | # regions larger than the filter size), then simply set them to zero. 43 | # For example, RGB FITS files appear to assign nan to large regions 44 | # of zero weight (as a coverage mask). 45 | data_out[~np.isfinite(data_out)] = 0. 46 | return data_out 47 | else: 48 | return data 49 | 50 | 51 | def resize_image(data, scale_factor): 52 | """ 53 | Resize a 2D array by the given scale factor. 54 | 55 | The array is resized by the same factor in each dimension, 56 | preserving the original aspect ratio. 57 | 58 | Parameters 59 | ---------- 60 | data : array-like 61 | The 2D array to be resized. 62 | 63 | scale_factor : float 64 | The scale factor to apply to the image. 65 | 66 | Returns 67 | ------- 68 | result : `~numpy.ndarray` 69 | The resized array. 70 | """ 71 | 72 | data = np.asanyarray(data) 73 | ny, nx = data.shape 74 | 75 | if scale_factor == 1: 76 | log.info('The array (ny x nx) = ({0}x{1}) was not resized.' 77 | .format(ny, nx)) 78 | return data 79 | 80 | y_size = int(round(ny * scale_factor)) 81 | x_size = int(round(nx * scale_factor)) 82 | data = np.array(Image.fromarray(data.astype(float)).resize( 83 | (x_size, y_size)), dtype=data.dtype) 84 | # from scipy.misc import imresize 85 | # data = imresize(data, (y_size, x_size)).astype(data.dtype) 86 | 87 | log.info('The array was resized from {0}x{1} to {2}x{3} ' 88 | '(ny x nx).'.format(ny, nx, y_size, x_size)) 89 | 90 | return data 91 | 92 | 93 | def resize_image_absolute(data, x_size, y_size): 94 | """ 95 | Resize a 2D array by the given scale factor. 96 | 97 | The array is resized by the same factor in each dimension, 98 | preserving the original aspect ratio. 99 | 100 | Parameters 101 | ---------- 102 | data : array-like 103 | The 2D array to be resized. 104 | 105 | x_size, y_size: int 106 | The new dimensions 107 | 108 | Returns 109 | ------- 110 | result : `~numpy.ndarray` 111 | The resized array. 112 | """ 113 | 114 | data = np.asanyarray(data) 115 | ny, nx = data.shape 116 | data = np.array(Image.fromarray(data.astype(float)).resize( 117 | (x_size, y_size)), dtype=data.dtype) 118 | 119 | log.info('The array was resized from {0}x{1} to {2}x{3} ' 120 | '(ny x nx).'.format(ny, nx, y_size, x_size)) 121 | 122 | return data 123 | 124 | 125 | def normalize_data(data, max_value=1.): 126 | """ 127 | Normalize an array such that its values range from 0 to 128 | ``max_value``. 129 | 130 | Parameters 131 | ---------- 132 | data : array-like 133 | The input data array. 134 | 135 | max_value : float, optional 136 | The maximum value of the normalized array. 137 | 138 | Returns 139 | ------- 140 | result : `~numpy.ndarray` 141 | The normalized array. 142 | """ 143 | 144 | data = np.asanyarray(data) 145 | minval, maxval = np.min(data), np.max(data) 146 | if (maxval - minval) == 0: 147 | return (data / maxval) * max_value 148 | else: 149 | return (data - minval) / (maxval - minval) * max_value 150 | 151 | 152 | def bbox_threshold(data, threshold=0): 153 | """ 154 | Calculate a slice tuple representing the minimal bounding box 155 | enclosing all values greater than the input ``threshold``. 156 | 157 | The slice tuple can be used to crop the image. 158 | 159 | Parameters 160 | ---------- 161 | data : array-like 162 | The input data array. 163 | 164 | threshold : float, optional 165 | The values above which to define the minimal bounding box. 166 | 167 | Returns 168 | ------- 169 | result : tuple of slice objects 170 | The slice tuple that can be used to crop the array. 171 | 172 | Examples 173 | -------- 174 | >>> data = np.zeros((100, 100)) 175 | >>> data[40:50, 40:50] = 100 176 | >>> slc = bbox_above_threshold(data, 10) 177 | >>> slc 178 | (slice(40, 50, None), slice(40, 50, None)) 179 | >>> data_cropped = data[slc] 180 | """ 181 | 182 | idx = np.where(data > threshold) 183 | y0, y1 = min(idx[0]), max(idx[0]) + 1 184 | x0, x1 = min(idx[1]), max(idx[1]) + 1 185 | return (slice(y0, y1), slice(x0, x1)) 186 | 187 | 188 | def combine_masks(masks): 189 | """ 190 | Combine boolean masks into a single mask. 191 | 192 | The masks are combined using `~numpy.logical_or`. 193 | 194 | Parameters 195 | ---------- 196 | masks : list of boolean `~numpy.ndarray` 197 | A list of boolean `~numpy.ndarray` masks to combine. 198 | 199 | Returns 200 | ------- 201 | mask : bool `~numpy.ndarray` 202 | The combined mask. 203 | """ 204 | 205 | nmasks = len(masks) 206 | if nmasks == 0: 207 | return None 208 | elif nmasks == 1: 209 | return masks[0] 210 | else: 211 | return reduce(lambda mask1, mask2: np.logical_or(mask1, mask2), masks) 212 | 213 | 214 | def combine_region_masks(region_masks): 215 | """ 216 | Combine a list of `~astro3d.region_mask.RegionMask` into a single 217 | mask. 218 | 219 | The masks are combined using `~numpy.logical_or`. 220 | 221 | Parameters 222 | ---------- 223 | region_masks : list of `~astro3d.region_mask.RegionMask` 224 | A list of boolean `~astro3d.region_mask.RegionMask` masks to 225 | combine. 226 | 227 | Returns 228 | ------- 229 | mask : bool `~numpy.ndarray` 230 | The combined mask. 231 | """ 232 | 233 | nmasks = len(region_masks) 234 | if nmasks == 0: 235 | return region_masks 236 | else: 237 | return reduce( 238 | lambda mask, regm2: np.logical_or(mask, regm2.mask), 239 | region_masks[1:], 240 | region_masks[0].mask 241 | ) 242 | 243 | 244 | def radial_distance(shape, position): 245 | """ 246 | Return an array where the values are the Euclidean distance of the 247 | pixel from a given position. 248 | 249 | Parameters 250 | ---------- 251 | shape : tuple 252 | The ``(ny, nx)`` shape of the output array. 253 | 254 | position : tuple 255 | The ``(y, x)`` position corresponding to zero distance. 256 | 257 | Returns 258 | ------- 259 | result : `~numpy.ndarray` 260 | A 2D array of given ``shape`` representing the radial distance 261 | map. 262 | """ 263 | 264 | x = np.arange(shape[1]) - position[1] 265 | y = np.arange(shape[0]) - position[0] 266 | xx, yy = np.meshgrid(x, y) 267 | return np.sqrt(xx**2 + yy**2) 268 | 269 | 270 | def radial_weight_map(shape, position, alpha=0.8): 271 | """ 272 | Return a radial weight map used to enhance the faint spiral arms in 273 | the outskirts of a galaxy image. 274 | 275 | Parameters 276 | ---------- 277 | shape : tuple 278 | The ``(ny, nx)`` shape of the output array. 279 | 280 | position : tuple 281 | The ``(y, x)`` position corresponding to zero distance. 282 | 283 | alpha : float, optional 284 | The power scaling factor applied to the radial distance. 285 | 286 | Returns 287 | ------- 288 | result : `~numpy.ndarray` 289 | A 2D array of given ``shape`` representing the radial weight 290 | map. 291 | """ 292 | 293 | r = radial_distance(shape, position) 294 | r2 = r ** alpha 295 | return r2 296 | 297 | 298 | def legacy_radial_weight_map(shape, position, alpha=0.8, r_min=100, r_max=450, 299 | fill_value=0.1): 300 | """ 301 | Return a radial weight map used to enhance the faint spiral arms in 302 | the outskirts of a galaxy image. 303 | 304 | Parameters 305 | ---------- 306 | shape : tuple 307 | The ``(ny, nx)`` shape of the output array. 308 | 309 | position : tuple 310 | The ``(y, x)`` position corresponding to zero distance. 311 | 312 | alpha : float, optional 313 | The power scaling factor applied to the radial distance. 314 | 315 | r_min : int, optional 316 | The minimum pixel radius below which the weights are truncated. 317 | 318 | r_max : int, optional 319 | The maximum pixel radius above which the weights are truncated. 320 | 321 | fill_value : float, optional 322 | The value used to replace zero-valued weights. 323 | 324 | Returns 325 | ------- 326 | result : `~numpy.ndarray` 327 | A 2D array of given ``shape`` representing the radial weight 328 | map. 329 | """ 330 | 331 | r = radial_distance(shape, position) 332 | r2 = r ** alpha 333 | min_mask = (r < r_min) 334 | max_mask = (r > r_max) 335 | r2[min_mask] = r2[min_mask].min() 336 | r2[max_mask] = r2[max_mask].max() 337 | r2 /= r2.max() 338 | r2[r2 == 0] = fill_value 339 | return r2 340 | 341 | 342 | def split_image(data, axis=0): 343 | """ 344 | Split an image into two (nearly-equal) halves. 345 | 346 | If the split ``axis`` has an even number of elements, then the image 347 | will be split into two equal halves. 348 | 349 | Parameters 350 | ---------- 351 | data : array-like 352 | The input data array. 353 | 354 | axis : 0 or 1, optional 355 | The axis to split (e.g. ``axis=0`` splits the y axis). 356 | 357 | Returns 358 | ------- 359 | result : tuple of `~numpy.ndarray` 360 | The two array "halfs". For ``axis=0`` the returned order is 361 | ``(bottom, top)``. For ``axis=1`` the returned order is 362 | ``(left, right)``. 363 | """ 364 | 365 | ny, nx = data.shape 366 | if axis == 0: 367 | hy = int(ny / 2.) 368 | data1 = data[:hy, :] 369 | data2 = data[hy:, :] 370 | elif axis == 1: 371 | hx = int(nx / 2.) 372 | data1 = data[:, :hx] 373 | data2 = data[:, hx:] 374 | else: 375 | raise ValueError('Invalid axis={0}'.format(axis)) 376 | 377 | return data1, data2 378 | -------------------------------------------------------------------------------- /astro3d/gui/qt/overlay.py: -------------------------------------------------------------------------------- 1 | """Region overlay handling""" 2 | from functools import partial 3 | 4 | import numpy as np 5 | 6 | from ginga import colors 7 | from ginga.RGBImage import RGBImage 8 | from ginga.canvas.CanvasObject import get_canvas_types 9 | from qtpy import QtCore 10 | 11 | from ...core.region_mask import RegionMask 12 | from ...util.logger import make_null_logger 13 | from .shape_editor import image_shape_to_regionmask 14 | from .items import * 15 | 16 | from .util import EventDeferred 17 | 18 | # Configure logging 19 | logger = make_null_logger(__name__) 20 | 21 | __all__ = ['OverlayView'] 22 | 23 | 24 | class BaseOverlay(): 25 | """Base class for Overlays 26 | 27 | Parameters 28 | ---------- 29 | parent: `Overlay` 30 | """ 31 | 32 | def __init__(self, parent=None): 33 | self.canvas = None 34 | if parent is not None: 35 | self.parent = parent 36 | 37 | @property 38 | def parent(self): 39 | return self._parent 40 | 41 | @parent.setter 42 | def parent(self, parent): 43 | self._parent = parent 44 | self.canvas = parent.canvas 45 | self._dc = parent._dc 46 | 47 | def delete_all_objects(self): 48 | """Remove the immediate children""" 49 | self.canvas.delete_all_objects() 50 | self.children = [] 51 | 52 | 53 | class Overlay(BaseOverlay): 54 | """Overlays 55 | 56 | Overlays on which regions are shown. 57 | 58 | Parameters 59 | ---------- 60 | parent: `Overlay` 61 | The parent overlay. 62 | 63 | color: str 64 | The color that shapes have for this overlay. 65 | 66 | Attributes 67 | ---------- 68 | parent 69 | The parent overlay 70 | 71 | color 72 | The default color of shapes on this overlay. 73 | 74 | view 75 | The shape id on the ginga canvas. 76 | """ 77 | 78 | def __init__(self, parent=None, color='red'): 79 | super(Overlay, self).__init__() 80 | self.canvas = None 81 | self.parent = parent 82 | self.color = color 83 | self.children = [] 84 | 85 | @property 86 | def parent(self): 87 | return self._parent 88 | 89 | @parent.setter 90 | def parent(self, parent): 91 | self._parent = parent 92 | if parent is not None: 93 | self.canvas = parent.canvas 94 | self._dc = parent._dc 95 | 96 | def add_tree(self, layer): 97 | """Add layer's children to overlay""" 98 | for child in layer.children(): 99 | view = self.add(child) 100 | try: 101 | view.add_tree(child) 102 | except AttributeError: 103 | """Leaf node or not available. Stop recursion""" 104 | pass 105 | 106 | def add(self, layer): 107 | """Add a layer 108 | 109 | Parameters 110 | ---------- 111 | layer: LayerItem 112 | The layer to add. 113 | 114 | Returns 115 | ------- 116 | None if the layer cannot be added. Usually due to non-availability. 117 | Otherwise, will be one of: 118 | Overlay: For non-leaf layers 119 | ginga shape: For leaf layers. 120 | """ 121 | logger.debug('Called: layer="{}"'.format(layer)) 122 | 123 | view = None 124 | if layer.is_available: 125 | if isinstance(layer, (RegionItem,)): 126 | view = self.add_region(layer) 127 | elif isinstance( 128 | layer, 129 | ( 130 | Catalogs, 131 | CatalogTypeItem, 132 | Clusters, 133 | Regions, 134 | Stars, 135 | Textures, 136 | TypeItem 137 | ) 138 | ): 139 | view = self.add_overlay(layer) 140 | elif isinstance(layer, ( 141 | CatalogItem, 142 | ClusterItem, 143 | StarsItem 144 | )): 145 | view = self.add_table(layer) 146 | 147 | logger.debug('Returned view="{}"'.format(view)) 148 | return view 149 | 150 | def add_child(self, overlay): 151 | """Add a child overlay""" 152 | self.children.append(overlay) 153 | 154 | def add_region(self, region_item): 155 | """Add a region to an overlay 156 | 157 | Parameters 158 | ---------- 159 | region_item: LayerItem 160 | A region. 161 | 162 | Returns 163 | ------- 164 | The ginga object identifier, or None if the item 165 | is not available. 166 | """ 167 | 168 | if not region_item.is_available: 169 | return None 170 | if region_item.view is None: 171 | region = region_item.value 172 | if isinstance(region, RegionMask): 173 | mask = RGBImage(data_np=region.mask) 174 | maskrgb = masktorgb( 175 | mask, 176 | color=self.draw_params['color'], 177 | opacity=self.draw_params['fillalpha']) 178 | maskrgb_obj = self._dc.Image(0, 0, maskrgb) 179 | maskrgb_obj.item = region_item 180 | region_item.view = maskrgb_obj 181 | region_item.view.type_draw_params = self.draw_params 182 | 183 | # Redefine the region value so that 184 | # it will dynamically update during 185 | # editing. 186 | region_item.value = partial( 187 | image_shape_to_regionmask, 188 | shape=maskrgb_obj, 189 | mask_type=region.mask_type 190 | ) 191 | else: 192 | raise NotImplementedError( 193 | 'Cannot create view of region "{}"'.format(region_item) 194 | ) 195 | self.canvas.add(region_item.view, tag=region_item.text()) 196 | return region_item.view 197 | 198 | def add_table(self, layer): 199 | logger.debug('Called.') 200 | if not layer.is_available: 201 | return None 202 | table = layer.value 203 | logger.debug('len(table)="{}"'.format(len(table))) 204 | container = self._dc.CompoundObject() 205 | container.initialize(None, self.canvas.viewer, logger) 206 | for row in table: 207 | point = self._dc.Point( 208 | x=row['xcentroid'], 209 | y=row['ycentroid'], 210 | **layer.draw_params 211 | ) 212 | point.pickable = True 213 | point.editable = False 214 | point.idx = row.index 215 | point.item = layer 216 | point.add_callback('pick-key', layer.key_callback) 217 | for cb_name in ['pick-down', 218 | 'pick-up', 219 | 'pick-move', 220 | 'pick-hover', 221 | ]: 222 | point.add_callback(cb_name, cb_debug) 223 | container.add_object(point) 224 | layer.view = container 225 | self.canvas.add(layer.view, tag=layer.text()) 226 | return layer.view 227 | 228 | def add_overlay(self, layer_item): 229 | """Add another overlay 230 | 231 | Parameters 232 | ---------- 233 | layer_item: LayerItem 234 | A higher level LayerItem which has children. 235 | 236 | Returns 237 | ------- 238 | The new overlay. 239 | """ 240 | if not layer_item.is_available: 241 | return None 242 | if layer_item.view is not None: 243 | overlay = layer_item.view 244 | overlay.parent = self 245 | else: 246 | overlay = Overlay(parent=self) 247 | layer_item.view = overlay 248 | try: 249 | overlay.draw_params = layer_item.draw_params 250 | except AttributeError: 251 | """Layer does not have any, ignore.""" 252 | pass 253 | self.add_child(overlay) 254 | return overlay 255 | 256 | 257 | class RegionsOverlay(BaseOverlay): 258 | """Top level Regions overlay 259 | 260 | Individual region types are sub-overlays 261 | to this overlay. 262 | """ 263 | 264 | def __init__(self, parent=None): 265 | super(RegionsOverlay, self).__init__(parent=parent) 266 | 267 | 268 | class OverlayView(QtCore.QObject): 269 | """Present an overlay view to a QStandardItemModel 270 | 271 | Parameters 272 | ---------- 273 | parent: `ginga.ImageViewCanvas` 274 | The ginga canvas on which the view will render 275 | 276 | model: `astro3d.gui.Model` 277 | The Model which will be viewed. 278 | """ 279 | 280 | def __init__(self, parent=None, model=None): 281 | super(OverlayView, self).__init__() 282 | 283 | self._defer_paint = QtCore.QTimer() 284 | self._defer_paint.setSingleShot(True) 285 | self._defer_paint.timeout.connect(self._paint) 286 | 287 | self.model = model 288 | self.parent = parent 289 | 290 | @property 291 | def parent(self): 292 | try: 293 | return self._root.canvas 294 | except AttributeError: 295 | return None 296 | 297 | @parent.setter 298 | def parent(self, parent): 299 | self._dc = get_canvas_types() 300 | canvas = self._dc.DrawingCanvas() 301 | self.canvas = canvas 302 | p_canvas = parent.get_canvas() 303 | p_canvas.add(self.canvas) 304 | self._root = Overlay(self) 305 | self.paint() 306 | 307 | @property 308 | def model(self): 309 | try: 310 | return self._model 311 | except AttributeError: 312 | return None 313 | 314 | @model.setter 315 | def model(self, model): 316 | self._disconnect() 317 | self._model = model 318 | self._connect() 319 | self.paint() 320 | 321 | @EventDeferred 322 | def paint(self, *args, **kwargs): 323 | logger.debug( 324 | 'Called: args="{}" kwargs="{}".'.format(args, kwargs) 325 | ) 326 | self._paint(*args, **kwargs) 327 | 328 | def _paint(self, *args, **kwargs): 329 | """Show all overlays""" 330 | logger.debug( 331 | 'Called: args="{}" kwargs="{}".'.format(args, kwargs) 332 | ) 333 | try: 334 | logger.debug('sender="{}"'.format(self.sender())) 335 | except AttributeError: 336 | """No sender, ignore""" 337 | pass 338 | 339 | if self.model is None or self.parent is None: 340 | return 341 | root = self._root 342 | root.delete_all_objects() 343 | root.add_tree(self.model) 344 | root.canvas.redraw(whence=2) 345 | 346 | def _connect(self): 347 | """Connect model signals""" 348 | try: 349 | self.model.itemChanged.connect(self.paint) 350 | self.model.columnsInserted.connect(self.paint) 351 | self.model.columnsMoved.connect(self.paint) 352 | self.model.columnsRemoved.connect(self.paint) 353 | self.model.rowsInserted.connect(self.paint) 354 | self.model.rowsMoved.connect(self.paint) 355 | self.model.rowsRemoved.connect(self.paint) 356 | except AttributeError: 357 | """Model is probably not defined. Ignore""" 358 | pass 359 | 360 | def _disconnect(self): 361 | """Disconnect signals""" 362 | try: 363 | self.model.itemChanged.disconnect(self.paint) 364 | self.model.columnsInserted.disconnect(self.paint) 365 | self.model.columnsMoved.disconnect(self.paint) 366 | self.model.columnsRemoved.disconnect(self.paint) 367 | self.model.rowsInserted.disconnect(self.paint) 368 | self.model.rowsMoved.disconnect(self.paint) 369 | self.model.rowsRemoved.disconnect(self.paint) 370 | except AttributeError: 371 | """Model is probably not defined. Ignore""" 372 | pass 373 | 374 | 375 | # Utilities 376 | def masktorgb(mask, color='red', opacity=0.3): 377 | wd, ht = mask.get_size() 378 | r, g, b = colors.lookup_color(color) 379 | rgbarr = np.zeros((ht, wd, 4), dtype=np.uint8) 380 | rgbobj = RGBImage(data_np=rgbarr) 381 | 382 | rc = rgbobj.get_slice('R') 383 | gc = rgbobj.get_slice('G') 384 | bc = rgbobj.get_slice('B') 385 | ac = rgbobj.get_slice('A') 386 | 387 | data = mask.get_data() 388 | ac[:] = 0 389 | idx = data > 0 390 | rc[:] = int(r * 255) 391 | gc[:] = int(g * 255) 392 | bc[:] = int(b * 255) 393 | ac[idx] = int(opacity * 255) 394 | 395 | return rgbobj 396 | 397 | 398 | def cb_debug(*args, **kwargs): 399 | print('overlay.cb_debug: args="{}" kwargs="{}"'.format(args, kwargs)) 400 | -------------------------------------------------------------------------------- /astro3d/core/meshes.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides functions to convert an image array to STL files. 3 | """ 4 | from __future__ import (absolute_import, division, print_function, 5 | unicode_literals) 6 | import os 7 | from copy import deepcopy 8 | import numpy as np 9 | from astropy import log 10 | 11 | 12 | def make_triangles(image, mm_per_pixel=0.24224, center_model=True): 13 | """ 14 | Create a 3D model of a 2D image using triangular tessellation. 15 | 16 | Each pixel is split into two triangles (upper left and lower right). 17 | 18 | Parameters 19 | ---------- 20 | image : 2D `~numpy.ndarray` 21 | The image from which to create the triangular mesh. 22 | 23 | mm_per_pixel : float, optional 24 | The physical scale of the model. 25 | 26 | center_model : bool, optional 27 | Set to `True` to center the model at ``(x, y) = (0, 0)``. This 28 | will center the model on the printer plate. 29 | 30 | Returns 31 | ------- 32 | triangles : Nx4x3 `~numpy.ndarray` 33 | An array of normal vectors and vertices for a set of triangles. 34 | """ 35 | 36 | ny, nx = image.shape 37 | yy, xx = np.indices((ny, nx)) 38 | npts = (ny - 1) * (nx - 1) 39 | vertices = np.dstack((xx, yy, image)) # x, y, z vertices 40 | 41 | # upper-left triangles 42 | ul_tri = np.zeros((npts, 4, 3)) 43 | ul_tri[:, 1, :] = vertices[1:, :-1].reshape(npts, 3) # top-left vertices 44 | ul_tri[:, 2, :] = vertices[:-1, :-1].reshape(npts, 3) # bottom-left 45 | ul_tri[:, 3, :] = vertices[1:, 1:].reshape(npts, 3) # top-right 46 | 47 | # lower-right triangles 48 | lr_tri = np.zeros((npts, 4, 3)) 49 | lr_tri[:, 1, :] = vertices[1:, 1:].reshape(npts, 3) # top-right 50 | lr_tri[:, 2, :] = vertices[:-1, :-1].reshape(npts, 3) # bottom-left 51 | lr_tri[:, 3, :] = vertices[:-1, 1:].reshape(npts, 3) # bottom-right 52 | 53 | sides = make_sides(vertices) 54 | bottom = make_model_bottom((ny-1, nx-1)) 55 | triangles = np.concatenate((ul_tri, lr_tri, sides, bottom)) 56 | triangles[:, 0, :] = calculate_normals(triangles) 57 | 58 | if center_model: 59 | triangles[:, 1:, 0] -= (nx - 1) / 2. 60 | triangles[:, 1:, 1] -= (ny - 1) / 2. 61 | 62 | return scale_triangles(triangles, mm_per_pixel=mm_per_pixel) 63 | 64 | 65 | def make_side_triangles(side_vertices, flip_order=False): 66 | """ 67 | Make the triangles for a single side. 68 | 69 | Parameters 70 | ---------- 71 | side_vertices : NxMx3 `~numpy.ndarray` 72 | The (x, y, z) vertices along one side of the image. ``N`` is 73 | length of the y size of the image and ``M`` is the x size of the 74 | image. 75 | 76 | flip_order : bool, optional 77 | Set to flip the ordering of the triangle vertices to keep the 78 | normals pointed "outward". 79 | 80 | Returns 81 | ------- 82 | triangles : Nx4x3 `~numpy.ndarray` 83 | The triangles for a single side. 84 | """ 85 | 86 | npts = len(side_vertices) - 1 87 | side_bottom = np.copy(side_vertices) 88 | side_bottom[:, 2] = 0 89 | ul_tri = np.zeros((npts, 4, 3)) 90 | lr_tri = np.zeros((npts, 4, 3)) 91 | 92 | if not flip_order: 93 | ul_tri[:, 1, :] = side_vertices[1:] # top-left 94 | ul_tri[:, 2, :] = side_bottom[1:] # bottom-left 95 | ul_tri[:, 3, :] = side_vertices[:-1] # top-right 96 | lr_tri[:, 1, :] = side_vertices[:-1] # top-right 97 | lr_tri[:, 2, :] = side_bottom[1:] # bottom-left 98 | lr_tri[:, 3, :] = side_bottom[:-1] # bottom-right 99 | else: 100 | ul_tri[:, 1, :] = side_vertices[:-1] # top-left 101 | ul_tri[:, 2, :] = side_bottom[:-1] # bottom-left 102 | ul_tri[:, 3, :] = side_vertices[1:] # top-right 103 | lr_tri[:, 1, :] = side_vertices[1:] # top-right 104 | lr_tri[:, 2, :] = side_bottom[:-1] # bottom-left 105 | lr_tri[:, 3, :] = side_bottom[1:] # bottom-right 106 | return np.concatenate((ul_tri, lr_tri)) 107 | 108 | 109 | def make_sides(vertices): 110 | """ 111 | Make the model sides. 112 | 113 | Parameters 114 | ---------- 115 | vertices : NxMx3 `~numpy.ndarray` 116 | The (x, y, z) vertices of the entire mesh. ``N`` is length of 117 | the y size of the image and ``M`` is the x size of the image. 118 | 119 | Returns 120 | ------- 121 | triangles : Nx4x3 `~numpy.ndarray` 122 | The triangles comprised the model sides. 123 | """ 124 | 125 | side1 = make_side_triangles(vertices[:, 0]) # x=0 126 | side2 = make_side_triangles(vertices[0, :], flip_order=True) # y=0 127 | side3 = make_side_triangles(vertices[:, -1], flip_order=True) # x=-1 128 | side4 = make_side_triangles(vertices[-1, :]) # y=-1 129 | return np.concatenate((side1, side2, side3, side4)) 130 | 131 | 132 | def make_model_bottom(shape, calculate_normals=False): 133 | """ 134 | Create the bottom of the model. 135 | 136 | The bottom is a rectangle of given ``shape`` at z=0 that is split 137 | into two triangles. The triangle normals are in the -z direction. 138 | 139 | Parameters 140 | ---------- 141 | shape : 2-tuple 142 | The image shape. 143 | 144 | calculate_normals : bool, optional 145 | Set to `True` to calculate the normals. 146 | 147 | Returns 148 | ------- 149 | result : 2x4x3 `~numpy.ndarray` 150 | The two triangles for the model bottom. 151 | """ 152 | 153 | ny, nx = shape 154 | triangles = np.zeros((2, 4, 3)) 155 | # lower-right triangle as viewed from the bottom 156 | triangles[0, 1:, :] = [[nx, 0, 0], [0, 0, 0], [0, ny, 0]] 157 | # upper-left triangle as viewed from the bottom 158 | triangles[1, 1:, :] = [[nx, ny, 0], [nx, 0, 0], [0, ny, 0]] 159 | 160 | if calculate_normals: 161 | # vertices were ordered such that normals are in the -z direction 162 | triangles[:, 0, :] = calculate_normals(triangles) 163 | 164 | return triangles 165 | 166 | 167 | def calculate_normals(triangles): 168 | """ 169 | Calculate the normal vectors for a set of triangles. 170 | 171 | The normal vector is calculated using the cross product of two 172 | triangle sides. The normal direction follows the right-hand rule 173 | applied to the order of the triangle vertices. 174 | 175 | Parameters 176 | ---------- 177 | triangles : Nx4x3 `~numpy.ndarray` 178 | An array of normal vectors and vertices for a set of triangles. 179 | 180 | Returns 181 | ------- 182 | result : Nx3 `~numpy.ndarray` 183 | An array of normal vectors. 184 | """ 185 | 186 | vertex1 = triangles[:, 1, :] 187 | vertex2 = triangles[:, 2, :] 188 | vertex3 = triangles[:, 3, :] 189 | vec1 = vertex2 - vertex1 # vector of first triangle side 190 | vec2 = vertex3 - vertex1 # vector of second triangle side 191 | return np.cross(vec1, vec2) 192 | 193 | 194 | def scale_triangles(triangles, mm_per_pixel=0.24224): 195 | """ 196 | Uniformly scale triangles given the input physical scale. 197 | 198 | Note that the default physical scale was derived assuming a x=1000 199 | pixel image, which can be printed with a maximum size of 242 mm on 200 | the MakerBot 5 printer (scale = 242 / (1000 - 1)). Note that 1 is 201 | subtracted from the image size in the denominator because the mesh 202 | points are taken at the center of the pixels, making the mesh size 1 203 | pixel smaller than the image size. 204 | 205 | The maximum model sizes for the MakerBot 2 printer are: 206 | ``x``: 275 mm 207 | ``y``: 143 mm 208 | ``z``: 150 mm 209 | 210 | The maximum model sizes for the MakerBot 5 printer are: 211 | ``x``: 242 mm 212 | ``y``: 189 mm 213 | ``z``: 143 mm 214 | 215 | Parameters 216 | ---------- 217 | triangles : Nx4x3 `~numpy.ndarray` 218 | An array of normal vectors and vertices for a set of triangles. 219 | 220 | mm_per_pixel : float, optional 221 | The physical scale of the model. 222 | 223 | Returns 224 | ------- 225 | triangles : Nx4x3 `~numpy.ndarray` 226 | The scaled triangles. 227 | """ 228 | 229 | triangles[:, 1:, :] *= mm_per_pixel 230 | return triangles 231 | 232 | 233 | def reflect_triangles(triangles): 234 | """ 235 | Reflect a triangle mesh about the ``z`` axis. 236 | 237 | The triangle vertices are reflected about the ``z`` axis and then 238 | reordered such that the triangle normal is consistent with the 239 | right-hand rule. The triangle normal is also reflected about the 240 | ``z`` axis. All of these steps are required to properly reflect the 241 | mesh. 242 | 243 | Parameters 244 | ---------- 245 | triangles : Nx4x3 `~numpy.ndarray` 246 | An array of normal vectors and vertices for a set of triangles. 247 | 248 | Returns 249 | ------- 250 | result : Nx4x3 `~numpy.ndarray` 251 | The refected triangles. 252 | """ 253 | 254 | triangles2 = np.copy(triangles) 255 | triangles2[:, 0, 2] = -triangles2[:, 0, 2] # reflect normal about z axis 256 | triangles2[:, 1:, 2] = -triangles2[:, 1:, 2] # reflect z vertices 257 | triangles2[:, 1:, :] = triangles2[:, 1:, :][:, ::-1] # reorder vertices 258 | return triangles2 259 | 260 | 261 | def write_binary_stl(triangles, filename): 262 | """ 263 | Write a binary STL file. 264 | 265 | Parameters 266 | ---------- 267 | triangles : Nx4x3 `~numpy.ndarray` 268 | An array of normal vectors and vertices for a set of triangles. 269 | 270 | filename : str 271 | The output filename. 272 | """ 273 | 274 | triangles = triangles.astype('RuntimeError: func "{}" will be removed.'.format( 78 | self.__class__.__name_, 79 | slot.func 80 | ) 81 | ) 82 | to_be_removed.append(slot) 83 | finally: 84 | if slot.single_shot: 85 | to_be_removed.append(slot) 86 | 87 | for remove in to_be_removed: 88 | self._slots.discard(remove) 89 | 90 | # Call handler methods 91 | to_be_removed = [] 92 | emitters = self._methods.copy() 93 | for obj, slots in emitters.items(): 94 | for slot in slots.copy(): 95 | try: 96 | slot.func(obj, *args, **kwargs) 97 | except RuntimeError: 98 | warnings.warn( 99 | 'Signal {}: Signals methods->RuntimeError, obj.func "{}.{}" will be removed'.format( 100 | self.__class__.__new__, 101 | obj, 102 | slot.func 103 | ) 104 | ) 105 | to_be_removed.append((obj, slot)) 106 | finally: 107 | if slot.single_shot: 108 | to_be_removed.append((obj, slot)) 109 | 110 | for obj, slot in to_be_removed: 111 | self._methods[obj].discard(slot) 112 | finally: 113 | self.reset_enabled() 114 | 115 | @property 116 | def enabled(self): 117 | return self._enabled 118 | 119 | def set_enabled(self, state, push=False): 120 | """Set whether signal is active or not 121 | 122 | Parameters 123 | ---------- 124 | state: boolean 125 | New state of signal 126 | 127 | push: boolean 128 | If True, current state is saved. 129 | """ 130 | if push: 131 | self._states.append(self._enabled) 132 | self._enabled = state 133 | 134 | def reset_enabled(self): 135 | self._enabled = self._states.pop() 136 | 137 | def connect(self, func, single_shot=False): 138 | """Connect a function to the signal 139 | Parameters 140 | ---------- 141 | func: function or method 142 | The function/method to call when the signal is activated 143 | 144 | single_shot: bool 145 | If True, the function/method is removed after being called. 146 | """ 147 | logger.debug( 148 | 'Signal {}: Connecting function:"{}"'.format( 149 | self.__class__.__name__, 150 | func 151 | ) 152 | ) 153 | if inspect.ismethod(func): 154 | if func.__self__ not in self._methods: 155 | self._methods[func.__self__] = set() 156 | 157 | slot = Slot( 158 | func=func.__func__, 159 | single_shot=single_shot 160 | ) 161 | self._methods[func.__self__].add(slot) 162 | 163 | else: 164 | slot = Slot( 165 | func=func, 166 | single_shot=single_shot 167 | ) 168 | self._slots.add(slot) 169 | 170 | def disconnect(self, func): 171 | logger.debug( 172 | 'Signal {}: Disconnecting func:"{}"'.format( 173 | self.__class__.__name__, 174 | func 175 | ) 176 | ) 177 | if inspect.ismethod(func): 178 | logger.debug( 179 | 'func is a method: "{}"'.format(func) 180 | ) 181 | if func.__self__ in self._methods: 182 | logger.debug( 183 | 'class "{}" is in list'.format(func.__self__) 184 | ) 185 | logger.debug( 186 | 'methods="{}"'.format(self._methods[func.__self__]) 187 | ) 188 | slots = [ 189 | slot 190 | for slot in self._methods[func.__self__] 191 | if slot.func == func.__func__ 192 | ] 193 | logger.debug( 194 | 'slots="{}"'.format(slots) 195 | ) 196 | try: 197 | self._methods[func.__self__].remove(slots[0]) 198 | except IndexError: 199 | logger.debug('slot not found.') 200 | pass 201 | else: 202 | slots = [ 203 | slot 204 | for slot in self._slots 205 | if slot.func == func 206 | ] 207 | try: 208 | self._slots.remove(slots[0]) 209 | except IndexError: 210 | pass 211 | 212 | def clear(self, single_shot=False): 213 | """Clear slots 214 | 215 | Parameters 216 | ---------- 217 | single_shot: bool 218 | If True, only remove single shot 219 | slots. 220 | """ 221 | logger.debug( 222 | 'Signal {}: Clearing slots'.format( 223 | self.__class__.__name__ 224 | ) 225 | ) 226 | if not single_shot: 227 | self._slots.clear() 228 | self._methods.clear() 229 | else: 230 | to_be_removed = [] 231 | for slot in self._slots.copy(): 232 | if slot.single_shot: 233 | to_be_removed.append(slot) 234 | for remove in to_be_removed: 235 | self._slots.discard(remove) 236 | 237 | to_be_removed = [] 238 | emitters = self._methods.copy() 239 | for obj, slots in emitters.items(): 240 | for slot in slots.copy(): 241 | if slot.single_shot: 242 | to_be_removed.append((obj, slot)) 243 | for obj, slot in to_be_removed: 244 | self._methods[obj].discard(slot) 245 | 246 | 247 | class SignalsErrorBase(Exception): 248 | '''Base Signals Error''' 249 | 250 | default_message = '' 251 | 252 | def __init__(self, *args): 253 | if len(args): 254 | super(SignalsErrorBase, self).__init__(*args) 255 | else: 256 | super(SignalsErrorBase, self).__init__(self.default_message) 257 | 258 | 259 | class SignalsNotAClass(SignalsErrorBase): 260 | '''Must add a Signal Class''' 261 | default_message = 'Signal must be a class.' 262 | 263 | 264 | class Signals(dict): 265 | '''Manage the signals.''' 266 | 267 | def __setitem__(self, key, value): 268 | if key not in self: 269 | super(Signals, self).__setitem__(key, value) 270 | else: 271 | warnings.warn('Signals: signal "{}" already exists.'.format(key)) 272 | 273 | def __getattr__(self, key): 274 | for signal in self: 275 | if signal.__name__ == key: 276 | return self[signal] 277 | raise KeyError('{}'.format(key)) 278 | 279 | def add(self, signal_class, *args, **kwargs): 280 | if inspect.isclass(signal_class): 281 | self.__setitem__(signal_class, signal_class(*args, **kwargs)) 282 | else: 283 | raise SignalsNotAClass 284 | 285 | # ------ 286 | # Tests 287 | # ------ 288 | 289 | 290 | def test_signal_slot(): 291 | 292 | from functools import partial 293 | 294 | def return_args(returns, *args, **kwargs): 295 | returns.update({ 296 | 'args': args, 297 | 'kwargs': kwargs 298 | }) 299 | 300 | class View(object): 301 | def __init__(self): 302 | self.clear() 303 | 304 | def clear(self): 305 | self.args = None 306 | self.kwargs = None 307 | 308 | def set(self, *args, **kwargs): 309 | self.args = args 310 | self.kwargs = kwargs 311 | 312 | # Basic structures 313 | signal_to_func = Signal() 314 | assert len(signal_to_func._slots) == 0 315 | assert len(signal_to_func._methods) == 0 316 | 317 | # Assign a slot 318 | returns = {} 319 | slot = partial(return_args, returns) 320 | signal_to_func.connect(slot) 321 | signal_to_func() 322 | assert len(returns) > 0 323 | assert len(returns['args']) == 0 324 | assert len(returns['kwargs']) == 0 325 | 326 | # Signal with arguments 327 | returns.clear() 328 | an_arg = 'an arg' 329 | signal_to_func(an_arg) 330 | assert len(returns['args']) > 0 331 | assert returns['args'][0] == an_arg 332 | signal_to_func(a_kwarg=an_arg) 333 | assert len(returns['kwargs']) > 0 334 | assert returns['kwargs']['a_kwarg'] == an_arg 335 | 336 | # Signal with methods 337 | signal_to_method = Signal() 338 | view = View() 339 | signal_to_method.connect(view.set) 340 | signal_to_method() 341 | assert len(view.args) == 0 342 | assert len(view.kwargs) == 0 343 | 344 | # Signal with methods and arguments 345 | view.clear() 346 | signal_to_method(an_arg) 347 | assert view.args[0] == an_arg 348 | view.clear() 349 | signal_to_method(a_kwarg=an_arg) 350 | assert view.kwargs['a_kwarg'] == an_arg 351 | 352 | # Delete some slots 353 | returns.clear() 354 | signal_to_func.disconnect(slot) 355 | signal_to_func(an_arg, a_kwarg=an_arg) 356 | assert len(returns) == 0 357 | view.clear() 358 | signal_to_method.disconnect(view.set) 359 | signal_to_method(an_arg, a_kwarg=an_arg) 360 | assert view.args is None 361 | assert view.kwargs is None 362 | 363 | # Test initialization 364 | a_signal = Signal(slot, view.set) 365 | returns.clear() 366 | view.clear() 367 | a_signal(an_arg, a_kwarg=an_arg) 368 | assert returns['args'][0] == an_arg 369 | assert returns['kwargs']['a_kwarg'] == an_arg 370 | assert view.args[0] == an_arg 371 | assert view.kwargs['a_kwarg'] == an_arg 372 | 373 | # Clear a signal 374 | a_signal.clear() 375 | returns.clear() 376 | view.clear() 377 | a_signal(an_arg, a_kwarg=an_arg) 378 | assert len(returns) == 0 379 | assert view.args is None 380 | assert view.kwargs is None 381 | 382 | # Enable/disable 383 | a_signal = Signal(slot, view.set) 384 | assert a_signal.enabled 385 | 386 | a_signal.set_enabled(False) 387 | assert not a_signal.enabled 388 | returns.clear() 389 | view.clear() 390 | a_signal(an_arg, a_kwarg=an_arg) 391 | assert len(returns) == 0 392 | assert view.args is None 393 | assert view.kwargs is None 394 | 395 | a_signal.set_enabled(True) 396 | assert a_signal.enabled 397 | a_signal(an_arg, a_kwarg=an_arg) 398 | assert returns['args'][0] == an_arg 399 | assert returns['kwargs']['a_kwarg'] == an_arg 400 | assert view.args[0] == an_arg 401 | assert view.kwargs['a_kwarg'] == an_arg 402 | 403 | a_signal.set_enabled(False, push=True) 404 | assert not a_signal.enabled 405 | returns.clear() 406 | view.clear() 407 | a_signal(an_arg, a_kwarg=an_arg) 408 | assert len(returns) == 0 409 | assert view.args is None 410 | assert view.kwargs is None 411 | 412 | a_signal.reset_enabled() 413 | assert a_signal.enabled 414 | a_signal(an_arg, a_kwarg=an_arg) 415 | assert returns['args'][0] == an_arg 416 | assert returns['kwargs']['a_kwarg'] == an_arg 417 | assert view.args[0] == an_arg 418 | assert view.kwargs['a_kwarg'] == an_arg 419 | 420 | # Single shots 421 | a_signal = Signal() 422 | a_signal.connect(slot, single_shot=True) 423 | a_signal.connect(view.set, single_shot=True) 424 | returns.clear() 425 | view.clear() 426 | a_signal(an_arg, a_kwarg=an_arg) 427 | assert returns['args'][0] == an_arg 428 | assert returns['kwargs']['a_kwarg'] == an_arg 429 | assert view.args[0] == an_arg 430 | assert view.kwargs['a_kwarg'] == an_arg 431 | 432 | returns.clear() 433 | view.clear() 434 | a_signal(an_arg, a_kwarg=an_arg) 435 | assert len(returns) == 0 436 | assert view.args is None 437 | assert view.kwargs is None 438 | 439 | # Clearing single shots 440 | a_signal = Signal() 441 | a_signal.connect(slot, single_shot=True) 442 | a_signal.connect(view.set, single_shot=True) 443 | a_signal.clear(single_shot=True) 444 | returns.clear() 445 | view.clear() 446 | a_signal(an_arg, a_kwarg=an_arg) 447 | assert len(returns) == 0 448 | assert view.args is None 449 | assert view.kwargs is None 450 | 451 | 452 | if __name__ == '__main__': 453 | test_signal_slot() 454 | -------------------------------------------------------------------------------- /astro3d/gui/viewer.py: -------------------------------------------------------------------------------- 1 | """Main UI Viewer 2 | """ 3 | from functools import partial 4 | from os.path import dirname 5 | 6 | from attrdict import AttrDict 7 | from ginga.AstroImage import AstroImage 8 | 9 | 10 | from ..util.logger import make_null_logger 11 | from qtpy import (QtCore, QtGui, QtWidgets) 12 | from . import signaldb 13 | from .qt import ( 14 | ImageView, 15 | InfoBox, 16 | InstructionViewer, 17 | LayerManager, 18 | OverlayView, 19 | Parameters, 20 | ShapeEditor, 21 | ViewMesh, 22 | ) 23 | from .config import config 24 | 25 | # Configure logging 26 | logger = make_null_logger(__name__) 27 | 28 | # Supported image formats 29 | SUPPORT_IMAGE_FORMATS = ( 30 | 'Images (' 31 | '*.fits' 32 | ' *.jpg' 33 | ' *.jpeg*' 34 | ' *.png' 35 | ' *.gif' 36 | ' *.tif*' 37 | ' *.bmp' 38 | ');;Uncommon (' 39 | '*.fpx' 40 | ' *.pcd' 41 | ' *.pcx' 42 | ' *.pixar' 43 | ' *.ppm' 44 | ' *.sgi' 45 | ' *.tga' 46 | ' *.xbm' 47 | ' *.xpm' 48 | ')' 49 | ) 50 | 51 | # Shortcuts 52 | Qt = QtCore.Qt 53 | GTK_MainWindow = QtWidgets.QMainWindow 54 | 55 | 56 | __all__ = ['MainWindow'] 57 | 58 | 59 | class Image(AstroImage): 60 | """Image container""" 61 | 62 | 63 | class MainWindow(GTK_MainWindow): 64 | """Main Viewer 65 | 66 | Parameters 67 | ---------- 68 | model: astro3d.gui.model.Model 69 | The gui data model 70 | 71 | parent: Qt object 72 | Parent Qt object 73 | """ 74 | def __init__(self, model, parent=None): 75 | super(MainWindow, self).__init__(parent) 76 | self.model = model 77 | 78 | signaldb.ModelUpdate.set_enabled(False) 79 | 80 | self._build_gui() 81 | self._create_signals() 82 | 83 | def path_from_dialog(self): 84 | res = QtWidgets.QFileDialog.getOpenFileName( 85 | self, 86 | "Open image file", 87 | config.get('gui', 'folder_image'), 88 | SUPPORT_IMAGE_FORMATS 89 | ) 90 | if isinstance(res, tuple): 91 | pathname = res[0] 92 | else: 93 | pathname = str(res) 94 | if len(pathname) != 0: 95 | self.open_path(pathname) 96 | config.set('gui', 'folder_image', dirname(pathname)) 97 | 98 | def regionpath_from_dialog(self): 99 | res = QtWidgets.QFileDialog.getOpenFileNames( 100 | self, "Open Region files", 101 | config.get('gui', 'folder_regions'), 102 | "FITS files (*.fits)" 103 | ) 104 | logger.debug('res="{}"'.format(res)) 105 | if len(res) > 0: 106 | if isinstance(res, tuple): 107 | file_list = res[0] 108 | else: 109 | file_list = res 110 | if len(file_list): 111 | self.model.read_maskpathlist(file_list) 112 | signaldb.ModelUpdate() 113 | config.set('gui', 'folder_regions', dirname(file_list[0])) 114 | 115 | def texturepath_from_dialog(self): 116 | res = QtWidgets.QFileDialog.getOpenFileNames( 117 | self, "Open Texture files", 118 | config.get('gui', 'folder_textures'), 119 | "FITS files (*.fits)" 120 | ) 121 | logger.debug('res="{}"'.format(res)) 122 | if len(res) > 0: 123 | if isinstance(res, tuple): 124 | file_list = res[0] 125 | else: 126 | file_list = res 127 | if len(file_list): 128 | self.model.read_maskpathlist( 129 | file_list, container_layer=self.model.textures 130 | ) 131 | signaldb.ModelUpdate() 132 | config.set('gui', 'folder_textures', dirname(file_list[0])) 133 | 134 | def catalogpath_from_dialog(self, catalog_item=None): 135 | res = QtWidgets.QFileDialog.getOpenFileName( 136 | self, 137 | "Open Catalog", 138 | config.get('gui', 'folder_regions') 139 | ) 140 | if isinstance(res, tuple): 141 | pathname = res[0] 142 | else: 143 | pathname = str(res) 144 | if len(pathname) != 0: 145 | self.model.read_stellar_catalog( 146 | pathname, catalog_item=catalog_item 147 | ) 148 | config.set('gui', 'folder_regions', dirname(res[0])) 149 | signaldb.ModelUpdate() 150 | 151 | def starpath_from_dialog(self): 152 | self.catalogpath_from_dialog(self.model.stars_catalogs) 153 | 154 | def clusterpath_from_dialog(self): 155 | self.catalogpath_from_dialog(self.model.cluster_catalogs) 156 | 157 | def path_by_drop(self, viewer, paths): 158 | pathname = paths[0] 159 | self.open_path(pathname) 160 | 161 | def save_all_from_dialog(self): 162 | """Specify folder to save all info""" 163 | result = QtWidgets.QFileDialog.getSaveFileName( 164 | self, 165 | 'Specify prefix to save all as', 166 | config.get('gui', 'folder_save') 167 | ) 168 | logger.debug('result="{}"'.format(result)) 169 | if len(result) > 0: 170 | if isinstance(result, tuple): 171 | path = result[0] 172 | else: 173 | path = result 174 | signaldb.ProcessFinish.connect( 175 | partial(self.save, path), 176 | single_shot=True 177 | ) 178 | config.set('gui', 'folder_save', dirname(path)) 179 | self.force_update() 180 | 181 | def open_path(self, pathname): 182 | """Open the image from pathname""" 183 | self.model.read_image(pathname) 184 | self.image = Image(logger=logger) 185 | self.image.set_data(self.model.image) 186 | self.image_update(self.image) 187 | 188 | def image_update(self, image): 189 | """Image has updated. 190 | 191 | Parameters 192 | ---------- 193 | image: `ginga.Astroimage.AstroImage` 194 | The image. 195 | """ 196 | self.image_viewer.set_image(image) 197 | self.setWindowTitle(image.get('name')) 198 | signaldb.ModelUpdate() 199 | 200 | def save(self, prefix, mesh=None, model3d=None): 201 | """Save all info to the prefix 202 | 203 | Parameters 204 | ---------- 205 | prefix: str 206 | The path prefix to save all the model files to. 207 | 208 | mesh: dict 209 | Not used, but required due to being called 210 | by the ProcessFinish signal. 211 | 212 | model3d: Model3D 213 | The model which created the mesh. 214 | If None, use the inherent model3d. 215 | """ 216 | if model3d is None: 217 | try: 218 | model3d = self.model.model3d 219 | except AttributeError: 220 | return 221 | model3d.write_all_masks(prefix) 222 | model3d.write_all_stellar_tables(prefix) 223 | model3d.write_stl(prefix) 224 | 225 | def force_update(self): 226 | signaldb.ModelUpdate.set_enabled(True, push=True) 227 | try: 228 | signaldb.ModelUpdate() 229 | except Exception as e: 230 | logger.warning('Processing error: "{}"'.format(e)) 231 | finally: 232 | signaldb.ModelUpdate.reset_enabled() 233 | 234 | def quit(self, *args, **kwargs): 235 | """Shutdown""" 236 | logger.debug('GUI shutting down...') 237 | self.model.quit() 238 | self.mesh_viewer.close() 239 | self.instruction_viewer.close() 240 | config.save() 241 | self.deleteLater() 242 | 243 | def auto_reprocessing_state(self): 244 | return signaldb.ModelUpdate.enabled 245 | 246 | def toggle_auto_reprocessing(self): 247 | state = signaldb.ModelUpdate.enabled 248 | signaldb.ModelUpdate.set_enabled(not state) 249 | 250 | def _build_gui(self): 251 | """Construct the app's GUI""" 252 | 253 | #### 254 | # Setup main content views 255 | #### 256 | 257 | # Image View 258 | image_viewer = ImageView() 259 | self.image_viewer = image_viewer 260 | image_viewer.set_desired_size(512, 512) 261 | image_viewer_widget = image_viewer.get_widget() 262 | self.setCentralWidget(image_viewer_widget) 263 | 264 | # Region overlays 265 | self.overlay = OverlayView( 266 | parent=image_viewer, 267 | model=self.model 268 | ) 269 | 270 | # Basic instructions window 271 | self.instruction_viewer = InstructionViewer() 272 | 273 | # 3D mesh preview 274 | self.mesh_viewer = ViewMesh() 275 | 276 | # The Layer manager 277 | self.layer_manager = LayerManager() 278 | self.layer_manager.setModel(self.model) 279 | layer_dock = QtWidgets.QDockWidget('Layers', self) 280 | layer_dock.setAllowedAreas( 281 | Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea 282 | ) 283 | layer_dock.setWidget(self.layer_manager) 284 | self.addDockWidget(Qt.RightDockWidgetArea, layer_dock) 285 | self.layer_dock = layer_dock 286 | 287 | # The Shape Editor 288 | self.shape_editor = ShapeEditor( 289 | surface=image_viewer, 290 | canvas=self.overlay.canvas 291 | ) 292 | shape_editor_dock = QtWidgets.QDockWidget('Shape Editor', self) 293 | shape_editor_dock.setAllowedAreas( 294 | Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea 295 | ) 296 | shape_editor_dock.setWidget(self.shape_editor) 297 | self.addDockWidget(Qt.LeftDockWidgetArea, shape_editor_dock) 298 | self.shape_editor_dock = shape_editor_dock 299 | 300 | # Parameters 301 | self.parameters = Parameters( 302 | parent=self, 303 | model=self.model 304 | ) 305 | parameters_dock = QtWidgets.QDockWidget('Parameters', self) 306 | parameters_dock.setAllowedAreas( 307 | Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea 308 | ) 309 | parameters_dock.setWidget(self.parameters) 310 | self.addDockWidget(Qt.LeftDockWidgetArea, parameters_dock) 311 | self.parameters_dock = parameters_dock 312 | 313 | # The process busy dialog. 314 | process_busy = QtWidgets.QProgressDialog( 315 | 'Creating Model', 316 | 'Abort', 317 | 0, 1 318 | ) 319 | process_busy.reset() 320 | process_busy.setMinimumDuration(0) 321 | process_busy.setWindowModality(Qt.ApplicationModal) 322 | self.process_busy = process_busy 323 | 324 | # General Info Dialog 325 | self.info_box = InfoBox() 326 | 327 | # Setup all the auxiliary gui. 328 | self._create_actions() 329 | self._create_menus() 330 | self._create_toolbars() 331 | self._create_statusbar() 332 | 333 | def _create_actions(self): 334 | """Setup the main actions""" 335 | self.actions = AttrDict() 336 | 337 | # Preferences 338 | auto_reprocess = QtWidgets.QAction('Auto Reprocess', self) 339 | auto_reprocess.setStatusTip('Enable/disable automatic 3D processing') 340 | auto_reprocess.setCheckable(True) 341 | auto_reprocess.setChecked(self.auto_reprocessing_state()) 342 | auto_reprocess.toggled.connect(self.toggle_auto_reprocessing) 343 | self.actions.auto_reprocess = auto_reprocess 344 | 345 | quit = QtWidgets.QAction('&Quit', self) 346 | quit.setStatusTip('Quit application') 347 | quit.triggered.connect(signaldb.Quit) 348 | self.actions.quit = quit 349 | 350 | open = QtWidgets.QAction('&Open', self) 351 | open.setShortcut(QtGui.QKeySequence.Open) 352 | open.setStatusTip('Open image') 353 | open.triggered.connect(self.path_from_dialog) 354 | self.actions.open = open 355 | 356 | regions = QtWidgets.QAction('&Regions', self) 357 | regions.setShortcut('Ctrl+R') 358 | regions.setStatusTip('Open Regions') 359 | regions.triggered.connect(self.regionpath_from_dialog) 360 | self.actions.regions = regions 361 | 362 | stars = QtWidgets.QAction('Stars', self) 363 | stars.setShortcut('Shift+Ctrl+S') 364 | stars.setStatusTip('Open a stellar table') 365 | stars.triggered.connect(self.starpath_from_dialog) 366 | self.actions.stars = stars 367 | 368 | clusters = QtWidgets.QAction('Stellar &Clusters', self) 369 | clusters.setShortcut('Ctrl+C') 370 | clusters.setStatusTip('Open a stellar clusters table') 371 | clusters.triggered.connect(self.clusterpath_from_dialog) 372 | self.actions.clusters = clusters 373 | 374 | textures = QtWidgets.QAction('&Textures', self) 375 | textures.setShortcut('Ctrl+T') 376 | textures.setStatusTip('Open Textures') 377 | textures.triggered.connect(self.texturepath_from_dialog) 378 | self.actions.textures = textures 379 | 380 | save_all = QtWidgets.QAction('&Save', self) 381 | save_all.setShortcut(QtGui.QKeySequence.Save) 382 | save_all.triggered.connect(self.save_all_from_dialog) 383 | self.actions.save_all = save_all 384 | 385 | preview_toggle = QtWidgets.QAction('Mesh View', self) 386 | preview_toggle.setShortcut('Ctrl+V') 387 | preview_toggle.setStatusTip('Open mesh view panel') 388 | preview_toggle.setCheckable(True) 389 | preview_toggle.setChecked(False) 390 | preview_toggle.toggled.connect(self.mesh_viewer.toggle_view) 391 | self.mesh_viewer.closed.connect(preview_toggle.setChecked) 392 | self.actions.preview_toggle = preview_toggle 393 | 394 | instruction_toggle = QtWidgets.QAction('&Instructions', self) 395 | instruction_toggle.setShortcut('Ctrl+I') 396 | instruction_toggle.setStatusTip('Open Instruction Window') 397 | instruction_toggle.setCheckable(True) 398 | instruction_toggle.setChecked(False) 399 | instruction_toggle.toggled.connect(self.instruction_viewer.toggle_view) 400 | self.instruction_viewer.closed.connect(instruction_toggle.setChecked) 401 | self.actions.instruction_toggle = instruction_toggle 402 | 403 | reprocess = QtWidgets.QAction('Reprocess', self) 404 | reprocess.setShortcut('Shift+Ctrl+R') 405 | reprocess.setStatusTip('Reprocess the model') 406 | reprocess.triggered.connect(self.force_update) 407 | self.actions.reprocess = reprocess 408 | 409 | def _create_menus(self): 410 | """Setup the main menus""" 411 | from sys import platform 412 | if platform == 'darwin': 413 | menubar = QtWidgets.QMenuBar() 414 | else: 415 | menubar = self.menuBar() 416 | self.menubar = menubar 417 | 418 | # File menu 419 | file_menu = menubar.addMenu('&File') 420 | file_menu.addAction(self.actions.open) 421 | file_menu.addAction(self.actions.regions) 422 | file_menu.addAction(self.actions.clusters) 423 | file_menu.addAction(self.actions.stars) 424 | file_menu.addAction(self.actions.textures) 425 | file_menu.addSeparator() 426 | file_menu.addAction(self.actions.save_all) 427 | file_menu.addAction(self.actions.quit) 428 | 429 | view_menu = menubar.addMenu('View') 430 | view_menu.addAction(self.actions.instruction_toggle) 431 | view_menu.addAction(self.actions.preview_toggle) 432 | view_menu.addAction(self.layer_dock.toggleViewAction()) 433 | 434 | def _create_toolbars(self): 435 | """Setup the main toolbars""" 436 | 437 | def _create_statusbar(self): 438 | """Setup the status bar""" 439 | 440 | def _create_signals(self): 441 | """Setup the overall signal structure""" 442 | self.image_viewer.set_callback('drag-drop', self.path_by_drop) 443 | 444 | self.process_busy.canceled.connect(signaldb.ProcessForceQuit) 445 | 446 | signaldb.CatalogFromFile.connect(self.catalogpath_from_dialog) 447 | 448 | signaldb.CreateGasSpiralMasks.connect( 449 | self.parameters.create_gas_spiral_masks 450 | ) 451 | 452 | signaldb.LayerSelected.connect(self.shape_editor.select_layer) 453 | signaldb.LayerSelected.connect(self.layer_manager.select_from_object) 454 | 455 | signaldb.NewImage.connect(self.image_update) 456 | 457 | signaldb.ProcessFail.connect( 458 | lambda text, error: self.process_busy.reset() 459 | ) 460 | signaldb.ProcessFail.connect( 461 | lambda text, error: signaldb.ProcessFinish.clear(single_shot=True) 462 | ) 463 | signaldb.ProcessFail.connect(self.info_box.show_error) 464 | signaldb.ProcessFinish.connect(self.mesh_viewer.update_mesh) 465 | signaldb.ProcessFinish.connect( 466 | lambda x, y: self.process_busy.reset() 467 | ) 468 | signaldb.ProcessForceQuit.connect(self.process_busy.reset) 469 | signaldb.ProcessStart.connect(self.mesh_viewer.process) 470 | signaldb.ProcessStart.connect( 471 | lambda: self.process_busy.setValue(0) 472 | ) 473 | 474 | signaldb.Quit.connect(self.quit) 475 | -------------------------------------------------------------------------------- /astro3d/gui/qt/shape_editor.py: -------------------------------------------------------------------------------- 1 | """Shape Editor""" 2 | 3 | from collections import defaultdict 4 | from functools import partial 5 | from numpy import (zeros, uint8) 6 | 7 | from ginga import colors 8 | from ginga.misc.Bunch import Bunch 9 | from ginga.RGBImage import RGBImage 10 | from ginga.gw import Widgets 11 | from qtpy import (QtCore, QtGui, QtWidgets) 12 | 13 | from .items import (CatalogItem, ClusterItem, StarsItem) 14 | from .. import signaldb 15 | from ...core.region_mask import RegionMask 16 | from ...util.logger import make_null_logger 17 | from ...util.text_catalog import TEXT_CATALOG 18 | 19 | # Configure logging 20 | logger = make_null_logger(__name__) 21 | 22 | __all__ = ['ShapeEditor'] 23 | 24 | # Valid shapes to edit 25 | VALID_KINDS = [ 26 | 'freepolygon', 'paint', 27 | 'circle', 'rectangle', 28 | 'triangle', 'righttriangle', 29 | 'square', 'ellipse', 'box' 30 | ] 31 | 32 | # Shape editor instructions 33 | INSTRUCTIONS = defaultdict( 34 | lambda: TEXT_CATALOG['shape_editor']['default'], 35 | TEXT_CATALOG['shape_editor'] 36 | ) 37 | 38 | 39 | class ShapeEditor(QtWidgets.QWidget): 40 | """Shape Editor 41 | 42 | Paremeters 43 | ---------- 44 | surface: `ginga.Canvas` 45 | The canvas to interact on. 46 | """ 47 | 48 | def __init__(self, *args, **kwargs): 49 | self.surface = kwargs.pop('surface', None) 50 | canvas = kwargs.pop('canvas', None) 51 | 52 | super(ShapeEditor, self).__init__(*args, **kwargs) 53 | 54 | self._canvas = None 55 | self._mode = None 56 | self.drawkinds = [] 57 | self.enabled = False 58 | self.canvas = canvas 59 | self.mask = None 60 | self.type_item = None 61 | self.draw_params = None 62 | 63 | signaldb.NewRegion.connect(self.new_region) 64 | 65 | @property 66 | def canvas(self): 67 | """The canvas the draw object will appear""" 68 | return self._canvas 69 | 70 | @canvas.setter 71 | def canvas(self, canvas): 72 | if canvas is None or \ 73 | self._canvas is canvas: 74 | return 75 | 76 | self._canvas = canvas 77 | 78 | # Setup parameters 79 | self.drawkinds = VALID_KINDS 80 | 81 | # Create paint mode 82 | canvas.add_draw_mode( 83 | 'paint', 84 | down=self.paint_start, 85 | move=self.paint_stroke, 86 | up=self.paint_stroke_end 87 | ) 88 | 89 | # Setup common events. 90 | canvas.set_callback('draw-event', self.draw_cb) 91 | canvas.set_callback('edit-event', self.edit_cb) 92 | canvas.set_callback('edit-select', self.edit_select_cb) 93 | canvas.set_callback('key-up-none', self.key_event_handler) 94 | 95 | # Initial canvas state 96 | canvas.enable_edit(True) 97 | canvas.enable_draw(True) 98 | canvas.set_surface(self.surface) 99 | canvas.register_for_cursor_drawing(self.surface) 100 | self._build_gui() 101 | self.mode = None 102 | self.enabled = True 103 | 104 | @property 105 | def enabled(self): 106 | return self._enabled 107 | 108 | @enabled.setter 109 | def enabled(self, state): 110 | self._enabled = state 111 | try: 112 | self._canvas.ui_set_active(state) 113 | except AttributeError: 114 | pass 115 | 116 | @property 117 | def mode(self): 118 | return self._mode 119 | 120 | @mode.setter 121 | def mode(self, new_mode): 122 | logger.debug('new_mode = "{}"'.format(new_mode)) 123 | 124 | # Close off the current state. 125 | for mode in self.mode_frames: 126 | for frame in self.mode_frames[mode]: 127 | frame.hide() 128 | try: 129 | for frame in self.mode_frames[new_mode]: 130 | frame.show() 131 | except KeyError: 132 | """Doesn't matter if there is nothing to show.""" 133 | pass 134 | 135 | # Setup the new mode. 136 | canvas_mode = new_mode 137 | if new_mode is None: 138 | canvas_mode = 'edit' 139 | elif new_mode == 'edit_select': 140 | canvas_mode = 'edit' 141 | elif new_mode == 'paint': 142 | self.new_mask() 143 | self.set_painting(True) 144 | elif new_mode == 'paint_edit': 145 | canvas_mode = 'paint' 146 | elif new_mode == 'catalog': 147 | canvas_mode = 'pick' 148 | logger.debug('canvas_mode = "{}"'.format(canvas_mode)) 149 | self.canvas.set_draw_mode(canvas_mode) 150 | 151 | # Success. Remember the mode 152 | self._mode = new_mode 153 | self.children.instructions.set_text(INSTRUCTIONS[new_mode]) 154 | 155 | def new_region(self, type_item): 156 | if self.canvas is None: 157 | raise RuntimeError('Internal error: no canvas to draw on.') 158 | 159 | self.type_item = type_item 160 | self.draw_params = type_item.draw_params 161 | self.set_drawparams_cb() 162 | 163 | def set_drawparams_cb(self, kind=None): 164 | params = {} 165 | if kind is None: 166 | kind = self.get_selected_kind() 167 | if kind == 'paint': 168 | self.mode = 'paint' 169 | else: 170 | self.mode = 'draw' 171 | try: 172 | params.update(self.draw_params) 173 | except AttributeError: 174 | pass 175 | self.canvas.set_drawtype(kind, **params) 176 | 177 | def draw_cb(self, canvas, tag): 178 | """Shape draw completion""" 179 | shape = canvas.get_object_by_tag(tag) 180 | shape.type_draw_params = self.draw_params 181 | region_mask = partial( 182 | self.surface.get_shape_mask, 183 | self.type_item.text(), 184 | shape 185 | ) 186 | shape.item = self.type_item.add_shape( 187 | shape=shape, 188 | mask=region_mask, 189 | id='{}{}'.format(shape.kind, tag) 190 | ) 191 | self.mode = None 192 | self.type_item = None 193 | self.draw_params = None 194 | 195 | def edit_cb(self, *args, **kwargs): 196 | """Edit callback""" 197 | signaldb.ModelUpdate() 198 | 199 | def edit_select_cb(self, canvas, obj): 200 | """Edit selected object callback""" 201 | if self.canvas.num_selected() > 0: 202 | self.draw_params = obj.type_draw_params 203 | self.mode = 'edit_select' 204 | signaldb.LayerSelected( 205 | selected_item=obj.item, 206 | source='edit_select_cb' 207 | ) 208 | else: 209 | self.mode = None 210 | 211 | def edit_deselect_cb(self, *args, **kwargs): 212 | """Deselect""" 213 | self.canvas.clear_selected() 214 | 215 | def rotate_object(self, w): 216 | delta = float(w.get_text()) 217 | self.canvas.edit_rotate(delta, self.surface) 218 | signaldb.ModelUpdate() 219 | 220 | def scale_object(self, w): 221 | delta = float(w.get_text()) 222 | self.canvas.edit_scale(delta, delta, self.surface) 223 | signaldb.ModelUpdate() 224 | 225 | def new_brush(self, copy_from=None): 226 | """Create a new brush shape""" 227 | brush_size = self.children.brush_size.get_value() 228 | brush = self.canvas.get_draw_class('squarebox')( 229 | x=0., y=0., radius=max(brush_size / 2, 0.5), 230 | **self.draw_params 231 | ) 232 | logger.debug("brush: %s", dir(brush)) 233 | if copy_from is not None: 234 | brush.x = copy_from.x 235 | brush.y = copy_from.y 236 | brush.radius = copy_from.radius 237 | self.canvas.add(brush) 238 | return brush 239 | 240 | def paint_start(self, canvas, event, data_x, data_y, surface): 241 | """Start a paint stroke""" 242 | self.brush = self.new_brush() 243 | self.brush_move(data_x, data_y) 244 | 245 | def paint_stroke(self, canvas, event, data_x, data_y, surface): 246 | """Perform a paint stroke""" 247 | previous = self.brush 248 | self.brush = self.new_brush(previous) 249 | self.brush_move(data_x, data_y) 250 | self.stroke(previous, self.brush) 251 | self.canvas.delete_object(previous) 252 | self.canvas.redraw(whence=0) 253 | 254 | def paint_stroke_end(self, canvas, event, data_x, data_y, surface): 255 | self.paint_stroke(canvas, event, data_x, data_y, surface) 256 | self.canvas.delete_object(self.brush) 257 | 258 | # If starting to paint, go into edit mode. 259 | self.finalize_paint() 260 | 261 | def paint_stop(self): 262 | self.finalize_paint() 263 | self.mode = None 264 | 265 | def finalize_paint(self): 266 | """Finalize the paint mask""" 267 | try: 268 | self.canvas.delete_object(self.brush) 269 | except ValueError as exception: 270 | # Cannot delete brush. Finish off anyways. 271 | pass 272 | except Exception as exception: 273 | """If no brush, we were not painting""" 274 | logger.debug('Cannot delete brush %s because %s', self.brush, type(exception)) 275 | return 276 | 277 | # If mode is paint_edit, there is no 278 | # reason to create the item. 279 | if self.mode == 'paint': 280 | if self.mask.any(): 281 | shape = self.mask_image 282 | self.canvas.delete_object(shape) 283 | shape.type_draw_params = self.draw_params 284 | region_mask = partial( 285 | image_shape_to_regionmask, 286 | shape=shape, 287 | mask_type=self.type_item.text() 288 | ) 289 | shape.item = self.type_item.add_shape( 290 | shape=shape, 291 | mask=region_mask, 292 | id='mask{}'.format(self.mask_id) 293 | ) 294 | signaldb.LayerSelected(selected_item=shape.item, source='finalize_paint') 295 | 296 | def stroke(self, previous, current): 297 | """Stroke to current brush position""" 298 | # Due to possible object deletion from an update 299 | # to treeview, ensure that the image mask is still 300 | # on the canvas. 301 | if self.mask_id not in self.canvas.tags: 302 | self.mask_id = self.canvas.add(self.mask_image) 303 | 304 | # Create a polygon between brush positions. 305 | poly_points = get_bpoly(previous, current) 306 | polygon = self.canvas.get_draw_class('polygon')( 307 | poly_points, 308 | **self.draw_params 309 | ) 310 | self.canvas.add(polygon) 311 | view, contains = self.surface.get_image().get_shape_view(polygon) 312 | 313 | if self.painting: 314 | self.mask[view][contains] = self.draw_params['fillalpha'] * 255 315 | else: 316 | self.mask[view][contains] = 0 317 | self.canvas.delete_object(polygon) 318 | 319 | def new_mask(self): 320 | self.draw_params = self.type_item.draw_params 321 | color = self.draw_params['color'] 322 | r, g, b = colors.lookup_color(color) 323 | height, width = self.surface.get_image().shape 324 | rgbarray = zeros((height, width, 4), dtype=uint8) 325 | mask_rgb = RGBImage(data_np=rgbarray) 326 | mask_image = self.canvas.get_draw_class('image')(0, 0, mask_rgb) 327 | rc = mask_rgb.get_slice('R') 328 | gc = mask_rgb.get_slice('G') 329 | bc = mask_rgb.get_slice('B') 330 | rc[:] = int(r * 255) 331 | gc[:] = int(g * 255) 332 | bc[:] = int(b * 255) 333 | alpha = mask_rgb.get_slice('A') 334 | alpha[:] = 0 335 | self.mask = alpha 336 | self.mask_image = mask_image 337 | self.mask_id = self.canvas.add(mask_image) 338 | 339 | def get_selected_kind(self): 340 | kind = self.drawkinds[self.children.draw_type.get_index()] 341 | return kind 342 | 343 | def brush_move(self, x, y): 344 | self.brush.move_to_pt((x, y)) 345 | self.canvas.update_canvas(whence=3) 346 | 347 | def set_painting(self, state): 348 | """Set painting mode 349 | 350 | Parameters 351 | ---------- 352 | state: bool 353 | True for painting, False for erasing 354 | """ 355 | self.painting = state 356 | self.children.paint.set_state(state) 357 | 358 | def select_layer(self, 359 | selected_item=None, 360 | deselected_item=None, 361 | source=None): 362 | """Change layer selection""" 363 | 364 | logger.debug('selected_item = "{}"'.format(selected_item)) 365 | 366 | # If the selection was initiated by 367 | # selecting the object directly, there is 368 | # no reason to handle here. 369 | if source == 'edit_select_cb': 370 | return 371 | 372 | self.mode = None 373 | try: 374 | self.canvas.select_remove(deselected_item.view) 375 | except AttributeError: 376 | """We tried. No matter""" 377 | pass 378 | 379 | # Check for catalog items 380 | if isinstance(selected_item, (CatalogItem, ClusterItem, StarsItem)): 381 | self.mode = 'catalog' 382 | 383 | # Otherwise, base mode off of shape. 384 | else: 385 | try: 386 | shape = selected_item.view 387 | self.draw_params = shape.type_draw_params 388 | except AttributeError: 389 | """We tried. No matter""" 390 | pass 391 | else: 392 | if shape.kind == 'image': 393 | self.mask_image = shape.get_image() 394 | self.mask = self.mask_image.get_slice('A') 395 | self.mask_id = selected_item.text() 396 | self.draw_params = shape.type_draw_params 397 | self.mode = 'paint_edit' 398 | else: 399 | x, y = selected_item.view.get_center_pt() 400 | self.canvas._prepare_to_move(selected_item.view, x, y) 401 | self.mode = 'edit_select' 402 | 403 | self.selected_item = selected_item 404 | self.canvas.process_drawing() 405 | 406 | def key_event_handler(self, viewer, event, x, y): 407 | """Handle key events""" 408 | 409 | if self.mode == 'catalog': 410 | if event.key == 's': 411 | try: 412 | self.selected_item.add_entry(x, y) 413 | except Exception: 414 | # We tried. No matter 415 | pass 416 | 417 | def _build_gui(self): 418 | """Build out the GUI""" 419 | # Remove old layout 420 | if self.layout() is not None: 421 | QtWidgets.QWidget().setLayout(self.layout()) 422 | self.children = Bunch() 423 | spacer = Widgets.Label('') 424 | 425 | # Instructions 426 | instructions_text = Widgets.TextArea(wrap=True, editable=False) 427 | font = QtGui.QFont('sans serif', 12) 428 | instructions_text.set_font(font) 429 | instructions_frame = Widgets.Expander("Instructions") 430 | instructions_frame.set_widget(instructions_text) 431 | self.children['instructions'] = instructions_text 432 | self.children['instructions_frame'] = instructions_frame 433 | 434 | # Setup for the drawing types 435 | captions = ( 436 | ("Draw type:", 'label', "Draw type", 'combobox'), 437 | ) 438 | dtypes_widget, dtypes_bunch = Widgets.build_info(captions) 439 | self.children.update(dtypes_bunch) 440 | 441 | combobox = dtypes_bunch.draw_type 442 | for name in self.drawkinds: 443 | combobox.append_text(name) 444 | index = self.drawkinds.index('freepolygon') 445 | combobox.add_callback( 446 | 'activated', 447 | lambda w, idx: self.set_drawparams_cb() 448 | ) 449 | combobox.set_index(index) 450 | 451 | draw_frame = Widgets.Frame("Drawing") 452 | draw_frame.set_widget(dtypes_widget) 453 | 454 | # Setup for painting 455 | captions = ( 456 | ('Brush size:', 'label', 'Brush size', 'spinbutton'), 457 | ('Paint mode: ', 'label', 458 | 'Paint', 'radiobutton', 459 | 'Erase', 'radiobutton'), 460 | ) 461 | paint_widget, paint_bunch = Widgets.build_info(captions) 462 | self.children.update(paint_bunch) 463 | brush_size = paint_bunch.brush_size 464 | brush_size.set_limits(1, 100) 465 | brush_size.set_value(10) 466 | 467 | painting = paint_bunch.paint 468 | painting.add_callback( 469 | 'activated', 470 | lambda widget, value: self.set_painting(value) 471 | ) 472 | self.set_painting(True) 473 | 474 | paint_frame = Widgets.Frame('Painting') 475 | paint_frame.set_widget(paint_widget) 476 | 477 | # Setup for editing 478 | captions = ( 479 | ("Rotate By:", 'label', 'Rotate By', 'entry'), 480 | ("Scale By:", 'label', 'Scale By', 'entry') 481 | ) 482 | edit_widget, edit_bunch = Widgets.build_info(captions) 483 | self.children.update(edit_bunch) 484 | edit_bunch.scale_by.add_callback('activated', self.scale_object) 485 | edit_bunch.scale_by.set_text('0.9') 486 | edit_bunch.scale_by.set_tooltip("Scale selected object in edit mode") 487 | edit_bunch.rotate_by.add_callback('activated', self.rotate_object) 488 | edit_bunch.rotate_by.set_text('90.0') 489 | edit_bunch.rotate_by.set_tooltip("Rotate selected object in edit mode") 490 | 491 | edit_frame = Widgets.Frame('Editing') 492 | edit_frame.set_widget(edit_widget) 493 | 494 | # Put it together 495 | layout = QtWidgets.QVBoxLayout() 496 | layout.setContentsMargins(QtCore.QMargins(20, 20, 20, 20)) 497 | layout.setSpacing(1) 498 | layout.addWidget(instructions_frame.get_widget(), stretch=0) 499 | layout.addWidget(draw_frame.get_widget(), stretch=0) 500 | layout.addWidget(paint_frame.get_widget(), stretch=0) 501 | layout.addWidget(edit_frame.get_widget(), stretch=0) 502 | layout.addWidget(spacer.get_widget(), stretch=1) 503 | self.setLayout(layout) 504 | 505 | # Setup mode frames 506 | self.mode_frames = { 507 | 'draw': [draw_frame], 508 | 'edit_select': [edit_frame], 509 | 'paint': [ 510 | draw_frame, 511 | paint_frame 512 | ], 513 | 'paint_edit': [paint_frame] 514 | } 515 | 516 | 517 | def get_bpoly(box1, box2): 518 | """Get the bounding polygon of two boxes""" 519 | left = box1 520 | right = box2 521 | if box2.x < box1.x: 522 | left = box2 523 | right = box1 524 | left_llx, left_lly, left_urx, left_ury = left.get_llur() 525 | right_llx, right_lly, right_urx, right_ury = right.get_llur() 526 | 527 | b = [(left_llx, left_lly)] 528 | if left.y <= right.y: 529 | b.extend([ 530 | (left_urx, left_lly), 531 | (right_urx, right_lly), 532 | (right_urx, right_ury), 533 | (right_llx, right_ury), 534 | (left_llx, left_ury) 535 | ]) 536 | else: 537 | b.extend([ 538 | (right_llx, right_lly), 539 | (right_urx, right_lly), 540 | (right_urx, right_ury), 541 | (left_urx, left_ury), 542 | (left_llx, left_ury) 543 | ]) 544 | return b 545 | 546 | 547 | def corners(box): 548 | """Get the corners of a box 549 | 550 | Returns 551 | ------- 552 | List of the corners starting at the 553 | lower left, going counter-clockwise 554 | """ 555 | xll, yll, xur, yur = box.get_llur() 556 | corners = [ 557 | (xll, yll), 558 | (xll, yur), 559 | (xur, yur), 560 | (xur, yll) 561 | ] 562 | return corners 563 | 564 | 565 | def image_shape_to_regionmask(shape, mask_type): 566 | """Convert and Image shape to regionmask""" 567 | return RegionMask( 568 | mask=shape.get_image().get_slice('A') > 0, 569 | mask_type=mask_type 570 | ) 571 | 572 | 573 | def cb_debug(*args, **kwargs): 574 | print('shape_editor.cb_debug: args = "{}" kwargs="{}"'.format(args, kwargs)) 575 | --------------------------------------------------------------------------------