pax_global_header00006660000000000000000000000064152105415150014511gustar00rootroot0000000000000052 comment=ed0e5bdc832e636e44d40cf8fb8eb5728d906e1e ufo-kit-tofu-ed0e5bd/000077500000000000000000000000001521054151500146145ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/.gitignore000066400000000000000000000001221521054151500165770ustar00rootroot00000000000000*.pyc build/ dist/ *.egg-info/ install_manifest*.txt .idea/ .vscode/settings.json ufo-kit-tofu-ed0e5bd/.readthedocs.yml000066400000000000000000000013171521054151500177040ustar00rootroot00000000000000# .readthedocs.yml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 build: os: ubuntu-20.04 apt_packages: - gobject-introspection - libgirepository1.0-dev tools: python: "3.8" # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/source/conf.py # Build documentation with MkDocs #mkdocs: # configuration: mkdocs.yml # Optionally build your docs in additional formats such as PDF # formats: # - pdf # Optionally set the version of Python and requirements required to build your docs python: install: - requirements: docs/requirements.txt - method: pip path: . ufo-kit-tofu-ed0e5bd/LICENSE000066400000000000000000000167431521054151500156340ustar00rootroot00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. ufo-kit-tofu-ed0e5bd/MANIFEST.in000066400000000000000000000000471521054151500163530ustar00rootroot00000000000000include pkgconfig.py include README.md ufo-kit-tofu-ed0e5bd/README.md000066400000000000000000000110021521054151500160650ustar00rootroot00000000000000## About [![PyPI version](https://badge.fury.io/py/ufo-tofu.png)](http://badge.fury.io/py/ufo-tofu) [![Documentation status](https://readthedocs.org/projects/tofu/badge/?version=latest)](http://tofu.readthedocs.io/en/latest/?badge=latest) This repository contains Python data processing scripts to be used with the UFO framework. At the moment they are targeted at high-performance reconstruction of tomographic data sets. If you use this software for publishing your data, we kindly ask you to cite the article **Faragó, T., Gasilov, S., Emslie, I., Zuber, M., Helfen, L., Vogelgesang, M. & Baumbach, T. (2022). J. Synchrotron Rad. 29, https://doi.org/10.1107/S160057752200282X** If you want to stay updated, subscribe to our [newsletter](mailto:sympa@lists.kit.edu?subject=subscribe%20ufo%20YourFirstName%20YourLastName). Simply leave the body of the e-mail empty and in the subject change ``YourFirstName YourLastName`` accordingly. ## Installation First make sure you have [ufo-core](https://github.com/ufo-kit/ufo-core) and [ufo-filters](https://github.com/ufo-kit/ufo-filters) installed. For that, please follow the [installation instructions](https://ufo-core.readthedocs.io/en/latest/install/index.html). You can either install the prerequisites yourself on [Linux](https://ufo-core.readthedocs.io/en/latest/install/linux.html), or use one of our [Docker containers](https://ufo-core.readthedocs.io/en/latest/install/docker.html). Then, for the newest version run the following in *tofu*'s top directory: pip install . or to install via PyPI: pip install ufo-tofu in a prepared virtualenv or as root for system-wide installation. Note that graphical user interfaces require additional packages. You are strongly advised to install PyQt through your system package manager. You can install the requirements for the different guis as extras: pip install .[gui] # OR pip install ufo-tofu[gui] depending if you are installing from source or PyPI. `tofu flow` and `tofu ez` are supported similarly: pip install .[flow] pip install .[ez] pip install .[interactive,gui,flow,ez,test] # All extras ## Usage ### Flow `tofu flow` is a visual flow programming tool. You can create a flow by using any task from [ufo-filters](https://github.com/ufo-kit/ufo-filters) and execute it. In includes visualization of 2D and 3D results, so you can quickly check the output of your flow, which is useful for finding algorithm parameters. ![flow](https://user-images.githubusercontent.com/2648829/150096902-fdbf1b7e-b34e-4368-98ac-c924cad8a6cd.jpg) ### Reconstruction To do a tomographic reconstruction you simply call $ tofu tomo --sinograms $PATH_TO_SINOGRAMS from the command line. To get get correct results, you may need to append options such as `--axis-pos/-a` and `--angle-step/-a` (which are given in radians!). Input paths are either directories or glob patterns. Output paths are either directories or a format that contains one `%i` [specifier](http://www.pixelbeat.org/programming/gcc/format_specs.html): $ tofu tomo --axis-pos=123.4 --angle-step=0.000123 \ --sinograms="/foo/bar/*.tif" --output="/output/slices-%05i.tif" You can get a help for all options by running $ tofu tomo --help and more verbose output by running with the `-v/--verbose` flag. You can also load reconstruction parameters from a configuration file called `reco.conf`. You may create a template with $ tofu init Note, that options passed via the command line always override configuration parameters! Besides scripted reconstructions, one can also run a standalone GUI for both reconstruction and quick assessment of the reconstructed data via $ tofu gui ![GUI](https://cloud.githubusercontent.com/assets/115270/6442540/db0b55fe-c0f0-11e4-9577-0048fddae8b7.png) ### Performance measurement If you are running at least ufo-core/filters 0.6, you can evaluate the performance of the filtered backprojection (without sinogram transposition!), with $ tofu perf You can customize parameter scans, pretty easily via $ tofu perf --width 256:8192:256 --height 512 which will reconstruct all combinations of width between 256 and 8192 with a step of 256 and a fixed height of 512 pixels. ### Estimating the center of rotation If you do not know the correct center of rotation from your experimental setup, you can estimate it with: $ tofu estimate -i $PATH_TO_SINOGRAMS Currently, a modified algorithm based on the work of [Donath et al.](http://dx.doi.org/10.1364/JOSAA.23.001048) is used to determine the center. ufo-kit-tofu-ed0e5bd/bin/000077500000000000000000000000001521054151500153645ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/bin/tofu000077500000000000000000000203021521054151500162640ustar00rootroot00000000000000#!/usr/bin/env python3 import os import sys import argparse import logging import time import re import gi from tofu import config, __version__ try: gi.require_version('Ufo', '0.0') except ValueError: gi.require_version('Ufo', '1.0') LOG = logging.getLogger('tofu') def init(args): if not os.path.exists(args.config): config.write(args.config) else: raise RuntimeError("{0} already exists".format(args.config)) def run_tomo(args): from tofu import reco reco.tomo(args) def run_lamino(args): from tofu import lamino lamino.lamino(args) def run_genreco(args): from tofu import genreco genreco.genreco(args) def run_flat_correct(args): from tofu import preprocess preprocess.run_flat_correct(args) def run_preprocessing(args): from tofu import preprocess preprocess.run_preprocessing(args) def run_sinos(args): from tofu import preprocess preprocess.run_sinogram_generation(args) def run_ez(args): if args.ezvars: LOG.info(f"Loading ez parameters from {args.ezvars}") from tofu.ez.main import execute_from_params execute_from_params(args) else: from tofu.ez.GUI.ezufo_launcher import main_qt main_qt(args) def get_ipython_shell(config=None): import IPython version = IPython.__version__ shell = None def cmp_versions(v1, v2): """Compare two version numbers and return cmp compatible result""" def normalize(v): return [int(x) for x in re.sub(r'(\.0+)*$', '', v).split(".")] n1 = normalize(v1) n2 = normalize(v2) return (n1 > n2) - (n1 < n2) if cmp_versions(version, '0.11') < 0: from IPython.Shell import IPShellEmbed shell = IPShellEmbed() elif cmp_versions(version, '1.0') < 0: from IPython.frontend.terminal.embed import \ InteractiveShellEmbed shell = InteractiveShellEmbed(config=config, banner1='') else: from IPython.terminal.embed import InteractiveShellEmbed shell = InteractiveShellEmbed(config=config, banner1='') return shell def run_shell(args): from tofu import reco shell = get_ipython_shell() shell() def run_find_large_spots(args): from tofu.find_large_spots import find_large_spots, find_large_spots_median if args.method == 'grow': find_large_spots(args) else: find_large_spots_median(args) def run_inpaint(args): from tofu import inpaint inpaint.run(args) def gui(args): try: from tofu import gui gui.main(args) except ImportError as e: LOG.error(str(e)) def run_flow(args): from tofu.flow.main import main as flow_main flow_main() def estimate(params): from tofu import reco center = reco.estimate_center(params) if params.verbose: out = '>>> Best axis of rotation: {}'.format(center) else: out = center print(out) def perf(args): from tofu import reco def measure(args): exec_times = [] total_times = [] for i in range(args.num_runs): start = time.time() exec_times.append(reco.tomo(args)) total_times.append(time.time() - start) exec_time = sum(exec_times) / len(exec_times) total_time = sum(total_times) / len(total_times) overhead = (total_time / exec_time - 1.0) * 100 input_bandwidth = args.width * args.height * num_projections * 4 / exec_time / 1024. / 1024. output_bandwidth = args.width * args.width * height * 4 / exec_time / 1024. / 1024. slice_bandwidth = args.height / exec_time # Four bytes of our output bandwidth constitute one slice pixel, for each # pixel we have to do roughly n * 6 floating point ops (2 mad, 1 add, 1 # interpolation) flops = output_bandwidth / 4 * 6 * num_projections / 1024 msg = ("width={:<6d} height={:<6d} n_proj={:<6d} " "exec={:.4f}s total={:.4f}s overhead={:.2f}% " "bandwidth_i={:.2f}MB/s bandwidth_o={:.2f}MB/s slices={:.2f}/s " "flops={:.2f}GFLOPs\n") sys.stdout.write(msg.format(args.width, args.height, args.number, exec_time, total_time, overhead, input_bandwidth, output_bandwidth, slice_bandwidth, flops)) sys.stdout.flush() args.projections = None args.sinograms = None args.dry_run = True for width in range(*args.width_range): for height in range(*args.height_range): for num_projections in range(*args.num_projection_range): args.width = width args.height = height args.number = num_projections measure(args) def main(): parser = argparse.ArgumentParser() parser.add_argument('--config', **config.SECTIONS['general']['config']) parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__)) sino_params = ('flat-correction', 'sinos') reco_params = ('flat-correction', 'reconstruction') tomo_params = config.TOMO_PARAMS lamino_params = config.LAMINO_PARAMS gui_params = tomo_params + ('gui', ) cmd_parsers = [ ('init', init, (), "Create configuration file"), ('preprocess', run_preprocessing, config.PREPROC_PARAMS, "Run preprocessing"), ('flatcorrect', run_flat_correct, ('flat-correction',), "Run flat field correction"), ('sinos', run_sinos, sino_params, "Generate sinograms from projections"), ('tomo', run_tomo, tomo_params, "Run tomographic reconstruction"), ('lamino', run_lamino, lamino_params, "Run laminographic reconstruction"), ('reco', run_genreco, config.GEN_RECO_PARAMS, "Run general projection-based " "reconstruction for tomographic/" "laminographic cone/parallel beam"), ('gui', gui, tomo_params + ('gui',), "GUI for tomographic reconstruction"), ('flow', run_flow, (), "Visual flow creation"), ('ez', run_ez, ('ez',), "GUI for making ufo-kit data processing pipelines"), ('estimate', estimate, tomo_params + ('estimate',), "Estimate center of rotation"), ('perf', perf, tomo_params + ('perf',), "Check reconstruction performance"), ('interactive', run_shell, tomo_params, "Run interactive mode"), ('find-large-spots', run_find_large_spots, ('find-large-spots',), "Find large spots on images"), ('inpaint', run_inpaint, ('inpaint',), "Inpaint images"), ] if sys.version < '3.7': subparsers = parser.add_subparsers(title="Commands", dest='commands') else: subparsers = parser.add_subparsers(title="Commands", dest='commands', required=True) for cmd, func, sections, text in cmd_parsers: cmd_params = config.Params(sections=sections) cmd_parser = subparsers.add_parser(cmd, help=text, formatter_class=argparse.ArgumentDefaultsHelpFormatter) cmd_parser = cmd_params.add_arguments(cmd_parser) cmd_parser.set_defaults(_func=func) args = config.parse_known_args(parser, subparser=True) log_level = logging.DEBUG if args.verbose else logging.INFO LOG.setLevel(log_level) stream_handler = logging.StreamHandler(sys.stdout) stream_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) LOG.addHandler(stream_handler) if args.log: file_handler = logging.FileHandler(args.log) file_handler.setFormatter(logging.Formatter('[%(asctime)s] %(name)s:%(levelname)s: %(message)s')) LOG.addHandler(file_handler) try: config.log_values(args) args._func(args) except RuntimeError as e: LOG.error(str(e)) sys.exit(1) if __name__ == '__main__': main() # vim: ft=python ufo-kit-tofu-ed0e5bd/docs/000077500000000000000000000000001521054151500155445ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/docs/Makefile000066400000000000000000000011101521054151500171750ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)ufo-kit-tofu-ed0e5bd/docs/requirements.txt000066400000000000000000000001321521054151500210240ustar00rootroot00000000000000docutils==0.17.1 jinja2==3.1.2 sphinx==5.0.2 sphinx-rtd-theme==1.0.0 sphinxcontrib-bibtex ufo-kit-tofu-ed0e5bd/docs/source/000077500000000000000000000000001521054151500170445ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/docs/source/_static/000077500000000000000000000000001521054151500204725ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/docs/source/_static/css/000077500000000000000000000000001521054151500212625ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/docs/source/_static/css/custom.css000066400000000000000000000001271521054151500233060ustar00rootroot00000000000000@import url("theme.css"); .wy-nav-content { min-width: 50%; max-width: 55%; } ufo-kit-tofu-ed0e5bd/docs/source/api.rst000066400000000000000000000002471521054151500203520ustar00rootroot00000000000000Application Programming Interface ================================= .. toctree:: :maxdepth: 2 api/preprocessing api/inpaint api/genreco api/util ufo-kit-tofu-ed0e5bd/docs/source/api/000077500000000000000000000000001521054151500176155ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/docs/source/api/genreco.rst000066400000000000000000000001211521054151500217630ustar00rootroot000000000000003D Reconstruction ================= .. automodule:: tofu.genreco :members: ufo-kit-tofu-ed0e5bd/docs/source/api/inpaint.rst000066400000000000000000000111471521054151500220150ustar00rootroot00000000000000Inpainting ========== Module for inpainting images, mainly used for the following: * interpolation of holes in the images * seamless cloning * removal of harsh transitions between image borders for cross removal in the power spectrum Power Spectrum Cross Removal ---------------------------- The helper function for the removal of the cross in the power spectrum is :func:`.prepare_border_smoothing`. For details see :cite:`moisan2011periodic`. Our implementation, which uses the 2-step forward/backward gradient to get the Laplacian in :func:`.create_inpaint_pipeline` makes the "v" field slightly different than in the paper (defined in :cite:`moisan2011periodic` below eq. 11). The right/bottom border is equal to "v" from the paper but the left/top border are True Laplacian values. Nevertheless, the other side does get inside, so the filtering result is very similar. How is it similar and different:: g_forw( 0) = f(1) - f( 0) g_forw(-1) = f(0) - f(-1) # This is different from the paper g_back( 0) = g_forw( 0) - g_forw(-1) = -2f(0) + f(1) + f(-1) # After the forward pass, the gradient field is set to zeros everywhere except the borders. # This is equivalent to the paper g_back(-1) = g_forw(-1) - g_forw(-2) = g_forw(-1) - 0 = f(0) - f(-1) On the top of that, we use the fact that: .. math:: :nowrap: \begin{align} g(x, y) & = f(x, y) - \mathcal{F}^{-1} \left\{ \frac{\mathcal{F} \left[ \frac{\partial}{\partial x} \left( \left( \frac{\partial}{\partial x} f(x, y) \right) m(x, y) \right) + \frac{\partial}{\partial y} \left( \left( \frac{\partial}{\partial y} f(x, y) \right) m(x, y) \right) \right]} {L(u, v)} \right\} \\ & = \mathcal{F}^{-1} \left\{ \frac{\mathcal{F} \left[ \frac{\partial}{\partial x} \left( \left( \frac{\partial}{\partial x} f(x, y) \right) \left( 1 - m(x, y) \right) \right) + \frac{\partial}{\partial y} \left( \left( \frac{\partial}{\partial y} f(x, y) \right) \left( 1 - m(x, y) \right) \right) \right]} {L(u, v)} \right\} \\ & = \mathcal{F}^{-1} \left\{ \frac{\mathcal{F} \left[ \frac{\partial}{\partial x} \left( \frac{\partial}{\partial x} f(x, y) \right) + \frac{\partial}{\partial y} \left( \frac{\partial}{\partial y} f(x, y) \right) \right]} {L(u, v)} - \frac{\mathcal{F} \left[ \frac{\partial}{\partial x} \left( \left( \frac{\partial}{\partial x} f(x, y) \right) m(x, y) \right) + \frac{\partial}{\partial y} \left( \left( \frac{\partial}{\partial y} f(x, y) \right) m(x, y) \right) \right]} {L(u, v)} \right\} \\ & = \mathcal{F}^{-1} \left\{ \mathcal{F} \left[ f(x, y) \right] - \frac{\mathcal{F} \left[ \frac{\partial}{\partial x} \left( \left( \frac{\partial}{\partial x} f(x, y) \right) m(x, y) \right) + \frac{\partial}{\partial y} \left( \left( \frac{\partial}{\partial y} f(x, y) \right) m(x, y) \right) \right]} {L(u, v)} \right\} \end{align} to remove the need of the subtraction from the original image, thus, we implement Eq. (2) instead of Eq. (1). :math:`\mathcal{F}` is the Fourier transform, :math:`m(x, y)` is the binary mask which specifies which pixels in the gradient will be zeroed, :math:`L(u, v)` is the Laplace operator in Fourier space. Inpaint Module -------------- .. automodule:: tofu.inpaint :members: ufo-kit-tofu-ed0e5bd/docs/source/api/preprocessing.rst000066400000000000000000000001151521054151500232270ustar00rootroot00000000000000Pre-processing ============== .. automodule:: tofu.preprocess :members: ufo-kit-tofu-ed0e5bd/docs/source/api/util.rst000066400000000000000000000000751521054151500213260ustar00rootroot00000000000000Utilities ========= .. automodule:: tofu.util :members: ufo-kit-tofu-ed0e5bd/docs/source/conf.py000066400000000000000000000136011521054151500203440ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a # full list see the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath(os.path.join('..', '..'))) # -- Project information ----------------------------------------------------- project = 'Tofu' copyright = '2020, Tomas Farago' author = 'Tomas Farago' # The short X.Y version version = '' # The full version, including alpha/beta/rc tags release = '' # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.imgmath', 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode', 'sphinx.ext.githubpages', 'sphinxcontrib.bibtex', ] autodoc_mock_imports = ['gi'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. # language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # The default sidebars (for documents that don't match any pattern) are # defined by theme itself. Builtin themes are using these templates by # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', # 'searchbox.html']``. # # html_sidebars = {} html_css_files = [ 'css/custom.css' ] html_style = 'css/custom.css' # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = 'Tofudoc' # -- Options for LaTeX output ------------------------------------------------ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'Tofu.tex', 'Tofu Documentation', 'Tomas Farago', 'manual'), ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'tofu', 'Tofu Documentation', [author], 1) ] # -- Options for Texinfo output ---------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'Tofu', 'Tofu Documentation', author, 'Tofu', 'One line description of project.', 'Miscellaneous'), ] # -- Options for Epub output ------------------------------------------------- # Bibliographic Dublin Core info. epub_title = project # The unique identifier of the text. This can be a ISBN number # or the project homepage. # # epub_identifier = '' # A unique identification for the text. # # epub_uid = '' # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] # -- Extension configuration ------------------------------------------------- # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = {'https://docs.python.org/': None} # -- Options for todo extension ---------------------------------------------- # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True bibtex_bibfiles = ['refs.bib'] bibtex_reference_style = 'author_year' ufo-kit-tofu-ed0e5bd/docs/source/figs/000077500000000000000000000000001521054151500177745ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/docs/source/figs/harmonization-images.jpg000066400000000000000000002502071521054151500246310ustar00rootroot00000000000000JFIFC    $.' ",#(7),01444'9=82<.342  }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz?((((((((((((((((((((((((((((((((+H[s+Uc! H=@~?`~F2_7]M³v#sTg^"Yh -efXePhJU+`3̣zZ&wY  nXx7֤ݳ"mf]k=U(NG) II|:~rGoCRƌ G_j;ccT@(3qgyŒ3,m=+J÷۰YB gTE{wb*e=S [me<4^eޫ$P{+J}Zk_|cdenCνN+@ȹӎhϿ^YץwQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEW;1hX|ҹ֑ݱvNjBrzb[ ob*V-{~|D1(!L{&{З#r+&]72"/xOXkS/:^#S3 %$3ң5UB`8oiqޔg#'8])8(Hcfzޣ;]K)\hl/$k9U z/$gAԈ¯Ha(uSZ:ͲQ,@zMFr*NY cך&)"#E,p*WeǍf =֬w&`;@?q6vjL>Q01b=$>F ˴)G,HЖ9:K &Įa~I?yWEci"(_幺VXȌOVoago+e(7Xv[Z@K4w~TZG 3 bG=W>s[ 5puVFumSbRam OV,*!}?*>2:w}̎TwȒDdl‾k,HA/&m.񈛟N?U9F.n9W%LA#j5xTJ sZS\ m n ׇnPzڠvCNTB+@LpFx5NyeIr2kfAcԭw0$0<J!C5bLpyQ #AW}_1k"7)ԊrԞr8ըBI@}2^٥VLe% kF>`O_SH+~cIH((((((((((((((((((((((((((((((((>e\ \;x;An?[T ku-9=GZӼ)wq?oDAɮlt؊V4,|9O?;C >&_ӑJCdg*+C$OXVrLdgXEj6rv>i&+= [S~'ѪQrǐ::jޕcF̘VurD1GAT^BxAMUZqsژ[ӵ5i(E8S~0zi*8V֧kot:m=?0ַ W+.F^s*ֲ7P[VRNR+yT`;3C"a²y{ZXl#JƼe+ {/cS[&)P24NS mF4@!ʠTZ=2eVqGfZ bÁ+}U5!ZK#9$VR7(i.lX|ZY K"Y\~V%o`=Qg?}:C#d|ѽsX&Zpljͼ5ir.I*~lRk{\>ִHAq!&{fUח%pz*59" !/x [XMH}U| 4~"F Q]N*oC7H Һ8j F3bdҫ336B^EQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEqdh8Krʣ XdZtK|==ϽsZZY瞦+2G29)Fnn3TcVmCH;48JƑΚ&6uuaITɬmPԴtټEgԷY.[8'fΝ,B铌\"V"۰3+w]%rCNf. Jlcdx#VMʒd*]Fu<m[T߷Ki?*<=ݱItF̸VQ&}v3K\Z ƺB}JжŊysʴuݬ6%$Xp'tv;OBw_WH&˪I3AvNhOI"t9Es!UG2m>ÓZuڢ,F9 fVV%|=$K c&Xw-摟J((+þyC7$jGƭ"_ܿ#>224~e_錟M#>A>L㴧ƞs}ᶛ댟MZt25 eɶ @H?Z:6s&*7jh-|@C|d"BG8M !dh24i?_L?A'H|k :԰xDˆs8JE ]cٿUixyqR?4ؐ(b>FѦ)&w.T׬diaJ~(6䒥H0q>I:O&6 `q;uC ?ON7A'K LJ0_a'O3O&|mqdiƞ=/ M/&Z'Hi6.ROڦbO&oC ? MxO&îO&VtXC{ӏ*G&]`' qMmy'ȐZU񷇚1 2d&O&o+8$ ;iCQV(((ϊG2ޭ#L%mm #xmWS]3,Du<#f5NY:C ']sQ8ɨٲqIIo̷%P\zս-n4KA+zj_]O$/&&Ocy Ǟi#$vǯSZ$0-& Vd)8 Ұ[W])K[.k_.Hw;&]̇%=xǃ.RWzn#)?,ȒS@mLGea@YP0_#R.&o_ʦ qkbK2uTm#7 p>]|Zxs&&n@MXʠwM$*V4O-|-{?<c9Wxڹ zG/ ]Gw4E:ln({?jE)^#AJWokOvUDuZ"yz%݋¥Ԑ5kRČ4As% 68Z $^+7Wɶ5!]K8 *K- {U+Oi10%F2gpsdҨ' >XTCFaxOo_,Z3l X r*Ax\t#rnUQlML5gY 0aW?9B4V|s8DU_a YΑg'?8EWKZZop;Oֵ4.5\s\HfѴZ\ ר4bOk(((΃uA5^l.ZrN%:Ac?4!S~'MBĦgKMIWr0HowI\P^Z,1*Թ)P[m%lڦ]ͬ=EBkEy˜zrk/%7A)G_ZP5`5W=V7vDZW3AjY)y歧p߇$t9P1FDss+6I?xNsFr:w * {gSvVI2eRz4\ɨMѸoFG.LxPzp h=אUeLc֒[LJ&8l1*[JcE˪%x( 8{G嵨m25[4p|+|"vm]QEQEQ^]!tc:o綑8njO$cNkaU` jRYIFM&Nq.8u|9nاbfK2:G##'Œf'4fOqւ̫xA5 IrvcL{|Rg>~G#d+R+Gȣ9lk+"Zdw;DcO.YJdVi\o", ObTB\F U5O,iw'sH >@ȮNU$ğMS[=i Oݛ w H{k<۱$nzYU8OҚ{xk((+.-~aqnzʭc~xgloeZy:F_bSz/D~*Ҍ9XӷR xZͲ2\YXAvqWOm3C2(\bk^! KsMa؟+ˮ1 ߗ\ȣZ$/DdASu%ʹs-mNZF@[k3G,Y8^?]#7S1B!ȩ_KIvA0}5Iu )M&G$BDQS]7?犷(B}pABt&cnPslN6$1<$rQY1lȅ{S;SwARXm ªLJqPT~t֕c=)T}kKD|+f>r9oJ~;~u ?Vk(((:uA5ڔC Ly@ԗ3i7IL sOB?E>b>g/54/ VnRã1#Jd~_S5 ԕT$٤+h WNgps sZ=G[\鲏C?"K_1uRw~5Bl OvYfՏޛp_*܂7:z֌ 9e>Ķ–aձY#Apɟ>\!pѐ;v }3|T 9/\Иu0qV\59Oi9h5^[cOBnM=VC7dZ&E;>5`)XsF_H2uSr.be>E>3fL+r1nYѷD GJ8\vRӽ#Jjd'SZ ؟°/"F#I[ssc^c_ ծŠ(((??O׏&3 {VmiGG[?UC:GamXTODp0j)Fdo}ҬڲT7VˈḌ?l*<+0vscƶF2vp돥P6^?My arwK5FgmHMWv7W6PecAs'\UY8UۨeUq'K.6۶]pP{ҏAZQV!QvlgqJo֭)>Je( u* 6imCN`$ ]N=<(x)vgh*V F9uVf.dq#SH;Fi*ߨ8O#=*ǩ2kkABs*δ.>?\ֱnJ0N?1Uk|^hDŽG v(((!x|U:z$^BH)nJmm=\(E=o1<Ü+(Y!88 ~}j\K1A6ERiډ],{z,%PEZ R11usv1\~6&kD!jPD'uZ}C!rzr ,..{fg|88,QG~4^/U+(BR.k_i:]ҏ>as¹KxbU(8qV@RE_Z+ݲ"cwb{=*2j S`uȤ梲>ϱ@eՋubHf;p:Di7$}M!F=px3SG7}+\'wvQ' ?$lXzץ*=;inlgSðϥX:P[ƪUhM7skE4aq&d|ԚGb̹*z殙p2 fOo“=ɮ| #]̋Z3ɦ)G5'Nn= 霱Be`d;I#kWk@^T@C )#>bRP#a'TV~cw<i|F֫VDq յQr:S5 LԶ|!~F>u9\O)~|M KIݳ!R@hE "و#; }pkׯᔢȼsQG=Hԍ+3xuKpTUHd`#FUaOIs̊?jzK s(^xB|_uQEQEQEVg j;qg1ϧkn/\IpwmZTy|Fj)GZ}K7%[7pɣr]ί,FXg88$L8۸xKY U 'TK xYQ5{CŶ[ʮg'"^SraY0?l+bDq>m%#\!h}+Ut+J~+#ef%D瞂%P  8M&CB>9SmБЃ\^Vwc91@M7"id H*[#J~Q Pg֜1E^p?VqOv#ޘNb2xKs5H.1¤q<栒'chP G0|?w8¡p9O/#nG0l/TKYFIVzRds֜3O/XWk*wV8\|*K[4ߔmRO3ZY:YEtbAK>g>j eg͌+)Q0ࢊ((_Ҵ kSZHVaKZjC%ʪ~;Vg6Vjlnc<Vf{h1OSK]Gl,'"gi}*2H뫍谮 0Zhg\])c0uaA :q*HJ{0&O2Q<}؁c^m2i,KE0F~jHv1L1HAŰV[Xk ά֚37?w P~t 20p?Rsѻ&0*m;]qjw~qIdsS܏ X~**2yWЄ3'jg+#[Ҹ '4GopbKC]G5lqXbQ'/_eHk,] ˺F$`υP  \F[w4QEQEQEBGr$YM5kϨ4=rJ4DwW#5ОO7}!,mF(*ޟ#FZ H2ĠLjϸyXS WLѫ((+?hH?iWp3MAAc^Ը})G(KҬF=J@# E=:cޑJFH~4P(ץH*E5" v2U$q<6cTr> sb$qV $R#ǖEVFLF|xݚ5HC%!1sP csMl 08'2#z뾫[ynP nn6̘8d4$-VܙT, s=VEͧ@ckOj }"WqԮOӁ\[s6bb1GH ŏdwWl7jڲ5T3[#דYO,խὲjbfcۅkk¶AbOzfOzR`ܴQEQEWPXtmRҪ\^ &GrYr: *[YniQkwa{%6MC kk}Gord cGϬ BY,$8ź]kYsX͟+wN//',LP2.]'s)ke-6>vU{R9I`ϔ2gRIc)g`'$]s 1{Q!Ǹ(?(h#qK\FGMVzGJ3ڗd%48,zڢvŽeMJG|q`{w0"rJln E2 Kw?IuţV6ꨢ3 _ҡ6m#ݏAѓW{o`;|gdcֻ^1~>(((8'zM-[F`{[:+ri-޲KI,Ǧ0sRm7+Fw6;],|? gGcԝ"+rTXGTl@>H׫.lrc=cM^I%BXҰSw,-,IĝX=ԈJI{\I,&*[ݴ=m^\Q@G R{xm?T6>*hCb.J,exem^+/ֹ(t[%Z$̹cԀ*fdfI\qLk]>->entT%#y8iUܕJXn?֝(AUUSjURpc?sӇL+:bwtƔ \D! Yu?Ulow?y@n}jw3iY7{u=k@4f4-[5oWTň@vhx>TM/\'0_\n([py@3UI|W[dK} 9?™ix-HcW<{/ ]=QEQEQ^CG=D0GjnzA2HYO0 G}<xBai!;U0$Q 1ZO;y"^??Ҳ5d*h3Fh#'٭ 7!? ME*xܬ0OןUgXI\7F weЋX|R2!U_@bUxJyqs:E3ϊдZ^\ vY;6*Ȭs%+4' n䳵DWsn?O b7eC-VGuEQEQEQY"xgV[kIg2c(E|l$ oڲ=cN޳).1AI;yy2nYF~lc?56[ x]l*_^ա"I\.Sum4K 7ڙaMb\jፗ* V}" wv\jbvFs`T#mk{8;/G^/k7<0K`מxPI$9U%~"yi`5BmZ2FB&#Ƭ[r5aޫ7z<{7H mYXl6e*8((l,Ƿ^*z,+U>e Ѫt'Ng5&9*Z[Z[b(o*O-<{ T{-Bio/X>HU91}{v(ojudufqt5m u &g½n df5ܩo(ʻ] cHL2!`p~]0'O݋B +~tQEQEQEx <=??JI0NdbvbҕS֑'Jf25:H1CCsUWHO(* ,sJ:FÞ9a R ;q)?AR"8SƠ0@TBըWsn3ڡOaھ/dL c"v4s?֩ ȸoƔPa. W9 ^~U"k*n~[M@ Iۮ)m#jmm'e\;=TZ@>72\SDlxtG-F0^ydW1M Gj6z!fhDqVel6AZ>;|_ |Gr뫭iЬ0 _SXwYdݸT[93N8_ZMW+((+$Ywo6A"\DIcf%܇qҭ%Grtp۵qMb̟'_v:CI"'?\?λ N=:+ǹ3A:(laE8LGHU[ )xip64i$g%ҮbV;2~_nIck$H#A8x$Aj2Es%h!2ap\H]\0 i]G|WtJ9i'(nyצz=nYaU*z^le}94Vv蠓TQ)[Amy׊nW<FG^X& cQQꚶhv*J+2Vtm;q Ίfw,t@<9#RaOT͖IqCr?3`a_n?P-`)=3QFf1E#JrrzTxҴfHBJyOʚ@Hjަi, VUџʰ=Faܶ6\`NO銿jMtlEsk(:}7u"j((( f𶮩`1/e|\F|SIZhZk6EDOuZ󸏜皷$Fz#5Bi{uFm;N,…vcD<gajn|¯=h-$0 T̸&Uws~.X.89'Ҥ7k1*UuȒOH!`?_[x"U5ͭi_^sZ 4z|HEsW1_ ZlT-I,b q.B(5"F~.co &7 Ӥ% vq} }t,XVK"Y/@ippb;yiv3ZL9u$)ֳ;KK9RV4>ݏj؜(TIc1JAJI*R춧<0ƪLE;W8ZjS:${@Uϵ$,Ҁ z4n帀}@H`s6ҁ}TԢMoPܔYrw5u>&䶅6:֦w.efg.ń1׶/#Ǚ5tTQEQEQEy;vt.#t)Vc9 80n擅QKsO094pATJ_u`sL}A@&MUPXw5'=i(eHLTy,y⟰h.㩦$bC+Z^Nv03*/`O5`Ii H}MHn7㰩rpq'9m>ݢK IOUpF*&0iA=@QmX[ᚎBRDl} zo|E:da]իkUQ#3uRG૶l+oZ&A@|] -lZ@dy@S#c*ΠVV!>v2' V8p}@J[k<j6ȗFŏR1>ÝM5W :if 'I*Maf*׀8HR`qϡLv 2!j((+R-#Mt簸QUR0ʨ : crGoPIx?$@FMaSv^y$VAb fQw~@o4K^,yD1""2/A!s#D9ޫNA$K (wY[HH6ucg*yiBm 9?ZQyc8$csRٮK44nO^bʪA83XWzKʙfq=G4^Ϧ]eb\|=啋HvI뭢((+>7ƆTgμvG1orz\U="ӷcM)\4zQҜ3R2qR;/!5HtOqS¾N?pM$Rv4x~AM٦cw<*pjG,8ۼ$mHN!)(^֥<}ʳidv0'4213dw5i*Ȅm8ҹ_MoVgT sS&ETnઞyҽu]9Α[׭^P !_*THݕYUnAkMlnLy:a}VFWM#E_ҳ4k lٴ9bUecYEBOc, s ys捞w>/\kS,S͖IG#M$RPmX mNb(( X-jvSq$]s t>)Ryƹ17`Zw! "+<#uz-I%0Gp"BGSmat-!_^HQI2VUg  fIΤB8&l(9s U#o[pg_LCk'(GМ:$ԪÌZU48{Lcy֜>?U5vI d?ZC+E ~&F;}I݌qY {9 ZKg~db_ hQy=:+'i;e~99N3#V0+/WUO托{湽FXmC RZ14K0ۑ"Mԑ9A} "g}8'HoR0=R]b}i`zkP^y_HRB"#=ntO#^ K{-X"$]?uTQEQEQESUEHG]Hz&m#c^Οyn,&5YRTqxJ3@ ǂSǺj{8"˦ŻRG.C]_iXԐI9 ({sFaXydǁXժVe`: |څ[9\qU X}erOzQ1?Q4wHH4 :6` uo4} ,U|S[ۍFm*`caij$p~4nyUWz)YQc#ҔNo?iX%Ҵm=FFi!*3b6UvfT0+եEȶ8~Q\O89cO^cpn`'*e?{B@Acsұup-%dpzAk WHdF2qb}f8ZEo#O?Lwu'ce+_GQsIsL'銳mAlfWnԎEA[_>~~{?i&Hy9X[xn(,jhMlQEQEQE!c3^I%'y)SY[ q׸EQQs0Cd {1F0)9h pG(GFǧJWa,i:b9 6=*E\ ~ iPg"3.@ޮjaFXl4Kܫ}jZ[]X'tǽ88#o^*ϗ,M 6U9n?(<[ėW:Ew<~\qZp9lqjfCthX$@2i嶫(99VM=3\EO+,ؘzlzSf8tuK=:2C]$?@&RH~ lLZU+RߍzRƐ 5-pDeorkx#a󑁟LUvqe$vJ{$ Z{fϙjpk6 9;LX]B7L{0=?*+QPj/r?AZ:t~Y)(.> U lҤXMh3ڢ0 +:((+>GV\D<Ն" ZK-!L7!iF0N+Xom]ya1=BC8?6AVL7ZJDb֩nbCBWH_v뚥y6H]۠};EĎ4<WAcjd/9f o?Wyr*(U/u&=qYef9G>f㚑#ԟj_*g6!gޠUe]d~[?AK)?m??@kB[#e·6v<ȕ7Gď$#j qXz(WPAJUn|/j;so*<LN< 8UzĐI jڕ.'t> a޲5~bvUV`ĎǏ\O<>D:F|?~ ô#?Tmˌ~]jעxKP7Zr R;_c3k(((ڏ&(Iʌ`dK%_5YH^z~xQH_\f>iظI>Ti[@KG'JxBH!x쮖VSfC \g\x^c³'Xw+l$8N@Q'Tfy.ǒtoe`cַ Ҭ,OvFYXSb77VX-qw f!}r׋eQF<jloyG- ǽuQL$֖IE'z\:jP2ֲgYvlGn\*?y4LۏяֶwqJVk[Kyf3HdB8Olk|LaFR~ROCY#eӬ L g54n'FFѧNO;Wu[K2{jj|]m%l-a͞}GH8Ȍ^0ʼnz{Wro|+2E X|z 3S6 lǭDMzRc#UM2d$L'\F)}K(5"'z'9杹#\~ e|Ž@" Wj5k4Jp+4,\ 3U(n8* ;ɌVMdP59!q"Rcf%pj|A-ďqmw]VqeMUB$7TtKHKo"v5$ Xrv=j[ˉdx*u%^)%'cU.by#Oo\}`<*#g2`Vτ-ɬMԅGٞo +Dtd'%*R=/?!9kX2ݩ=~xE}Aln5^/HUL =Wep0Nr=u5jf9 fY2ːWw#5G7Gsj?[WdQҼcŶrnyO6̿v0GpB*KkiP}:NM>! h Ў8KGE}EQEQEyZ4$Efjxn3!>^aϴ FE0 ¹.YTC!+Vnt[Gp"H W%w L~?iu%gl" kj;HI9+x(3?.iӧN/<*ʌO}J]ȱu>P1˞S< 9==f,JYO:)j.n 2BEKy+lu髪@#`t6w".v7_joFw{e @^E%l#Lp#GץFVo$]QOybz᫑1Uu6 5G;2Pz|ۇ ku66wGm3Dso;giwh2rx s}Jyˣ7&o*#r ]d7bJ:s-kqk"f\_< O"ƈY߀M=bV`JփAK >eZHKKeuRCdEo[,^]Ճ<@Gg9T*,IHt_9vs} ,19EQEQEUmD.Ϥ.C\DR3Fv bݳ[X}ddwlgμ :R(%2@# kW[d lp@Ytb.<.BT~Wug_Qy!~9;oZV~}ԌұkGʵOHcKh/b39ר'Ԯ;;wpqZw.\l$}Ici0@7˷$ՉdyFcAN[PVGfxK-Ztbo"`U<6֖ ddq8-=zwح{mFE,wCK[vA͚( <ETOVm2x61\8<=%'}zQEQE]`у׃XoiTԮg`aP+Fm -F°O5{}QzW-%Ԡ#m=@I~b^a#I"4 bD]GSH#ޱ2+[I5"a"3Sf)_Fӣ|-1N:*n=E~H#o/֛gII7Is:֤h j:@=Wms6H6,đqMcs%(O_ib%w76pVIlWoc117/+^e m#ɒb溊(((mmv K5|*kV^Wk$kk-lLlRI"I׋ﵔ{+pC}3ޠHYv䁓V#8#uômZi*n3[,mb4Nؠ kqcڒgϯQTb985V^fHoJʻK]9&Dk-6¹F$K;dOq$39fҪ3;ڸF9)kz~}q]4Mix4{T:"+>L6Z`B" ssT k4BϜ(5AmNVٶrxO?ޔ@Ժ83k!>£:U86gQ}~-Aҭ[ŝP#UInO9欯=*\f# Vighc>Ox-3-y5kSn rKZ Ks?wS=aÒxnF+Ѿ!6;"VR;#Ś6˸z0?皬vʂrz5QEQE­Te㊝ma_$z?Z2^G_C!`dcdd*I3(\|3}M&-iuzg9S^\*ʯ,M Hy!_~V, Yz?3V9 sOyրSQSKu4W}\GiAi һEV@0cY-,>,.+N=-\m&4 L TU'no+~B5w㺸 >Hwa,0I!sLV>si S7Qxk>fNGi%epSJB薊\IaӦHc+XCcM/f6F.8ҡ1Re5xvgԱ$Sb 3pL.KsUmCrjN@rcx1]F@O.3ɯM(((J ƭ3 vs1Bkd5PwcUKX]I:#oUIcIQ}; st$ct\ :qu&ʹNIn Pߓ5`֡\ (@9zy?sšrFO"Mؼw9 xxFۘUڽ(({IHE+'x 5Icfe\G_EmLd!ZŭiW1g4( HhhӬ#SuیY}h}(}1Yww֑&OOnj;Uًj)% 5&~W)kܫbzz{c31AT<M=C3|)*2L-=irx MuE)iޅhA>b^Otdgi ^WM3]m dԠ݅XE ՃNw*ԓ^E"iu\_߭\ѥp |pB}:}&77X,Il?T$M<?bCkP*)<׻|=|l4QEQEQEh k 3O(}-peR}p+M4BIEeGML3ԟҰ5QI5 2O̫b<;=?sE!U#Rii@9fѱRz;9|9 })m%ޏual'Vmg3_A7"^Gk((+(&y>-p61:##*[8c1[1F{ޱ.z6ֵޡSAxw;$<8j> &Ea8?5\hw2p!q-ybMm9{mF#8Cf^hx=Ym$?Um/TDJ7gӚ{ZY_3Ԃ@jn<*vNy5oI:N  [HMprJӹ.-"vŴ^R0` ^/R ͌mߖdx"SZ/Z}H$p῭[ 6^GBqکѝ1RzjČG#z9/HiX瞵T$vEN9d,zUkkdpJ]x]BHW.UV#,"!Ɯ\81ԙIDvbIJbHn+4?X8>>ѿ,V^xXd]0Mjx<5O&wa4o0J#n*g,M+w7f{4L89-:~ɟ$5vT$ ɛ⽐Y`Ҿ _-v;M q,J8e8#ƪIEE=˭9|#nN *UwsS/l@G֖_ %X8F?T"d2Fa1W/8̶%Q퍰`}1VF#`~5,[7vC'嵷pI=NWUEQEQEQUu(VKRHzW6ije2 U+|mԣ qƻ5Dž,+*}mip+nY0(|LSeiYI:1%f{sU$EHQ B0=H5ʱ @R`sjhc@w6?ʹ}Uvvh2OXD#=M)abJ7AP\Վ$=V ݺ)({ 4mLLn;ۚ~<ÊպlmLVy?1:}gR؊' _\v96I5]# ɒKq]MӴԉ<vsԵyI&n9% O*ȥv=kE]^~#Oȸ<TN欯lWZlI"?<2TJ". lZ֖F5NNεC [F$|S)nN_wlZ3aK8bkQEQEQELX"2.$ߎwָHI$@*G>L"Y[?uN* tJ<Pү; ej: xG +ִ=Y>U8Ae!6?q]lz5aVrye/M15dFF6 qw~TF .01~MH<7b|p02ϵIgJ!?j*fڹAi<(qW[x^{yTZkK+,mEP<&'vnmhsz|I2Y-\7w#݅RxY13#j8Աy:!G֝wt&sfz]kw4F%&5RG<+5Ɨ2LЎ`8^QlbU' yU=؝GgS*čILDԶЁ5I4\-Z-Wp^w:_]ݰ(Pߞk#jz@N,+5;X,J/4AJ8xKKh 'f $wdi<0K*ܣԱY Gڣ !ƯvJcsT-{yL#gYBK*s弑8 ^Se$:AکآLa٨'~Xhj#:~6HH鎕z[3tWUJLIu{)%+/n_^9/]WF(qVmUޮ2`{VMt nk<=wxTG f!O׫c0eqloV%S"qߚF4g{QZ?*162RrjS(9*'_m| hsM B[YIず̖rUPWb=i#?6X4@QLz2{MnSbVxSa2YPVŊFzR*PḌ*$m6e;*"[[ʹHjA}kmr79O+N@*-}'q /{UR *KYcφWbXқMqJ#}^'{Tc;矺*u<ӧEH1UVZGbwVne]s8*&IC:8D>Q Q09 =Mi`@r]_K*VGWr2>QEQEQEA|=>XӃ^]q%Vejqa+YU#kG,Q6.j|A-dfޛ SA =:j_0!QI7a֖_0}*i v>wx3I$k>iwB"DO9ɧnaŽ)ɣq֓94[`piNX%n`LnY :VX1]eRHϚrg=ܮ6#WFE.ش OΔpK1]OWFI#nswZ[ T.IsMmOՃMȐP}v#tu{}tVCYZQ[<>9GEYtx#b*|1*ŤkmsE?ҩТŽ i-cZFuI[ЏJUcoƚ$Aot4> ^((+Ҭ_e-]V7hWpkE%bL\UI&kHq})~@B#%N翿ZmSQjJ̽~u|AjQ_aʓ]i~NUfg EbWwv6-xeVEnγlR;dqϹ{p>㚌oIp>F4F幧2`c4ǑwՆX.yrF:knEKr{qU'CF|9,c\I٠Ҡ 8Ow9!o\*~u2#R`< 03=kF@y="|WCmke`1*698+2FFr{TVbE 95x`%Fu ?&5@20QV'!7]}נVE `N{ttMJXtf.#uHvRFM%EtOdӢX$IÀ~qNn2D<*H! _|_CRʒbNq\l^}x ?Iꕖn!SJ'RtIj$^p54Dns6bOu'UݎqTMk#Cʥ jHh-+\8V}|s[; .:jr%YM3 ?7VpQ ׊ ]ɬc-0CޭB:ʅOg,!(UNyV<0F4r ZaЃ^EQEQE|yzo[=rեgW\B{7zw%m,d=YI?sZ^!zK,zbcRsA'U |Fp #i«"5M`[/J\Sׁu,1ژ[=ibҔ(R>U9k!Dm2p w]whc*+WkOO:b1g2.~u09Ѱzƹ{4<\ޣ$Ojfӎ88%~koڃ~4bv<i+/q"Uh =F4N=pI9y횝mOz@gh}I֛-ɿ<Z%a\[]du*<`ϻǹ=쀀Y7:@k־xZ鳜^(((lިm?_?^d#u QMm{DYId7Y>ZΣVgUoӚK[=ZnCE-ڦysޓ>BtU'u5Uߧ94Ӷ});S &:zR1P3ۊ]4+JW:0Nj==-aPmoPFqxsX{=v0R~s`f9rO⦨ɯ!zV-ޮ&27G3r;mPl,}jR0)bF}:U-pܚ_5`M dB u |y'y F\=y+mg?ѫ((+ ă"+xыӎX;gaqJմX7x]ɬuwsHQч i$RmRw|jۼ8 vZS'ln= ?U_BU1?04S0kC`{n!?֜sȦ<Jr_FB=i,QST3]-Sĺ}b I 8uik9LZʰ* u=:].{2Jq,sֶtX)wFx$H1~431*M;a=+BO.2ʃJ|h#Z:z?3;0E_@#@UGyIVaQ ZÆWOPfЧF5?<+m~PAHi䍤!B;zM|,w6+@י[ϨK=F+@݃+[–/#tRG]2`&:`r@"`pVy=+GO~j7,ˎ K6{;%H$ r1[S |nV{moTE,$m:O&qKf^^cpk+ze<;;.zWQEQEQ_!k b "d=A 滟9}DvZT,*V;*g8hWsSm+s4 27U&mQBbL$O4r#iOj5q#QJGwd@YJOQƹNssiu5o HI [ۓUdՉ#Ug rQ%T5ɞsOURZqy*~-Mxm#.Z0pnn@]o:E+%@[qfW1rF*~5 ONX*&?Ow4o$^:6jj^S8'5J7xwPun-edq={ 8#xAIyJr |)C:(((!%B+mjdScfXܤs٫"v ?SYC0o(~yE4o = ̀zy}He oo5TvR@_AXqH5()vS 8#9SxiҜU?^>U+*&5G9&x5Opm&ǹp`ăSn%? aҠ7{$i HAەziLU%pyVaW;YŹR)Y2>C^.W1;k9|CcnߕXOێUAId DKėROm$d$N1"BŁjLK~\2n#Vݒ}MWRi/xOW8?J_4W(((/gm~+T-&WW[(ա-ۜv<ք^:|7G7i"'ҳ8^I@qTiƫ9$hJ5ݴbeT6Ga[: ǧkZV";OVv Z[[eD,~SNo)Ơ?Ƥ|/$ 6@o| 0QqIyޔ6߽jci ^\/(/]ç^of8⯈SqBY*?|MZO2!d;nOҶ5%vS qƝqTcֹ?I,͂1?i-e{5#󝵡H6Z9Ϯs@9⦴]>⪥)u+-㚯md-sp#?*,3O_og?Yhq 6r:qS2*@59,ј=N{K"KK h˗ SU-sF]+\,!wVz98T#%pOZ۾E)k\⹧hfb0BS~Vw!]3#IgmFnܡxSBHʴT:秄]Jl^kwRL0^*Ϋ#}kt> ѴX…>_#l.nKlWmEQEQE|c]5"BēY7עHmRG 8泮sl ZԂvj'byb0 i8!30&RpzP~^)7ӭKg #oR<)9zt-ڹǦ+`A gHf'8isid\RYCvTlp kC7x(ߕ(Fߕ8Z\* 63`N*Ibn XHmkoCZ\A,d \UrfaYym&: XӯdԲhB{Ł'\+ hpƚdc־ ڃtqG(((פh;Ȝ:ZJkFo6嘦=j`4)½ͻ^p3?_In`Ҳn\9KENo zkNI}sM'p<ڡhԐM81&`)Ay{QSIYQ=EJz,6^ÁfhKrI/F+egpqqSi ߉8w8ݏ'cS{T&Cc,4<=HW^fFgҵ4ymZ,Vtv?V 2cgk&WF+vq֏ FqOw+\jjĖ(!?]Jd$;?T<@sW^uPV5xp1EFf(zTWSj"I;%'ɩ쥊+tT@.K!<`ScgΓEP9=0s  >[,&Emd z9B!n6U3ʷlCu0B >ԉ#6GRj6DREsաCpM1tP~P4=dѰSIEƱ.\ҹ)S_R .0XLxnX4QEQEQEⓏDOj:Lw6z1~%Wo$aG\Ֆ>'˗ m\_Yj 88>"/% 1\NI#oͦ$u shՆqf&8<'*u\qDqrG!ˢI9Οo|-]bf>7oj zs*<7l d=9Siƣҳ.0s׮M-AgǧSs#q:S^Ujm ~|T"Ze;3KE/BSt$y1V%gڨKx dI<­.} )?ZQxf9EOS*ٕ5 =UJQ^Ҝz L}U_ ]e^xޛhtTbԙgdcZԛqɉA (n0)w{T00ޱlÆAs*)f27n^Oz%}[A(ިk iu$e{}Vka)è=3޹Z[?!HOR<'zݵ)k!b H2CZ>|ZA2;gT{g)0=8KiwUvMhjow0B.)?Mizm1jH ~Jtt $,+ҵv erñ7Uc?ſštKwHtKiuodjyuow~)L67Rc j@]2sR&C鷊Aszc7'SS>w\y-R Um?H.߆ zhlfc7Uҵ u~[*ajMEϧ ti1 "ġAj ĉ)XQ2QV཈q<A=V&vF$r8qUy/Vk70;dfS0)IZ #FBH"8P0$\T/^fRZLѸéRn4[bRl3TEWMѣjDas~*Eu&# aCK& ZLI#'t3QY-m4`\i7RcjAu~*ΟߝF=U%"⾌:g>&FԤ<7~>k袊((+'>r2>?jhWS~,[Ӵ{sN]8b9†lj$73|LVJDRZjI#1dnsq⴯m- ۽cn7z>)1J;QSwǥLjENzT2ҥ"ԌTh6IY.v 6.1V<8 =N.;Ξ(>R}ǻ&_*j!}90U*:Jܩ<N[#\Bj̏ anA ,N#/esV5oNVs0&xVXGٲ?!YmnxۜcsKԡ_+syi_C|9bXRLu4QEQEQEpua&L+(ܧm?EhgL21.>U3jq*8<*w֢jeb|Z}܄a9E!bá2AL"l=5I#(UUNqYpy #c'w"?.'H©,jjNjom\?5ckW2moJk@1!To?1fy#Gp۟#m2qF` )$:BDdsK+od}B۞=8ٞJW1Lb>lq?/ЂʡϨ4uO*6-$\^UO#*=i|3o>\Vl~&N#Lϳz0OQQ\ڙ줟bA^}r]J zѺ<9WP9(eԞApPYY k ? _ZڃxJ62W!vG4ai к+G\ɂJnu#@8D@sUk(bOp;WṸ- qjzG=N+F\D:'>6@=k袊((@/E&i{P) c(,@ dxs݌ rn>5/8)=Ԑ6ˈ[)i$1^;406{sո4|ՁR1H1z +o?ΎsO;FqڗEޔuqJ: SJ:RssH;W4n@J쨢((+/SX_;DvԻ󃚊C֢_ZrsO2q1Udnj5 H "{1v8ոPKv\vj>ϏY׻k-\Ђv mm~UiTKR ;U'jUR z(i3UGLnLR>j&'_(L Sտ G;5 Ojs'$5" ?!Fu4QEQEQEyw& 9 ?yjN@$g`?&FyT㨩TqjMϛ,! J-MR>*H`R@<d P`⑀+3o488)tKƠV+|_\hSմg,mIq.f7ƨK~5]*,ڪ\Q&ЍBFqZEsץ]Yr1R#8Lb)r0i@H:RQdRɤϥ*۶ kUż-4dM!`δ-"}&hĤ2Ƭyw & Ry!8SUXa )Wt5"ķֲ)#cINMd(UXn"'ix=)ǟƓ090/P(E;GnN< y4K=:&ޔ)<⍤ ԴJQӧKܚO.\ ]QEQEQEeE]c |84OjLc%#=Lǁ֧yHOfK)V5CA 1dY Ry!ܯ4幁ߴ0hilڶeX/OYuHNpo~OiG*ӲGj1R)=LRKIU&{ҎӳR)3 FJiM4d#ښPԛ}.(#4iB=)03^D=7׮(((:Xs+/~Қ坴Dݟs]_e3[R,|!Ǫ/"8hn)QQ51q?K\s])l?꿙{}) 4HFO8ڜfzUy$yZԎluH+F,sԞ4!Q횬jqDNBcߓT Rt۩ٳMcenK#Y2IH?/ /@\v)O? z1N8=(Kߵ8t8nNh> &Me*)xSPɦm會ޜX㯥1l9jMOU8SJ)<I| }EQEQE|*Z҆ qڤqިjOBce\N2qZvH@cҳk&&% C":c 7aH`cluf{V*1Fcʂ*&>Xۊ膚/^GI}a:{.«W'ԞVza:+:z:N0ZiZ͔~GQ.y(8sAj:P*PSA?gjU9k`sUܡqQݍn);{ԃ?S9zQ\ ]QEQEQEeEm_ |8#]+J(3?s%.xȨ^2=YZ[*؅ |ҰMdIe,}+ѣPxUAc8SghNCM1r0{TvB1j&$ZH̯p8U zBY_#?Ѹ_>nΦGq>xJc@xZO=(^Fy"zqMLe Fi1IERVb> ;)s8[ p)jeyW,mk X1<#Hi^0 ?jFQG4`2=4'N*mF5Y#,8?Ӵs,-[%V^PeG`un=x9?[VZ;qL1[?ZQoΜ!50SS9*@(Y¤gF:ֳZ˪\\p/@s˳jA{SexRƤ \S1Iڗsȥ8#SUB)iyK1kmj2(k`GJ52GAULg9J1pzR9T87G:4%hGj>KeTb,E!RRfkJ@emtQEQEWy j ̭֤Yj\tPdvJlVa8sW;22EX̐+n*vul}A?a&B[TeA#+*Xoʣax]janFe';SY2DErpԏQ.&{JIR1Yr7Ӻ-;ڌCF QW#A'͒?4>޴qMxA#Vh)S|c;skxv ]QEQEQEfx 7kn;-N #R4%j NqXLREh5e`'{ʉ#16S[Ɣo-kh֣&-` ? 䳎5.A+>m1Q[ceeKxx.h$r[IJ2Rs3)a+8|рGT)}E(Z\Ӳ~RMw3uKQG^S4J5bN:gO0',=:~q4M})7dܚ 5Y95r)Lb9QLYqM1RXpjH!Nh73P-x%GJ@gjzNa((h[8<  YtSU'(U 7Bp*P?K'?q8[=IpdK؎naK+YwGBVdJúfT qB\Y7#<0[dޓAKڔʎ2k:OtJS8/40 r}M[AR/ANqN8?4`uig of*qoӧpt)wbsQEQEQEQYLp׉}VtQ'jh((|p3?oiSh\d4$^aP8V_뺴&3 °=iY3XOMjUԛI~^?zf!?4%(8#4zQE/c8杻-T7Y[㑂#dVc[0'ҳ[YѤe_:Td5WI3y?uq(kIi G7QEQEQEU o@z1i7&1 RƜ͌qK ' &bv?ң=C?T|U᫸'LU[ռ Ew`w|.6´ϝctaY)}5UֺKz@!}aȄ>P?L w;tNb9PqbLqԡI4ҔSH H8":s?(Gc9Y_?l@cJ^Rd#M'i'8i jcSO+|DžlVQEQEQ\Ϧ+HCC~١Ey|fH~cD$jf054]xnk&u 2 -m J?jYZ>cUcҐtS@!CR1HZLҁ3Kr,w0Ry>zz\g2?үI+Iyt1 ~$bH c\`sү=EJ*Zq8:jgeQ!QphCKЬq1M:gD.~.?/U#MVy8jF:S旊Qx ݎ)! 314sT/.17IGZrP@Ƕ)TN-ӌzT@RqӄֽR((F| 9Cߌ##?)9v)961D(YAYICᘏ|KB>[kݟO]>B#+䈮ݓ?%ooTܤer Xo-m|ՑY lg72@sL|p'b('4gZP HG? iX8`kZV s?Sz.xPʐК N ޞץ8##ޓ4P3NX*_/w^f2 ~T#T20*g<_>?Cjݢ((+{ŏT0.csfѕv?r>1éhO65?<0հHR“j)ׂ eOZ7ӧ7HfYKy!q$XT^wy4ғ(=ii))erR[_jdjۏRdv0Z sȒ;vޑu#ԅ+]15JݾY7)!c#֘jf~j`Ni3ǽX8MgM@Ő* O|_/`Z@=jl֜H9?C9zQHK'*ELgI=0ɹx)v M27E:ҩ1#fxsCt9Ru8@?*B)4ڊCOOa!}C4uZ?j&N=ML3)|%i77cdz?OHw5WqOueMCoZոp;74"aP ޛ*ZܳcA^@ hx> w2+.YHQ(<6OpލI玔 GN8?zLi֤ ) F343ӵe賷k)]pΫߓVOABH֦7͓Ӿ?Z\I~; \r>yd((H7v<0۱1ߵ4DZךr?JP3Ґ2F3JweI@4֓ ȖͺJc:zowHHx$O[Ft;3qL-nkM0p1u!CvO}d^I۵/Ȧjc~ZrۺVqYCpjr?VPND7⡖6j8)Dҥs*6?:PqN {P\Ii=zOA~m6/=j?cP'8TSEQEQEQTu9i?^4LҤ 3׌v1ӊc8ޚ?7x ;x^SK7^MC+Zk?11톣]A21?٬ jhЖF̖lb9U?#pwdקqZS!8P?UECW>֬c!mgqzBl0x*FµcQ6jb9jpҪι$sg9=;C~U hǵzӔK}(0:jE`FOګj(X2U6oR{<#`[QEQEQEq: }?5M'Y?*<\9k?K0{v[WHj5ۜG1GSYK1q޵C8ӭfj6mz+UNQ֓Ai)qGN~Z ˸4Σv;v9Tw&lqӊicǯJB? Ә{qPlL+9G fM2{P%zPn+pU{7'NNUð?YJ(((yP~i gU[wOιo ]VbpTnz{:5.QV5gje,~Z9##?F@9?@'=k Ŗ_jd68⵴&D7 [V|j 3 1\ѢLG&=~e5/Aޔʛ1K4r)FIQ cˀ*Hn%ʀjjzҬ*~ U ~NE&sڅ*Pҟ_xJzg?ΙI*)8 MOZ NsR23dd{v:I9]FEz=QEQEQEfZ@5azO-_ZBv(,<0iy3֚$'<LWr?S xs By =;7dzaaϺ]4W s WJEiP$pa}zx\m}?_Z<(.XAdLG҃ }3K(攩;jqq2Aƹj0G'?* qqP;cB[=AUG9#LcS)!^?Z@4CHrsQj8K Nt3cI8dgҊ(((Cm%yrn. WAw4\W1G߼>XǒЯaYŸpW?W#uk{@(x!E/ Q|D @[ 񩜇:=W]?Nl~2z8+m-܄=}It1zH܋=*MJKwNqN. i rIrxҹ LM{7ēA;X~`~u\?r=}j$9lcn!ODoP{ʺ3R"Aw/!?9QQ3PqX=:VMu) 40He5`bjEn֥F ~Q?=3ƒցRj=*;g~ѠZh۴1+~tsBqAп*߸*B}?/PL716UxvG;n0]0i ~b::r;cA_Gin"yU(˞N;zT+N7Fp*H=Lu>Jo9;Tn^:~vs(SP})z3 :֘[4N`0j'=}j?0⎙?(]Ez…J_FJ袊((J&#CמwOrs-VhxYDfZ=\jzU˅>,5vf ׅ*Hxek/- []HtˊM4t ?1VV}3\^K$oM SПjHҏC \ưeX4s]KN1ӊܦ#kul=F[>*l2MBDǽ7 3Ӑr8ȬYD:&14GPǭ;~2I,{wIin؁; ((+=#  Z^Rv)4=:f񊉹O4I? =*@ U\+>F|<4vz먢((>&/q\jG:MSS8S5^wӵ OI!L >]UV]tgdsHx B`G]L2S|iAP# 2iFa:욵rO΃ C9OoQ]q߸4sNO=ԥ2El& Ur{'5B{@ 9\;W 5ғLh^{LQ'r_ ƞq{Q֔r(ێb6+JQ /^g7h)w;\1Hd4zBi䚌a9<L҃kqk& HY Vӊw8H{ъ:^jF?#(ʀ^EQEQE|OxG#k[iO4qF9&=qN INH4gssBHN)b87jxfiw!Mϥ!9ړ}i I=x+>(?޺$?uQEQEQEq}tCjEq&nA  Z>9'\zRc `1&XPуT6 OKa>i}ax巂`GlV/lf>c~¦ *zD[?Һ#8IA?=*u"E A6+YMB-*1̌;޶;hc%ڈJ槁Z23~) d4IbWw8FҞ$?~xߚC&:iniiO9*HK?5P Sƺ*(((?17h<.ZySIiH>,fc޹Ei*25pr=}jg8=|UU@@$BOWJȟQzƧqzPzt?sq#_Vzގ$4%ڈ6v ؼ=$4m«h7qnӍj #F(w!v?ZYd#>=+Hx(ڢP^#8So?0UoX;5±펟iefSR$t⚠ sGV,OQ(qڝ*zS?*pnh'=i$:Ro.0OB2?&4&NO|oA uM&߿sGQ~"f#Eaջm`PߘRH:Ҏ? yϯN?KzsRt'=w^EQEQE|jOn')@sIKS](SWh *KQTTT,@iRu"㢁 .`Ϳ¢KsOJ *Df'5 ’ :oNJ9X$L1WJ?q0܁(1" c k*O9~ 2:;G4+cs!nHc{XGcUecGrGԊ ֔)(<҃ 98NqNcBi}yhG|g^Q4SڕQEQEQEVWc-徛 |F'bTfk#8N(AϯR۠y@WQd%[q)H*W=?j*svc ?E_$d=:*"8*XOhGgz[?tH>e.Ș|k XJ3DDƨɦ<0mɉ&L\=Pv+j;(sNg(( =O:/94G֌4vJpgT{O9bA$8n+T3OAFttQEQEQEq=uQ5b5ei?ґb+y `[vOX p^/JˣwE-ϐZbW!ۯ]Dìmk\4`AlY =K¨k7<*擛'_{v(glʼjoz #l;jܷ.x*9=(ϭKiLoziVSc ҏҡ/^g"2qΩhWP}ff~dt;{ˉFx3F?K6WEQEQ_5L:|vlF jr_R*Ԇ 1ȦYGmws>y$#%nj\dy8;?O Ӗ"w]xUVrN9GtX(>BGC]x9^#q&׈hZ wl9ϔ|?!IVkzrJ}BMSTd`UnRvP:ށR9j3ԜG;QqǮi:jH֕ۓБT[玵9 dsKvW!<֡)Rt^]f2߻ EQEQEV_"[W="xb[uWs\Z0zdwH犑\lqHT:;(T,Yܖ'jA;o8QD\a/?*P?|p|)$)=@=LRAzh7ooyL?0;ݱֺ6QГ֜ F $LWRW*>H=6xб#Pg̐~SRSFr)G@ڗ( N䚌p==TIuÞ:h\g|QΥ-4 N1NX354VV`qjRGJ*aACo0~r>~< Ռ\Zh((~ ͦy?J-h< {T3O ϗ})$s1JV>7DJ-䄘e?^U?t`\g_ȇ"O>op)'T˰|\ݢٰfu8s5 1{.})PruNh㸎X V4N.pIF Ǹ]ıS䷠T "oCß5̄}oի3ֹkT#1DO*S]~T 1?qj3V*z7fOUEؔDRݩ@22L?{kw/JBO?Zr(R(=j4c 䃌J`GQc8.yϭ^Ilb0fI#Hq2F1OR`~R6< Pv=ɦީj B(l2Gdb=1NM7[+12|dc%OҺXR$sJ@&e#?Zvnm6bKoz&0z?48J((+$g74#<;?*co#Ҝ=?*\(q@LҖ@<;$Uɼc#jԪ| P 9%YIpqx'/w{\)U- Tg#><=˾ͳ&C78'+Դ+$vY`ֲ#u]Ah7|׎U;#V<MD;6?@.2)iT0ғ:ڤ e9GJP7V"\( ҐҐzM8E,KN8E""3А[zW8Nώnծ(((ڐ' ^NShxS>Kktb9#Oɂ21S*=(@qtV@ ⠶}cɜjP snG;P sJ9TM882 Ȥ?ҩShMt@jrpy O5wBӮuQFA'͓ߨ5:kD q_Pѧ]#62; 526ĭ3ZvUxzzt>4Р"#iH⒃֗(/j6 FANd?Ӥ3M)sRӚGPI)6})<q'; Đ*{d9.SǾ8` ǂ?WEEQEQEWdh7u,MV7*Y ނq$]NB"Es3/4 SdWOm]\,3aCdrdK4\˙V?Һ˫ 7 2aH>ZoKSfmXPp?,>.m;SdM6FOC0@l{-Zm w>[c$g/78Z'گXi7w  8=zW+u%*;c]64A7'<1 `gćq?Ͻ\Fg 2D~u8V$>dK00 CH^L ?ORs1l<{Ң@4֔r9g4E]=MssS/Fi =@?Z(|=rz9TzI a?0N?:m+\}rԌ{T $88gsӧ1Q2+]lI@0rJvR=84M29:H"_9cOI$⽶((g}ۀߙ>KW¦C vdz=fd1H?!nSQOXG #\F[8k2Z^&dh|cV9ǥz?Þ<9/|QEQEQEV~qHZ]v }8늊>d8=(nCv"$r4 1֐K(:{Tp7  NXS%晜n)l2i~!) ţGr+~Vִ Ԩ Ge_owr< ư]}'WhHܶ=s5=F bS!\vVq!N4+GW;ӽ7I2UtY5+i{?_Qڔ(J;ґ(֜@\Қ*ESԪ{ݓE NiGnfS`:U{Na NOTluKs*էlG<O^?QEQEQE|Db$ҹ2{^}i/,x<3V—2>簨wFc1R@u0/g_ xV DSHL n_s]oXHȲ(d\Y3(CpJuC~[]Gors"i(kIgqs7icj q&r lkF=5S pSl/bYc#ӎ*LÊq[_yc8?VmB%Rqh:1gr~a[Q\7}l>T #9+G`L1:f1k&/.-Y "8$sV8`ީq!2QN~M=Io8I ?qƣd(O#i?"LpTvI~ƑJSҞ? ^9(/oIҞyN=j?\MHE`;֜O`>iC(Aq^N)yjT]Z1յ.62cq"pΝrπm0*rAT 9e*EbkHe̟#6*kݨ(( 꼇NE1[fk6SOMbVSb ?^1VnIϯj>$Dxm%s0|ae „1v\]z7ĩ4 ]@=/+X"m`PqסUFGlcy7uL~5FvtQQ]DH-\|?uJ 8m'?ʴO@w,o COZDWs .8Ҵ|8 S vCyei,>SOcY)Jz5XU4]B}L#)Eԓff9'ƩD HDt ~ Z: XK\Ĥ$LFX6܃s\g9oP?ҥfyװUqlGOSۂ9 p3ҳu_+[d ŀ`V"L"y=$ ZO”.MH矔RsO{r2;0=wJ$ S?AAq~87p]E's7PK5+i\Gʓg^fgr9ӕx^EQEQExe]??ҪU9\Rgץ<~݂2A`ڽZǗ]V UE$0a w8s$_jX{3ʠQ1W.p&u-s$ e,1ӣӮ,Wpΰ|%FJ %ʬNʏ5kZN6jFs+eg~U-y3Žئ\:YZMrF|${βS;&chAL^V5f>~JLo3Egp+SN_s4C*퐋I` bRO5Y\o]9Q.ХWguqps*O$zdQ7kĺψj`bUPSMcXPƌ"FP'kݍj3Xt&K'y?[-L2\/C?h"gn#Z>ª9=\aӰȩsj5M1LҁJw{v??,qNAcj|c˸p/֨cJۂILR`ri< {B4fhԂОj*˚onHҠq>ʗViFVm pqp3]SI,ҽB/j(((>#4 JZv@"LݱZV=^dv1X\3k>[u{ӞwWzxu- 6EMxЭpk.;a@xCT,x,\1V*M2[=ǑX$B~ebr*ωfsrl6(?7򮀢?yNǽ {{T[2r)H841ɥ#Rg${U%{~q*r1SČ~8GS"11rO6NئsIsm=k#T$F1f 9PaY=^nQ%p ,;ԇ)ڗ.=j}i+ Z1[{xG$q:u=WC>: 6+N@Y6We0Yo THm\e1޴g?7ڽݘc ![Iquh*GHنÁE''sևI'OgGԧ8H=1Rb9+-~aV9<$܆ g=999BiN1&9'(=y֣U효`H3ϯJf'O')ΰoWj]A|J?wK=LEQEQEVwE= xŔD#eJ  qBӨb8I*yYvl8UrՎRGx՘bQsDVEns&tu/)^qNg5n!=fk\YXVQ؎{1S۝KH5q1B '8% u;)Teqۥ(OPCdqT*m4'?Υ ȥ ={х OzPFچDVҪ5yMU]cͻqJʽ۷@lO+gZg4H6qk?4F,?ѫ((++k)vf'̴BD8Ǡg[ie}?C5ޣO^`HugmI>swQ,pXޢiRD!«s3N ~\nb+/IF KyUl54ZDy,2tTr"Fmw3fKcjҶ>V9`:әARҚa;ڳ\}`gy KDO Auz6gFjwޣ*sڷE!M2}NjF.uFd ׌B;tkҴiZKm!W+ҵanTqGJRA%Od"X ̞r1cb"c?FF" 2ln:1j*G OmNsqSHQrH)OP2*x Qդ8ޘU\'9֧@:2H6I2n)IQrŃ_Zg5<P] N1߅s14AIKu-̩$ 4KK;1;PI.H?ӑ" |€0)qTOA`Sq (jDQ[sSw$柆>w((O }V@?dp?n1؜~c?1ry&oj?H0KϏΞ[{gs˝SM2RVCݎy>kAofX֢/vݫ+[~,aUI_~G5܊碌Ktִ|q'Eajq8]t;8Tn }MJP;UsM&F?ZX @Fs]})Ci(ߚL)EP8J@v$%4yAg'I5D[ vEox z6K-ZEri: 1ε*Kbnb%uu 5eRBˏCgנQEQEQEVg&#";)^=ngרrZd ;s8mF|_SP!: nul-.no OR؎ Hz5iڎ̓8,բ@(|Ea5/ N*qğҴ_DsG H9EWJIx溔:Hq8?ZJAr9P38.RSפ3ӥ.(.M4"}*E0:(`Г?JoG3KG3֓ͤ3kFN±ry63}ݧPUs>NM#P&?ֶ)4ۃ#4 6O3(((oڵ$26S8b rxZѴm-\'j)Xb@PW1T,c 1W|?tĴd9uyg?A-29ɫf q]*[=O=+g?S5t?/jbK<~ B$k`Q2"F0?~) 9)TڤUW?_)`@9x?a4yu'84^94'Q8U.?65,z]BKyz~v7 ]A[>[1AGR8d#t}Wo$;?jF\*=Yu,G̕_IRM,1cVjqW$((M_?%%?c3VK 85^PA pNƦ 48\w᭞ ǀ?*xL̠֔q?QȰ;'\*=0ki"BŔF:B7]~rךm̀+Hz@.PpڠF4@#aW3XVh̭jUۈ~cU|!XS$^kCCLe,=H^ u?ϧ?e` (Ulqf:}iȧ9"ܑN?za9~LL(G_ qJ~p T7 o9+ޫF!.mzK yHȨ]FIɣ'#s۵/^9}֕0~ح ]?XPX'Qѐ?j>vQEQEQEƳx{SJPׇheNs%yMb̂=F5ïѪ%?1S2)fƫVGbǓԈfe~<i7lgv9xta;t-&( \ x PvmS Ocojz5A=pwc*HciH}n?IW勎ưuF~.B=G4kP!cfkeۆ}8f{(C|JfİGs/4T<QL^)"PzNOUflb'?\T1XqҧaN9xftLɣzJ?Z1޴4[qq,aW5bԧұ56rNFB lOb<τlF(((.ZB?θ-6iw6$"x~*mYAk'(kϕ QmFp sZ]Іm*վ!IOZAqm`;#c?o ?{Қa>DeJJ?ȧsO>'?YZcGڊ}8U ii ׊<| ݍl!ެ!8~ߑ "R81(V'=CI\+J!`=q\Xk~sG#Qֱ-ll`QqkIF1 {ϜXUI5hF@8 K>R&u#+BHu݄u1{Ӂ8p'zr~bʱ>Μ~zFscOI4/OW1ec"0ON>ЙQ[EӊjQ:ՅҴB!P1*iTU"PC)We<)ʫmF?˜gOzARJ*F:cBYWCo|x?t93{0A6H:U4kfOOh)iz|˶[W_FHUtmTQEQEQEy̐ғ넸W;_\/Q6-T օ>NDSpk?I.PD /ٮs]֬xaX*[kea9Cl8M!hu`< NL(T5XQIOJ!<ʝ=ɬ=^D}fQA99$:؛?,pAOLӤ7$c?Z0ŢSkU89j͔8,@~WJWNZ-U?XSk Ttn(+c+5!TOɊE]_.F;j˞{TT˜‘-ȶn[hGv*~@I$q <+ Sx.$R9յS gY/&I>Xxw.KRKڃx2A'+`<tV-|bc[5pj 0kN$OtnjӜjWY̸`G\}G#?295^rdrfÅiD~US*:bF#s_DEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQ^iyi6\Nljn7 8Edl{ZG;,`2xCG)OƣfP[e'iJ`OZbܤ7dt{p ɪRBj\/L+hN\ Oћ?{ҏM#'`ӏ"vAY/٬#nR{2OrVq ?ݓgr8YۿfcGi!Vu$&HoC1[v*yN?qD^3ǀ ʘ=C;`„袊((((((((((((((((((((((((((((((((5K&6ƱaEį+Nҷj~R/OBfLFֻ# "R@-"RKIi-Y#.s W9q)ǵg=1+1$"ȎEmcW\Cі@>w֪+R5}\~p1\}8;/F"#;ft}bP9n`9P96ҭi#p8`rP>ɏ°t0@yƫ<{ 0*<ѨHFiG|FTs{ڻTʲF#5nSUy_GٍE:dy[*N1W*{jɳql_juBq#?<ՐQWPy?jp2qnlkfBz{QEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQE;O*OݘrѤg,h#E_VݸN9/X$xN3r27g,ȬBdTnJ#ᵍ3Hɞy%3nmDO~T)~wƲF,U5`fǩ [$œa5g4am6f8R [X7F~f[|)#%Ԛk1&Gn*`vnCXV_q hEjŠ) ȫb[Mh"c!.9b(X[5Yb*3޽BՕX<v6?r!9U;V{F9v@Avxّ ]^}O*X峞@?JpSfp?ݩ_x20 Z<$S$8\ @aܢ=?.zҀ3Mݎ1O#zFke?1VMJXgmZ;OڟDj?SO_3Ȭ?d-?뜜·y<3YڄF{ ?A_3*y]Af01W<%ٴ\i2vWEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQ^sr/3IG?²)_ 4&I8-쌊%d8_s7>ZDkdut.T\W.w #J2p*{ NK>Ӵ -W@?W 9gjhf^5³ Em/ڼYyM4Wwތ!G9P{fɱN\kzhl+v7Wm4Ƌe]Md<\yVש{!&nnb1j kchPJV''!ʟ[W;5] qyʣ'JҴ|ȾRcIl[ivvQ,`ݯS)n5( Yuv-Ѽ$vo+aYq8s׎V' m^s+ 6 sE`5fH!AK| O__)p{Y%^VuU1ZWzTs=ze&$8 fuXUaX7:<|{s׮f"}<:9 h(S_ǥe6or-?u_β'omK7G!צPZI4T@ǧl'Q7$h,y ]1,x\tIaRp R DNs#Sgoqʦ[!Gu1vp`)=Ĭ=s⦝#B͟?v($`~)1RF_s)c>g9<{8Pۆr.o B(}+ܹ/#3νZ[rF \ug"I_NJ+U?\ΘgXcFjJe K=pY.8 A&ld+Ji.XoQ9?֟[oysHU(̬6$!ۧJeMm,h|Œt=cm !Փ((((((((((((((((((((((((((((((((åDҲ~URj<0Yڤqdt>iG%62kRI3< ۏDXȕ+f112z&rIU] bCEJX7kA9vUoO1]e\ }CP,CR ڳ~;zۺ})ISȘsZpf"Hۄ x8Ge[)r\D~r8Ĩ¬KFgUX?5.ʄf=tY3pjf>YRYH#oe-T\].0c ?Ҧ[;_:HCDGZ̊EnRQUg9Iϵ=qHpY?"go;pWEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQ^U[$1əgyx.y{[WUkioT3& /э[kli^`THIF:ub$BSA7sN)8n'ǿ0+dvQҰV<8\-+R Š|[ϸcN9cZHgRI ~T譗 UQʸU%OzTx_&0TEg]^yo*@0 \n-J6+Nk.t{!xtxD(rF"֭gYM '&McR Q}>QUBq)8wE,*ؤజcr#u@AW a}AjƦ?KrOzPzTacP:Ÿ}C*Vlm/j+$k  c*nfݘ(_*72G9-"E[v9's&P,2}Xd[Fp k"[m*dd({×CK.xJ`N֣--qa*9X6"]i9@V4W2~Ջ"YL OκM& d<ϧE^ D8^"_9A<7>!÷AXskM >IicKe$rk:((((((((((((((((((((((((((((((((+@h,7?I\R5a qJ$UϦj4ZA.z3 yGS4@ M$xYI, ,GdU$X]eH?OW2PGp]­Kuy ᶵ¤*pz+f[s] 5Pk EȬO]G@%GOַTo @WvI i~-!<\cO]EXL^+Ҭ-Xⴌ(Zufo-kit-tofu-ed0e5bd/docs/source/figs/harmonization-ips.jpg000066400000000000000000003112041521054151500241520ustar00rootroot00000000000000JFIFC    $.' ",#(7),01444'9=82<.342  }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz?((((((((((((((((((((((((((((((((++6v"h?!|@1Gz@Ô <ЊolO8J{翭5UcV%I#1iapH' qL+* u?4P3"w%|ӓbf c3R[1LAsn߮z~aKR #̈́ɷ O:k,$NqϽ.6|8Z_/-䁜!$ޑV+G_r *9#~qQ'$FZ>Rʠ8O #>ʷ p*FUdܨHCCϷXTb>0}{v7AV){4 ޤG>pW):~`0=Nܜ+pcc8L#v:ʅES?S=)QVZ >8 s8?Rc7 bޤEAsۮM/ۑ׃)smunĂOnxaCgqǯmqОz~!R NF3st8qu?;GL攢<_twPU8gޤx.33vye8"+zzHQFЩ[뒰dpCWQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEWW s7o^hLO^:v*!?(<JB6I-C{S@ ÆO?&H$4yn1'1 g*'<֑;2H#<CAzf uFp=@nrHNlr 1R3p@q: p 9E8(%Iy!T=? rcqGnpNSAR;s”+9:1QɹN :J6\m$> azԛݹ:{ӓif,{rҚ?ž|yR0})F.uKQ ÃGlY@]͖!Q8?N3)%#=9~BF2zqL?.1RU^+.rT֚sק H3dalCzwg##Ґ.ܕtsHu!]8BSAzN0sIaȩ2#'Ҡ9 \x{6p_=aD'k((((((((((((((((((((((((((((((((++dp?}(rsޢ AРFN>ۀ%I9RܑCaAo``)<9PY?^^szLwLln9?ʜN r:קMa)T p?Tp.O<BT|p #!-s)8NfH?_Mm2@8?(VRC)'4y$h $RAL{i@X sp=)+Ӄӿif6+wo$dGO;prb;0d +G5$jd*;FQ?9l0q9i~)8swX/$x>߭=Rq@bN?JUP*89#'@V#ip3AĜ\zR=GO-ݙ{ӈUA89> Z`@Wh\=INH냌q Tfюzւ9cu)>Y#8i IANG_M#x 9#'?\R!UuMʤr?֗(Si-2x`=H'(=I Lg')`n p-:?vB8r8|H$mڍ?(?09$`i"BFWgҀA3c)p1 r34t-C^ZQA)^pH?K19â */L}t4ĕ%yOO˜I p~n3+x9${{1랼TLP㎼$#H~S$$d%rq׎OQ܎41Kc=x2ah%Qu9K&vsێ⤡0^ ):‘T3;7*H Hp0=)6'q FTNwB=9wSTyjo:>)1$x8l?( v.2q𡔱s#sd4e`JsS_=G8g989iQAԎGmQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEMS:rQRs;OicbNAqy0zd7 :d<~#6?:җ$)N:Z@-9AUP ԇk0{K BW`Gm8$[?!ʜcAI''ZE|Ѱzx7Q@Q~tj/osqzm=bc =FcsӊId}9@tSA> *sA8r>clg:Socяlg=iJ*,r3vA(@!pǸV݂sRyHXn1}EQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEy|쒧˯9UB%N_ӥqQAQƀ2{9?Jh?*gOzL2ᛮ:jt<(G( `QEQEW7 pO OO1O?Ky??qpgOߋy??3}mi׼M!ew{;DM cTyA§4y_X^ڜa/H~"xTT鄿MF?ih<*@#UKy?bSmh<*F?^_&XyC`8z4^_&X?y##^Q§4jq.~3L$j#€꣏w!xXjso(iG cT#?_&O1A?'G'KƗ6sDP_;Wh((*+-eN X KQMxdsM''@[<@/Mmjrz ӯOyS0P ӼM S1_'IK8K8%}?|*Ϋh_ V4?xyyhO 伿M/,_ Tt{$kty?dxH\gA?&3ǼM'AoGKK;K ¼ק;?+4lcU_&?-/\v Ÿ/H~#xL.=~/G,_ s^/@Cj?_&D8y?bW?y_X~uNS,Tj#i-j伿M3=h+Mq촿|/M:użM>g<0FF-M,IS<ǴM<+S4} 伿MnX_jvQYA&vҹ @=A4QEQEQEyŐOFqמntsÒnqY6ceGn?pl˞ @l #$Mڊ=œT>OJ3?C~!]Q`KOOUXIr=:}L8>ӊi!9n:Oڅ$0$c.g(#8Tr$ghO< ~ C~brsqR |JQۏ\*;2Č@'ޝmxHAi~sytI# ښw zBg$}COXېc1zӄ`_\cvRFޝ~) ߽G*yV2zjHTws}Pr9p9Ƞ(2(9n F"@a'OԢ6x={iT@?46 T稨HۅsRc qA(0=?!,>_J jC;}3TsVd9'Zc'w\})mPx J0۲X&+ [^ޝ+G<=eU!P,@M9 (98:vB$aw -z~ 'MN3WQEQEWeF #΂Aq;@?O(AX<SϠT40 qiR_*NHcTce0  ns9|ޣ$HDn8xcGoҜT',8?G13ڊ:ןzE;Tqz#4X':FҬm ps cJI rNi#ogRỎ'?:Eî1r0{}(4D;\`֬& FI8=W\~c r5&I9[*xǧO'=:v}{DJo#8;y/@\d<'s2cҔ(@#p8ӰOAxOş1qM4KaIRnA,}2Fix+N0y>F7AA-49*06㞿HXW?:j?g1$rNGp97ȣ9`Nz~AP1J0g9bIncoo=c"WoEQEQEQY!kU9_>`';I*I=NǧZ_mv3=sRsj18Pq3ojc+I'⃀pn '$GC ]y:8S֦UNKFH'ifTxHsu!O8ޟMᔨ#sT!ѕn{ޚT3O0#?ʍϻn0;L` Hй`poM#|MOLcrNw!<NLaO9;H ͂Ho֚X0*rR!U Hi6-nUlA$c@e<^o¶p3ӯjK8\*u O 2?cNcAܣֈYlH%vK/bzw8)X$bX^v ct  ֆ!t:wxܮp@$ ܹ;\'T\R$H2qlӎWxD;?Ӷ3Ʈ(((6O2:3-1SI!7'Gz> H,{Pqt**`:!]հGw)$}3Ȧ %8$wM.TdR3FOdFXOj ._10@9 s8=?-01cnFfWqx=qڝnsA/ ?Ziw~:{Ԫr8նrXӟon*팲{ʌv{SGAzzR#vPF;ztX:M(czw$ԚijTɣp:@Ubsa?5:g T(Xd2ϴs鏭'wFyzyJd$'?cLF@sM%@xe6]*s{SU9JB9ڠuQ?zznAӵ * q0 G#zg1+1q+1*_lJ0T2[$@(N?>Nݩ:M=0:ϽHH'%z{4'9,y=3ߵ+`~\ӿ'E\p?ZU, Cp}N@'69f;pq?;~ `(!@x&ʼnTw}!EQEQE|H .GI|Ĩ2y'@y۞H8挫(8#mҐd9*6*_s.=tCo)NFcr7*3рb9cB`H`k rp38NA ` i1:x'iF' 0'$9]) q*OL󦔓xbHҜ'8=JjG rw 1j[lv \r6k>U,灃Ƕ EGQNvsSTܞ_8OJzV@P7uj]a8?ӑ2Œr?Ή2-Lzz^Iz-ʌzcF8NK?jp`013<  %Y!Q<};sjQFwd υ^%vQEQEQE^q7k1ʑs4 $(;Jn $z) ؓyU>_4;K.KcMI<~e@Ur{ 5e;?0Қ\ 6 ~Fiw HNp?~A)~Q q4ӷ%H<SB|}IrwW9^RXݳCaw Ǒ܃ץ+' = \88PvApAHIC׊*3 e@Wǵw7?΁`p}>dQ-N@#쥺G^U|TvOk(rJ 'HIrIR@S?? r1ꦂ7|iBT{Ԋ9ہ08TmRPlSWBЊ\9f>#/,C74gs8Sr q^4OEQEQE?siq^jq8r`dJpe]wM 73=:c'oL;v Y@d8$OcHI.UsIb8'M'܏c5#=TtSa<7 10s$9=J )60r:>()Q9 ڥx;'L\7g~oE^@A8r9"|\8t:9Sߓ<Q s򬬧 QH6Tn'N?—@dOnidLǝv$8cʰg4ǵ U2nӴ.bdLHsq?։1ǽ1W'$si0gh$t ^0A'nv_Zذlg$)\xȩQv3oi׭F 1Új 9\P8@vnMr.sґF6siZ5 9׿?Ҝı< A^@҂eA%{zR21<#Iȏ9@s. (.:dO <ޕ7 #7ڀ; '=sArI9(aץ'ʧ's(,TߌqH{+((+ô} `fI<8E4|2OzqJD#vKyi\L{ѷp2lgj~`sLqDEā0OM :99q5sQ$l=ϊQ䏧Q4 yQ$ޛUrF3sr< tHp8=HʝFGj`Ϸ҃C7 $bhݞZUnU4R< q__@ʩ y%z#6q9~3qNA6 ?МNl!R6SH`:oOROi\~5 >#nڊ(((|C"Ϋ^s}p*F{JOsK`یQt89lzv?q8FA, cNH40<j@ ݞ:|ǐx)u4b#?(y>gRe9hCǒ֑bK/#9*{q߯Zj3s@0_PlWK#w8教Pc#g1:3NC1rOFr=)d*P@nFI{65V v0۹Xltءg}J6\r/31}iN@#L>Υ$qqJ,xcG'i=9RuRݹ}y}))^0 :PUA=ϥ?j8;Xl_q= <{V;v >iYNvp̛A=}?:R%w ר5/Ozu9]%QEQEQ^o_iO>u`$Oەʿl~/gyz~b p=Mo"YM9?NÃ{ȥ,v ]8ϯ3USJcxpd 9 G,`13#'4Ղ\IX FI$5)v<dcosGD WS$jrZkrvלJ]8݃z@six}7?jU=ӯ_p<ôNzڕW. Y;S.⍄ly>(Q2'ZO/'nT{USMӚHb;FrOT1r>a?P0'# IZtdb2@X6[8k. מ\i q{ ӁڗfPN2<Nce#+S1|p88Ɯ#IRn$g'A~y6v8NosN9^)b,'+9 p6`g F00?ϵ0a%N@׿zz<$`2}sMdda ((+K ͎8Gžlq~t7d yפlmWi\֑d2/*G9ҐR8##1y8׷`o  83aӟjMyA8sM.rǠ^iNN>?UN P2BSsNe%~'};S~!_ko, {Nx91xb0 ܞONݩ񍯴nX`Ϛ=oսDc\޳r;nty>SJcZv<rrn2iI*NW'B/pNyh0玼ciĐH}): N~=рʀ8S7!qOʜ3o~i܆PS+S <@ '{={Wiahl{Ӱ+r2G9:}iBRXddT0 71[ZA?z(((!Džse7kI`99$ߥ K??҇P>PA +=jk2H+=qu#k7wR ʃϠd.1Lp9⛂ 89?*8FP,Uo8`ᑓҘy,p4c=+T瞿1Rј9#A@ @$9"|̰8$d W;"GqiH|CL֦vhs ņG`*"2t]Wi-J;Wh 9^0G*bI \6̄ qפFOs攒 H!~\s6IR@Q:8:첱X;_ƞIv8„`?AMos0*RĂ3w8vp{# ǒ:d w*G$=S68B ޜ׷J(((b]rF8#uC4a~r|_En+<=*>Ulgl?(J+E!2t&F>lc=;fQp ŇN?! ;t\ddd_>Î ?XS򃞘>֚2s N0O#qJt8C#*@(<}~4hH0=u'šWLjiVeG;#< {P  ?JvY7*nO%b| ӟ_*u暘*WvцAe[cw~?񡔰cOeB 1{}($}zteOP ''8@NO'<P9\d0:HϷjr1#&I6|OTL?!?L}P1y+nIal`G?v@ v~֌Áu OɥDM'8=1Bo`I }?99=8rWp'hL.\?֜fRHґc8b:g9pS$1*Bg?O@` Fs=Nf({)oRH=;oB{b R8Uc?jC`*v!#9lN\R㟐 9/;OP1WU%sϭ"nvzzj~z`S6]srFK!*e}?ҐB98 dcsPdt~jx}Gj`6N0k:QwHDT&1Cc?a^9G^)Iox]Ā=xsQ&ҝ#9❴XBTI\` AO +}cp4p[ dNi$0xJň s׿c#T8/b .8E9<~!OF%H'ޝo<O=zv8P#`60w}?*߅>kJ袊((+7#>G9eEz**8 c{?|pړy9oaA.:BxjqrCrx*I9n26RX9=)@ N~A#> :IFRҜwg :}AJd^?PIa~AN>u%iŀ?LHʌ2ǜ_+,Nr:sH@RA9<\i1tj?()8wu`1 =7{SUH qiv21zqT GdҚπe۷<JN'qq~Ҡn$&$7)cL{3{W*֥,Hv&9Qd*4Nр8 0I'ӳd-Qj?=}i<@z{S+$QQVbyGןNO$c8t 9=Fh;sԌ:>O̡|9Ԟ>Δ)7mS}^Nø㌒3ǷGA&p9[),Ax΅dcz_ND!3xnOs۞9d)c$㷷ڜXZl6@9#?ZjscM5I`ry*O;9_Β]XGLaH9U<3ez~(Pr:9#nz8#tx1_FEQEQ_5Cx$ vl;_H~Mķ9?@BOP$6N{cӊs}s*0ŀ;9}z<#=ZC^vsw#HY\ `R2c{ƚp;A#րvĖzqK-TqqOM}p8c#v?Y!=sjvdWO # df$68H )S:x?O%03t_Tt`A'M;dQ۷g8p+ )ͅGR2=L2\|dg=)@3$we :L cx0ŗvZsd)'sI^֜7w̤ -mz) p9\d]v(ӭ#~bz5 y㢁) PK%Pr#8#F߽y־ȱsm|cbWqEQEQEQY# xcV'SupbT>0tIce PnJ${P@1IVq?LVbCOp,/'rPpy3J ߜ8S8Rb 烏SH ?JStdߵ WWpʩ`w†Q pT1ӾL NIiAҸ{ogOQEQEQEN@;~/Ҙ q;G?\J9^'Ͻ q%<U@ ~Ӝl8sHSJx($dЪr?1$?|qCq BAFwg![PWx?zUMy?)`Q9:x]G W#Rs^=Ncu*H铟֥3OHdq(og# aT2>#ms}sD $z?ԛ89x4-x89T,71#2Łcm~s֑+ 0F6SFD; G t8hIar3~TL`bvo zs)]A :, q\sӊch2GE?֕F^x=i"*>lrKzcu!'N3SՊ#C*dO1v'zAGA8-vԔ PǡŽ `:}s6As?S^1['=z{l=zknB@@OZS8HnݡC(8zЫA*6#AKRp}k((+;m sێ( ,*i3q>1F&s߁} g*}H{*yC8i%E`Te{zRd 郟MI'#>oJE Ar 3988:y y]( :8REbNiP/]},h򥂀rex&/p{RhH8?1מ`8'> Hݜ'N1ҥ(J293ϧ4s*)^cq֫kp22q4HH#qR:S qOҒY06@7~߿1, ǯUTwm rE༌縦f#uz[;,sb劃dsHApy'?7{) &ޝ9=ir'4lXhz~%vqjP9wxa~2K2H&媜cӠ=#p8 `~; ?{y`$ x'4.dHN=Z0QOOR7ޟg qAB9ANWU~'NǧEQEQE??=&μALt>ϵ+0t-nj5d0H'?_ŋ䟑zHq3$u8!a:R~o4=]ۆz{HϏ9wLN#DWq`?T9vҰ*9t8鷁*3o$QCa?\FABȠG q10r)wܒzw1u GHb8 2~=q?פs`N3niFKd21Jx5Jg`!ipv4e¸ 'Oܥ`)%}v>Bp1ip!,FSSNsp i *gpʑs F8<ҕ9'9 ^O_; lsD0yH6Q>UJ.r䲂͞99e@<`;r^S ۜu#'Tݏ@p'֓acv89?5daK ScA9YTt'qH#낧9ʡx%屜 BjB˷,@N?h-R60;>kq9C֛pp19$cnێ@brsҜ ^+((+BO==:U?NWQ9`ڇ_JlN AVr pG^֑UW=})0?(Qqϥ1rIҚCOL(=9?"ȡ~lO>ef A+i ҢB8$g=%rr9>?npOnѠ0;ڞiO.c#q=?:zn sS^:w;{~p%>c A!A$t p3l`szRq9EAw sBp;Ÿ0N#8S#̈`NL`gʃ!cf r1Kss'?dd#4|Xd =i0-ʞqڕ%[ $p?֗h'syT8GbB<< c;z(((:y20cօ#%qMQqxU, ֑ ;\)_ӽ+7˵1$A8=;"!%v)I$ H6Hޗf_zTA! ?ӕTC{~&J֤D&"R6ߎO4pugswL!E)Vf'>?ZJLa `tR' xi yPGA@A140T޸>͉1O}*yUB^,c@i sm8ZSJR| tqySB``z?,x OPV;Ҕ/ϯ8)'R?š0þI=:x OJx0A%dzЧ9`S?b9aׁ>{Wj"`vlt𤑙zW(((7H~V|>kԀ8`vO~!YZGpNX&8WN`} 0Mߜ`v&ryǧ_p 瞿74U ip298W }sHd, j@JJp $rFp? `aNz*v P0͜Ⓛ8y0oO%'=O5'X\׊":_mQ?D>y=F9l=2wuϿH`zғ"7,r_jW烻[*= r~ZvB l}(@n֗hr0 ƑX߶iǸ{f ϦqBL$.IliJvXrp1ȥ?$d193{T9#'x9$xW|:Nhc#qT[ ;{Xg_&Ъh<^Z0p0=SS,` mq{S6ɂH?OJC?.8J9qq<7ц'9J G9zS g pLv~eO⍻YN: J/QOjA!p:`Nq@fa*XNpOOJ3cp?PFr ~'{?9~ Z>Dڊ(((QQ&oԁd{cd%p|QdC84fQ3T}COQ XzC!Qg#^`1$~?ϥ 0ryNu=pP2s52=ΐ ܐ_L׶jue|*@<_bQ~(%p|֑NN+O1((++-Fs x<烞⻃D9:ȊrrsHJ `Pzʚq Gqrçnݿn7 ȧiOΥ($8u\1QAG#;Xy=j3UPX#vJaszd~t1$i9RX#8Үv@wJ\|9t<Hq#_N`H?Zp K3sAvj`#֤FaINM۶=8ϡc8ȩP*,2FOQ*d`Ƞ@_A:Z1V Cg/*RqGM1%_Ҕ ay8ϥ8'y/>BOS?"B<2xTbA`N9S7Xuǿd_vxԟ0Fqyt};J%IeA_~j29E$|JR'-?HP BJ`S)6W8sIWk`pW&0r?SB`s}ϭ @8d'\HU E;fBJB=“ < GfRx iYSy4ұ*e=zr*z|FwjFTq(1|/O?h((1{c9z@1Hsp10ǺϿF mܑ_xRF:_ڑp?.*~_^;ڑ`cqr:R3p{"0u$8]%r8ϵ8v}1 I'Onjho8l9#=xzV8i H @S(p71=?ֈzԂ-7 3E2wluOªȄyy)I^NG*{xidL-883ф.P9(I8Ƃ23׎C1{q\VRxqQ(n3Ƕy=zҟ@n֡>dQvÞ21\)@v6OڟB@9lp\i ;(bߝ(@݌4`q?җ+^ዌtcs%vQEQEQE/5n37k$dǚq )7eaO<19,r18˵B1;Hcgҍ ^ToUvf<=9I\ 2}Ɓ%Hnz?LRqۑ{s֑V\ϷS A9b8nhM,p=8y(RX`@0>ӔyDcq1Tр@ T) C) @+9ޚːk'ә|sх<09DI*Iz)J\p>L9+PnPrO9)d`@x{E^?Ӈ1#a ^߯═0#9@/sÿ073`~RU ӑq`ʂnB9^?ѕ 9cy *Cm=ǿjvX O,CFGӹeuvѷ)n3PCd ?JC #=?'OWIEQEQEWUJr]f:󝅂s)lܜGғ ʘU-l?? yzph2hx<})[jO\nO=*0\1sC23G<~jFO:z8 sס.:08H1{tFҸ3Q0^;{X?N]Łh'}~;~tT1 13_ZVFP;GR@?Τ8Ӂǿ_zG8&MQ*~[,[hcH]qct*Xgx}qQ4S';Zh(rqti\;ؠRrI<sqSJF7?lJc 0:UX3L-  q?ițԃ F jS  w*1ھy<>ÒO\zzy88 @ ۷OMPΣqCilZH%-q{~! ͌p>^{u6|\ivFsӕ6(=yck0$1'ߧ=ϵ#(s呕<iYN1OPwc9#?H~`B{{?(`OGNPJs[ڐd8n9ϧJW  uSғaR X3n2G$g4F>E$QJy}FLRm|JyQӯH~8cHwGx$}G_j#l[{5Տ9LTO^yjNe,qH+*oNs>Rʅ'=”G 1^ػС"vH={Ԋ ѸibA #ΜF2TbA$қX4(8s[\IvQEQEQEuRz 9^$`p0?L  tҜ$=?_OT?Ei's4@G<\z9 6'#?u?Ȕi@c++H[? rr?0GNkn 1x=8UĶﻌLrSzqԊ9|88Գ9$`H 2dcY'},ޑ8׿NB'8zcqM|=Sc(W%9'ß+S22w6@=s7(\O)r2 qi l kZ(((bNCyU$<*V@/J~'1B(|wR9 E%v,39bGM#?p?/=x@T u$Rlz<8 HӮiXIĪ=oO҂W8 ۇN@~O9qHI`83\玅Gnޕ"rF::~4Ukv)C|x枨!(V1Гq*$ ~QsWORXrTpI=3Qۋ8p\Ա#Cǹl{S]N~i r[{u /a{+aF0@7 R$sV@pæ}@?1ap{z1GOiA,>`dRܨs7 Üz6㓰 gӐ`Ďv JO989?Q~`W(~gTPX̥9ޞnO8#*3\iFݣpy4cy{qS:RtJUSy? > z|]QEQEQEfEW x˒L!Gqau4C,OӰ O8,Rcs3(rWoz?&3|9eGȤH?'8ql\݇{R*=:qޚA0G?ˊpMăy朡Y2Q$ PwsGZrYqR[t:g5`#sLT{i 9銍_#^yUbG-ԀÏO֑؋y_S$1*8уR <``5#Ƿ?Ҟ{FsbY6l~ty]'nܟ'9vx8OONSh3ڔ` ӉyF(((;MWP3~\FT\u';XI>SyJ~:G|8~¿4 sۏoӕ%x88oFQd9 r7)Kn9>@U!zZd:}?Tҧ9Jrg`q=i^Mp8Bw?ɩ-dz֘GY|8,~?@ry#(Pv9c4Rь\>ҞlJlTİ p%d#9u8KSe*R}GNU.T |1$|'?8y8ҚA!r}OZ8|9 & |<1nvr}:Fy=O?"߻ tqMlxl~m`'=yJ2zQ+p;q js$ gSNs*YG,}}B$gx!)8?JFo*03)8;9:L$SґClmqG_ZFy+HsOO06~$q4)ې=[8r˓FqYT(W;acI;ss9t`,==Hv&dc+Ͽj &YOϭ#mJ$rӷ%v~_Kn?OJ$6dqtm$r͟E}EQEQE|c!R =H $9T/˖<QaA,ݩ`& ,oOq[0}1X@3u?_O ?,#!:9Mc` (x#2rqp8 8hO֦Gl'=Ñ2=Zi+Ì@8ad<|?E%PI##^9pqST8;A*P#qɤ`>sTn:=O ?8NJom~osGANn班40ڛ$`~@?/|c%xM_Fr[w8vr3_J pןL%~bǹFӍk1Aڠr89j~*|6Jpڎ[ 6Œ8ȡK 09}:W/R7tZ(((φuP?o׀!^FGA Cc҂0'_f ݌#0#p߯ 688Ͻ9s8' E*RN1N:RlcG#+0cFI)t1e9u> Bi<{ѽ ֤ArsÒÒ <4#͆'Ji sJǟʘeL1 {~uX(`c?^ GNv]%QEQEQ^wKOL y Iqaӹ&A!'9=lNTޘѝ9xj@JC)C*r19 SH+ x#=Hԃ=Fܪ$;~<~^7J| v8eG#$CF7)6.v4d'鎔S>G4}P)7t\ ?磏]nx(߁1=[Q<JrO<`vʙW'<~l*Dǀg@S̬Dό}iL+增14ʸDi~ =18"9BsJzpFH{S>Qٻ#ژ@tPAq,Q$19 ljPJI#m1ɦ#8Qd^N:MMCzt ˴m$G> cr5ʱNwA?#1'ZdV)Ԏx$nÐ#߇bAwu >c&6 噀)2O?^m 0qpqJq{8c ڣP #$gךqrQ@4m*'̡p'3 W?ʣh9,04d\\4%"ȃ cۭ}EQEQE|/)bzH<Oi]Ł=ALv/!#xxu+3nvB18H~ ;o?)B !YvxSnQTJmmNywU LiPJsoF b[ҦD_ B*L9p=T|պ$dA;qʜ-Ch01j(((Zyɀ9$199W N?N'zm`Aۏ(piq$CfN[8b.Gp3ZϠz҅ف'OF%@9/^NuTwaA?ڣy2S JvŽ9cBc g*jc1sS+ g֭CH>?ңC02qhؾ_f1K\w'#7!B;VCDp<~=$T0}j0!0sԎ OCgi%MK6p=TLVMǜ(>r:$|UG\Z@8' 1l mAE"4m[DXAC3cRpO_ZrW<xϹ ` brz}Hۀq5tn.g9`2䜖)7|q)P ޞ0yy.$֛G˞p:Ċq!N${?J1$ӕPi;;c0PrA; 2wt] 6=;Od#'(VG''O[$zuSHFт{v&`>UZ0Jc3xDsvS2 1a4;9 MڠnQ)~.XGa*7 0_C;9$g֣ z`x<~X.]Yc  L;r䏯ZM};m'iQqJc].C=6qO^G p8}{vK p !H1N n'q\CJ>Y9?Ҿ((sٍų0H.<q6{cevO9?M'ss ~69\cތ pG>h*= 'ޗi@Y10JzPz=0 Np$,"Sۚog8q{~͍_a_J% ;峻ByIzP;Tz2ℑa?@,{I޸s׷֕_I,X{w20`)#?Fr-`ucjUw)<)k Pw yU`L.3}S,*:prTvsS.A6g<קI1})r> i[1 a[Cd0zw;7a9H2y1*aC~F>W 0}OJrB< `b\䃴JqP89xǵ4 m<^0l ]QEQEQEgxgzI^T 3j~T2GڿOS;N'Ppn\ g4V_?<{(D84L[!OWav}(r+c'B#>>S*D)u$Fo@P%FOoU=qrBu: AqO$Î@sӞts?G]Hr} 3pW E H:Bg3h%WwB6f6Fb3L`u_4w ǓN BB:q맨!Vp89*3#?Ҕ`V8v>f@ OJWBz1dRmOu`J.ܜcb{vTd?Jrpۚii|qnH~^hFgq4I,N?uxs6{ KHvϭmS 0mILv~u(S,Ov4Ƞ8銉2 O4EX?/JTdp8 psQ?1Ӟ'֤pu) QO\ǵ9vʤ8P=ӎJCAxSCOnqN~;?ʚc`S"[.O~)pWh9Nx;< #lSq#qxB1EG gel\R߻-x=)JMF2o @Q8|sr*"bGѳlWMo{6Rs3ҕF8_yVb?J(((>?_ׁ1`rWns`m8zPoATeB"rY9uYLzφnۀNy<ʣPApyo9LƨF>e v*ʼϷ?Z B0C8#{jx-oQT{:G1@%yM n<ۨi̠2 <:H*sq*Y:qO} z8ERT,KdN=Jr[##C* ҡ( 3.Fp Ld@'jC"^=9ɝ8R[?QNϫR-.2GMmk.?I@VP dԲ#=i$֑Fܓpq۾~Tj0$^y.q q2~u~Tkt$t=R'ہwsv?|s#d2FzTc'$cU{?]AWGEQEQEW|RȓH`: ~:,O'֘p0,1N|zP\p JGb*;Q ?)}A45pIcw:n sTmf.ddT=N1";OƘ ,77'^jT;N8_xc>} }iJG~O4&`n0B9x?jP[$2 *;m9<?QA-gպcX{d@ic!9M?1Q?yn#5"c?ξ((rGWvq;PLH*}z1C/s؝܊FC`y) f?w|6;Ј_ ǷsSVMbzj]&H7d YFHZje݂I$ONd *W8?j|.20x=iYAlrAY8$6Ԟ>q$4)^Kw$cTs J_}/˜4 q4vߜqʙr"f8`uuSc,x sVBg8$ʪM=Fz( a#<iT:T[)08Hn]p֘.z)l{GcǯԌXsFEFrK( g8O@ 1>".htE?J0w`tqӋ!9c:z}EMϘBJF {dc_~OOJRpa?N~^!FNO?֣M%FFysӜ{{UXqB3'HѶyۏiQp͹X<`s C7CZT tlŒ@<3K3@;s;ԦJhғx5{sQ3͎6;œcv9<cF?jh((ߊ|^u* 87qSǸ1˃tq'ߺ1׹Ҕ(l,G<)frݞ:2@$1ۀ7g)亢F8.H eqH$!d;@֘cWl<;OQ۟DHO>~U99oNՌϕ{~u#(pR3}3O<1اIsE+%@'@T#O*A OLn9y@+>l#xMdNёy;bR=qn]#Б`Qe@pG~җi #&HV9S?PX@,~`0rN;}9ҫ 9٥)PW8&߀o79%IM}͵pA}s.OssODRsHAg8=E4$g8}j13)S n#DHU;Xv$})Gۏvu1Фr0ps<_sJ=A$rPh.w29Qo M_#zB3QQ*8 h!AV$g# 78y=Ϊ#` ]ǒ1F93!|px1 I`}sNpX2v9 `M|Q5Qd 3F9L`*;zrkn#`iې;ǟʁvvn(ʜ7dUJ~R#!Tp3r!a?:r[O&Ľp 2~QUVr 9>x o\Tݧ8W6,K@c/O?_ʔ0@> Ag-J(((ZC@5vθ+Q; 2Axv\:ʜ|8,NsJj)T-rN Œ翧48#zX4F?#i6Km$Re# :=t;3Ps$&m8d6al?R(ܙRsЁZ$=@jA3Cx#pTriG%V.v6f[UfRÌ֢?s2?ϭ5AWnqJkG==Kg88Lj<1M[o! Hઓ#$ ^){nc)[$2=hbz*9T29-S*D7>=$*}TXPrG?ֆ,Nn:~мr=>|f<94 Ӟǚqqv38?Z<ry?WGEQEQEW|OiN1v'?rŶ0pp})a?\T\c;P<7wq#v{4!\O=)Xgi_:>nvÎILzJz i^8~r9ӐW^s?.$t;Fz}y vޘIU8=OϷ֜7\v zM垎~𧜳.1J>XF1S.rpG>?ڸl.6=Z# sƜӌTm!{S0;*wWr)׏TEFnSے'rG;rDŁ*ߕp:}=)v+1} UL8;TZC[#{Go y=1AP)a 2R3@mrqqM]_:W*Q@8LHU= =Zh¸$~N__)gOoADdzaJ8MېvIA'l|#?P ”O ILbgpt))}*A ǥ/#` MT H_B3<Ґ9T C3~ibP> Ϫw`)rܚ((_?LʆPJp'*ggҐ~ʀ0Ni0,V#?^>* =A_(\F1R|Ѷ<@*B1ozHJލ?.6L)Fߗ996 N)ni';H?߭/31j_,ƻ ʛcL82.c 7/ެ E/ʌI #0eB$)#Ƥ <xR'Ҫ/p>?4mrs?iPK@R#0I?8)hו$:ڐF&TG9a[ ǜ6?ߥIFT_ZW`#~_P2>3np9%C 81waz?8HWU\XONS]Q\``98s_R+\Sz's"'3;aԷ`uIg #q)6E\ =)@SMh'q NnRČw{O> L:(((\y?T|֔6WwC oJQH8āS'#$80I9ޑ(2{ px#9!/N'c?ZLA(A?ڞA1zssӇ";J $p;֞QW#;zvd98iDD1$N68R3sZU =U6A'J*rGo/8Q˼),F=0;JT2I2”802svJF9q֫i6g88c8#?/#Z d coMGHns֪yA=?Npeu~_>Ս1q}i(8^O <ӞuHkpHuvz #pA==Ҟa9*sw&ā~#dId%NHFǧjSOG8;Ghtg at `{ޚd0FK0 Q_BQEQEQ_;HB;84mOS<7)qɤA /'Zq%Ԉ 8GcNpJ7pyUyFpsSnVO p8 q*v#q Fx,zpN=i@ۓr[#Ε!YIܣ''8֔0@?KTR ք @n=)$08l'. h֮rȧ-Uv+Ԗ"rёSUdH[<.S8'iJzpJ#$`&I n=4# Q*`Imi8#֜?.?Z*Dž#y'Q(]->T<g;xQN7Ґ~`*0s#O'sN?Z`$ȥ6VO?ZaBJp?)U~ԅ@'08_8jnҊ(((jͤ ` .+II\)e8>_(;S8)8_PˍFPzUpzҺ8bsx-rFx{{T6v T.dcJ N FR'}{R!<}O!䲩*yn)C߰U@w!9#\s1(t2~@ƒߞNEL{?XHc}1H@j%s )G13#:vur# =/Z4$qrCxa*N>9h2r?JFt\1eOPǎ\0vR)06I$}}h r߼x1tQEQEW u=[ӯ4׋|`1` `c;8=?M6$0Fz߭*̸N1RL|rWjBņKu}F?Jw"pA1)BeA+iUVs7 >#<jG(8Ndct1s7<O#>(nNP1ϿcGO}i3)#!A\.@xz< $EQEQEVoN jg1 xBUc 4KHTs1}*BAx}!{RoHݐ:(ˌ3}xi`~d9ϭ w!NzWc08F1~\:_*j|21q׎~,~UN9 yPs}~5.m˞" ~#Vf9s< =3??ƙ$2 gS5j(+J9Fyo^:GA}Fv*cRm 9GN= M$Hʞ"'P~iQ0I a#qHI*wuҕ]#לi^O\z2ĪgAҕ`{iUثl}(TiwNsFӿғ3y|)k c!?R%䟸xgל \r=sWDӯtj訢((ϊM$/LLOCnh2Ah#3ԓvrO=pyc)$ ~J qLT!>,@>`Ar9PONVE'=>Pؐ8nO?N]g<2A<jir.=M8ڣ9֜O\ M7b-G^[#i篯oZ zN#:\($qW 01J9'b:O]!|r89R68ҤVu]ː)lGP:) #?Ϳ($aSԓ÷A'`9QZ1pOU 3S\M@'k=2?'Q̀ҟjln==3)`5E883aF3E0Isz]m xps曀8<AQc ⇏kNߺ__Jq nNڐEI )Og5]Ks/q鞵o+?'`SڂTl0Q<'Q+l<Q}hUP*0Wb݃q?7>ƚ t= vQEGu cړfz0+&O0P`K 1:6>|4~l G^˅vJ쨢((+?^?_ׅQp3=1G?C.ԩ t4O=pF((z@<2Ar3󡑄W 0ԘgJ21^҈uIZP#8TWv# PH,Xn9g֚-Bm$rN4ns0GBu!pW֠i i jJ_ g׭7q.C az @s#47` FQLgqӥ8XO^п6↌NA1'*rO~>a1Hct'U^**r?yWCEQEQEWM`K9Pq67#Rl=|xW $ tǧ~CH8)*PHҀzG# q10*; }iD)O,=zš`z8'C?)SzF 2>\iBl\?B N&2rb|hunI;r/qNps?X22:sY6m('9#=}ioAjaAA> ,pGG'}~U`8u;I=1?<.Id~rIxՈ[8SqJ/}?zU+I<=sGZɒQ3'鬘;i0~b29GqP}I$Aꁊ9<"7o(pTW_ Ě\vHY8v8HKnu n)bI=:BWnT8?4Ё@Қ2@ ,HѲ >()+L&wgԪ@<gGsQ;S'x+=9yhWp\A>֑DUF3Oʞcm/˻F}hHĒ9by?8Ÿ&I[e^EQEQE|\|+.j$~'ғ?1ڨA3ua ri|a 01S$ UVd`n@=&ңn6?_v{Fp(@l{l0>oIxO9L/Y$pc<Y_d0q*(n SmAp~SA?΍^R8ؠ*T^~>QPNS@Q(; S<0\9s#0sh˰ %DB q۠PP#u݁מݼ1v>8P#y0x$OjF\KHF3gjރAib`*-$70pNA=;Ι:`򌎿JC688r0qI(;GWRz408kIc,2r 'cקҙ71'uqv #Ґ?7 $a^ٷzc`h((+?^8zK#HhŒe<}?0C(8;]O-q'P6ךsH 8@۞ᵟ 7ӑ8d9R{N# ~^Nϭ UA'DA Jv` aph0I(#sz=,I13H\3}?Pp/+NX3`c֟PYJ"(P"I#52ϑ+֝`8ǧ?΋r2#Y AϮx5Wp=Z M$qA>:oƑp`QL+ǁ ?RH$͘>_5rW)jcҝFv'?4b23ΛFy=󦴞c-xA(D !8q֓d/NH_!!J6O q6 rH?OU)Hv ƒ+(Tz)pu{Xsqm(((t7I8LWLBSpq9=sO.G9Zjf$zާaF1L7\ߏN2|OZb0=98P THު_UH<zL>_b9$fNz3ڤoI˽v02N85gT˓Aznȧ?djZpp9Pa6xLOgڣlT𣑷Jk)\e$qL%P g1%@?Nl6H&~Sڑ/eF9L+O8I#MD``9;(p$8tŁ 9!ISF!YNn$! y~h–*2LSV 1#nYFϽ؞.@]򨄠q8LҴ F I8#HpQ>o c{Swcc01U^A|d2}HS>',دN%ڒG}p݁ry>Ӵ QEQEWρF+@:r+(Ts?$ 1`Lmݻ ?*P.ጎOhVD_%IV9k`ZF r2J7c,JI 3zOP. 3?d#;D 7d}LޜY d{Ӄ\v=G^?̡ی{~=O".XG89Ǯjlc<kFAC.Uy~V<ڮ\Hz`1YHK>2Nv[sQXq?Қ09&VrNz)A?. x?!zTm!0OFڊG<0NK҂˼OO3n@?;jgipSpKmcQl|;q>8(((?76Ir}>C^QwpFSP#'xTKla8Sʘؠ`uϾ2zQ\Wy9( Q~S#T5;=TpN:}iU'}?+T8yfnx3,HI<j`8cJkI6=8KʥrOzcex>ԛK0''ٶ" 01j\ ')ΥHI$pV jBN[ǩ}j?)ؒwߏN( @syOךf7w\w8=J$f=֌ʀ0@?TIsx'9ҩ4n?(֗z8<$˧֓a9]9C3 d,͸rrܤd1펙 !bV#<'56藇$``R}"b$ 9hIC1-q}Rdb9n:O@ Ү5q#H^59qMeaߜ 1$C}\]$pHTGXc?ϵ/6rR s?͂yPy9j&mc?ɦȂCmM9i͸ !#jOjBN}lo=y=>L'x#v`NiI֣;9T2r:s?(9 0uV#\~5!9q>'߅ g߭zF Ọ vJ쨢((+;_ xsT'% xhN19r`$CQ`g b#*[p0p=WuHI3b&S rz҇gG ?*:"L\>"apscHw60@^=C9l ~8R9't2|ӵ y0_LwWq%'8#pyf]'#H{!I tѽgK:JRY2~8^UŒm9F$`X0ܞ<yy?Ƒ,G)hL`W@pFCcO, S408q?j?0oX`EQEQE|K,J*y^:>^O֝k4֐/A篭"˹Cv$uyyݜ># r(Cq7- ‘{ld dଞޝi1ap9; ?9YH-\w~?ց1%vPJ֞aL5T!ʟ>lڑ|7qE;A<<┐19 9NImg> m'q=~A]t2A$>4qUQsA.Њxs~f WxzbEp#YO@U1r`69:zq21988>ǏT1I<C1Ӯ0sTϓ*Bx'* 16v*N1'm$amq'r?o޸9LzƜ68#=OW=}=)wq=ZLKquҐ9ۼzg?J]9V۞TjB Luo};`!pnrW[eޠ|O#8;3 uϹPcMg"CÏʍrxӥ #rJ@\Ҕ:=a$L睤RFTw9B黖{>J((+uqvJAɑwe\8*`p:~Il@8/ƃ Nl RI/>Ԏ =i]ٟ wg_COa)|rH$Ð}*vw!pYAvVcRb<+ c 09Ԉߐs~O3yUUT5,ysA#$XlN\*ȧ žNzKs C6qN {W]@ `yUpG?b_;im#'ޠ+>pJBCgCc$m~tG?ʛ1\n䁃$⚤ X~ nҫ{~vpwONJ`fS$r:^)sdAG4CTSܧ7d;~AcjxXc韭ݤnb*Ga>#==yVf˂ɻca+(((|Ú?/| QqZBNr܇~?H9xE ᶌz~=œp>n8yKcL.\vJ$Sѽ20F<< iVۻʗHm<ӊriQ2TA-3zSyo?y$7*@J$i걧\qScjZgb$ B\?cBP qw=H w69 Usy9r2y,GJ@Ll!G'o'*9odE8x}r8>bW^Mo9޿Rg#?D 9Ci WrA: iR x<FrǞE(PTy4gnx|) B v#J.@=xo<axH'?ѫ((+>'4Bߺ>q &E#=:g?~8R@,z~4rg`60UQ?8(RNI cR g$[IBH})NO9ϧdxrNO^#Ҟnq?zSՐoG'w (\78\G;g=dy19M| W'n3 ~d✘8t1 *(q?ԁ #SE8A#: F0 pb i]8<Ҝ7g 4Lr3OO ?$}+F% pI*] ?䊍zsYJw@G֙I?0\7^}柼Ӟ}8B~GoKEvb&aI^àdlgezpq9$4# gBOf_4+ ~v=O6Aǵ)M x9Bdg?皏9@1?0= R>01S s60HJ͵7`N2w)#~TmBĜ0:zRL1J8 sMyv7 ۟~w=wGOn3ޔ'E=X'fFuǠd?}EQEQE|nQ{*A9H^xrI<{ҕ" 1\T!9Ş(9 d+B~eQK7֏=@*F>_SQPU'L,C$rGNfXgVy;ls~BFK{z,@=ys3Aq*7acրǠ9RA$~4ݠ9Y_֭,:0}0V%W<sNC(/.fPvs?B y1=NG`3#'s?keS#nOWbo ''v?֗~] 4Hŷ8n*8ꧫ$2H#p$Nң9^L5]BP?<Qޞ]^KFQ@9w4Fۓxp[)?(ѥcH8ָ@08 è$Q󦴏 |p8J$r#99[1)1@@8vF><֝A# 4+ M)rNi׌u*1.aJ`f y}}1@JΩ$PRFCר+H^1⑺SP?'i#' 0O4?Zc$#9 Fy??ʓ$ T`$KC:q?֢̇9SiC B sKS 0~b}y} zVp>R8$ՄbI Uyy'i ck`Nd@#MrrB`wq暛H'O1uTFr:3lTۑޣ$yq;imjD.dЃV"0>SqߥD`t ˗8$sښS dSiI,9cgcI<{qJTq?^⑘! $PApI? <0o;cA+S1f|Lr01w33@K19LJ`M Fc8>x)W$qԅ t{m,7#~WQEQEWdnO7!7cϷr@9!HۜuTߴz gr=@sSFo}T۝GO=v-ޥ$ziiFs P̙,$2=?4q*AF89<ٳ6lC!+A=1B>~M_'yTw(9cR>_syTe\ s6x玧LF9ǧeg$qӁy $nU#ڣy#cSc71G$>zF[$|9ʀ `sОߝ/&cw~6ҽAJ((N=%~#$R1 c)*HEbNxw#Q#O&ԞSTB@rGr:zJ y'? ),Il`ҝۉQ)$['P'iPX:i~7d<}~Ƚr}o@J쨢((+7  ꠌg6F39'i]rP ddt'j.y?:>$ip8& |0?<CZ#P (RH6ON9٩J6Yǒ3 yUJ~e8<<ԁxFUW+1OҤBHlg7`4wI9n{2.q횖> 84cqʌ xR*n؞p:_G*0B*$(?(}'A^ =iU  3a#r@#XaHs܁Z.;u:CeJ֣D/I+r=q/^sT:7>_ e89Hzs}ǭB۔p0zN)˝!ݜu3(RH뜎8,?7^i|ѶA?N: x:QEQEQE0F8>oAy!Uӿ?ځas"9' ;^6'g;%K(p{wwn9luR,3~y.0=iP8 c})F \&bN{)l#z;3 vGfhAԖq⃌;n[4ЅŁ'!NG⟼nz?[0 ?ڤbv+]!,SqiCo9Q4/6YOjA9Ld<W?)9l'15_b-sy*Mrc~_ґ1NG50 c>01SèRQ:drGo㚓SRϭ08moΘёO~Oʘ2юϿ=<b6ZcrAN gQVrOoqO4 3֦ W{)O˹˜I/<H:nr .{ Rc@p Rr=SXڬtn,OޅlUe*ye)YOg?8i$+ڞb;_@EQEQ_:9\۞`v)rqzw"CpFyR27۞_5ԀNpIߏΜ\cM)߼ Xd/G\c$ d9۩6|az2g$ؚDvܞ[:ƦQVy\0;UPz8MG'8Ab989=?nWiZo,|۳xZ((( džSsup׃p~gِ0}49,BR #*7ʸ$$֤g#/1ZUߐ&t</H8 ?4oa!z mE9 4i2==I3NO~N ?'3RqQʂHB~>poqLs :JS0'$G4@!yO0H'Αx 6:[-pxG vE!ʡ O\ڗVR/N7+ץ{O<shEQEQEH;ܯ<ٺFym\ǿEB%q#ǵ8d᜞B9xB.F[#OA!څ01ǽ P\A/+1 Ov#ڜXzyh\3~A ~9&=FiUc`yyp 6z}i[1) 93})LGs #?~XUG_Ɯ8=NU A)Cg8O_ F0ANq"0 ]usmĜҬF#ڼl~Fsz~T+cY_<ҡ7@$gҠ`}<9#2$P>֚ 6m#4K!ÒGyLHsH g ϭ!vPAQ>)̀y(!6PѲa98ǯ_OҚ@'l3AB6A=^Js*s@9.2FNOZl08E*2m׾2;wh~$Є)#s8?JN F9B"Pd0:F~=y g8ʟcN\dCF6} EQEQE|' i>>*n[68?2'%W>ӽ84h EQ0ASЏ֢2e ghޠ@1ϯps9⓫` >Fn-:zVP $'=1HdH`{֜w c>*\ݞK~tHq)wPCq=i brJGSӧ;hc͸uI LsN Iǯ$bbDDGzӡms^8;|3TFqzSc`nTS`C1<7H 2;p6F8M%K,HJqop2on4'g~ZI08;zzv՝Td=c@pNi";q}i s)os~t"!$m'4# BK6~}{di2 23G/x+N?I)!95? A?J(((+7?-j9^ 2x*1nYr9iːsy#98v7o3֔9xlOs28qE;IU۞{iU| rcNpGO~j)%pd҄ϴir?`p=p@2axdc ± RKZf>Xp O~Ր8b'#rߝ=+ ΢md8pF# A)e2}ҤIAQENJhL*NNNӚlm2w?ސcEۃqv^QFcޅgsE9UK^x>C01CH4Rq‚15s+O `p|sw='q?_:M#x)'8 pWsr#=0@Gfpӎ րB8\cpjTˏ-"((t7A#/˷sssO-X@OJsbrcAr3< p;z}zL8/2zƐn,q`I* `98>~P8==GOݒXڝB:wU '#897062'O?;r#vRsSatǯ? 3nJO0~PI?$ɧ |d@'ޮ>>]ž1꣱QR Hާi3*.p3?z$rU"9⠑CmDӓHd@ B9 FAg֣l+I{~@r9kGE-:Rqu&v2=s`RI9}r3JvI#ҝ $pz8LR0>'Oz_A\0JU$ns=nN/3 NA翭z/?Ҋ(((|B3Tn^ X )l1=? H a`G9^qzecZEY ]o898%G^={RBWs@*r3֡nr1ɣxG3 Δ  zzLcC)S1ciTb>b $= !AnS-p3.i9<Ҙ1.nLvc>"h7sR[q?TؓsǵYnlpo$)ӷ41bI H|TaHgޛ#]UeJgQZ=s2FOHd,nI=8JAtQ`c>i!`O8ϭ4έZמG =qҘ{eOaO =~;tK.y rs4Ie]ݸҔq,n/W 4p)H݀pI^?5ttQEQEQEy![~ u@?J$eG zvpp1~iĬgGquy`F8i^pÁ8&7%WSIw2קwqꩼp\#ӎh n0بʹ`cqQ;X``a$=ipJ!`.wum0I: &~=DJb?ҕ2GUp '?ZUl䁷',@aAbMUPcǽ)GROB8i'?* 1HwnN=;rQ`@>5rW}15A>e T|ĂqPRrG^:X`1+N dg)1J0e H)97`J1&$ryuz0+di$J@Qc2,GPp~d(~8ӭ#+0q߿CKp9'ڑ6gMF8*cÒJ)щc9mÜ61HƔeIg'*T۴^FF1VY **:t$@8?Qmz_4D{?t F###9n2:Sdbyq4<#gʋ#8Lf\FvL/U*@N1-pyZ X(i?B&1`,6ǯ1ڍb}8#!6ssL,2d18;OƂ9l 69~789U`lӊ˸ !*gq^<:F(((:vɤ7p&uu8j<8mӏZ{) p[qp@#XG9TdmOR' g)墋nM@ŒQ4ӸX r:,1`PX*(oU\l?86QK{~iF8?!Ď6P1(a^ހ?Ξzgd vHιIbp=jOٴuR@2K*}1W @$//ʑ'>x{֞& ;&21<>nšY@=- B81B}W0uNNҤ.Pt((IҲl?\1O,v|KNN}z(B]*~z2'ߔ9=zʕw|ǧ月Cmg#v}8>.p|?Zjc  }?Ƒ8À9Lۂ=Ѥd}V@@T Q[K`z~u JQ#!|BHʰQs烜T\ QNy F`Kp{֕>VNQ#$6;ܹ\w7FH^qCv1 (}`7wil$ʎw7 0o ?>W=׮07Į֊(((|C"֫kqʢܜ#җW^2OʂB_^sl.={^:gJJeN:g ќ8OlzG˸S=s }iT>Ax?:Wqsx?і(TsH93l9C& :鏥9 8'\;g$r}?ɥij|:.i,y°z'!'!^vN1j& rӮ{~TPi؆%M#oE%N:~sI8-f? 8zYP:g<%@Zq.lTiѝ_?Yo=@G?Qҳ(LOƂᓂFO {HѲ\+۟a)G(84@N!vc<q{R8dNJ ^?Ʈ(((; m'L:^v썕O;I}iWq[$8O#lt'!W-ボqU+$@+HG wSڏ1CQHByǔ' fH21䯠)Un;?脱ù\chPB ^g -Oc @i܃Ndt]cOoݭ8-n:zhڐa4/]ښ2yᱏJPP8H,% gHN…spΥ7@() #?L64IϮ0،NqM gʃ=)w?^891(("m=qA8 БcI 9A.$`rqd}4$aGX>ҐຎrF6ƕ`I8֝zSO#"r䑂>Q:N;98 3;4v$ғe 9J@Q.V2d:g{ FS?R>ie=us85jU*79 {1S5>!W 7dߨ9=E&2 7v2,QAm$~ 0+QȁI#n1=zgU,iHG>F28 {~201qNg ʚTw8d}(rXNON Ah}?_hH *[>'cNR r͸;s} PcO\28Ϩ+ ~r1??Pp'׊z; ``~rje',pSc~LwfS/cNBTp:`W֑TVIn9TʲUA3=U v 韧Q{,Ac"~Rp0~cvwr:!S=;J>f%ҏ n?"Y I+A~t$eT|;פ?}ndgq=xSFY88;Ro;?wx‌8'?<̒GOd#N1((+~* Adӡ^i 03$qy6s)i88ӥ8GnIEfP[:u`N;?T+}x֥yyv4#lYH8*c1 |↌Nݤr*皅bxyLV|w$dv9#\ g ŲQ;uZtr | gO=iKp@Hb';i>\#i%O7uL~a$w+CG9ENZX8ޘB~9pGOV,A#ӎzrr5I=iI&8=) _nih@Đ=T9f.<)=//Hg;QN~kj((+><*8=};w|ŰIo1֧m9< ؿ('$1 qps@Rpx_J@'9>ӷF1r9tLwd88)uB` 3`c)vO<Ҙ0PX/Lu?vC2z`;:f qZto'`1ԁp= :3E*A-:W'qmqfy%gE$x0,pĜp[i#cAU*Aڤ⢘(!lT[#oa2֢,XH9HxUc\W0#QѰ"{QY8B/%\?zUsBsW'8oN#6ݸ u.S_j}~*$dg1sJwy30!Xt}v}㏜*# I??{42nϿjars<]*NDA!@~Ni#dtENC)8&" ^xy= M~POZTr>ΫcOF=M7B[$~])z0Òzc8?֚Pc8') 2;1 G~R[#~ySG>r>f^9q)=N23P۾Qetm6 g?zTpAǯt.q@fn ӃB<NHP0Ge^ΕgiOCTqz \>"WmEQEQEQY#6^v:@?)e+{R@1ۭ5G'?Cs/Sw =j.9Sp;PoGLzsJA,<wn2y|2x)>30{JJeqoʜ쮍(u4󟛔[X*X=tY;@p2~xjAdu97I=HsSUBm ({ Ƹ{W*ī23^;jQנn `qz3ʹ!/'2m:a'vHOTڸ9z< HZjȢ-۽#N ?RC0㟔$8jcz{*86n99N9/Jb2yl}T*93$g=E?N1$0{~5 I gQ$$uE)F'C99%dpO'T1Vk|yϧN*Fs#nT-A'n9 NO׿caN:Jp85QEQEp4r}['* 鄬r1d#scS-<`*&21Cb;:F#?7B0K`c\֗ 0s(ʌ)ʃ#ҍ\ͻ?ipysG4mPsI4pKz|$n JsI9?+ Ny|`oй  .1`Zh\6s9N)*F6ߏKh}}[%]p{gn#` +Ѐ99(nV8Sslw?JYw`c eP98]1;N00 ǥ v%e\sF7GoR .PyqMf&@8Ҝ9]b8?Jv@'hb3~܏Tw =j c#ZhrG'1V*z=@$MRPg), qml?J_ 8ϧx6zgƜW(֑Xy'?8?vl ss<H`sױ>YvJ'O9'o'5tQEQEQEyBGI8uHJWg {)*ܞJ;B䌞OzqN\Oy]8F$g#ZG*Wp`? Ѵ`Wǰ#x<3utH#C =(1\@ci_X#?twwuzS ~_t%qQ $'ipYOQo˥86d O?Q /X#T!$}ӏ*}z(`9 xt:~'ʲH3oqڢiܦr07?Q&) _K# 76y=qR^m0%dpzKFA {;c#9qJ*my#x_7pH$~}6F:2sRvzHqOS* qNG\;@Fx?JROl:['Xg=:*Uؙ9p4?6>r9^ϵ(vjP)puD(#>Q{4q`: 6MRZBAr3q҄*G͑Oӵ 1FH\{u'S1>097~})R2{c?B['~Z)nU}±ol? /s2am㓷$3wœrGRϵEUB`t?•ZOpO_֐Mz9pw8>n8OYH6@Bw*I?^I>z#2~;N*AS3N p~I=3N : f {r)6Ӱ);Ov}?jzAs9R8PT!P?50^N@z᫯{=s ]QEQEQEf:@5>psml҆$ ?*mq=]s8j]p38JU`Xn{ M2pT(dlZ@|ǿQrClP{c8 cx=I!O? VyI*2luiXĞr{~gTA" B!dO'&L 3?~hgA&;ON޾a#:hpܶrp{Nʌ #E3c#iK)xx#qʆ#q=`pJ6W<+||Z(((mFsu2pRH~`%{cƙ@A,8?iyHd3?Mq_#T-#?Ӌ2>nJ篽!${TO8ODw0qA4}ҙ99җ O)KyG#}~@ 1ڄ |5r͟Ҝ>P+#>Kv{/J0[3?C4fԃPܹ߅cZxNH94PGF0*m3RLmR< 1zll߽R'<:Q*C8T OD}yJ]#?* yg+HVQzuP9<㑌 mS;ۭVg/qz#NIt,lF& Fѻ|gH܂$/8}?ϽߩF2d#\E8S'?j?09:h2;p[A~CM+ 7t?7)y E pq =\8=<ޒ1U˃s:HƑB61~)nH=}y^@9%LaH9Ⓠ_vp~2su?ZυO.6[q%vQEQEQE?5l) x*XpBM S#vsӽ!p =x4]M A A=5 {d}? ;0 8?ʗ%G n?ZBK?Қ$tqI602pi"1؄#pG9?ʐF0zE0xbpr$T;S>ў[42pppg'uS08`7 6/>NXpiHq@h$cHf<9^ӷ^|nN=3vT^(bA0:D*@9'rݜ܁֤߂0:vd<1{{Sv*G*V8ui iFS۽cܟ: 8U9Z1@o 23IR>\|&FH*Ϥ Jό^uǖNƂXG'8қ9R&zv֔N}iK9;R9u# $LTAR98⓯8m 1t`3 V88l~T z!2y8?zPa 9r{ړ ,aJxp?ǥKc9<;.*r1қ#:sژA q}DMϰn{NU@#88<(8ag?ʔ0n>`A2I ~EjsLR@ 6nx4 7Q1o4t}3PN}T  0})\9yȨ(rFf9pӥ*W ^N#1cS*|aNTUnytH78J6#da9iP|q=sKظb^ohLztQEQEWe@ciR&@ؠy9t T!lsmPPÞ;f lȐ)\{"! P7n }7 bP{Zh|ѲpXzqZi峸;~)]T@OM^yByqNo(A9@Ē@9㨧"*@eBBm4sW(Wp#Il)PRN9iCzʬ8#~đl=z§%Nsx9(((φ5`  2A<jc`ǿzҀ .ю=N14!7z=Fy)#*rˀ_sQPgP);ʚWG%'<OʘO,ĎP{v yHNH䜑ʃڛ**g SXFUyt\!NÞxi|ӏGN_*]Yp9}zt݀ }i|ហ(q؀=ZL$ϚQrd~7y, B9R3{a_ƙY;?Z{˂ NGQ`pit *8chiU/\s@W䓞 zgv;{~&2ITP?_Ӛ#};T89=x?ZO tҥ܆ vHN:ԇ8{BY/N~c|/?^A@s0h$?ȤVKycƽ?|W(((9I9?n7rU=@$v#h'l}:S_c4dnwl߯I'9\e{f(e,< "`UNt,dtfÂI :T!8 ׎aB 9'?րLO-0:GAysOXE>P$3Mf (?xϩ|m XW#ݽ)YWR)n )lVfP'Gpp1U;4ǯ@Crz?En!NAlk媅<N(bx0$qqqs|zc@"pX<`P#轁W&9cPR?\1b;gx,2>pC);FۏևڡF7zF(H~8}p>4NG4Nƞy(A#?" Ԇ^FO#׶OZPyx9~De˨'>MQEQEZ18#bp@9/RJxS ݀z9zcq3E)|zg5ű1ݿ&6Pmn6񓞿ޤ>p#w%N=9Wsҩ]wH۞@>RXnO͹_o#? ˍ+ ?zk8Pl~trpN} B p{==)FpY gnF[nAS+3jPXs?'@Xd:sN,,z~olPV@76 ڜX[# L}zrhT@Fv9 uZq,@߸FGr .{z°Gr۳z8a1]QEQEQEfxE}[Mr r~ #<a{u҃\3;c ,A#1 39Q}zR)b) P 4pai#<kw,v㑑3PlR=A&pW8?$RPHQ ı?Ѵ q4q"~ aq@2y?V]PLn*z8uL䓑n_!_*۔tG$;JQA9sEK@LWÞ~4dP@MLxo%~f8$V p:ӂb21~R1e@=:c=80' xiAlyZXo<NݶV?7<Oo8wp<Dn~x G_CTXt{_+m5ILc'j騢((󟊟{Hӯ9R1pT8 ?csn1iXns"J~;J(065ppۀ랢)6)={󌞾H#=T IOP}ȡ}?ϿIH}G_jn=ҕc\u\Rˑ<~ +0=ʸzTH`H''Y^C}q E'] ?*RŤLnb2y?De I0Gvjqsz]<=&K%?aLAs`xTK/3`Gן֖G d`9|+9gڐd#x x?1Qँg9="iэ[b0OcʀqIcڪ]9<>߇@,@9G8`qME;FT1{:瓞r=)O_ǭ+`y?(fs'wC2?)$esN) 9nG_Dm'•[)@qiX$E4w͒Kv#6r" ۜ¾((l$?iFZzc:qjc(*N G\j!ȃnҜNq}Q^˟ʕ Q'<2 c?/HIi142+ $c9'AJ9Δ@a bsӷNPNl0J1p֛I#Z#IbM ֜ 'ըYu>jd8*1ý013H2mt1J!_X:.?ϭ &Hï_U.N~ՍXIJX HX~3p{ACeX@,~U yt$dOiK(?tcn|>U}֤98Ͽ)̜y҂X9U^_qsB:hX660 pzSg&=Owaφ.~b^> z(((:8CIf«A8s0jANO d'%̤#08=)Wo̸ܼ㑁LRxGf9ss=P c9c.;q<" T qp))$d@ H*ű;$IN1%=rN;Aw*ĮrA8!jpBcJ`,<20:Jr0^OBdP=)T<T |"æ-4n>6# (vesC#_qL0AcOSH3#chuLr?*sPHp>\??*U#8뷷#Vwx# p0{nZlz9FI'`]-QEQEQ^qX:Aqx/zSv #ԛ]#ZU+v$N*4Pr OP8N@R0;Xah~4 ܣF1ҚX 8rr>ڎgOsQ# d?8۷}0:u=J\mF:Q)9p\P37 `Ʌlp3=iqAJ=40022ONMʰ$o9A'1ܚ0X ܓI0BP*r8~?=>⑎=q6RsR9#4@ Z@(cvl#m,@ F}}S^M|gקXp3>*E0>l$֌8H ӲF2_i9F38(-Ѝ=;YN:QHѪ NO?I`ň=z~4)S^g+Otlߏ曁yI?žrzsFz韔F`pFy8MLn>r==w(]?12GPxʝ7 f((n3:^)%HROIaN1"FO8cu6iI%rרi'ӷ_֘w(BzqNeܸ^0:Yrw< I#qca\1Snsd $zK3zr:udܤ23eG%u-y0pT<~8fRTf9S،}E.W \ $߷54qx2Yr9~alTQ)i\8s2{c-4܈NO|SG=:r}ݿ89ǵ9=ASr~o,x..Hc#L`=8?ҟ'i>L}zwNVbc N.т1ai$ ?,[ pc=h7m  x \n1/ɻ_WjoVzq]X2osJ(((+7?-j7k3`'P? M,Ia?49$g*%[y K$e'=Ȧ n,A63M!'90uV@+֚ eO>Za y<*5zO>SGs s_JV\ysϥ#ab9%PT`~b7I@?rX^@=0)aw) 9;Q;8#Rb[$ ?_ʜ\Ϲ3oC!zc<ƚčwG 9ڕ/=9-t ddӔ%<^:Tܬr_^Z7d_ғ,36ҜdÆ?=@x 8^:NH808 sPۛ?7?~bp>c19ݜvBr7!HCzq*S `8jW*08wϘp6]{ 99FtQEQEQEyT.rx;9 _;qz6! }qN =pa \t?1 {Sːv7cғa|0CtNi~e$+< ΚN"S~J#zSdYYQEQEWg$8wW2^QNq$'8?z@`3vXh)Fbx HO,ݤ-OϏ֝B?xNwr)q J`*:y@ܼc:=N6Hl:<<T3! 8/?qORU;,cI 19fVJ Qڢ3F~U')0.F j䟯O+[m$p:/J94p댓DuϷR:e9*:h;Gl! GH_'Ϡ+ F7}sӲmsz@A'펴,Ѱ2KX8҆ zdҞ7lޔ*6ސ=p ցP's)|mr;7~*=H%U 27OoH2qBySMNo] *c<J 6W{{SݻF1j8 ~lt y%a}}ԻNн8H`R(˕e;w82/ϴ{;NibCu}Z>\rJȑ$э]-QEQEQ^oXq-, \r01Nq񴓑9JM!y'Dj<(y89=攰^?A8`03HwnA4<zJG%[se8bPsA#b0rB<(QH*:sJRX;C`pqtՈ9- 2N9Pp};4ꥈ gS;Nc/\N2܌{@vH屏RF mIϽ4x+S' nM Sw >; %'(2@>)6 z ᶪ@R i#((''хAr:gǐ\XU 2O'ރ6w F}})ZL>2Nzj&*IN1T f%؟=E=G cHTrD?(Nj6* Nޙ(cצ]$Rn8oR,rAPF2GlSTp8IQ.r7rOZXV'F$qdu_ y +u pNJs gnA]O랃ҞYYPd?)HpIž21)my{i\7GPVsI0C⾍((l 6ᴜףm=֓r00=hv%9#LfE 'ڃ0Nr3b 9?Hͳ cSHڹOBoqALb@#m ybN㟥D@[ç)ʼn˂:9?犏q 'b\uI,1B͸N샟(ÔuRGBܕ_& $ոK}Tqj֥vgY~|ıPr8OJ Hԡ.ןƜ2xE$4nO=O?7U@Quw@!I'Rӊf$q+(((ȵל$FH2&nO}z3h'4N,qߍ#0{cI8F9<~@ yar@zP*Fz:fdTǩ=ħ,vqI @[4!~BO^y84r9>9cIڕF:=i'3`E*T&P.vvzґH*>nNy9~T1EL2s֟@<C@'qO])3dMU*1PG\?!A*G o' q@V!9#RH}#d`Wr@oX0CI ^QTi]G$~H $l=iC}p3ӭ8đD*XpA@=;qJ*=T՝Fќ>k`y?͞L_JV|>PJHv+Loӧjr(zn'60[+jV!pH.''p\cui¨[o($ ~)O$)1jV!sdwq&㯧z-#}ʗBrg>Bр:Tyd> !#p9ꦿQX sE$gqi7A94}N08?֗IE!+$A 0NohFG8Q tR{@9V +%NzwsPm:xk6'EDmb1:Ӂ򞃀)8PT16~#קVMܖNzc?"zn Гӵ*`I)*y'z9P( z.0Tt3څdtu>Μz=r;l#>Vpx8lplNx&r~s? P8H\ΣoHR"E$8&N>O~) <\|j2LH3O$"HbX@9_ƃ ҔH8#0r_>?BEP89XA pMH2j7RḌ6 Rhn}80vsڣc)ݸ6gB㜎zQ;3c^iDZ?cWKEQEQEWVdтQH>Rd})}ygҟض@};Sp@V94)K̃!JM;Uw1 O bK.x,H? RrAnj3*pO0bg~ ǯOҦ~R:N o#S/2Ld3UWK~3JqA<ڕخÂN3E1wn篷Zl񰜆ޕºNG3j49/ͷ'c| d篷o֌0T<~=ԑp[u}hq\8֠b }` *)Ǚzצ8Wv??H c 翨r@!;u©</J37'\1?3H _NոwVTv;G$OGbïzS718?Bܰ rRFޤypu zi |h9&Obi=G# ?п *_ӿ?FtID(F1i?(kA4?F/ Kޅ@];S)#EOQޅ@];S(@4??”xwC4m;S)?г];8zQޅbT w#&??xsCƍr0SK@}?(wC6;dm?uOvv1-b 'p;?*((($i4O2OQci Mo h-&shK懌b*hӺ=S(ЇMNT Ú 6jIւqM7C`wR5M7(A$hnO_D iENJ?O4(kA4ת: u4)?]MH—oQփ@M7S)?3=7?R $oG)H4t)?2O>J_Fti R $Kւ:ho'R1q=3R7NS(kA&hva$J<7iתZ9&(@4ת/#ztOoB'?غv?FcMzR7Ƌ*hx6G#zIӲ?“t4O臮* 3NK I@m?S)96_T oo+ 1jFTdz(((7Fv?8ΎI=G0Ggpz*TqC =&Mnަdnr2?CdH-ӟ֗vT:w9b:~nm͂8o4eau8hg{n0He'0ɰ36x)<A$ y:Nxsʡ (,N})P94v 䞠c)@apG?;{BBTu.R7d#JI33gb3Io';{h$PrzI ؽKzž2h=|r8zRʹ`nCJHb<.zAAl>dRgg$=9۾iNJ`O\s!@'tSتrӀqTllݸ/@wr/ PjR۳sɣE'=9Ҳ sZG8 p #t=驷s9N~' ~4M>=1J9G4y9+((((((((((((((((((((((((((((((((+~-WI <?μsn3 }r)žr>lId O4\cqRwnoMocxR!SK/)GI1̈~p9'׊jrss֔G'e0q^ /9Jw`.FI cь0`F; p8ߚa n#ڐ&dzgOƜ2N0ÒG?hHfrs^4U#ȫ 3wcvTʭʆ$c\ԁAdGc#9ZC=Ws\?Z Q9/~?OҜKm=B<ڧtk o-T#_oJsw U>S^ijAO^iNdr>W,,H'I)ϵoU N_֝;/'/9(0ĩpF~ap@c._#vp_ڜ(;FWY@ 1hcq?3HHbϿN`*6tgR$ρ !sIBv}QEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEH9;syv0drqӃ)"oV`ۈqK\$?/+#LgG8kwヌ8H )r_w9݂[v)sA+!*zc?֣-p'KJ(V'#iT@+?s)*U ڑ?0x?΂rH+>T୓Ҙ$*'$+c•py9GvRS_@ysK 1O^?^bN{s4p8n@~)`Jo΀bH=@ZSPvsr(P\=9!_iN czWPF9)Te*㎿ImVrqR7z`;H$-zӸ98T<%rGeI{0lw`s QYs&` rclڤ.[О;:RW*zAڂbHAs}qe錎zh!? 29 }iX)\݌(=>bl-?.T\qh9#hJ13sQ;*vBrHOo\1oǟ ߰ݞ0qץ8rT{P] =O9 A=)SS2i1Dۻp>SeQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQE-cn=כQc$i=1ӟoƙ+ڠ`L44j2@:U߰r24)e Ma$6`zG^wqh`<yz2GXy_cs_2 ^ޚAcAN p$: gpN0<chFPӕ}(y$?E$ ` t9巀s88?HNA;9?} J g}6A kcFqi?(A-+_BȆT`񒇞n8Q8~=*N$Ϯx=GZE۵4Đ9 p=hd&NAfwoFGˌw@,8(`Y oAv~0q+`1 ;03e 7?N\q?B?ɠ (]x\(42W+8Ǯ=%IJx!/L{** UEp?ɩj ;(' 85 q{S" A{ҍ~iġ}'ad`?Η*K x?^i6)AKtRc}EQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEycu7#9~F[)w7<',ضю{iu-OZpldQ%OiJa ;Qބ |2I'׽#-Qg߽89;A1`p摰9݌g${Spu+؁1O(UWީue <}ih=\{DWVRG?{=01gJRۛYF#t$G43Ǩ>B0ul)a۵ 㝿_in v<2zT@퍔5#9ےOO~߭(#sH+(IݞæyڻA$98ioF`0#³1 @ l0npNjRB"?Nj-Tm/G\xGZ {SOg?J$em Wvp 9;=Ͻ5U7ӽ.ܳg =n8# 2rq~A$g'׵*v71qc@!gL҅V 1sRq8ߨeU۷9?7n01\T͒2:$(dq1J1p #9Iq8=OqgjL8ws se}ā{N+1 v)@%>uQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQE~q>:?8~Sϥ3qWdV.qs\ҏBsH]ߟ-92;OiNAҐm9A8ϧ)fP=7qۊPUS320Fp#(%wn2֑ S~U٧03h@CruH\P`:m<8dbN=8!$?HQ8y/dtQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEWT:HN`VQhg<>ߕ('c=r8h8> q|ZRFXbOzu(m3qJv1қYK1 ?I*rr=F?l(Қp6IOire9eF 4̭oݟ1j@HA c8<]9 )T qsޑ;Gjq? D!cJ  aОfc g #߷_Ҥ69T) 0~ F ꁇRsԞAcw&Ȁ9y?҄S9gpOK~ONߕ*/ BZM\0J> l$ қ*|'n{Ә6+((((((((((((((((((((((((((((((((++sCyd`ӊy`{<&)2w3wϣJ@ =84ܧ99oXy!~ʘvpAmoiʁ6ݧI'>)A,ʂ=:p?ʔ0bGU aHUیr^B`=qlb  x mLUszIus*A2A9?΃[- ?C&џ/-JA1۽.|܌u1BgNޛz?׏ʌ@pF_U*339=iJ3FI9!@ɦH9 |qԛ<? 0ۜ`qtAhԜ}1b18f׷xGv@zTn62ysϵ ;{t#!U =}}=y69oƁ/88ǷM@Z^@ c,ӣBibĞ8?8`~T`u< ᕋqF F3E9B{s㐅qJNOoSPp%wdi͆RxIxpTz#SA\H$20>;hy۳}EQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEyń/0 lC^p FH ,{@;z|89N}y5 a84Ӝg8קHKnr"q߯Gt_Ͻ2B071< zd֟HI)?18T1#Һ~c U€Ocߏ1;ێsr8c* u&͵w~h=jn  ؑ9s !1=(8=0r239ɣ1BGzS0 P0ׯΆ9iWvc,[8y WN :SP1Ga?:1 Xyv60oA>{4t~_I 1>_mޛW$G׹ZB :tHʅp::;Fz9=N.798=Avrp9+O#`Y~NG~ߟ5 ' Qa7 wx!/ޥ/Uv6ЏcހfB zUJ14($ )p a|ӰO MvVӜy8t[#$w5*sg8?9SNGפAoLdޡr1J+me ݠ p?*(f^5b{ufo-kit-tofu-ed0e5bd/docs/source/figs/hi-inp-comparison.jpg000066400000000000000000003130511521054151500240350ustar00rootroot00000000000000JFIFC    $.' ",#(7),01444'9=82<.342  }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz?x=~5&bOj>y})x眏.GAތ A98dHFF~sԑJӷ<(8O?twRsލ4g9~1F;Sf9i4=?Ɨ _ )sr?Ȥq$G_ғ Hw<#*V tu'rGt Ď4F@CRtCT':~"H'#A$z~z҃Nx?j?Fn OQ0NO|r'.racR ?uQ_ sFЎ~R1=?ϭ)FIu$q;x=?Ϩ+/Đ][=ͶH0do?l }OpH$Idޘ 5@t0!dE*7jD(c S c~5p} e2y\ڕi[F?wh1:h,O?JA_qΤ9ϯJo͎OA ӟOzEb#XT c?.s{SЙ9pq=*Sy99G nUY/83R{ӯ>pK׷ןA1/~O$cMi#'_pzqT$(ݜ5c1QI `I})Ā1xCת$W)o,uk>0%p)Њ^=Oo8}(å(({Q.y()z?4区ʑt9xqK}Px}?-.NoJL=Ÿ@rjڀ;Ԡ烊NXzTk+cۧ I#.er8$5nO6"7ԡ."@V#)REakSCSV6 suUNOOzijUlsAAӽwqO;צi|\zӅj -S1<^V6Bj'akM Ac)9$x# S'@ qnIHs mn?1"ht6F{OҞ>f{QLK&>b:H*r >`1!3ޅ$Ӛq cڎK}3Q9>C֝~:_J#NqB|3j'3UqG !c=?֦[5 Sϭ5:1ׯU) (pS9?41S par~?d1^k2 r;aF0sh G84ҥD%?]GqJ9sJz*XS|Z+>*rG_UЌ0e!NA?_]O%8?[60Xz:r"&<\m6?qHy d~U7iɒ%cN\FXZ&*FTd+}Z[KD^3‘O4EAWHnkY$̮?dvQp uE6wyȗ{n(N?~(fw^#מ?ODL~= HEԡ9=)eQFO귑KU/"0GZZEl.Nx>7~K//9ҫrOsI6@1@#Sz hny>W2yCpۏUdjќAA8*^0?3\ qҁ4=?ƌ91JBp3 0x?vqd[JЂ0Ad=gO9SA`1E)S E%lI 31x>ָ?ҪIlp #__znpN0??x :,NTdg<OimmBnYx ?P'ךPA$Jθ|. 韯8'ӓ7i7VӐHR>Uu-Τ E! w ?ңK?!$GPIT?P dHҔ?NFO9?JLE,8Q}8! ԟ_𩃲.I%J@E~p+kzƷtC ~±YL j^8c=P2W89?"l2 PALui>{>c=ָjL\s1UX9#tu<}Jhc,+0V*{qsV]Z$CQ\\ݝW$4c sYqFN,t=G L8-}ep=( sv?֥\ybt@Zv*" v>74| +.s{T~kn {Sj;OΜeӥ0_4Hx9?J$8#=8? 9-@5" c|xiڕͼsCO x'8Ӷiqs?R*pOzS9~8㌟:'[$$Z0WQp|'qR(zO#l"$Q1Hʢt`09튁䏩Ϩ3F8>ƕ,0`0}8b?_:!18F?Z1ʧHkF!@8! _UBYZz!I9>* J;z1QMh̃t)k6m!)?0ja '>ؓhMnE3\Q89ƹbA?qV⌵'9?Xb} {wr;i''IK0@0ӎ9~:7Qir\pp=Oj<9l ĕSFL%rEO9''Tlg=;I#z9\qH?OOҤ\)S;@gisǦE!?7_5M$\T/ǃyڕcڭ"ϧ[zybr =?srd?ɦƣxv>SSqHtq}I{RQL?У?¸I5b=9 B^vV8XSq֕`T}i@v9vqO >s1'3i'@Mou νVa/w =M(*H+"1_cjiF Wg?@@F,2O'?ңL2"珧|֍H'Ϩ17bʏqs뫱8 HLIzw}( ? NnArF0'T 8'ɐNHz.̃*&qؓgHO?\)Hr?S#|GRRrUf1yğRSZ9G?=i`|ȝK㌔AGvZC ջWU)BYDdwGϝ 9gըnc4[$p޹YgLʘXdbk/ "PNI9U+u w9;u?A8=z02 b@bp v5+De1e# sgR7|C!?a!=WU`U0|}?Qr1?R'NϷK}2p4)'s::t Oa7ifu=ҐI?TEH= bP $yH"!+(=JMW4=O|r?P@1+x> sΣ]4+#9T#f9?Z"*#=_ՐxI$ps ~^CԎp?_֜}??JP a4:z{Ѧ 46wF8I橑l~j$L^9S`)|\<0/ֆ%#Inʬ,xOʌjW=q*[dt˽>Lb?P+]=x;Xq0}v!9\8UPC˭Vo.zm[p*W1|jqۃMve|NCqQC ƣh^:g{sM9+i)*Pdo_ gҩSIJczOiOiqpn~we>kE.szӥ'qϥҐ55Wpʌ((S sTdu( 1z-{PAqVW`~$UU,Lc?iEH cd])c?Y`uOV ;tjL>܌?NF8z`Zy =:*PdtJ W /L#NA=G&GN?:'?J~8ʰ|e}iV璃?Mt#Dx#&-8>?! =.;a"\e:ynڡ QY.:͌,d3̏:i9y) 8i$sj3A?2~;vc|~GA?J|EeH': d~^M!8?Ґ ?//5 ղMn|c۷W+/{p%=l(NI85~+gky]7ޡ6*FѲ${֜#w q1R_Hm1==4;ԋdA,.1S=j[(qҢcp? -3),1>UϠU>r18[¸<מ9{ 泰T0-s9z~NI$rH83zKlIHj##?ϵG /S`c}'L~u?7LdJoNr@u*9GlSK 4+1Ru8SR8Q#82Wz<ы.p 3ZP$]Qp?i9>:8}i }˜\c<`ucl{,1Zxe`w2:gf[ } uLalȢy`IL~yIa،`y?yk9U28,K~+@$ 2A6{ J#zT.F ͜R.ta~PĨ$`W,'H##֛{cc:.h#4c?!?S^ G=;- 9=Oa\ͫOt 9Rz aںVC%R>3O[ztK,pvğ`OS#)ILH 8#zmc %8Qrz~4,aWz簥HJJᘀI<2C 3raPA?PiA8$q֘er FG'iTVIL+0=[~Bĸw$g㰧K(PNϯJ+Ͼ&[JM너?WA'8dL@*[sӭM2),s;Z|[?Y1~$={Ub|zԆoLtTyȡxATjsAN<8)b[4\c)H?wi'͎ ar3O\)ȬKxC]Jy; hq[s:X0ǨZ] u7>l}O7(yJ;`?F$l?Ơg7=aq׫' <q\ċO GrGPz-Yׇl96 ֬3MqQ33ڷ6mw$3V6hzy[?\:?_N) ~\v^SOL@!x{ۻm@AkǢ[c"``cH[j#@"0)neH3lVtA2 ? 7vHc^H?QwS$qU|9 }j3>BaFI_Lޤ*3nd(g@~?58G֩cQ?sWR9$c´Rߌ/Nz=yUddqz@sz~@i Pӷ9UAcoʦ2XFNy_֘I3"v=>?ZPN;gI?R08?3ʚyF?>Bܐ^S'$P,E! >OWmVXU # OPr| ?׹xg?Icۦ}nI`}?:(r"@\JMNx41T߇銡sz@\c"epHr:O09wfF3SU # @ cj#ў*岆=iBc @c={.9V 1 tE܃֓z*8}I?Zz 3GlTlןDˎAs_)C| i$v? pn*pq=*wk0hpSOU '?gӍ9_Q'|e1Hdڙ>ƞd(ԟ-)eiO##Ms U{[y\>xy-MN 9P{`o/] qBD}:ޭsKwct!$/j7:5ܦIfO5A#GQ!F0 :tHFsUuȴ n_ XHd[ru?P./sxjEAn'`rCZA8zEy_^$(sIGU= $[/Lk6KFsIzH A9aHlgɧ871Z~ p׊o,z\2oZ>Q]DDW?LH^2A׭hFļGL_:`?J{H9О;?Uwsx&2) d=_=:w$ELӷҌ`G@?>Lb~5 #$_*2R09=~p9OZ trIϧ?Ih8yer2ja\8W0<@9~5Z_Z3bq> ~$_4I ݿZa ?Ώ;r?OFw99Ls3 z_:G&70v޹W|x?^Ct#<FHzؓaҐ=~aGA {GӏYVg0:^ -cdn.y"IINBSmЙdt\ЅqXAM#;Ei.7pD zG'̌A9 ܓ߂k"6mV0;RSIesFG*A#dfT T[; (ZC KܑO?_U%@d8߯ lqR~N L%;hVinx>m\TN2OUyF2G|: F}A<4d3R㎝M cZsrF'?0I_k׌Y#_N=?+ךBHg~o8R秹tyX6s35 <ҞdN;Hf 2p<ҫ'xE1&VPFp?*dr5Z(?{)ex8/\axOVW5ݻrqsO*'8}+$Ө>Fz?:21qznzzuzcʅP5OxZFhwJGz/]Վ,"xUQY@W;LUqUP=wpHbO "rzINJv=s롾 tұuW+[ח(U}*ã"eGw1j9 M;r#kb&Gqr9i`@$}9yg?֢BQynN@=*9 T ?>ንđb>HGF_''+ g52H皯#z*l1 ?~`~ғׯ9!e`TdRq V$OD#Go$`<Jd$@xIp_KE nĀ:s81H4̃>8UfGRvI$X YO~I8Sڛ9ܣ ?\F?>!fc^CZګ89t3U+#Wwx8`G,n؀?ZFl G9;E7c?MP@ 8 HN u=sY(G *xr\-_˨3ehF78^ֲèF6II㏥\T3U;G HS~p:cҤG㑞3RG\T{QNps=56z1_89}.&rqP'1M_ vF 9Νjf.ηt0&O@N׵BpN3֜9@1 uЩ!m7g"?_ѻ'X>5<6Zܖ hxRVrd N{B'5pʳDe_n֗܀^e?pUӨ40=y?)@~b1>ưSN^x=#KCR|/3W&X(z,V YuW:|YeS+]!tG%zXEǼek`F$kV 9InO(9'UNMgFʫuJgs֜,CVy`"Icz3c%x~ympO0ys;/aY ؇G?8p:GҤө"RHR'=:Ml'rĆ䓃7#y _?$)?¤YHS{=nqJfvzpG?I1>?Jk3:G#o~N? bI=yצj6<\iH8~d/ZL I@`S~?\Dž0eA(Mt9?^GRG.sO#iOsNp0y=?ϯFɘ`zd*;<<9p0;19 _j[b e985kx/a%dP~ }ACpw\'Fn XK2k-&PJx#9#?t$x?0y9)#9$3{gȃ< A>s{CYCe#\݉I)YyO^'1 8y<$֞>ӕk1f2}AkҮu99=mA(XʜCҼq4zF;5Ʈ]@~f>ӐǧIzi~AIL`gTx#1Ry֣_ҔMF[NBK79=&8L<~#8_J2Zݔ&8IKv05/@ \489_<4{t)vaI(@ P?{x?.pr[jF {F$rF׮GW2i6̏=OǠҩjk##$gW!8c{%_ Y3Ѐ]u ʟu'BzL##ڕA+ z'탔gOpr?+ci>3% =>u &^0qW)Rhscg)`Hg9Te`~;SCՉ-H !0n׷Oj],556Y6g٬(_!O@=Ͻ'#'G?O4d0x?Pc#ԃjMHg# $0@~ҕg3?;c$Q  rG*qe& ~UgMH7ヌ B 'qx!rF9嚓Rχ.l-{#;qg?Oe6BDRܒC~bpzt<? ,d<$Gv)3UqG" J14d:9qߏiIzӹf%̄nPBSyo iRM^IVU?*dbù?9HD~\o䜅Rr}zaꫧiKki3 'iDMY/o<G- ڜ2u) s]!#ܔ$'5<\g`p1rsm'ie`AϮG?:BdPA,'9jy$0by t>U N_JLf}Jin߽78( j\d@Jd\QC֒!zH0qS֐gہ49p㚘n 8&ePڳIn*B:r~悬q_3` ˹^xΕ*m\~*kl+ÌޫޠOSn k: n>f]B@{{լH$q׵(`z)F-nyubԟ%0Ӹ#$`*:P|AL-wjFan\|N-55`R'3Ǿo溘60)ܾ(uXَQOQ?S϶-q~*.A*-XȠJ =xӥ1Jr,s85iBL:C=8+#܎|sTqf)j.<ÙJ:֫xsO:V$pOE+AŲo_pҥi} Wv357< vi z=hO> =g;O#iI#$?Α'<>8Ȧsp;8q@FzP{qI#_zR䜒 H9F9iDNW I# g?7 )G9TЦar:?Ju BMts;s Ry H@uCr t?O_KL1֥AOSVPBFF?"f2=8 ?ƩMlsEcqr[bR9?\U"nj)3Q~u$ۀHv?o󋨇>$F6>Hr=a]./dl`sߞ[{=9'd×pITw*FG dxK2XF:mxtw!I>$zW;FŎx[st(y$a v<2? lp0F3??ҫ#C Oۮ,n*RhA_XsM?ڀĀH5MU4oo^].$@'?Srb[2ZW3d 6yc~ʘ\t~UW ]/fu,{ҳ'&N~팟iO&43a2Gj/bOO֗8$ ӳ? )nFO989'߿Y?Ja84+`v? <~t\g4IF2o_& 0#3 + OQ?`޲CGfG8E>?l#7P;δUw4Bn}(ҭ\@t؃"-e~!w4'-Wd"ھJUr tURqH>3)v6e ??e%yUyF@zu9>Jڗw?{Io^>ɮZзK\IKU=B~5O=p?%̡T6@7A#!F$ hQĚWo'NGz>jp=8?JPz.x<9b|zt]2/8=df$dcvay(8NU,^6h#JFY5*Z2P\*D1d*ANIMۂ8YݰաW.eb=1~KH:2;p t0a|d?ZY*C>`=04G,RfR3"QIbryJ!%Þ>J@@ }*s򬲱VDYϧORys'qMᦑ nN8*r7#p5C񖥢Hq~ Ҽƒg&$Ow8SJA1i+Bem(},OgԦY8pk [gP!#柌K GfrAG9Waot w0:_bhC+/|3*sxSeb<ӣS .r6q{Y |~lt>)r ķ'Flܜ1Z6i1ZB Hz{[U~Nֲ5%4%xP۽j& 80kz1.r?S3~ qN3FTyHP>rI{)>R= U' s5$ 1<Qs8NH׎?_R@xؑUۀ?CbyG5Vp'?\*_8~K0'9<*vTGߠ?U.Z`uN ` $*3S7ĚkKK@>SlE,Ѐ(?&`c[4 A1y F?/ I8s}*uB¢玸?W $!dVI'U-$,I*_ΘubqӞ DeE0Bc <WZ, x. ?9 SÍEua0_Zxf)hIAT^}s*(B#`p QKNq, ?:ĦC,lM8g#d&]'=<?c>ßMu?>#с? T|UjoċIbDmٔ$Wi2ͧ7:2+ӦYHA](=2+C NI{s<;<~Κ\,/ts;~t؋On% F At?Zq !O΍*G?71ZKu)#\{Y.^]r5 ~? + W+xH5iG;9ܽs³MrwߝZۭS'I.@SяQx·$yM*MPU\gj:.dD,@\B{=*n¿r)Yj8i𤟣oε*A?J +~4υz͜ ا) GAU{R,N3? >`col}r?#GB^}9:|0oȹP: ~9\w{b֖~u w/*鴫/lGL 0oe4+pq)o6@Ic$p y# 81Cb.D[N7g4m^&=Uy!~~[Jrwx^)Rѹp=Α 6UprL=%ZԑcKz׹\Ŀ7u'J ZwOçҞ18с{ӗN#ғ?J0M^'+,酇O@.mKlS?B*h%2EX vN0zΤ 69IwLIqɤ󉗧HzU?upqɪ3ErsǹbjV'oC%̓]G5OU X;.f!&ft*6_sW{[m{·Q9ܼ̄O`,w M~Y8( p|z2ԹQ@?8q2qQȧ3R, UISeI$u4-nqOY1ԚzHRF=*9#?SLO':Ep9 GvVKYQAROnG\ֵai&2 w[h";(",&0vx QN2O]*C#99ƮC9CBk5"̧%slXVޡBܐz<\4dd%Xu?QVN.Hgp?A"I ~T Hɨel5n=TaH?FyrX?M+1¬$OXĥB8$\l"o~"3Ȕ#֯C{ۉ"V[VAAS~\5MvS jFXn1og$ sK**T2|k DƠr8cxgFOk"G'_˯Őm.IFIkB Yox\}qW'.x=A_z C9}eϰZ޹Ko$ X?JX-p?oۋ \daecJ,v ord>/l`Fڹ[ӂ]`Ă}s)C ;`#'A׏(9#sWˣj$I)g~ǸBrA$Uu{]*b :?SX,ωI‹[,OS4ky;1%nOМ+QYAydaA8?>g!yPH9ӏʢ<I^c cF0sSgO|IAPPia ֥ШT z;s.8=;ӄzR,֤9?^:sw?e֟mz Iœ#SMs o2>`5h *nrsVp_Wo{-:aH5F<%̨8 Z( ׯWnbc%lۃ*pN;U/CXEe1sߌV!1>1c+ /BqK#4ˉ\d,LߞEfPYX$qF#6@򫠐w kݕ.G<3W@qo 5x_/ ݌[8*?G*G_zE6{qn1?eP]6q# /\杤Eak i̔]Mc=`^xUK+|gVm2X{(|lCG.=:!p[N#c3ZƱ+x'`\FӎZbBө_P8=z?Θb`~Ms8 cjLs郟oNN3zmg9v1JH;OLzV!`Qې:hE!rBׂA@"oO^G{dOGpAP MkG o)A}?_7sc~^ #>;OeWQ)pp񴎠 ŏLgk۫X $gqΉџ?J :YLğ'V8-:d8+nn%rz'Pco8ǽ5A^ @ԌX;izѐJ dW>T~jc(ϘǨLS ؜AtǯLxGcjX?Tm zptۯ>FOOmy Ҝ3mN=5VFw]Wu4X+S{="QUG 7WTO'Lp'SiXwFdAzjb.&Sg(|:RPD?O\p*O?1leR D=ب:֦gդ9|$:Y"`1 23aun3dya1F /~(u =ݼ: ?Yl$giώ(u/V+=Es*gU:Jdk$1W'rAW|a_鐫y6zVu+KmI /; !״;&V(0F9OΒxV d9U P G H"{IZ}\}OtKd.;/Yl n\^_֚ 'Jۜq}*nM6X ^q/m%nf_N5`[wosp G rxR1ւ`VCeYYIp%(r,OjgLPXCf,H<~g_"Kc+1v:\``c:pu֤@@@㷵hFrOqש\NIRG)'#z׶?ϭNx<q֕ ?4Ղ%IQd;たFR*4*G?Qh o19 qϱhS>+RQ+v?ΰUBjD=WK;c d߼4펻TGjry#IKVf`q?~F*zv9'F)ʶ<Č V^n٦;5̠#?@Y21?1VOQOnLk&E%HrX+JWB{%cV犁`i'B*}Pay 6=D$?:+m^ c(82εg7l0 N/_QH[j %1%2~5MhUV9kI/%fn֬iy-"Gk`Qˌ2r3{֜`#T7DKd #M7U/hXLQeq劜q p2 _ypօ%Y@{}K*[xڱ]GlAK3: ֟_=D@d*G !H8>_mBɞPcC ;G~xk`}SR21[d#, ػ.-$A8'GP`GT'$ǽ-Z=n"8̉!IǿM(y~$1Dwc #8GMNɦ%^;uQ*⾊_Wtw~2ch`O>x?4uKO#URTW#'LdYN+@#T ƥ CԒ*U+>h'Qj/cU}_:|r,V8zfzv>yɧjkEip;X4=N Kk((v5Oެ ˄?Þ3:ފN1S%4Q.F; JUN \qF1q+a=Ef]K= -<%$ rF~xGxlx!\?Rgul:TfE?,) Y?0k?C@#4|anlNOJcmJ?Y-VnM|@UkHtHm`b_tvER&FXG1R ׏zӅb.`L*G#k2Ĩ0$&60iQ"Cq`W+uiţW;r=뷁ƙ!GC׏%I=s:<_bq9~'039F00zß?ss`apYQ,Vt֚ÛiR89WK闑i7n5ձ PI@hx\t%qk+"HEnܾ|c1F;NJHFp2@?"jRy_QR}+V00 `m@bdڠt)ޜF|}I?h%b0.Aʖ#yyly}pq\v{U I=5v<N`9pLW/a'i$}4BbJ^: 1Z)m$ }g\:{]9$u=:O$%WМ?<؏k6 $! :`*c^H9eH ?6ī 0C=5kB"_[$e$$8-}m%I ?ok50M$ [843}0  `9 Okd}oWAp#y2 gt*[XHOQT[$c=#M.I9$ b "}TB.|9m>Ah{0);KF}'TdD?PT~>".obQ<8)r;v[$=M'G֜52&GXMg=՘94r9OǟI `Z &~in444I=Uنz U+"3D{+Uv]fM/KIGi43T6[Ap?PY,QŢ;ru6d8xgs*KT])-X<6~\Ԭ#cT"u\pa ?S`o6iA\"^X[`W]H~5S0jJX)^I_N !*?Ufm8g[sys}Lq0VO?μRc=Jc5 T88$qRF `H$qEX+'8'c~ox #֘P~8>~lz?LƜϱ?QR,+,a(͂2@WIKHA9 I 񡄪2CHx?Zy9sQ=?K9a_pO?3?秽V$Kp#{OΡ_? JJt ZkxY8!-O@NAMC^;QZ%VNX9 zgxw+*Kn`:ѸXXfFQOLu? UK9?y6%aNOWoڳ%F4~(LFS'ɑ?\u?' jDۑ#U+O3I^BɣB#\s$J`ciR9^}q]̲]%0E}9⅞yY ~=9,@}:/i<*>v| 83ɭ;2DqGOY%1O#}  V,Ht=p[* ˣYK1Xʳ]*ξ`&*U,l8gжJA_@Q\_oq )G<ҬE H^}y=ZR1R8_ʓ#soOVb?ҟQ<(Ȫe9e^} I)Q«U[;'5CR Q$[{-z-׌t6 .KdXZIX,~UUU"$1տz4cZ]P7(qc61VkZA\#(Z|03hL=SR$1߻s v `Hѿ3kcDY;,kKilk/}X +8=1Y<u2g Tԉ񓌟sj9L{#JѶKM,Ѹm- &~5< ]Ps=jEQOdpdb-o0EA3sp>Ujଶrc 8V@9S?BnA+ZmpQX5큉w؟Q&4XQg'V.pG!\@eڻ{Z2A t[b\f٘:c#E^lP#%Tx*#2nR];?Lfm""`$#qch1JQd{pOYx AQZ=YRa H~zfWsh#{5Wq.F؍Rl>?;(Eڽ3rH ?'M,@Ndr?X0{(IPNlJAR!*8 ]갼V]iRpou;Lk-#"$'-+=I9 otx,Rܸgօ݈9&rv\ox zt-'Nslϗ'+a^kwPzVg'$g ՟un1̌=F^r?)u]3Ia[{8\=+gR۴w*E /\+HPG5;lF;Ul̩SnV~Xn<*ūg3/͎9*MR εtTiz(ʉaCg=֡œ&Ȫjo }ji~"0~̃5h/5Y- :@\b8y<\~6#LKc|8گkg r2`p m}N|A9&0 Ec=ah3gaOVi^僖Վ#rp1޵naQmN t?J5}-ggŽs[b^(@UL@=ryKiQJBE'r09'?: 6y<*A9{g:9_Ȩ9:;`81k'P[Z)=MRN>@qJ׷~kqƺ/ Kv U[ eOC5jX_qZb[|SODIMc+g# Se7pJIh?QZ.FZ5b}rc 8aPjZ)q(?_~f8y#an'-???9Ney`?5wNU}A )PXf$jgVd1"l+ŌQ\#RGPw[se$ۮ&) ] -\v j'PXH95{iܱNN0?2j& 1ڸ&^tb9q+(,pY1tUTq0?L/m,ys,n @9UH!AG$$ިh/mWfeqMT_z] 8֠6+`،78Ӂ\.KexPptA]pgIed'OJ_ei%VῈpbX55'po¡Ү_AIq,1X >T޵-r +3:F&ӯrTm#ۭtJNӴtM/AZ$')pہЎ~#Қ2`P @'A: o42FJQ?SŻ : SqoYޜ0u A:qqc }Gz+8dr z|HY7ȔnHѶ8_H*9r t*A`v9qw=? fOz*uWdp0 Lƒi .S>ρY*^л 3]铜)?Z6O߱ccsX.r>+6DT2)J3nO<V.$>0E]8m?z-Ò?:lTnTm_77wJNfɈ"q5v:'?D&u?&?׫!'TLˀ ϾI4_7|iqByO@AvÜ_W5OKr#ו\`#Z}A>ѦyyIg96?u,65x?vT;xG41lG1!U34e̙Lc}[Dhdeejp|s*C]g]tqT&1X?iaҖ,pVMH.4߳%ěnAz˩AprFq[~ f6)UՃ}D?O̩fV7l~Ccf)42m.L@s'>ݫfY4XuHRV+HSr}XSCm{m;̊P#gbt9QBN ~đ'fđ2:0=ZVL䑗s `n+ˍӤEa'L8<{c?\hc?Z}"TLRNִ!ԡM7 Lr&vM<<$<ƀ|Ӛcϡ\Ztn|p@\sX"<1>bD<;֟d? ©W,>r?K x;zgnGVk0{~Ŵ[`r'# *o`BCR}x ƴfL›p cGRÂd#nx-DJ5Id:2jv3BJBOkltkm>&X!؜N?j" p   Oגx?ڗD{9b1.F$cS2( xG@G[fPbAYQze:iA%ma9ϱ#^_H-fqn$F>Zw{?rWzΣtť瓒jWgo^NyM&:ى).^FvOP*2Xdl2⥏Fԝy*U?dpPjY<3ơ, 1bOOҏEyE4[Vm18Fp}<d)a}z x 1k1gdSb6]p{+w—del$^\12FO)"pSNvp \C'#vT w8FI sw?jЂ)㌂c$AˁA1IotE7L 8UnA>'Ѓbb!OAdN^Ep~fqА9So 3קX-yPjq0O,;q3L1BAg!3y2)gn8dddƪ[GoΣ$9¬7W(‰2؀<AG ֓v ӣ]>lG>ӨʻP9_bj# ?rvۏgެ[ >?/UA[=tMaӑQj: wn~s+ZawMgjOe?-G5Szl{zG?A<յ@F;MMZQU q wLz?ϭtgU{ѱ}H"POP:4TWjдT &7סv8v$axtY5Uup&G;ԶiZ}KVvh٦uIZnGh&ޜqX^\ْgc$S,RoV=R1x!0ƫӸ".{(-ju5IO4~֩HrèqA RF3T>կ.ͻD +t?d֗oam)m;F\ַasj5Z6Ӽ/4vL1{) ҶFrw>˘\?jImx2{A1?ƩJ݋0@qPq [+*OJ|lwS4y 9x pv?X6ZFz➀BS0q?f VPN[ qgh @1Lyʻ ҴYui#yYEq')l0 Ag:@6֧bۓbs@2IfǦNG?l`p18uרXc$ a<aʞAxqN`jFPU**ES qq]&m ߥj^=1Mq$P4;{g?~ 𵻃AGEh .nEfrV N9l`蹈^ٓN0psZ31XLć9f8h(p0mˏ?Λn@v@$N?9͕$v2$zFGSUؒ }cƐ\e=Ա1U ?JcBe(+g-t$~^kw2#lp@{E2H]|wzmԨ*QdqU Mggs8@"1OF@2?JcJg9'3P0]KrF8<g'qZTYZfa<ǾF>nUI'~d 5k#΅ gm𧾩i2J%"p@$z/dXI"YFN}03Y2jB}eJ P|Om  t–NNSCO"<ɼʐ}[?;<a4c I#P{m-EV( ON=wQ6HĥjK`ҷQA^ irc8br)8КPc֥bQ'ۮFOjӠ@>K_sٿqi)z?Q[FvB"fqU,[jyIPň-a ri'=1$!G<%F+gQj91 bsAhrAQ?*vy#=1MQ7/* Jk }wΣR?/a 3<5ðώ[6>qgoԑ7By]!LԈ_a]G$U֤ J.P@jJʸYc۩U!V۞UW 9?Mnu:YT(ǫW9sk%gf7*~5G+Q>n?,#.V"#2 ~5 Y[̚cZ6r=OZ.h5 aiZl"q AtXV1@jiuH2Fe\A?JIco[!`R?YM4qZ:̹ b5+wԚ\6WqJQ(԰K*u&ц ?MKn2:M6VRo*3Mc `خ:[$-wgtkkDu.#''*/m|ؤ.`hӁlbI1]An _[^D$g__]*Լf8cV"3 GN g^%,tг5☂瑞9y]CM#C( wRn{A'Q:B2 LO U6mT0Q'jյ\@;=:[|f`+q&ir(drp'w)Yqd){+Ե+L5e #\JxG!gW{]sY"HȧcewVpʠs On+j7&5U5[w'Qdi%''N{?MA1 =+,4-.!@O^ew@ >s 5r2sFx?4fʤx%H|Ztʭqq,$r)WROE8!T@yl{7Ac^BjƄیzӂk.X-ⶹvco,Ja`Zjq)(%y?Jz+O2J1 Ҝx"Sբaգ;=?^ʕJ?8^˪\ ,/AyO:I r:D[-O.wI MQ湽5pgMzmJ|`̯,FۘN{EzG. Oi/<}>Y# S|yiTy҂p?gf{17akV ❒ICs~;Msץ$dj9gP40:{)bh9^-r2Z8Xm9©|4'{♲2aM7fe[C7w2Y! uwx/I 뀪y?*ߕ_ݐऊx<Cv­\ ]S< ;$dS=6ce\#6x },7ڸUX.AQʰº/3g=n0ίZx_3wԂO*,v.s.?!\AQ?EONH3JDh_P( I{ƌc)?&*@`B*prhclQ8a9YX2d'UXttAǿn, cm\1&~j!d@rF=( ?:zq?'.HQ'݀M2 |K]z^?t᧑,x&?-Tp`#<z?e)c6u[|2?jZmuH Iօ$H0N=OjbT, PrB *.!Tdϴ:Le8s3+{UM.^1-۵{M"]\%"Ϝ HI5q .qIAhYF(f~zz~ a;k@N:}i-[HsF TׁEajp m]/d "x5ϬL3+y f$O#O {H#zkrZ[ZF,hX#8{ QqIlA<'þTW \9<NL)kG'sZ# '8?*!Q;1OSc4l^ϯZ}:[mVV yֲ?A*/ HY<?:M)fG4D3P6f2|NN0*i?eIC$I=4hĒIlNNpO~.l0[''ɪi r(!8$Q=1b$R6cUi1+" e`?QV89LAH<>]v־̳ڌ+XdG"2xKP >hF\Jt-AS3Ү c;g# ׷SZmml*%|i GqX4#`naSCkEoa$h ?EM7]qֲKSr$b782?=UozP"+o« źIm;#7#Tu?;POܘJCpOӟqKuab׷7*H ;;R&Y1$eqZ-k{MԸ_ε$^9<-u>?q,Y-`B*륃(SF?6d\>_hlh[uITaHU_kW֗cs1M-?ҹ Zqij:e$ ŅvpxJ+dd IxVzFc%IQؐFi0cȪ0:| kR :KIٙQ:si PΨ$ԟ5uy[bC: ֚H"I2 '#洢H6XFI>i^tJI'9[n*X=q[x`iIo YQ2NIׁZJt$g=D[D|K1c9'3¼{V.֡*D9sr?M6 JI?H-*<_h߀Ď?=tQ.HW#9?H|;p@ݒ^5- q erg/j4 \I2S{fz֗([[,J`.FSIul$Oʄ.ba9}<=dF=?UCg ![dpr0r1UZ;c$6T8>ָig_h`2[ ]r뷺 iƎ2#3NEs쑋\m'$`cצA7Z-܎d.9'~^wFhe[yprOF:n\\~Ha"6$\GQSo + yίjRc +Khc rR3c=k+;MM8\zv=I 8$8Xav#SZÿfչ?5oq^Ks)v9C1nĤ\r @q~kT;`)?-/Z~R: \11iKb7zj2?JI((# ?UywmJr3 8?TM>+5knn~Ģil@b2=yR+ ef6Gx늣 {dpysAThRhm J/W6ey:sYtM;-}7[ho0B+0`ï\NH'Ž_]=Fʋ$g`b<5*ZAu,\HCZh,I*p:VdRM!$F#U#Z6PI ?akjnN X22\[im<=j;;[vzIH~p?:Ciy.8tpQ|BG\AJĐjt Vi,p$lFBrq?r?ItD1e>`>?po37쒜yNsi~&fNwRh]he.8kEu,Tu$`{qg%A T6I8V,dgRP7 g,# R b@AӱGR8I,`s}zdjYI2U'e+4H! ,$L\d .pFk{-́YIf(iVi)ipIw'9*}Cټ![Kr]Xm>Pe< U5[ˑw,L{tW?js*ت8<NkCs6A${x5%[{VLD+NG56R{/d,Q ^s]]΍6i%q**@ TzWo.x^3;sq*(Wwr˄7dX`fVw!c;$Xڏ""˜x;V`.uYѝ N? EZҼ)wa$01kt֡CFIs?*b6b촅 F1}j)v* g֭mہS$qbЬf6$U8ˑ $?ҦYpj #oS$X nv<y-iV)$s:/Z3$*8UI$b"M2ssKa1-taGZƿ+"0c5~68=?*+-sQ%.!̋#r ПJmoRVq.u]kVm.dMܫ tP2I[7MN 6iKknΫXԱ%Z}F s! HcBjz.V`G뺙]b1 ?J B]H88Ij["Kh <{W<>46J o»ZW ؁(ɜq P{UbKtd#Q#,AzTmtk [ɕQ q8nCnKn1EBF\łX5XULW Sf2ΧԺe[Q:v~ ݔW .4f gW$)sp?U5l=*G?5!]}s4#љUҮ&A#wZVBt8$t%ajֲ8uTMK3^ U@}ҹ#ܷMq5]u+-̣pƥ!85Df5jŚͫE8&&r睘v学XplOעM.͖ ~xv:`kȎyƃ{Ȭ8s>'["[G#r=ZAZ>O(I ҳN2UO׵[F~?Ʀ|IxOtT?}j^UN 'NY#޻ I|Y(Nzv:6`% p? [8~frL\OO@F?GA>aǷ犄F=}8iG@IΔ—q ןK)21&Uu!?'Th A#ٍR 0)f u5/*! "qOՑ,iT3ބ)ҼHNAaC7pF6pN08Z;’^,I&F ïfp-"[n- <[xRNI ?3ZP5ȞKfh#F@90u_[$%JD@y }?Jļ"GAmj8ί"Mb2#Pr=jƗj){Y-6D9f#[JD$3\ #$ 49fZ`LG,9\jZJdr@ 9b}>\Jc{1 drܞkxEѝ\[~y9}0*Ci8U]FmJ,88UOr rI?Z)Tg=kӎD !@@˸RGs)Y jc$8S FyG}k־!smɸ;@^ϯ\WYњ pH'?y߉B{mۑ\I<朒gz?^pk{A<;5ѶG$1͂kw\P T,c FQZnO5V,7ȒWB"fP'HѥZuZٌ3 zvIKr5}J4A(1;_Oojt%.p8bDa `b8qEO WeTc8H'98(_8N9=;1H'15WUd* ZnyuQ&U( go|smP9<`Gj-a$GOQ /0,a&LA!$a4zI=;Ny51|"R?U@${AAmǾ~oY!4GT`G/5r)88ENG<JlKΟ4Il(p˷sSCOcpT;#A!#,VۜVUQX:U 4uvAl]76{ {4\Λ$v Ҽ" -oU.G"W4-idU#D LzL {Dy#/#v4}.\˫50%^X׉໒[v'y*3ZGXZ HٓvIe??DEֵi[C)G:7+Y@3[%S]agiA}ԩ4@eIj 5̦A=*O4j*(a',s;<*Jkisrw1~XLgՃ 128©g?ht@>UPrO-g4N4 ᶇo\6-Uc ~W239{zry96`Rl'+gL ϧI: e>2W5}nLibҹAf??lxQKt%XŽc $I$\Jj1f'kE!NCHOziڭqXbq*m#oݢƠ1s ?J`CD!a7SF`Bdg'Tmx#U :,eetr4! EA k ˻Ib,G`5ܺ!a[#+UtJm6qPmky"q0 Qrl>YX N'αAפ|c {ʋLI};ײYqG?q?p$,A$¢m$x_Pchpl0N1W|FhqSVҚvndxP $U?+81$aGNMInfhጟݡtLzDGK_A=+T ZhUAxW7( ʰ5ニw rF??ꄿ 4FY ޲<5V;y: eIڨD +geʾ8='5\KIKDy0 㯮8NWw8f'`Dr8-5w|0Pу܃sSWF@ [ӟJ~!up<)Tni@ 0?a^$̋ucz];X~u^iWQnT-7S9_Rtkxc"GX1^uxVYծCn'0X?L?*[Nq [[[0D!UO Z!v@X^ih'ː3w8Y:v58g v8ETkBy3}sTYcfQs)M"EC'dP=S,IPWkԺ*hw\j=xO!7H>y+^Lpx]?ZT$wJF󳓜V޹t7AFבi-5"xm, ;duZu1!XG #\g?QҮ$H31FLbiŬ.a9s`2pCK<ʈ6xsjΞ+ sGNwn1?:G͸cGi%R3x 9'rҪ6aޙU =q砪kwrK,WHHQp«I$̊ch8,޼W7xP˰I vN;k].yU cU`Lu݅VV6P:q\CO4&rO[xOZ; 鑊׃᮹*v[o.1gֵ"So&s|4c 2&joՁK<0W?@<9mi6Ҩv0i֞`,@^e+ `5? wVC^-7/4qd(*JU `9~ cs+`>?4`9=XKӑ{ ;ypDAq+Y*\ u$5q1#S 8?^h.:{tIzh,6B3ѡ±Q<ҋKk|4U[b%ìx Xv=1X6^sMpWkH4 Ni qe=צj?6c} L䩋A<Z_Pծ麌s4 RȏV~!ϥgUqqK4gyhH"Ooo \I%-N?Mjr_$du#8ڻO(%q # MW֢,[ȐKiwCj=O^W -+L!ڭ&޼[IB(,y<?]g19$q)COQnCzW4>Ȝ9nt4_ xNXNт~fPX;Kk+x*$/ןUw/pq22I犌)qqcY2$3!F,<`08JP(f9~_jins r*쨐rǏsӵ+uHVW*)ǿZLAIAy]~彮nӣ]* O+;IIWsំwZioRXmv9\3`?­o4m&K+ <\+Υ<-?Td*\2Pقic͐Ϡ(`g4k2sɴ=]> no!@ϝA#Em>uq,rD $rJ<c2G4qoO^Q⋧#s,F{pMq>$MqapfIעQbsUC=B9> $QyQ$I>1{g<~a1*~@nf]{0$ =sҨjs߼PpqkFW3%4%Lg?Z|7jUPtl`㏯4/e`X\@1?ZgoxUgn` ѵL],7z2OʽJ{t&\p{ZrGEA~z$ԤĞZvDb@1g%Gs$N?ZS,'0Ky?̓Ľa(I NsC{K픯|CO̸Ӓ>ѹB@T`z=KZvKqιgW2I>e23HFYI , *[*~dU3:7)<3ZDULpn#i<2O'~2jnem©tb :XXd}eq|څڣ = WT5ؖȯ#9Q`p~jA?w,n_@U@hUۥY vZ[SKZľ6r,/&K_=/yhwO\~i(Yl5sߴ29v{ob[-8P7|5ZKRmsқ sHŎzxWqm턙$? h%Sy}xh@Y uKgq$&5w6NsFvl$)Jq839??* fpefs>Lq(-k@7^ }d篝=ߡ4~`>p]p ;8둠(>\Ҡ"n屵G$)ETCo p:}z Q=!|$vƍR1[M )vCÚ㺚+gwn0 r;C]=ZEd$ex#k}`a{KT,Cn8=k𮃥z}Z'ڥPM̧s?a]4)2}8*!pUc*?ʼ:ܸ8E^@U'?AզbSS㈢(Uңg{η|<nى! Ewq_{"0 z ~&%ƙ6e( p ֳuuMРI1)QK օeפ4̿ $GUtF̂{i$nI~Uy!Ӓ`8#Te E9Ujk:ks4,d)=y8MVQ+O,O&mon_̑] zU.㵻XHNqZF3jg/cj+YB 1`I=+?W[c \d8 t*%?Zb@=z{wP*IWK BcT)|ݶI{ ~2F>$90([1$51v$I28er/5fG w rJ[GO.o祿*?ESs^wCNHLƧ V泬PK!`sʳR[xc Nrr9?t׳NH< QB3(2G=zokaei8g<~G}ns p?轈&Ӏ@Ž'FJX)_f$"<($=לx7׳;yvA('5^F *FGjh^3VYiœ@[-B( cU| &I$nD|y9u%a [9 Yzg`P^_Muv39hԸl`0kծdFO9^[wUT@~ʴd6bJ&,(H FO\g5ZMXR&rĒ3?TmUI4j;٥UVs0M3z$!ue${#k?d_LX{Ok,۳vz{->Os`pP*ڞToб<,'~?6 ( v< UdX)G\d/W2=I}Ep4u[Tu-jMY>NW /Kg͕>ssW-"y"?^m?;hn>?v ^j}kWQė [ ?Iwy4eK{qV ݞ&qw})iqZa Qb? _,H@Rg*E+*/_@-CyTMX 7rz*8HPs^\$ >I]=Y|=Y `+wN3~`~T)Z+u&d {Bڐ\UusQp'z$Rw?Z5Zn3}O"$`P#A9ZՌ3eb`Mkg!+~bXLJ L6=Oqpq1ӗXT10i.^僒] W&=ZGC$I +#{j[0r%(?SVnkTaU2$dcI#>YWP;)cl# }rzWagjڎg,FR[%H}CjƜv-I;I#py_J<|́ @⹽_W1ځ<*r{vךti8@X\q*<'႗ 6vFi:I$ߥq&7E! d/4'!G9<sF5l!GZm"tՠwH*GR$(@>Q:z] }Gxe4IP/kCBs5Ěm]2K(<{kg s]h_Ŏ^w\á\.6Ig XO(c14`# 1~ٓ 30um;q`Ǖ<q9JӵIF9 @=skm`gU~ʨ&2g@rsW#k[M0prxoO\x@^_PŨJKUz3XZƕmNk" Pc#89wTDJy4b%]߆oVfv!*s2]冢֌e.0Gg[wq4pY2 9tcƯ_om"@I r͜J<-1[ f'$?NkedO&g$OU.uXgCot#P>T^q\V-F{ViXʬFH~5mJ(m;o䓐3ιkRi1Bz*nni $P̍`@4I?JH$>`G|@ӮԱ2;A=zF7 3ҪlH=x=KImվaQpg'thLƥ*$W_!5-5(AC}0QL(>RNr0?aj7n'5k>Ewggv+sZ^![moGFz }0Oh33<õg]jpdz/lǘQ++*j6}΄׌RٞS#E1~$T[#ƓE,.:\}aYد>X#UO+"/lwZӎ)l])k4#;BI++B W/uk=.stcb-[0?JO61[K)$w< 1}V-S$QJdXm:N}iLFt{UDʃ'P4/ˤ:{ +z=Nq\G]լ"WsOA:{ Pt( 3[Ю5n5;y>RGFF#?::m [u حw5H8jޛ]BInGmc*z44 b6[A8Dϧ= F%X"ʥ[]7NB#ioS^ Ǿ"ռ+-#df(rSƹ-/,VR,z x.ᷴwfٱSZfb[}A^zg֫kuX6py"NH?Z 7[G\VƓs3O#K#81zvzD"s2z?ƴ]M8yǜc#V{mnȠ1i% ~5aK/u']uv1*[[@?AUEK$SXڇ#7Sqs=ϪL .#O1r$)[cӠ6#CkIBϥV?3iÏz Guf`bEmxNFfDe^M*%Hi[~?)7qF=kFF,'7GMĜq{cQ|m 2xr ̣;\E&xb@,ҖF!83sϵ{Z"2=cW!Qr2pyњ# ngU{HcP n?F ~zG&mV& "rzƳ JoC #eOM+Lm^9-bh1$I9>{+WIӮDwVZ)aPAfX܆'#`}jm.-^KVi˔E =2zH]L:Ӯn8biq` FX?*;j&FO{ Ezb<xJ 5X&n ڍ3gPN8X1q]H&1ą$t=GKs{QKKvgLV>KO]B`kk/U+(c³Rqw}M}ZL"\uwRLXT$.J0 z'RX P1?R_6i_X\};-(E$ qwƿh]HUNW G{]r#}ixEa-0f'o:9 *%̣2zfi3p!đJ)<8^~%ivTaz6H#9dXDFA3U|MwuskKgv6+-<ـ N [<ПoʫuԜձ~@f}<2CjȪ.+-< |O\g @( A79=dA&X/RH';c=^O~_bjf[#{q;߷\} (Jܳ}a׏qX3:,z}3;;S1M8Ss雲0OZ mqZT` aF?* Eֲu5hsCУ4p@8vrt'U$TcmwYTԩۊƲM/SvIDia5X+n@bNXH?Qa8zo+ҷ\64s(Ju6{[m y =+$$9zÚYc_|Q^]wKmVP?bYZ忺Oswۙs1aksc.ݒF (6 Ombxp0Zwέk7췴DH1t=3[kj3,.27=5ZԴ+9fA$ `vR|&գյQ *4FxԞ>WתC=ׇ!HP K:!Ċ6sr+ ^*|Iu8s9ڬpq+Y!w dpH)wO&8'54UpM^n ۊ$!2Iߋ.)t6Kf~u\cQWW {v,b)# cjlB@Gxa91Q}|?S{mЎW!fy+~C$Ak+EO~R+|LՎXɌq+`EU=* }j&uPxg\F9q}q\yr;Cf,qg񬛍^8=…zu=R]#ϟҹ{/XpF}+2IXBz s/8MFZqWR9nT\#WO1LTUڕ)2T93 ֶ A2İ>q wj,UŽw19e`CV?νJصcחxOMnER'LWq8I2ʬ ƾl g|G Vm&{;nPb`~#^2U ر?jo|Lr&?3|_އV$ߊ`^z[4K+Iʯ[12/f 'o$t5xk:lFHr=3᷶3@d|ǹNwJΞH (} F0+H[HUmbo1Tm'<3]!y>RKP587:3My#*J5x1[s8}ݟ$1!  ,9QH֡Ss߽boZDSxa]Uqq;83\׈5ԥ#R/tk?<Wwhvɵ)x3upw Hכ\s!9CvIˣ$vn\g7h3Ǿk>G2;RWh^#emE02>t]iw( ܮӒ:W\6+U/1ƻMBhb"u X ?3 .CK~\B.A#U?Kh, +c s}_yokVdmE@־푆.8&G*495t#8N Xkyl$ XwkmOaQ?úo]?ƳdЯɶ|q5Mp-$u(o8C_vO jp,R758F׀eiOe'"WY`̬ܩPAZ<.d"Q\ #0$ ѐ_{u-_'EM"1 RN6^Ҽ1+5!PbP`^+<iik|It]HSM;c'#qg?b|+-\Ux"a`q^\bB18?uw>^<6?:-R&$8Ω?`FbG8~Md]lH:Vd۹Bʣ'*bYº'P;<#My-W,<?~VW}@8.Y'#c>ui:Bd9q?Z6$@DQX kԼIw.!#h v嘐rI  {-21MYoL3T.^RN}qUI$sIE-}Ex:Ea)3~?SQɫx|Zʮc$.FeO*˂g@lc;#t?uBGXβ!Ԭ2Hڄh^iNsU_;T`m=yM{WLФp#)7G~U>Q$-%_ 1]\=ݥ,h.+%!p7K3P\Ƴdt@T'ҮH>\,fe/Fsm&W_o-%ҬEmZSڸR[7\DΘLs7R eb75[|>f ^eH|j9Yc>e1$Ao 9EbX[U־K}0 `#2giZKn$@)]~gZC"/$Oy hu'hpkttd B됿G& 9?ZuD5#$|jilA }}?ϭg\#b\e.@F*4}OaTAˮA|ŀ@n=8?jaR d}TԃIt8CyŸ~NڭO0eʒ ?dj^ӬdW2*lv큏֯/hDɑI  @yOQiY!D~m-AqV_(l80jvUTK@%8Jʨ$q5_ w%N0C!שxS k=v'##،Kr5C+D=:wߖyp01Py$?D8 ~ZlzQCdI끟NhR\I<;h`eԎ+.Vw8FOZdkڠ:*lT\y=Q# JLf'@'ZL}NA:Ӊo-8c&Xh gxvWx$5fː<mm[YH.O@p9nu_[oGrO`?z   FNp; kmv)[w5sv90#^g |ǜ/c'&ZiҌXc<פZwfU=AB*HO\̾K KX m.3RI2cH5k7 s8,@ bѯ"HI2g|k!5iBWqZ+&]#y4o$\ycE-jWhPJ|.-;fg w$w08> jQ.0ǀB^O$NREy$?έwsZiQ[DŽ!NNy<܆v,I c\Z\5n?JKx$&AF35!i"n<uOzΐr|sɬ|QāXI0$E-PII搜 Db;++ګ(J>\(hz\9_[" R뚟V[8X&IUl[MS  ζ!s@ʟEnhx*% zdcJe{}8{R9 ~MoCXufg̟ʳbWܟZ}} -®ߘ'mQ^af/u;Y+Dr]~%Cwct@g+usP2"n~@HN@ @⴮KX ).3 >AuHoN'Z7jd"?S%GjdNh52\?Sž52eLSW5:;ϫT5 %]sHal (}fL")Hh$'^ԛQe0(Q;*daT'$ ȸjkSՑk )ml7y5^:k ˦S'kxOűϪ=`2~YߏoV7!+PH<?ʼSLKmrxR+vR}~`M*OR;kTF @9$wy!u'Kݴe;9ϷE cH=Ҡbǂp3Sv)?43*2NNO!''sO4JTA+"D@ޫLRq0@T.'RĶ ~|lsW?oٟoq]"NZFbB2Iq$b@8Чihp;޲[^%!ބְ?.|O]vVJ4۹2'֥+F0D)?pw`2k3PtX8ɪ(45cMVbO|7aȵEhÜsU֌>eGEWDq,** IZiK-Ҩ_ݱ1c\\5EBgB4RU*KWk&1[tI GST7$xz/_Ga+Mu˖(AgPLP3JGNUu +b<rB&ŚdAhmNkL"H8Ο\[w48Q+^0ݣLgk|~H-ؽ@:H<6se+.@|V]̤sHa9!|59gcF!r;n7~4$`#) KdVPL8[ۤyrq(l1v:}_+tNs wfG*1yiګ| 뎇$V۬RQTOWtҳ(kqCxv cOo[ GO̽?kԌcIa9Zʤ+k@aSG{ YеHAV`לXjGJ֞ye`x_AkQY D2OA55IdqeϫL:~E)s|He^ģx,!WD*Wf1ߐ,x^ź /V-4Dns+x xp<s^i]hڤw TX]Eƶ$J2~wYwQ)*"e׸!A+0#7)3?α<)q x2zAEU:Ë1 Ђ~DW d (JaS8nJGE$>ڀ,Ĝ;ufT.99>Y^ƌsx<|@ԬBd8ϥ,V9 ĈzUTaF0j"3]p>بYO\*6+edU]J ݌4}rbc:젞ҀE:{u P:N+6WʂA5>0 3Y נ8NPd`wڝ];\#v+|L0U ՗{#MR4QE}Ep_#2٨ZKKkY 'QY9бS!}1֪)-6X+q۷2볙L4R_pNOP2F 'V9sLiOz7SךVU׊`e9;{l0[y*N48{G$$"ss)#3ݚڰmo\QC|ŌQͩ&~w_nk2}qA*:PjjAdTo̿d0N<#<_xxkM53ܦܞq*o7U7CԎ?#j+7#p^k҄~S `Hu4 sRRV}0)mr$gڳI Ļ$qJf|,j#0)O==p"Ig 9֢%,I8 m7ՐO$楟N1E#c@qA%`ctV0ȫxO[?I =2u#銣i}}QOzɸ$=*(xdk/q]?$hOI<(ԥE"n櫼 ڣ3:ԋv/ e\KVa)#S?S&?SGUaMiA Zd sU/|JF֎P?ε<7-CdJyRszI+@ IrsʣMjAWqZcUGl@]5g}1"]|{31`@pWK9lgzO5?T`=J imgQ<~#4Lq1 P[󰓜w YmVզ%Wx%pKsֳ.J;A~3ҞAǯPҀ2}j6ii=@k se=OZq|N{Юw`g*0CI9 js pjy993 cڜnqU9O?¥b Nd \d/  {]l `d!9I'kYmJ7 tdg kͶ"1zϑ3FA#8?I $#f jM r8?LW1AUwE99( O\栛. (c9\6H`WiD3dJGW"a}WXWĒq҃Y|Ҝ@NORi^i(㚷2;uT깧2SA s) &`+MlD҅c!a'5{v$𮗩]\Zo32#O2wUK+S’ xqm,cjaYڽܲ4(+V<&Z_x}30C}*~L9=EU7AHe'=gDDgL>#R c5=]{q"0$]UYE*)<Zb\+x:4܂q67؆88g])Yܳ Am""dpJLb4kH .ArW'TV̌8U29sMiO|SK3[>-%3ZwE\9[ ##F:Te,MRv#Te$OJ "b3\AP)qֺO ^ܬ#%?%4T$~o /ߐ&__9)9 } Zlw#J4˘d=9V)y"J.bk4.ќ(~xQ3P#)gOZd#$~isJG,pODuV^;Tb*u=j&R1Yz̋Zl+vb2}Ckg$Q1]3ep\].[6{o>^]x.`q^vҖÀHy=)=ztZ$xG$okO<#Wo0|\̊p?*Mh$tM 3J/MޱIk/c`u=P4S0$q^ww@:݄lJY9(OzzW]y\zG]UA*JrNE5$p{T{yFd'ޚXsB qȡw9,Lzwu`֞QRX8NG$VM\^T`~T'a#\Uf}rrj9\4RdPHwp8UǸ*{f< Wx&i,)F "V#0E__ts-ͅ dr?k̼WT B0v#VPr1Ȧ9i~b7u=zٶ8<}+k@I!u@#QYx4QI_\xtb[ #6!%\ҴcH UyXf͑W޴㱀r?ޕp]O<1f-hlc5$rP$#Ub[j^ zenb9Q98Kp;X ʎG% ao {מ0es"F$oQ\ۺTƠ|j"HavT jL\;!TR 02M38P'ֺLI#~Q9_/-BAWuȖ)fW!t9u :4/C8?ҠlmADGB)P'HsLi3lFI<3Cޔ*E~8֧O?7vpjuigiPrk7R}+[$u( Z1]`ˌ9q^_Y~ԲDnw^NIS.m4g$W0+ɧu`gU'98I7>E/C ?X2d5*R=QE}#mu€ Gi ?֙Rz;_pv:е%`d`G|sj%#\n`us'YNW-k:[yQ$R>Z%@9i@-F.X lZd fE|@WZ(Bρ{UR˂V=֙q;g,u >wHTk5ui1 gjXKpJ8UBpGJЮcqa R1H`G/髨Y1ds%@㎵(tBIy?jѼ^=X,ly7 :ӵs ZWͷªݱ_$NxNJ?쿆cy bW;xb_M7Rde?jx "brH-tݘƪ>`~Xx̄s}땙0z*Hd99OJNILqu('xcR <յG ԽF8JjۇLnGҳRx";{~2Rm*EPK0I]VM\\dĖo+BXmI+9+׭MF0THMCztȊPF֔~fՙ$p=NՆR;$8\*p~k#36P;~v'0*d|l$G:|gVOup(¦rLtkE0P !C򈤰8V_rv Sd"A[рI6U! Z׊a \RL8*qXXdamI-w&mLuw&x!,nێjEE_(ZFd}+>+fRF9XZKƤ?#XQ䲀;*2sMh,q}6#-!H,kҬ،IrY$b֑<$ʹ954^f-tC3ZZZ=vI$d֕]:D7DIgT/MITQ dW#vO^mwW8Y0zfO94(HAM'R(H=L8ަ`EC:kP!+*IJ\8g9*="d\FJny Mkkc1`3]qyl2GYov:#z'{6pUo@BS؁穥BƢ-IMs|0Ro4v$V'Xo#ULU9 ک2I >)WJV}_`xwZO j h8ȐcQ]cS!e=g 5xwQC|W#L@dׁש*yɦ-@Jz\v9£i%L'#Ҭf0fF01'˻Hw'< E3q׭E-.@| R0{Jj-.02IQrr*c_=YܰIuֆa6=-?\i7DhKq^O]Y 4 A@# NRr" pM)c?),QΒF@=y*.3F@ Hm`\@1؎?TAm?b p~JuX[b 1ME]rq c_mmi#p:^q\ߌam]'H'19FLJXN>SS`-)ځJz\RHkkkg' ީY¸'= 3Gh 4C`Ҫ@'aS)Ԫ U.= 1U3$B3dKm>)epI`?O]Y{|XczS@pP3I p'WE}rt-J;s) dD[jsa.^(0z?sV 8V#<="$h' Gá&9<=w]$:y%2F18ǰ+5[S[B<8АK!5jVl#ߧ>;F' lvCfL g4F3M  z86u4F޼ 5|:'}Jjc?5$VYmU#o:]i^+{yaFXrJKNԋ)(Fp=+Ѭ1J[MjF6pO\ª:-JV0deRq pZd#";s;FԵPLϐOnFwGCj HPq>$'hӰ[[gxOF'j9?x0ыwYmVa?5i|&AUwӎ?NF8;HO4) cUDX'TC"ʳtRh.z?*f,`o$rHU$}2FEsNҳ SO:}٘I 3j'ӤiINSA"~-9bshp`vnO t 8$F\y,Lk#5W×sk%r$(VݤnQ~m^xCNyC#O9O"=1Ar2# >j $)1*kV}R d+ yW/' 62O@_OZ(\dYMXNP[9-UN1Vl }R !(M'/LKlŲ*$P:ixFhO7(GR*nFp:)AF0zEVA?1d^O j@ŲsTxQ<9|vpM4W\'K}&P?ƴvL2zU=E\;v3ڥU02Y@9b|pb5C,`uy<]P2q?OҧRN[ph:"ԡo| d43Ib82?h$Ư#Ӷ1P:tR+#>)-KlXځsOΣαIG cmRJ˂uFidYftypiP-q u7Y=O"-Cr(I?!U\,FFŔp9_EQ\W0glq/\3 ӥ9#.`X|-~$ճo=&R#[-2z)XL=j9H{Fxél7 FqZnH "i G\ **:p}zVίw?^ʷҮF^v,/ Ȃ28RHL n =;mZUrMOokv`)[H% %YWŌmt"Cm nⴴ?u?kO5F?uVz:jw1 r8Fqx|5a[6Fx܏TI0Y2`٪,X.Acqfs,QKǞzFBCxM>goYrT3jKVpHD#>T_OH0hGesPRV/"šV&2[M;X8`Iz6XmJԧzO7.=*7nr=c&2L ÷-4hwjQ۽_>0J9$8&< ze,g'V]ܑȲݢ w$MͤoҴlt?`@k͌QV,qr x˳S ѣR0XwsW 2֬OI#)d$Gou?O˜:h .xƚbQ m69ڙn$aG*N\IV_c">R Tv49$opT#?Cq  j݌%'|AmZJ RYۤ10DNTjI1P"A-~ڠ04/ Xv9UZ{{2.+|=?ϧ҉,$4k>PI>X[ PZd\ N1ҢEn9i4܍J\_m* 5(#y9o;ܡDEgʬ1Ls9?U!e019+U`"3'<^4UDAg۳E $h0؆pr?OF(\vd}i6 F n/xC,8<EW,ԬP x=}ڨEmHQr9Ջ= &y`Xlmm ~6&fF  yTd$ ?Ze ̀F8e_S2r8Up#] *#ǧM~U%p2sF e+#ŀ  մYXUy*61ۉ?y.Ȑd{T#l!;UKU)DD>ޔ8SKй,R*GUH$)?ߕ mlXR3oYB`%zjOZVI~FIeƮ@:2*h:0GAȩ?ɪ`<*9$ $bٷ4H<#HYNq؁DN y?ԑy djd}謤d0@#Ҭ˿,Xx[[lqة1R3@I8V-&!pOjX#ޥP Xtfg$lN({H,X0R29ƛ,оL< ڊ!mdsbmG\ln a$w'<>|OBS<GAa^WEN[Ei/ (~ΥQGC18Vxr8GzdwW_N #rylGZWqKcs@5KW*B.#4fȸG?9mLI< |tr^=x橋+H35TYU# ` 1֤r:W ܐkB22I#ʧ[HcWY@X!@NGQMF DnC(|ޤ{9_?g>D@Ƭ@%.Ӡo%y~1 :Uĺ0 zA#qP >5TJNL\ ,`B&EcE4 >S ܟhGЊO*}wV,2Z%bF'?5-;` srF?ɪoj<j8nKʨipy%I\;%bxb!F˸F)]AbB'@t\~4a@?:p00֝O˹@'e U&3$QP$R<. ȣۇP{kkN1Zwp6’pUu=Q.fY1V麌,L`NG>ݪx;׭>TI]T#I& 0NFzĐ ܣhts}i:v#h{hZP >g?JhF8tΤA 0;`BqQ#3T qj4`18={H7B H_r+1`z׶jm?HYI@0&8L.x$g+R D2A=RAI<?K ` <+#dt#D$N$$ 8oZZ HNTd#?ZkthM'$t?3VVZ Ϊ*;ұ,Ɠ ,8ZGuz%$!-8TݙDı I_Ҥ'H ` $0' J!4yJ3NI3ƪ]ן=Ҕ )i&'9io /S>[Nnq=lu8ǰS5##vnފvk xsϭJFE7mKpcKS \Hr* $n-Vn-xԃ֡ʤ0U& RĜ` I#@ &y.c5\Crst"!>ܐ&bq($ң9w9@@Us'tj7} b&lmˑʰ Ua,|Ml˩ڛ8YU~dҡ_Aj/-HoLfPc@ V`2OpbF 0YG.3uJg0 dZln#EFÌGoo[t` ۑNanTIՖ 3e˥,V6nl15,C 4(ݾ R\f4۶cKy1y#LsIZ6UydpA998犒4Difܪ=1~C0#u&H .00*hm.#\*юAMv.^\[?+$@'wjqcjLG'JA*ǽ 5,fB^3ީ][!m۔`vZ1Ȋ G:8alD0[8SJ{FTN9wlA^@t,p2"x Knwک ɸΧx¢'4#8 qA`YC<ո|;95*HqM"…JHbF3sPIp@ECΘUsdm7fm5"ArV5$b68 qsީ> 3d=ۀHBq9 Fv'93ګ԰)-EHdP` q@_jǽN l:QՂu쨊[vwUHt=4a=dDd\O.H1 s!s,֤c,֣!nԟkX֤J֣C,WQ!?j< ZOSyGQ'?Zq*]q#I?iAF v#کͮ]-03,K_8RKb =Cק@QpX5<2#*/ҫ܉H;C0<ߏ۔uvEV'UP NG86[{SjV`0OQ+(YsN{i&qӟU*[Jf"ۚ|; 39u󩢋 KdI;l@"P\ [C11 `cөzxH'?,PU@AN\~#+.A/A~M*!{`,H⬬Ip6Ww}*%1] 3f⤗Ί4bb co>nBekG'ץ_"#@vr=H&eB{SA;x*9-_wv9šB ֘R+X *$,~?&iw1S-V HT˪1PloTcB]F{dU, gC*G#0#ӯYܸ횞+( `p}M;ʀ)(yHUhzD r9paQoGD'=u9XPvRx>ֶ2jg=j& YY1T?ؿg{-̊vOw0$$dXƸXYcsNK>Q UӕxWI q7.T⡆*IylXzmpNkvЄ(c<׫S c<Q(#!z'?G?<~8tT{tT}iG\ T}k}_G%~t}Q)}on2$d I[ U%wo֛5ĎTWTYg:ƨApsB@%$8Πv&A`I=:WegT I$j|.p3(O'<QS)9U%\E3rdd^ '=8-R·b2I WDg V CD%#>3U(I'=zsU>יYʬl;uϥ0j0_8@cTǙ 8 ``N庖gRʫԐGڟ;9x6i3"9=5(X7 >ɞ(DeP0'=HED3s!1\,t xF zF=?[_ g0)%I)`$tYc$LT`0$:zS$.rxy-ܙ[ 7A>g#)QG"h٘~cEO)hĊi}>ՄI+dVӭ3˅`NAw\igs+H[ɯ}/ob",x09'iԙ4ff4i 4i) d0ڛt{04h !NqV[>flq5 6In'ig4q$cM"`qZ,V;+tb6Տ0/G9#?4.] x5ʤ|7W2@$w9ypK$x_V<3ԶOa^Hf좢8b1pnRtƌ}xu,H=>;Fb±?Φt_zD8T忝ˆPEeLQr4Zڐ|PLn^0eyLŌy à➈@s9i$[TdnNxf>VXu!|>>_ҥur =n`Ln#S;8vZdP%TIڕYxQ֬yczo.Ns Ymv bX8{9vX4<VV%׊ y#߽T3D6@SOb,3䁴vUJ*`AVkF1ȘGi kw7+kx` GyY"o(N9=*GbG* x$ykYYcIN /3({4YI W6UrHpv޵anay^rnZ鼽@!NW dYFr*rꭺE2H'LJ vjbC,v,>a+ .R4j:^jI4YcDt FW}#MEf*82ZsjDd8ݼSΣYʔDYʗ`~T`f?*\,K~TyDE?@a4_Qp 6M^(yrT ZD{H-W&&I8#?֚򈐲یzp>T8҉"qp;q4 YLIF*F}3RpElLA9H+gIӴs|NYaQ8&a2wyӥ8EČJr1+sW4"{` ZOf]%`xIwey]hs$D:* P j!s4@.L%bsA{Q$˒b$>c(hc F9=1Kn?&~}߭&R߭&ݟ4?h؝#Mw4NCɓi3;9>oΏfvȁOҮo"[ I2C۰30HHJЀ'sZ1mX'Va+1 H.pH#(1Dn 388p5mm'1"ۜd&$"BE N~֡g2 i9u0L@?ZlFl=09Oo wgf9A+hhت(۰j%&PG,CF,G=u]J"~'YWeLιrm*եѭj@)cGUXVV|!3J\1}ϭTy;K#;qN&Qkh'1e3@ GqT?q cM,GL@ݘcϧCs& >Rx /M:\“yP@GmN3_Z)tڠ7Q5i2d7?ΓHK$]B, ~Zi1r&.lbD&Kmx=*,(F*8`% 1+n9=괚m[rzg*m-JT fyUdEPsW6I#SORu1 BvZވ+ bO8WVR'K$`w.,{.MS8)jI`0qIDOH83Vaf J^{J.w ~nA95 i*asҽŠq.>+NQQOa0doWS$ˇªdVų\>`pqOzd1E fUGM[tE /;+g<:s:KknrT 1(9b['h^՞X n[VO'Hsh lgU٦Y:Ucr䂁Bަ`;FZAp3;GP=7,`/zkb%8Ut8拋i)`Ӭ,.dKmcM403jV[ɦ%LEU|H'AU&u T l M Y$X99'ϫ(K"Hƺ;[ 6Gc8?[@Seen  bɩ=ȌqG>LNS1TKb#PzZ?-֖#M42C)'{Wa/څ0j!8UY. g[o-ǰZ%<"Nq85h\m r}1Qͨ*Г$pPÉ>p@'H5+a0SM}Fj I۩z1X7K gH^UCmo iŶѼmCU;i_MSz uKK9Yn顆+5)f W^X"d>.c3( {z=5clAT^X# qYw2I<ԾJJ7q& =1cۿh$]|Tَޯ[ԩ"\"*k4~DeڳfBQ4W#pj0@I8b7  ֭%[q&F0)^g$fYF C# %>Ȯ`ƾC12@# AǧSYbQNT`GTLd]-՘Dds>Dc(Rm"&e1$Z,?JlJ֣آ$#h`YB=9."bG9<ۯ/<6BTegCzKi&*ꃹSHQ9<=U8c8#**>b֥Vg8*E҇HA+#J"H})2(2AET X{Ti爈9ApkXi$;ܑ {X$exizug{vt<#N\ i'nXrO|VdҸF=GO92^&f5z7o,~)><^HUe`@*n^tH| 1QJ̑XrqYW%R[dWKp| ϖc N'1ti؆SZEi9=TW!DRݪQᨤwʩ?ޮWw`*i06Vv<1~ ٺm,Bp?ȬQNJzy76\Fm05"st۷<{ sw,I |𬹿na$gޫ -Nvg-ޤE|1楼_06sPIln3aHbe>YeuiN쟺* UҤ;H.sL2ܗ*p}XU@Nk>W:jrZKbo \;Y<!HA }= R00 wTRduڟo" 0?έ‘n€H# vad0;H=i,It?4Ey$VP!$ Z!H ="[pHJY\rNj/r vU{o2yR؜\p0x 0ZIg y֨7Iw`Rґ;0MwE\@L.<#]s?;IR{FC=GB3*szF EdlH2E^@5\@jxf䪅}O*]JıڥFz=$]8,l,l{ZF2F(8L(.8CpЀ|BDGq x)eBsO$)ӥq09EHʪ ŪKjri#kq.WgCrHRA*^@ϥURsbD3[**OnJ@ON+̪iT,' EAx1ݹWw lGj A[+gZvcT8隹/l+oUg:C2gh5[+GMz2Iz7a#r:'}=?k`bј*Gn6*+ȶ<~ Fl'9心s>̳) }^H3H/%f*JYnCFQAL̉[ګk&eSU /b%>R=j>+4a]Ȁg؜V?ES 8amҳ}:sNtTNL K!EG%ҒPTGq3g HΣ^t-F(#TG穋(ϽD2dr޹yi @mV[Bd0jT(QH;*N(uy[frz_EKB@Nz{Uy5 !$V\f9EBr <y'Fw?v &ImBHZ"4$O5_O+vԩi ]ÎsZ'|f&3V#q4+3~ *Y5X Zd\H8?b4"iCA[E!$)ZenF8PݱB%bM2!'SD:bٝr@>+Z;heАF7ۏ9c+s³d V62Iy+K>[TBvq ˤi ce q{rRxUKBYI-?f]h3f@1bi-Ep#)9D7|H'qVk׋QGe S}ԄĊY:OU ~?M@Nv=4VL$qZ;g`̓98W!"Rc _:?Λ*Msp#y<^hRFo0*V&_9wsOv #1,Q7p0w5EHNO?Zf}2s0$b^T^?ΧyK|+Q 4 5V7q^1j/.%HV&1ሧ<ڊ˲j]F6,}TO,1M{̌vRۭ`U9Zv2hnTə^u0PK98 LKث$ΨeFqU@탁Pw$ [X2V<"?siR{ȈWlԳ^a1u"E򦮧hH =m$=h6qcFD>Ny?JʟBfi&| ՘<;˷iyP^5۽ \Fg#֠H| >$z6ϟFk&mCQpL%!W;Ԇ˵Y,88VcY4nL+V.59`U 1jŜl|bdCFC{g`ݐ#W[I@~*?iv4 2yPik1#o 2q޶{i ⢹GQUw,ͽ@MV.c ; OFHGO\N YzZyDD׮E6GI~rڣ~lfo/SW6G!*u"\^Oms u)uGP#'j]M4XP ;lfޛH,*[+-礮jliw,Gz6O'HpECuj2 V Y5Ͳd%?*cjo, 8 P>c*p "p8Vu,ѕqZB˹hAv ӎ8qȭӑs%NO?iI,dz*gP@nơ%gȲmJC v ~\9=#(B^YT`A?ިmZ68 |)+OZn*$$~ޤش+#'?Anj7I2ccQ.%PG#־PCp8RQKiqfX{U}BH^EVR [ B\A&G(B;d< r}Fz¦KT _%{ #֡YPJ2LQ^2 '7ӢIV(V@8XC&?ϵY("Osj\Cu9FW jg’Ohڶ"  Pm'TE563 PFs8u.JK%$JY `0 ͑ӧZR,ri*D5H#wY5yNʱ#I"On!*:V stK"]V[%l9'WIkka"jGJIѿIt|Eך5χ,¹$Ð鶲cZ3Ԋ Fs773BH wXH&S]Z#iiW +}sH#F}> UGʊDDq#,c*y=qVEuD8O [jQ0'b4nq:S P3SUnukDU@x⣇X!xb9|Տ*ӄ? ?t1C" ڒR޸Ye#Uhd£ ,HXAFȴV,A9Vd!`߻WlЍ.HNhm"}kufo-kit-tofu-ed0e5bd/docs/source/figs/inpainting.png000066400000000000000000026476031521054151500226640ustar00rootroot00000000000000PNG  IHDR9sBIT|dtEXtSoftwaregnome-screenshot> IDATxYeqߛg3ԈF7nhZ&JrX JzP/v'YЋAvhD`l ǚPu&?tVv:F8Έ:gא+3W?ZnRK-RK-RK-RK-RK-RK-WRK-RK-RK-RK-RK-RK-}jF-RK-RK-RK-RK-RK-RK-E_ϱZbFӉ~6z^DDt:n1ϣ^lv~?6My֟mDӉjn7:NDDynDۍ^GDj*|^Wt:\.coo/e ҇(=^1ulb4|>"Ӄ uVquu~cX,J r|?X.1__b<ǃٳg//o;|RD7M5;K)jU䟾ԏLzhTx䲐rYfzlx;LJ0jj], C.yR6vcXݴ.b'Uۋ`P`з^UۍFEe<|0>cor2q`0(߽^/~\]]ED>\?zKB|E\ǔX&+qtX,bIvf>6 b?VU\]]uU7xʺD]j*~ ØfZb2Wh4*vb4/|s!&Nb8>X,1Xs0p8,v+=rryy(6M\\\QtK ?*&I xiL&L&E>;ɸ_sxQ)o?1:2CaыRXIjrXyy13o׋ۋh1zຍ@FO}{o;fY?>,ݻWl}=1*ܹ}Y(uqhT7t^d2)y,qL&l6el[d*yt:DZحk`*fp8|͞7u[$׉p?L&1K[( Em]?Y׏`C N?{^1m؂F/e,bjE2ruuUb{{{1˗g}x^Ks<Иe3̼\.aG x<.o}0`Ѩ`1X:ui b+c8`0lvqyyY6u>fRsLNh 9}]d|WEsyaj˴_.kQF#'mex^Xi(ӋN+okeh|pOM5dWjK^'|t:%t x)'Jn?8} ו,5;X]zSiW?e#5Q")]o`jVF2{}pd,i <5{opc ~ca*]GFDrˮټprll.Q39Zq] g}ooe< {AtKnZ;mltxVnn_%?HP}ʫLV8',.U9^`pX4~c~xxW~V*b]~joCVwgbչhcl6e/ҭ>By&3??,X$28MD4VS;DqTۭ8E4r(߲Mzgƿyl-ƤlSrA0 И.;J+;OT2~:ks.pH*\ZcˬytZ>sV|>/_dF m9#:(׿2#';ͦ%'gɢ^)TӁ(~yΠ텟O !yGHLrDUD!ɈQNfdGc)dkMK-RK-WvktE͟1(ž\OŘϮ \G~1mkrN ر '8"26wc1 <~mc>*/oW3A]W(cDnqߍ%8Js]ʱ1]/ng|2gI^sZ Sit_:A^g/`hʫm| |Oày29"@b8E.qj vWboہqylNZOܿ<78^P7rl] ڝmer>|Q2o??'p?I߈qa7# ,>ێ|$㛹~לc,j)<5{`C9`l3x܌u1΍(<'3G%f46θ߻ԦU_kʆaIw:=Ɨ~ &;EYٙ4qj1;1eCW]6ؑᾬ >ޫV-himt okoyt:%;1o96 3Eěoِ+qv[Q¹xE˙tۆ(v\ ]K="hqY9?l"C'@G|v2p߹gy_8jF^G`PfX~jtRK-RK-U 8qmd1}Ek@;^w%kj]HqdW.5Ʒ77q/ZDwFܼL{>f5dP,W_ ~Wkߗ)/3&{,yp헱g~d &f5z.&2k!jƥ5+jK9~4xi;%Gױa%F6{GWgqXۮ2'gol1۲3OL <޽{1L_O<|^yΰN|f}5#nq9s6Y_rتrwuMƲ1O?vIN s)3һ}_c>w'ٖ_veis[}S2r^>`A0/n4(<4{7rBR΋di$j k`0r02ΰ9;k)1PdC@VbO|Q62 lYs7;"ybZ0ۻdbyLkkYYm4 l5"}QQ>,VU[1 lǥ˂ۙ>Nr^rTFU_g hzrf9Ssr<^و:@{7X "1>ǎ21D_-"nɄxŋEAibDƬ{>2g1eyrՎRNlc˼nts0'Q_]9mvm2j3&m|d3vkWY!c]S;U﷍Y2X`?2_uD%a|?7^,f)XǾr}oo/El8::*%'Le<ԾN68^W|lVNH?鵤5t0x: ,[+lP|ve :OٙgmԘ kM3dCg#TS` VN 6l.vĘxxdG=5 ES~6bxeƧOc<GߏxWƩIw}7?.ri2ޥ|H|z vbʺa㻝-OyKq*>NKs鳓?;}ǹ8܋arI(Fq^1Cf"^9V18-RK-RK-PNkƮo|pž5Wxz//T㳟z[?k2 4_^ xw~>99䘙8)~׮慈5@=#zvǼ݌Y,xN'iYwd1+pi|d׎ts}5~w+K~~}sَus2r3"^8qO[1r ?)k-V2&lo䝛/9=9YyLSe-YG\.'tr| lV~^خ07z\o,h'm.ECc`dD=9k]flKYn X.1N?&LY@i!BFej g; f2Y愔 vg'LـnOڞ &8;4fwSf[_kDXatuu駟I|_;Q\KDmgqHٯɞ{ZOpryì(hZjZjwuՒbbDםZiW[w=3x[y_E<$L.?W'W˴9w?j/c_ծgN.˳k~Ϙ믍yyc|<anq?=8'_slvDDc&X2mЯn^xX|_=+?Ʀje}糽&vƭ/^Mq![NqGށH$W/o 9+ld"JSsDd2Xa) v}YǢZjZj@C"nb$ܗ(d ʘ Trk }sͅi~Ԁ(w+g߹Vܶ y\j}qɅV]<]l]HGQW^eopWӉ@TYѱ3o,ODnSܗ?w+#O]$Nަ۵:IӀyʱ^3~Y>_16u\.c2,MObaDDK)N&Q Dw&8i5"'dHt~?߿%qvd Sڵ(㝱 v[r:mcE7MRsv- rg-<_,Tw"{6 1ޙi,6^›pXv/rJ[F5' 1APsͻn,;c<uYK  V";!Lm|v[Ps/[|{l3vJ_Ή밓cqn_v,(H'Iƻɓ't(Ƃraejt4^xQH̉+VB~_6oMbcq^{I+Ti 'Il9h=uƙ˿N𤉎xRm ۾-z]q.;NFlF_=v 1z^2<h5\/cw)g;¾͋zqhȠb&ˊb"VU\\\4?~8~:>?x 6fvRpJl7MqjhgNnrmǙNS^n7yC9x^ۡ>Q6i~wc45ƾ +;^'ZrxDa,menQta;AZjZj^OVp50P 7h^p_3ksnGZf8&I 5ߠ0c{xxϞ=qqqQ0|f|>{w)mx/<19mU~~ˁZn{mw1a.$}e?9e 2Wk91`}mSe}u{ 'U-_?:İyYy>7ߝ~4^ۦ2Nrx5F>=?/52GyqAqʼn1ه3جǏt:-ۿ-q8==m$ OH;e&_l6quuضl8OxyIA9p"(q9q޽XV4'F'3UCWڮQ/cdmmm'aDZ8={VE-RK-RK_ErƱD7y^(e(A(>/|>1"~ik@;3k;$ aorq ~ˀc^u,2xqr\J;O/#ZQ9 2Xw"p28kPrE!Y-K'o3^Zs?/%Xa9q 't:Šf8 2X1E5ygo=y2mY>x0TqqqN'&IvXt:11~3^Y=;;,ȿwR"Nq`C(BTn[HU|_,qppPGN6̰_8;;+* r褛]q2EƮb`cO|BGی!9ų\ƒ~C'}FqyyYF(vNE>'1b86v$w^Ӊmc7 ؆7W9;ilbrvd3N.n1N6e`K'=. [_sCBv`YiaK􄲐ƼXVe%uYc*91y;[L&qvvVYbWWWզײ*{pl-֙+ ՜q&7 qceg Ȼ?0T(bR[Qvlۢtz#eØ΁>cK8.2fAgj1>WFMgWɿW*^|<^˒`}ىǸ!wl61[Vܻw/m|k_tGGGeqytJr*"j =\ jlsp G'PyǣG?iofF~|ߎpX2msp᠐6,bi/r, _gݳ|8借6 t?oEmZjZVNd_4~t\-3,Z6Ԁzc q}H/tJ`0$ם@˛W56$cL& @GjWyr?ycRC',lgr9y- 6_ $v=-7krYs[,5,m23jv,:ؘ{v} <7c\v5.+ecP?k>}vlѨ $es Gx"^x4D ŧ`I9> EU]-N`; #" VjDqP/>zqTmwƟݰ9E~z/keB>;qY΋|n #n3VEc<ʋ3u 9KH̓8-33P>{8局,Kmm1FXz3c P?:\.ˑp'ߊ#|dǾmq$6ߋ,iIÌo^(e]`0xIܿ?>X.qttTV ٘[O- Bw)vؑ;XęvnTޓ N<43Q; {K-RK-RKq_淜u?c sͣ" +cA /C -h"3 vo_q=&E`n]RD[d!Av]l]'yn#6@ڜ3xn?:1UI,c.]kI#ȱ}n۾K6ʷ~YL:cd2ϟy\^^w)'\__`K٬$Vܾf,  1vqMc5晏DˋKы[`~91'f=fm6,W\s)Z3|#a|~p d@|z-;Kv^9cL.cڔgK/0&jZO*'p! oad։dR^a:˘PgWyx# X&M>y -v 888(8~ӉČ Zvqzzxwy'_eY.j [>z~'G! HsFBp(s4>OZ^l3eFQ|ߎnoe8;;{-gc djIwd2?0~7rLrtʖV k?/.9DW?x'|.&yG}>,///<%тc3ԇ.4V0`Iy%)v ) o{c:ŲmҨZjZjNʿ |C}j'Mv.wo5w:qTls?_Of_2 g^dyʫ|-/Yeր9Ǿ~ rX /m6MYk+ugdt2v^7`F+afx=A3?߳.N?AZd gLSWNq eɊ,?56,o&e쏘xI{={ofVxeyg4888(p9<رa\k!Xya:}Cu v?g;~?&I9bpz>m|%Ske1eŸ냼6' N(xc3,,'c6xlGQtȾߑ~-%e˟-WiٖS󵱠 t:0øl&sQZ҈|"v FE,lr[΋=w9I{2% ڮ1>)짏PJ=NL8gA=Q>:e ObVhGrGK-RK-W dP9^`x'w̉S-pvs 3tW3|?nݖcW#cu,exAw˼u  .&d O[j6]q?cX#e`-Wqr]g cmy(nwƉ?f>eИT=k?06ďq@sre~wsNIݹs~qj"JNT 89`F4/h1vىDyMLtkmj %ۻk9+ ˜@e|F#Kp˗^d^m7"~ӟO~ <.rL]WWW1M8F~ Čt hcmdw,2L={,{xx$8ZjZjۏƿe1ivPZn] ,ǵsyg|.`HxݿeVO]sX㧯lPn<<λ1Ԯd/{[?2ܠc:r<M9zSmۚ,(qZmQ`BƑ2pk9 xۘ屭Qd$ ՝e1?fuҨaܽ{7n\\\ċ/ N_-tʂb^I*&g=8z.˧ʀXe ӲI2>{qttT0tZN(rt)yިD27#/lrdTNLeeO#nl7ME>o7 3F4,g܌t:e{i`V0]gIC{=b,̧lmBu'vg2_^^I Od IDATr;苏/I9ƫiI3Ji^˳3N Vag|;~^1_imv&m4X;c7dȔCbwa~MkJVmX'I5|Rv\I zP,$h+F&]ImEټ|cErǓ׉.o`QRB> 1ٓ`KNNED1l_xq&IܻwÙW0b\8g3q.;lΘepΫL˗/#"ʎW/ ~7/~-RK-RK-G5p?u\b896c5+08ps $y@P9ѱ5G4`3Z]C95f^dlWt>%(_$dQT2ؗ@'ڙA]|tMWǵMh~_s9;M^rg6fz]g 3j|}Aݜ@bxNˢvD^|$ 1j%7N2Fnil> g|n6L3"b:ix1!RLSGNtk9W-Јw;lvY&-o|ݕ}υ){sE޻)?"6:K6kdq:q@fSx;v!46$y"~pLw/^(+qi9~Qd I'gv 8?Nfb<-$FQÉ:aD;gY|H)F[V;%C9l'Q#q &e1mEff Iaۺ|~2#ywVҖZjZjn(ǖ\u_{kkeԀ[gz~|Y7aP1hmvnc|/hB{GPh%6&N"n 3 /9V852`2fAy``rA9^oYʔۚybyM$:3屭q}:jY_'7rjskN4->Ӯ*}/~n{ 82PK0"οÈrD@u>ǸO׋ø~˗/ӹ9,ծbscg6Q/ؖέVr\zϟd2n[^m`:ce.ar` Ǝ1˅ƃ;Ǝy!@dXyxu2Ÿۗ6_nRFw9yN!et:mw|3/p&rYNBgWx4 Y,qqQqyy$޽wލ{bh:~OBm0`<^=/qߏ\Qnw=J Nc FDyw9ZכZ<{+ &xpƘ&uB2a`;<"H!Oл0w5p?gm\[6$Vѩ?Bć w`m\್OV[`9 *[t[E&xבCeEg/4cU ~|N]<ev؁[V,>䓘{{qΝ8==?٧vTh WJ0Q;lcH) $;2A_8bQb - ~hZjZj29fO 37Б/5p)a\\&Zo4e01B^TqJy1emq 4y 8rk8e82\qے%GP<&YrY'~c50crr=]:rjoK֒~&c1~]1qcem ȶ~Y9L""qwzrl6gϞyY J"4bSH*3uIj___v+;# L~=yNϻW;Y.//cX@ϸmbtgggquu7g~dɘ?6ձ[ Z}PeIqf+=I+ s/+I0;qΝ_xa?__gϞA|l E2BLy5.28 LJ*<,zb̬Oa(&"&|.*+;,ao r(ŋǓ'Oߏ__lV&2#$lW'@YWeeU5LRv y唣Ѩ$3>,'7ވO?4ZjZjZQk+a#vve?9cP%ϵfLyE|nW7N9μ{ =yI=1Z;xU'k;)ee޼s eϙn]Z&jV ʮgNx)YM' w|r1یe;Xr"֦X]cu\.q_8Qx6ڞUl6+}899f/_,vNӢ,d1ƀ r^__ElO@Bj906KlĽcF=yטn#hdGǏc4ߏ[Vy3iƃc2DK^;6yB+)1ڌ7~8bStl}K-RK-W;3~W[GAYԶ/J5ܟm?E{>u^؍,$3>o^0Gq`GK{ה*{./F7&>8 Ƴ|=;??6gVv3f2p\{.N#ے4']2p{c81*Xu|]6a/;EbaaYXL\x<.Ǹ/x7b0#...b0ĝ;wb>dzgb:H v*#qrrRp||\@N@܌1F/8 qDEn]6R^sm@p@f*0Ĝ tZ6k`Tl%Nwl61JȈ(qz8;;fDZZJB dR m;69>p8,Ǚm6^t:ӧOc4h4zE/_YjvIJ0Kj4t:(˴d2gp^W^,zGd $13~H9 m[6Mv";[yq bZ 9 y_scNg"8 b6[0P+}m"rvJ67Uɬ2Q( _6NO&eG 'sng623[ImL8!7L}("R!}wi#rȤv\d/^(+l@Fd@ߝ󎱅Y_n=g^b{h4*S_gm/#DsTp+a_E;zFy>:{FK-RK-WtqܕGa|Cǧ?}5 p[ xI#>Xuԣ^!_N6>^qqcf]︎X8tU[lP^!x #*IF3Nel -ˌ1>YŎ7qqq7E9u>5Ԡb- ^- >#0X>;4<.1`jۋE;wƃmf_/r}3rb{6#xMBN'&IR׏bADD\\\swG߸5t[:v.oiz(|q= }[Ƭ=p81um-;"^O g< 9|4n }&J;wd2?4"+Tu˙jUdG^fFB)˨76e{w>"(~I,q~}r/08&NUXVrR3K͉5 ʌ!OxϟbǏ' ˀ$࣏[7Qw"#~{wgzl^.//=;qtɻgjA_7#;^u~lxç~ߏ7xp8,o6) 4e1,|c$'9k|^'-;.*Ǟ1YNXZ N&rx~ ~_yc! hgl{#  1NXysN#?ng{glčMz,/~T8"C+ˠwջHj66mv=^0z+""7ߌkmw21/U w<iv G:v#e:F1+ږQǺ{?K&jA|Jzjy[Ǹk} eΠpfus$a|}ʠ&, 4Vy1*Eℕo>YAIL&ovOXV1NΝ;e`0hmI1tfkki56D]mp*2͊|?'Ÿ>};q:u_VUYDv+P0;ZlڎOɲ _rB{Nl"Nfv};OAOƢY2ebݧym-wvvOEGߏӒ}|t:e9l0l6+/z%(J*836Y3ϹNsI zN=8.}Qx7׻y&:zrrR@Fv-;2Y l|?+:!bAyaK-RK-RKut5H /KVeee tf #h,0/o6` `uvXfKXC!6c[]8F`FXu1˝Wdv?{26FߩG}\;j1ʫ=#θ܌@=ˇed"eaA,X,e1 3a#/o<}2/l6%9tJrhSgbxءhy~~2q|k_+gϞwݲ]U8~ QÌ_K{;c<0ݻ<(+u#ߏl?O1GGG ufr$?`*yB|u( v9L#f y/uzl:c#lWxn -EâcENt}QG?OƝ;wnigw |0NKQ8;;lV]_l?e3mA1uF  8+Pe6'I|ɓۋgj-!Yy:c!8 ||"ugG>ՁU ozXYоӨ)MiJSҔ4b5Xa?]w==n7ו̀Z}j^W:#?7e04Laϑ!w':9{s)'.گ0iA%1d>^@QS_)hys8a?:gf1p s$t7tπvg>(XJ83.)oLJ*v4-b "n١B<eA&S"(a0?2۷ᅬO#+8p;,J,|T`_˫eڊX]]h,cww7]Xϟ?"{$ތz>ɟ[^Ux)t=vEJ 62R"|ib902<4]և`j/f{{;... >uٻX b>X0^xϞ=`0tZNY2Ţ`:;Ŵ\QI<`g^3AF\g&8|{LE}\~;"*C/g#̝u;ْ7Og&Bh5ـL>0Q!@1mzY  =0X81X2M_M}tb2Tpx'''82ˑ 3677cmmD-Vf3n@FD%!ʸ3fƩfɓ_h<E6_]4cx3o,ŁQgM|Kq/FMsn7)MiJSҔ4)7y73u}Gnzu?vcP%2 WF?_6 MŶwKsAvcԜ;N7qq 8ns:&7wLoi.3O>F 44zh˾;s q(̉:L0d2xE~nJ04:Ne} 2#Z3?Ӟu@PoeY^mxMqb^\^Wmys\~E\'8`Mu6mATfēQx*Tyc}}=~B;וhh7m8Zקgh -'^}l =.gX9Y,0̃ #órZ bQyh#="c(IvvVY jn%Dt.Ӝ#ȐX3kj't:d;;;1 v{/""^|٬=2sE.'Ǡ{||ϟ?-K_e~aɊdC:ouVZГ<(~F駟,˥Ţd69+'oO}>F̎b?Y6<i<3o(s֔4)MiJS^ne3[4.˾>\MD]?#&  a| \PwgW?LeS?=]+zoѦyM}<~sn,wyc8)|vS{a]g]ylErc1 exsJfz n>&ߔɻ,ͼK-qZ2^|>Or,;nv766bcc#677 y9# 虬,#؋qŋoOOOQfMhQMw?uYƃH[bpe+"Qа.<~@Gþq|\bZ{>MggQI\еh<˜2ZNg]_zNe>}Nexv2.jZg(X.v$FTOɅSVb2`0fcSЖGު 4?|'ڊ;wĝ;w˗14۸}v۱Ǖ6;NX\%6{|:37p0## hVQn_t:eQ|2...ŋ1g 'Bǜ4=|`3Ͽ?ϲgyYgryǺ~9 A ?Wnd,NxV0:{ Va\Y˗/˗qrrRNZ[[N+++%X jJh8h`ibm}²bٛ;+Zם8Pf{*>iy~\\fyp8K|n˧Lll$hDX|>/8׬jrɤr"嵠Q&bV89!MsA A$&6y~<6Ȇ6P0 m !805By1Q`XiȢn})!f#r ƎdžA#(+=hNl :AkՊ++RhՊ>,!o~XYY~;˫L{U -}ʦi%@"nV?f,O)t؋Ǐ7|8տWb2 eGk/^Q^@=<3ˑ(^+rmTv1g_4)MiJSҔ?rp d̿: :#ǹlЈ׳sp(i~ eՍw7 Di3ϛ|^ED9wlبBh}n'q,'z9g-m{2Vg ;f}Ĺi`B.B̀Z'{f>Xfl3sAm;h_q0 1e>&0.+++`|܁ʻgggŏssyy~?""&I%n|ebG>ӅC6,Wds*A^n݊X]];wqlmmUNf}g\[ȯpۖUNv...b{{`|qxx~%cb^;~,g}D,CSA^CVi94#1aʁadNPp0;ƻ,]Wu!ƌ7yƫW$iffy4YJl`;Jp,3 p2YkM/\E>7f ig\2'V:`;𖣮^h0@ x$~ǽ{ٳg_:~_zL 1b:kCzRieah̎ۧ*3r}cIm9hTWI"9\. Țc lXqP%2ݼkȉgL`,"*;@%m}g,|'c0~]OB.;>\_}I$O1D:0y>@a>{,uFú- 3'x:q޽rI<D/t!!G+̏ IDATfqvvV9 1#P]]lG亠:scr_u4o2snhazS@+C>??M.˂z2g$@wF8>>pXNL]^^Ɠ'OJvXܮ@eyvspr-7hX:z刿\-\`9_^[\[p`~>1͊< R !ɭ3r g$m%ۀw} |}xQQxE`u@!ƫ5G+P; }Ym7h[ ΢EF*A;vI0a@okg+岒]C^h}"fb,Ġu;㣏>:>xYlooCx<~~?q1H\x!yG< a[g;bNV GgK^z_~e?|>o6X__*w$Ɍ+ކgjِofiYnYoؘqBaIyadCNJ)MiJSҔ4ϽD4oPڎqNCk|lA:)"*>Тwp< ~ F0K |?ǎvr@AԠk~<H N%8veelh@[Պn[:c>VZv`܁«9NX8 r 6 ȸ_4gNrb G ,p\"v{dQU {{~,/mxn<CtfgӉt8n+|Q旓Hr/0^Aփqtur7z"z^d2NSɐqc44nG][[t'''w۷};;;%i1te'p0}dwDd@11 83"M޷> O^g1Oȡ1*ځ. qvˑzNR};dtee$Tu 1'Y.uL&Ey-F9?bޝU&/k \sǝ"/^ijgb>Iiv1L(&I3cmK0a0x<.cdf9hn:A&چvl(Av>:yǦe4h|պ:  ~)qh:6w8¿ɤȔyrt:c4թd,&I<{,Aܿ?\jF, Z 8& 4lmll՜ajbxwJv63&0?9Yݎ6TP$(f _߯([6raQ)p0Ӽg /scNy)MiJSҔ4)E _[lS'l;>sUgՁR7 fK]wA/7y2?1x3FmWl2"*k?ftr0ԉ@Yi4A ØL&ŋ;bSt0O߳@E׫ق ! +]'B_"(3j60l}]LwGsmBG`< 2z^|2?|A,>kc+Lfhw#Vr9ij+w>ꏾ8I`~wލO/%~_%htrrRfAspGGG V0/}k0-wuj]g)wڎNܿ?G?Q3o/_Ɲ;wg?Y/;;;6 ;i~c  }# UGe 5mbO(c/DXw}ߔ4)MiJSX_ Tlcfg f2aO7g xgw؏c`tВb`)~DT@c4 p76 8}|68}5}0XĘK9Pd6&`!A%ivp&zgKNt)/.1d9]$$>:q}^Jvre/Ձ<4?o[]CWwS'fJȼ( SN͊];mpf@}t:v^?==w}7:N/^]O8] SY=πfB]ŋ1JY<}І]]1vS+ q<K8l8YGP_>;-,岲(QPs~~^81|,Aޒ̜σEQRGvl ؂🞞"籿~a<|0bssX`c4qv]^^ݻwcoo/afX,իg^vV᧜ayF 딤 [ƍlzfCgދh'''eyQ\^^__nݳb@'eѮJ@.j&deN[tbOcnWxY<^,1N+ƒ6)MiJSҔGD~O]]٦uPݺT *:}3le."% a`XTqEi Ae䀁A7ez8sρs.G>]`(v+ 㞓ky 6$)+7z;-L?؇36cdp庄x:Nzqj駯qD\]3NOqDDޭVcwvv &-;"*xu1~ urde}v'?`u2>|X#[[[N v`5pȥuc:>68 =./m>l@ꫯb>G׋;wV% &1 %p˴Al\ۻ2`~? xgglԭ]s 8;;+.\9 Ac^DXpk|Y,H^\00hyf8O??<VN1KPh8FY̳jDeWVVb4!Z><:}Ny}\VBޡ>?={/_xq<{,>OZaS{{{EgY1zwI68v접|d,A,d^BOU67ϋC,#?==x\2캛Ҕ4)MiJS7^?g5Cdξ?emԿ1 2c~c)I,|]oՕ׈ʝFpӾn~#Ac샺Gyr f>dYh.`Wۭd7a:yS|\}[,T|~)1Ŵ4m睭mpڲc:]7+++1 "">w"do'z ceL!}Ʒb~cۭ$M;hrsh|ع""QN0Z/'I_L;뇃{n|ᇱ(~(`fͻ˘A# v&30r97_8b>N;SxɁ^WpYJ֋fvr[~zy] Wc9Px|>DIO0ˁY::,FQx<ӘNqyy{{{9;;+<%736-N]gŶsoy6uzwטrZS|] }g õnv;u1Nc7Xxytذcn7vwwcoo/fYA_;F f3Ŗ!À'I yDr3l%qw1Q 0߽q{mb+upЮ <~ o m tv;^xQ(xA[E {jaj@}62K5gcLȠPXzsv ~O7jo s_izGA ~ԍ%f{8ٯG&t}_pnƠg%T9Q~/Lcc8~S#n9gЧ̯󕕕؈lVyz3+yIE_W8Ϻi9d:`5"J4A7|n0~`0D׫Ќ~;Pqn42~2Xݻw?h4S{X9IL.䓹Aɣ9HeNW:h@KpN^qrrR9l }ʸ: oqv@z̲7-c}kt82zs`ڼ~S߁/k=-ӬWlletg xE8::ò{m<`0w}7}zv-)TuAߌkdذeZU楈MuYWXk"놬r3Ogyv0]mVWWc{{;c8qlnn\*A#,T^|i^(#@^WEXx?" ȼQY`mdx5Dga4X1 #AkHYIy۵'["fX3R޹M#5b~l`Hch xV<(/Tqk6\zAYqŃѣG.q;ŢHi}Ď!xƇ~qqqqnݪ ][[+pTEqb#1NcmmdZr|4)MiJSҔ?b,۴|o*u@Agz7 D˨2 `ίǟ&+@EDx4KT4u;.0) &o'ywٗ4ہ刯tZp\8͘ %}B1.FVW:E\92ϋl٤8Aݖ17c֢ + |}t:[.~1)Enc|{N,u?uAcf=a8cݹ>y95c襈(,"sa8h4rXҽz*b2t:-w']t:~_tM k d\ žGu("ZqqT&'qV2e̼|翭=G؇wch /|]0ؔ,޽[Λfɱy'L./~=g!gQ{g}VKjruVɀ }e;99)V(yScBަ~vvVG,}&ez9m7)MiJSҔN{$ 2Dg@vo*~^yWy l`o=&P Q bg;ž Gñ,jP wugPsut1oe?' IDAT'rɯw N@@;O5poD;qV b Hg`AY2z/`Bqzp6̻3b[n>ĉ̙< @/Xn >looW0>sbb(x^r`zdzggg;~|1N[nE}u|>NS ,2`{=&8xC)*.bAw ʕ/}W%Gs: x}'(7gLdsz:zܴt:ϡ'7e;xk}o%8'g;HGqY{XLWx{fqzzGGGqppPߎz$:;;tZp=a /fȘiq}ߓyrywNalycLW-q9ǒׇLΚ |c>;T nbΈؖJ(IlA|Du&Ar&~yc7a<Q&+R#ਭBNJ?#| mh@+\"Ccww7VWWɓ'qxxn;V^u1IZo΁/ˈ0dQ127st:?8vwwc0o-+p@S /^W2j`;Pㄮ^CЬ K4c\O-YRm^bP]YY)A% øHK Sj>Iݭd<_g'3˲L&Al' ={wڹiJSҔ4)Ms.PlQc{ٞuf@|= Sh;13HW>}v{d}Im&p!3m<3oq:쳿 a: }r3>>u'd`+++_3"o@e贼SI3'626|Y& ;U _8:ȴ'%x>wx.OZU}Z2O@7lμżx [~w޽{qttDuHY,++t:D$ vd}㠣e^Ș(:0}'0'IL&8::{Ž{t6ӓA3f =X*|;z81m8;}MN]9uXKu ߼V's^ف[U<^y}}ׁau=ߋVyd kHfV&'&h@߂bÏp" EƢ.(]oo¶\.n"hEɲCX.e 4ĘAF-3;W32}!0ٗ0L~;1`z;"`: C,Hm[[[ow}?G~18Cd:lE.L14Q94 /h fGņT]y@y猱/_??q'?]M~B.[]$VVV {3wQy6oh=N$./. AŢrYP;%gب;zh&@4)MiJSҔ?炝Z;uW A `%x~|fCԁRuԍ(&ui:|/u?|o ޾ R# 0 /d.ٴ=,ujUOmzg^w ob2Dۭ&[GGkjuj__9f\ߟg3@Lg"< ?Awc?(sB3vW3e<0?9Ј߾[[[oVVV? D;N5x>&j|PԱ\1~g#a ~Jc8h!ϩD`-`݋NF888O% FY#y(r0^B|>1UcX󊼀Uab,mg`M>){')c"O3eA#x?;)j;9,R'|B}.tuJMsc?+euu5z^looW˭<eXfrƚ1 ^{ċF6a`RiO6}B8;PapB E1WqZ0 ƘGaĘliAݍO?0﹏ff jG\;>_wwE9Q1:~fJGi0[o񸒅y"m?;{3BZ(Z2?Zxi__@ ;"ǘdAleg}:ƚbnooɊ`xϲRJL&E6gGp,À@w`m6)MiJSҔ:;T X# ΋p|707}gmlǺ LR$*&r m..b$Q(` ig0 F?@0Qa~ ܯb>>h6Ƞ(`#$AJASuHZ]K&l6w+:ә/Oṁ_|gsK4o3 ⓹mp'Y~1"e!ɧ}+3q}dI;bL m rYp t*<|'*W|{㝂4߿'''Ma%єyGՊpŋBCO~zt899ap8L=>ޢMY2H VvuӖqx;M<5 [W %e #7N] xL{[e v`AR/}Dκn2'hܿMry#ހ`1Oв>"+e*F,2/$cmm-*Cw ?4,+,–ӳrg[yAu}}=:NȂ mځoJSҔ4)Ms/h3 @l3~c ؤd7ů0``@@/1VgG`oDߛgt'ُp=wIm5u l_zr-g`nZ2?f_Iq?+8s E[8F?g~d.|GQ'5cs2%y5Qj.9Ǖ &@2W7(A3>l 'wyϯwepx/mnn1>6u;ලk:ҴN(|툨ϼtg'" pyxA|帽*iG)V Bql7j*Go`]P 8X:Z7Qn6[^7fݲjJzn w0,ONh7ļup h _Ǐ -GDw~V\H9AZdC|VΎ8jЫWogϞŧ~+++dvuR?b9kZ-+(w#_dyxxѨ;ʏyss?! }> M?t<p8x\ vww땹`MiJSҔ4)M:1*|Sphþ-YOg<;C 8Ȃײ36@ _s`mu|f'J0ρkk=5%>)Pj2G#pr?`V+nDD~.8S ,"Acya|M?>&0vAYf13Ok]v;Apzp c a6n_:h/h`p ƎVU" 7󉱓0O>/"ZV׿Ce%sc9pdZflooh4/2FQJ[1oR:; >XYY_:NflmmsIM EVIt)a|v/ym2hϋ%bi83Frv }FҔ4)MiJSrs cl'FMp2>7` 1,Xr_'|@&?O}bo2]D tMBa h\^Ep@Y2/// X,ߔ`DݎNS.cman fK@ O *BwR~6,xw ,8D&3R'9PJjkk4~;%?xwc2];w@ ;{́:`ޢXO"7;gh7+n6<(t:`.n>=`뤼>d,:wW7cS6@$w2E8 y҉CDTv%ArcZmdx̀١6xt`X̜i$)FaPlH%. w.٠ 3'( ;,>{??{词*eYeǭ[7M?gey;3J,Ɠ37y63=Wnd_&2m V|,n2M]iXtpXy>C|q./v`{vvi K|Q,Sl#CG 8F1+tQyr"c8۷#"l0>>6e۷c?<ӧO˗ep8gϞnvCo'gmbs ¼Xw京/L'z$N,sa>oPu"[)Nf9 2~~7o|/c 71 d79Z)I>g<Qd2)]Ń֭[%@`z~y\Y&7zN:^`6=sg>07qSeٶ{s],ON|kX7嵋D6o6y'k!;ew0Y`Ymg !dd ,.gfK6JI9j&7HED 5b:Af =@ݮl}yLӊqjY01ldàyt ^6pu}9ƸwyKB^8<Gߏv[[[r|MiJSҔ4)My=C;ޏfyیugq紉?s}L/kdZepu65妒iw/}l{]o\!x^oC} IDATy,PG3g_CЂvy;Y2/"9ߡ؀o|9I 9(ollql?xcNp$zztp}Z.@{(˻Ri;7̣'~_y;3܈gW q$N&2_)P3.'񬯯Nݻ(o_>.ӧeWp8,|_l{x$Eۭ#sf`YͲ A#/8h>mf]bGS9+(O`g׈@_\];ys{Yy֩yt`5NGa84ueA2 ^0ݐϕ8==x^O8ۋw4x<(wq\ÂFg^I NʇkS53d<9,g7Cƚݷl~{JEcvB?-~p3LlYs24"*;ڝF^%O' b5ceL"tP>y=ID=fD3 qv0#娢 3t ;8@c`L?-Y@a2ctL^mNv;08(:9 q?[oݻw˗oݲ]' ë~9lGϛ'pgEcV&xEӛ^d߱?:/ d*5m( 7(= ĢxGTNi t9fǾw9o`3GK7{㌧8F;|n 3'}v׆?|yyd12_ﴱŝ;wp8,Ǵb{n{4broǘSc+d:# Dܤ.5` /cG"<Tлr0cf,5rc6@>z+RLۼfcѬ7~t捌e<7 sҀ4y^ o2F08<ǜaŇ>:SxǍ#nf*io̘< 3DGk)DP]ȥd|z**5sy.;Ih5981AѲ^80lHl6x/^8::*ڊ~_ %pw"وro?_MiJSҔ4)M:U@Οg GT5K~@}MI|jmցA89s4>oE e?8v"q.d |!Fx:Zb<~'f;ցz9++W;Is]霡M919;4)e,sssycNh؉ evryۍ^NdAI>j 0f1 &}g5h ԟ+@W풬i^^,f"p4a0`}_Xσsp6bq֭.8???0ݾ$;7*\AEac,Y;jo5^P"Dlx^n (d=bgai}On:N[z|Z)[!_3>tSg#u<^,1=nw}7޽[:juЁԽQt1b<G~XOz Ncj,o<3?rȤF:1ϟ)f6˃3< |ѮuWq 4f 4"ΓX, ׄNxWtFNNb.ΈsOm YY>gr 4Y\.1n v6~LVg!G` {Q@?6~mY@3: 6k)vپ0CVUcLA5ꤿ$l<^n7ek.]ם;w6gJɆ|ЮƣGbee%:Ҕ4)MiJSrU2!mETWN2G0aPځǛ.?kpVAV۷&8eIB7g^A$4^'=c~1P h=dpW.;'؀A:r<8i/j]YY)w®VWWzg)swޛ5Ǖ$gڞ b% U,e%5RwZUcј1_ l ]*wcIw{O:J: ̓ĉp=ba!ZV,//,99ciilypBucuPg"~|z臽 coN%WPH7Vc<8mOcv-yBhq.YBz=z;&>  wYQ(1ðf ,L99" Y>앷c Aa+0bq d |ӎ;gΈ/_x<*"bf7H8CV0vM".k39Eeѣxuܸq#~_d0q{E3iKl9<<,{z1 baa!ףlr 8%sb8ay&AioшښBD6 ˀ|?` `@lv9z;EꝦkn'UASމ.:Э<Ǵb[9=T53X@@lHeR %ƴ{v0Vag *ZqACl1;Y<ԙ%yD3G;mN;ȗ簉dΜ=jZ h<4~gP`ˀn58' 0t:y53WVV zwlgʕ+qƍ}vZ<ǏիWK0*u{.>}Zt ]܌iی?dX^eS770v ;x"`kcQJ˃,cnzh|4g2򚗝UZe/b~pC_ZbZ>ehj<Gۍ8::~_㣏>7nD.rq8LlGq.b^q?ǬfggF:ƚ^Tw 3Vz-XZsX{U53{NᅬOy?c*t~ɫqrr2s0)`0({Fqղ-X?Gp8wޕj wtt{{{ \V+c}}s43`.0m z{O 888۷o}˼˼˼˼U.sX \{~6*1H7/ ^'Jl]$kZT=Av'`3)jڌ3&"M˺A켨xlUwlj1;wFlaıα3(eqۏWg >sfxf<8NK Pn:فd-s  k4n,g"d|r< hoك- x8O~!숥cO.tcc^A7~&`EGxQ X__"wتy,y` HRs5}ucN`n7e\XMbCH3"J|+>=׼ xxm<{,WUlllt:-r/,,-Ha p8n$:N%Vp}6ヒo~cqnKV2g2l{Ag@pxR|M|ױ_|E˼˼˼˟{Q?1c}t0@D@T]f6J6^|_`g;r? Ʊͬ/gVųYng` ]λqj@Jz3`S/yiD̙*z0ꜭm}t݌[=f,&`x<.g 8t@cG3 ,,,WN0N0&)g bz@.--9:#ED#6z۩r;w0x\%P|Y ߙ}60󎌇 }ЛjBDݎ۷oL8F8^xO&;2\vz\>sMurJgSv;β:;K࠻1 b0D/YFׯ_YgXs<~҄W|pwӟy7Ī͸ == ::3EMwٿQŧYO#yMuQ\`˂BXã2B`#!/Ll 0{&x$vvvbeeeF R?l8s,3.!T9c;~iM&$ϴ7B,G퀲NUv^X.[ġd\܃LvFfshl3dRX|X+N5tQ֋?#J3):f%/ S" `--qvv6&#pBdڡ,6XTɋA"bЭhۥ?vamid|d^cRa(,,\D{qadD8R?k,Lw^ǫWٳgex{?G8ի1g"O64uLVjzg{E|1N׿u4a~DPmZ͛qxxX΃QŹxY|1NjE-Df3VWW^(=n4b ]#?8Pv`b,|Y~:z,2/2/2/2/2&sml;/| ooы7\U;[&9=#cC.L|mujTp?`e݌gq0gո}=m3N۩v'_d ežvA9'mx1/9œ?v$p˿A|O<s6Qc|f^UxA`xcIA˶\#퐠m ¶rv:S \w{bo`Cdbqݸ ଁvJٌ{ܪlFݎ888vZ9?>F+cށ[ᱶiNA;1f٤VWVV<Ʉ#*7fGskys o`'2-:r0C~21/;Cea:fZBs:v noߎX__%C Ӓ#/ceesK^ =>9㓴%hA穗wfz_ Ogcl~Իl 22<_LxL&^^^.gb|= 9/|@0LTGL?tf(L Br30?>͂NØroUY!x2X1T+eDHWzlllh/# ; f^o=gYϴ5FКqW;}vvD8bg/(:8޽V+v?W%șsx,--E׋X[[b#.;g%%|c% xv@[\\xQ|2~B>k׮b@0/c bǸy|NŹM stS 擟r>nlFQqpz+G6Kqݒ!8;;F#vѼ` ?ouQ)k!J1Lflt,H=Q} `Ӭ;9ɸ4tFY.oL?g=`ey,o;5"WGKN#VL&m>ißi hO( e%½fB npY2 m01`,>jEV+1.v١`}z2dckXəi2ʹ{P*xglٳxYlmmů~)x}8)¥Fۍׯ_FiY9͛7e{nx<>d4޼ySfa">C0ώᏅ7"fxV(u!O>/^__~yyyye;v77(d@PˮNb`;3:J+kCmOg<6v8dfWlKp~]t06!fP ;ӶwD[m19;hSr c<qw9GGGh_-phZ1/2/2/2/2/?}P6]2]|=n[܏o_F^r|j te'l۹}7z =+Ywd 'y dp4̶xF\GsL3aGzGCW; 8YӨ 46澘M6Dwr 8 `AWϠ$$U`elΛOx3E!g] 懼q ^[[7ov\rl6 ⷿm2(%իW˸sNfȏ?޽Nqxx{{{qttTd#qWoxkkkt⣏>Dzzzoߎ?8FQMܸq#^x/_ىt޽hT"</;Y#7̋ŠEߏzggg888/"nܸQA=/2/2/2/\l8'6lgC2`+?%J };A,|?ӢZnsnm\l+b0q&:4k[`cp gmoqac|b|)=< f8Xnr~o湔y#;ӈx3t7V8 zY6d`^g*AFoߎ[n]@_%?_׋㈝bc0Q!9cƌLo~xșZ`gggj &Ap$ЃBy6gIr݇R7^ 5.yJ2guOMcx6[ء`wߏ`භn݊7o {E`dفhy:;@.-.s4v<9 +ywZ'R&i ^ve^uWU-Lo''ÝE)d>&G3}qq1q\ΓqL.c@[iYXn>!{dEʓ;"f~{isn#gn&@d@ϑ>v"H?}D@3"(Л% jW_}޽zj𙅝/m>::7ŋN VF, ^?wәQ>ONNӧFݎ/,<~BӉvv|ۋto߾W^ڍX]]-ak l6cuu8~_Rbii)>X\\ϟGכQ9kd1BlVtaP𘬬d2nnѣG{n\#Be^e^e^e^1d7ҡ$J?Cz$ e̶a'D?{O@+nprttVvid/oQfGCĬCUT{ٖl= # 6`gO2\;U\ 8g 3~<|D_^^pX->ؘܦ@O2epug]0+lLGUr24Idг*˅2c9(7oG' g0"fϩ~B~cᣏ>*;,//jZxQ|1˹JW\)Nv˶_3`/eq̼Z+eG,#;Kb8zrc2GL+ fl9y3X2m,dZ^{*L7ˆ˳'''1{~?ZVYO-;ȌVZ-ZVqJоò5bܹs' &GE^/ňBY\AU1d0J>-2"(ZૢF-9$NhbwWVVJR_XF^s39Ӷ;mɻx "H{.2}Xz yJ댂*·}o\UZ|7,񼲲~?z^UXDžI`ijqccc#ZVL&z%`F. )d1!;/˼q$̿̉k1|Ȼge{,\<[Esڒo}nsLGϻ33N#tPёMѝgy0`08DfHRSOjs@>vpQoa-̳?8S]PYLgCڠ2oENG Ui /6|$N/$N'A?7,K('Yzey&~ܼy3NOO~Qf-YA"C!xeg?w(ԅRR"(vAz*c?nܿ?˖9pmmx~~:A?/Vųgb0""^~dV-jJڈuԖވn{{{1Lիz=__˗_͛7R B?+2/2/2/2/c5;.sQW=ٱq3cWVg=Ahbdh4`fF\ȇk׮8*J=ig"cK<}C9`EF \WV+[Db|1q/_hɤ8Y2kϱueGAvUj 1FRnkv9Ɏj.;=d\Yέ:>> n4l6aMf:c9^ k6ٱqžײ1^Wh Me2`bn떦ߑ3bg_`YRi;PȎB s3EAa1A19ZN%; ^+ QPt{x=w]|wl6ݝ+BNZŸɓt:_,2Ù>Y釞vz|Gkkkl6˗O>dN'Jyss,Z qڵt~~޽"bqee%baa!޽{o߾قJ6h"}6hD(t:==Ʒ~wލ/m1ʞ2/2/2/2/rQ3s/jg zmgP92(oprZˆ=:0ocRܶl>Tya, ۟9>m;f j (^?0v{l+~l$X˸bS{l<./38d*#ە;L; HBH;qdp8磿gZ \v$ ;RЀ3u2ni6@#1,]o=^lbolg@PXE\2]NOOcee%߿W^-ٜz=A<{,޼ySc|jZlmmE՚ix<^7H VOv dLoo%tsVS;}I+yvw(?^*:j|vTɭ̣U=iՁW5WzOBFTqX]]7nYZFO>"Cr4 dz7{nI5u͈xu{.s3a!Z-:N1HQD?<af~̰yyyys-`3m,@lAo p\^^hT md,//GՊz>]Iąl6h3omKPK|:x<.P֍  o_t'B E() .X8x+orʕbRO( 5xiaa!a!G=y9@<Ve|ۉUlv$a}# :w?$4g3=3SKƗ` ]ÏŽFQK<Ȼ4͒ekZF6f%sk,D2M{2@xsƎѱӼgg2Ћ_`,".o皁*2<`^K3x^Q ۾vܾ};bגz(x]l6ի%sh2/YFFшX\\V5L&bYA̛rJ|\rk([VU֐h4O\۷oիWiy 02ZlvZZs 7ߍrglsNbR SzXDAnۿ-0vݙjyrjl6gp촡_mg_0!uNi,ղj0@2_(F1NKfu':EYףlyS>fo ʲúYeS"ęּGCÏ =cP 1adžcN+BѲ&33du;3d09ˇ{@bc|-?xe VpNQv/6Le>sv:2A"gVX̗;99F1@g:.2dvܹs'VWWc2ğh +l,6T'Iܹsdݼy3ZV{.q1~t6 =yʕ+qtt?/_Ƨ~~iQGD9B{:YXl6c}}=㷿mYp"clp8~GGGZ )o6Gߟi3bVW^nqYEX6!`AE/J{Ɓ>>w^ b8t$G2/2/2/2/Š[ݙOoeAörFS@JÀ6 ٱ`t3MRLgy*RЃKz 4av#@=0̃U|d8>AK;檂@MQ3ߍR/lAUe^ywM<yt3~Yr;M׈(FGGG%Pd2ոvZS2-4ַQ[\6n3bvsg7nEG`_D.ds]팷nCw>KյX/1zez^\K|0Fx3;V.d~${xh=6x:,go=H Go' O#RP{6ͩA|p/9XD=3'Y6PH#(+{JfH ;  q>y#rJt:jjjw{QxdzrVh}v<<ƍEA MlX^^l7 ƍWFqt(|N&Z-FQ2m&l9{ZV2i!}}]|WnWU{6̓2/2/2/2/R R[g]͙ l* Yxzh [+"fy6e@ ~gH-nw`}ﳟMC=Dc[/dM\8ؐk 6@-؄_{ d{ qaky,q>2Hrl)ۤm}ưl^w( >5yzdv32~KƠ|ˑ6h'zE?Q̭Lq4 I9&Ƒͅq0nD'[ðʕ+j.>-,,ĵkbgg'^zGGGe=x >>cyy9׋FzәYyҞuF-2yyPYd Qv*7~cc[XJzj4/-gd#m?99z>lJ6L˂?g ckekS^"[kЦG۝q,qƍyfxne'emq^\vf[`]܁%ƹngpxg屮dy̋d-mq~ׁ샷iܓu=|乖J*y2sE0jZAJ.Ljb;ш(Q%0$7;.SzG{ ]7 ﱷ80qCv[Vv30&ӖdBhQ QF4q,ФYC܌XYYwJē'OJv>v}ab+u||vc0T5qmyo[/ ?~ziL&1j}``Blq8t:O?4qa\zD41a60r:'2a<mllIz(d!#lIѴVt>u#hjGݞVyii)㷿mO)^ZoNGe92/2/2/2/2/vGDD [4P>_XʎRَuV?ΖFl l'og[;wӏONNlv[]%lm@`lgܣ.2z *6yuˮ'A;U`d2)l6ሽHh^\\,t6_oٌ~_Ql)8LJ_>OcO?K`[gx흳6hkdP2; c?H6Kߙ&蛝O<<9>>.Y`lΘpŲs[8;+"f)M !ce<-677cgg'666V?ŷ~[#!_ |;]b\ ,;cn3$ոqsom|4NᱢLhb0Gv{{;VVVbqqd6QgƄi׌et=ٹZیGvy6P_d rk N#dZggd2nGGGboo7n܈d%cn]u>.z Ga@Sub^#~=wYsTF%g]&OFVg~Z2 hH;5#=& j&PdD,/23 XMuBcoD -m1-a~XYeB::ʂx;/ ݬ<9p6C3;8xb ̮\+hFQ 6/z8B.Rf< `|N ~ߏ'OīWbgg'~_F{>ӳY3}Tb<^~DDپggą0Wًl2vܸq#q>BsŬA#[ZZYzA|wq݁Q IDAT˿Y9V(^e^e^e^e^ޗ Ժžs{ '=g Ӻt0"W9j"fAs2 zY1f<8 `;٩d#h7zlMF>Hng}cڑ"#vNy;0\v|e odo38os;;Hm/v7;6v<<h33b#Wơ ;vc9)_Ʋ N2lCcc ';i+Qc]9@tlp!l ev0d\ ,,,3㠅3dRVYav7n܈(`?c<{$2ZXM U /xx9bgDjuy9b@yӹ^0O̯}1C_W^?]eU gWueo@b屡Uݝk׮FX{FdfZ"{-2/fU=cY5ؘ2kB]x˪9Xg<YY16s=ۓ7ϓ\G.\|U;;.3N#nҘ=0c!0PGČ`3X_g ƲgE~U (AIQAo&28f\;,8*?eR?Lg`f)X^8::qd.X8v\(;FQ"rΐ-gzlsGƵ2Hf2^ 2؁FnsxHWGv1>WGӯ}7Ҳci'cؘe1#g*x%3fii)^|>,Q93ņa'a2-ekUxzG<̷\, ̎fcykǟa ,p$b4E׋Ò}Q PҬ^Ixc8w#!c5vAtƩ_w}{v^>d}$4w"iWLL>xX5\OϿW= }io+$rpMG֊A 掹; Sh"EYO&i>; 'נlқA~pO5xmOVr'ߗ#<+k6tJN G^Lf[\\WFә1Kh;u8;;+CDDӉ{EՊ7oӧOj~0W0GQZe}dQ^]ye;|^^^xO<7oݻw޽{Ey 4˺^"ň0F%(ED8?( jZt:e:[ڌBmN M>}vx=|0^z}Y,4lec:ɼ˼˼˼˼|XlH=c5b6 0۠ܓlwA=jCDNV+G,.fcEcT2H;p sQĬ@]СjHe6>n b0~$UV*̡ T C~WA;(2p'"2NaX4X-ώc2#r$"bc3( B;gZ20nbǥFԶ>wd540;3*sU` 25p vd7o唁Gqhb,m38__իo;<y2v&vʱġ38N]XXCX?)ݎafw:27ZVv l6K 3<3,#=ZZ=Y~?gwxoYF?c㏅Yd 8::~iܺu BXKbFU-yr6Mkǜ{Ț&U)z wN| Yڃnc8t:f8W/0nGV+[ñ@#6{y,..xj-7;di秕hyi|Wt?/;/fDSgo^e^e^e^e^ޗ*Hu:n^h@#Gos@pvpN 8Au%3xlF'v2 @*}`7b6<Gܗn2x|RO;5;ň(W쨀 #X|7h>2" OѧFQtZ@LKsv.5y}zW;f  lwyv2jr懱\1 _ߓ:U;ٱe7[q.*C~ݎ7oF,,\l] 曲c Cw-,,DՊssYwN?׌ % >x1OǫG p:Xx<Ǽqtto޼"666Ν;eG#Uy~;츁֍2{˴uyKd2/y_nKn݌w9v}3;X[Jn{2ܿU͛y UQ>؞և8,9re@yV̪0 /^`sҗ˄=uFw:p~39ʃdӎzRV:鱵Łz=~8==y' G1Tk ,Q,bmUGfY|"0lw(軵‹~zz;;;qh4nQԩ$4rJY(X,3d2[nYzdǟj]ݻ3 W `NaEDܾ}̣Уx<բ0Vp8,-" ;o޼)>JSw2ozon7~E׋_ױ=sL%rŔb˼˼˼˟s1owYN*c`˞zN#_gFvy;!g/"l~ vv-q`66<lqa;E|!2uWs[mmYc{iMJb^NeEcԅ3ҁUq)sib>N3.i ?^3v &64tێ ga3vxfK7c!`cOnxg=>>.lmm Vh޾}?C X^^.Tc%%wxAYy]hZvve2k1ׯc0, 'C?'GYI.3 8Oƻw3*Z{Z"%("I&n^/&Id"M&h6vdGL'}Yh4[Ѐ>h?s{xzgglfN&RzFxy|yϲDv7'Bໝ}||倾wE/чbs-l=0Ncssd YZZ7oQ9W*g Uvv[EDqRED<|0~f~,/2/2/2/2/J_2h ڎ1Pgp:?a deP-JԏbA]8n}9QՊGDl_=CPg#/ԛ\"[d 釮op:616 [$E?%nډwHcx l9b<m@2̀q9K>ϓ .aӏF FٹqN1˖p圻EP'vjv&dFAx'''1JAt:`' s0el%qҘvN?ޭ9nJ$EJŶ,ٴlN|RI||%?s8eIDQA$qo}-=LN_W@ck5\s1T r1HLyO]Wl"8J609TCđ"VwONN4U---R$:駟dYO6g.htyXOqݣnQ&r}rrvzz#@O>ɉffǍ19|vmVII̽뒓A̋W]?6u~kwdb3ngrZRo'''~Z3u Y r]=y$U{|I, "sg1^Ikd\aeONNj4 )}~~^PfNIoKqp#83su+6z3_dl݉N"4W'x&H܏9D,$vf!bm0 }7RPHսtH'L1z 8Zh4T*jL5`0HG_ U*t޴;@41@ -//$sTx|ut&k원;у4<pn۩Š{I-f-oy[򖷼9Gց_o}i`+v恤!߽unn.pHs`x\.ZfJ8 mG4>pc>V|=f (bIb-B4 i }%a\Dk\O/..IP  KN3PUTar_[Zj!L:~9H ~ɡh?E`=87~B1Oad[X֖!F.[ ;ܰNHJ;\ǽ a0pH9Őu:Lv8 rpaL&jEu\+v]K$%v}}] N #A/Y' IGk?.U0?.ƸsΦ_ŋ{nݺi C_ۼ-oy[򖷼e'"ݿ FqЎ枉N_<|> j^%qIo8!\##Atu/]eu Nz0\dSstJJϟ?"ב1Icb;:y\38033 Kalj5y@,`:p$@NG?v:YA])qtun8:|q.' xL|`.qu6Oˈ.( o~_p+\${<s3֬huuU ԟL&z^xd ^v wO#'4X_*0~gemlllfD?$j;nqk|brq']ܶsxC.7|\#:5MQ/yM +2)Jp+}FݐB^OFCT3'LȎH&ve kd^3m䊏yHy\܇sȟ7zsҍe$rfggӫR1&u㟻ϐPמ䆝u¯w{ˬޘ„󹟩cI~[<:z^kd'Т<͙CnqRE0sņ(T*,2fffRSɸ1B!U|:88УGad#+F%fLnt@Pz?6,Why4dJR}<CJVWW2gF;{iiiIzad-hPɦs.Sg IDAT<268::JҒ>dx8j1{[ȹ`k׮%|+냳NOOSo̅ۛB2s餹Cr|lRU;IpeeEwM Ӄ@CϫRoy-oy[򖷼7'l%x #&<.J#Hz}׼:e2RHb 1*!g"U-EKJULQ B\ ƕW'ߞ|d5q'1ȏc=?5/xĿǼ:V1 |tQߞxo uv].?d v7$b_өT*i\ >9@HsR$nW(U͝XBAZ-V_~N W`N=tӏ󪄙?}e wD|| qd2Q$G"/bodd$Yggg)IO+y^36ޛSlu/9aт6Kdv`|j5xŢvwwutt4paq⍯x^htm0 1uzwuu---^Z^lRh<餹VI\!ļjYULTEN{EFNڰ^ُ]7ǜzyupNhϰ q =$]'8T< `8&1Z-jn養uqM㳳:_\ػ_!BNEsf{)sA )y=?z9VV3~'D(&yCsǏ/d|̅'E#i1w'V>e=uu-j,΋X ;Ez`\6L 3@ZXTl*UEPΤb8}񍔾sNc4.C (ʍC¦B&nٸYϙs6=?޽dskЕ'>CE4Zjei?ƀ8ٻ0/_-Y~߾}[fSz]}>Wȳ,# "&;Ni2>%smmmO>ѵkt||ZÌ!<<ԯkkyyY}rr慱b39Fj"zX:::RIg"o5l, jZzZoFf39znK-n+}ggGGGGz򖷼-oy[vb b wZ@A 5In{lϞ챁'o:*e_tO\^T4GᲐLji"v2?j W t4`Iv u(!'2Gy'ju]\\b-p.tD2\];rsŸ͑eģ4@h$=_4ZrB|]\\4c$ԓ=^ Ğߗc}tɉ^Ik@Ĕ~^G@޻n8xyz7Q:gO]v,-;s'tuI'}=:v2oܜͦ666v0Y}wDZ-C2.%kCn5t0evzz![V:"efru$hE/۸HoquzbZs<כhLqGR:'Ǝj{љu]v-4FBote0$2~I\6n'2nT*)Y۴ynNȂP6NDrRZ(2GosˉSOXqOrms=-1pILlb|1+ru"ׁߗ>zNE` i 7M1wy?8|gi^@`M"LxrcN6u7mx=:I<==1('qb0mWzs if4 ]vMJw\֣G駟ݻogErq[P;=c5r@r^vnS5n=D̒}-v%w0SͩVj%zyf_Y*${t1R>cmO;İ͓%ѿV:<~|aZh4RK?q; 1|VE,M_р;JbOHtȽl'8s|g;m<)yK9#;?V1(82ӽnӠctڝ7NnX8:=1>؋˗D+5w}}rR'lEEd yPH෗a0$%R^@ɢ`!wpX0~Oqh!+JB^6t^q]1=͋[)tGysd glVTL8IrHU;0(w6M ^#!+^(1N'm~uA%2͞/\:~$Yl/^y}GDm-J}4N?=oy[򖷼-oY`c)fcL:q?1.'jψߋWHJp<;;{%yu+# "a-s8ꬓFW^M\TIO"^:w"&O[k 3ߙ⢖LVnnkooO:99I56Mݾ}[]GS./>y럏!ǏdfOs!yਐn{ 8`Wwj=֏ &y)xx81ssOs_;sO2> ] 'G,oFty63cQv /Lw"+=J16GGGX1 } W@00GﬡO+>NgrgZ[[{%e}}-..yGgو||uecc#_T*,L ǥDJt"œ~!x=371TpbMONtЎ@9xtϢؑk)by\lqǽ%/c T$ /c(8 zk|S&Xb-O2yd|<.C>䱓F>ӈHg*fYJ%I?|,4Bବ3'A.D M+k2&l6sWب#T 񜦐сweF;ͽ7w6KRz#/hb*}Z] 0 n,|Š`E#[PH`50dKsd'$ΰ2HluuUi"zd1 ]7dF+m<0^Rg>NrumEip~~vw}W+++ї_~C...A&&w4|]uٳgz~򓟤M:: l3DhсBpIT^WZUtMwXbzI zJL&ÁW|]I'OOO5 tuuT)lswZ-}Wt:Ofvg~~^PҟX*t||2>7oy[򖷼Ͻo걏/zqge=֊d:I\5gsc~d5'@˔89=~9 #@_ͱN4_9Hqa>s+Ztzz35Mj_x#3'<w-bMfY X.]a8m.5yN$p]ǘ"Z^Em= um1%j<kii)UI﷮V u:Lu(!6GWqx8jooO_~ݻ'OڵkZ]]lzLc匃;>w;ka_6Fāx!tB{u?c[|}8bWZgڳ2d޹x}7|SNZ9ۿ^^/mX򖷼-oy[mu4ȿy¢AFY8 /!Z0 HJG\RQw8ކxSK9|aipqp:邏?{V= a(3bXW*U*Tx4|w@-w$:@Lg^Hsß>c4iγ>F*9Uc}}]KKK*˚L. z֚M;=֜˘ψky$aިNC=ϫ*k {q w 36iD>8Q>>|/mǿ޸NcXHRZUp8g}NZֺ)-?)c9Y,._{s t`ŋjZeZiR IDATjuu5%+onnjee%C8?֒51;I'#ό{5d3JENGqh;Nb110*jPoIOzz}mji/;...umnnjqq1?jq4ɐ%W/趒q'r"P~~!d|xtA¡ I`mȚXi:"gNzsrӓ0߿oOX8.8̥#1q$c!׹1~'<3Cl4Š'?nV>> ?C;Ϡ8M|@>lv[j5=c<\F#X<ݻw,..jkk+Ux6 C r0$ݝyE3'_8@/ KP%AGfq^k^WJ*dZ. cj6I_\\TÇSO>IYr zZZZFn߾_:88᡾LeR _>|gϞi~~>GP&:>>Zqıο3_@q;///'8::J6G"^ZD28LTJOz>dct nooÇ*~gZX,j0X)>88Pff.3z^IONl򖷼-oy[ޮZx#pOp< 9Da*q _?}K~,uEA+Cz܋ J#[c _+]%CD8e1.3,|~\}yT8H<.Ij@W/ELu.óJR:c<>1cq 9ȈPc[}bI9|_>o.'?UM~W\|ϱ|=/qoURؽjggG/_iv丑Zq9{4~ "\.kqqQZMJ%ɵ%BE=y9w|.+{s=p,{;O7dOٯw7jdz8Yh3mh0RƍqFF7' [IR`;}'333 Ǚ }v[X,Vj4"X3),?/Χ,'\_"6#i}:qgL:ף7)ߍz.7d nm$ӈCј3)1S:g?m뛖+;P>Q(7I&E.\@ ^7)NLTf)3ŢVWW*XzՌ'w"S$9u!cO lOY$E"52b8?plq]ZMr8>q~~yUմ~8n}moojeAC(>^e.8~ )%zU$`NFXOF.F$לGljL^ 9O֑+cDtwmllhii)R\`0Ǐ{}岪j"9ȱR_W^fHgRƆnݺ7ojii)݋uܾ`.Ftv׹k<<8^υ_s\gf.cͺ;n6ٸFMi:99RUL^oׯ^](pB BRHTeǼI2~R\NNꉕ,=oOaN}PXjUj5Um@FCb1F#i T)k t'~0rL]Lgnmm%=w!7w B&a0--w6pkkkǺ={vcM&e=zH[[[ZXX~;#0pB5edg9#@7"ĺGG:*]N[i[}دH8$vI# 8c-]fm ]~]o_rPFk׮P(DIX&^L98kg!`!䴣)|!h~9DGxkp|ȁRt{<NZO`/Țy( ( qzz֦FK E*gX#f a B5?$$(3_|tnx32?hO/BmnnNǡ?s BХxfR48BAzZ>#ݺu+o2huuU;;;:99^|ÔY{O\1 Yx"UQx5̌n?<02]YYYr<`l`ztpvvV7o666t_ɓ'zw}/9 lnBq,9j5ݿ_w?W'|{ON'ڼ-oy[?DN{"߈?$T&^X`z\=nb|tE`{k f{m$# ||{f-2y|{:;sucJW ?^€#8 sNqUx< ]aه>:񅬐FTGA01+?ZO`2G׺HR%^g:&9z ?b:d7n\_'r,=Wc9Dž!Nv{ע>gVd從8r\ȳ]]/"&?1mjPH6 羏:xL'9AϾu>7'E?Ktq G?='2aaAkkkZYYIĘtuţU_юAĊ _8?JPH%*jY>A?Y3>Nq_Ы{6D^jzs2щM/Jʼ׶/bpklv@R9vpKpVև/$gs vITcgOBrȏ>̛Φ1_|ױ({^2\>M ,: Lo^N Bu` *WLw G `|#bdCv3YF~&zz677Q++RgggمPF r?dArtn]|S1`)kȰ FqQjG1.d-..^{"\}~ߪjlf1tn'h_4rcmookooO+++/< t?Q7oTӧOuLY0%qenNNNvs5&K}q\޾V杜$n78j5_芔dÇ533_rY_=zrO>$cXSaU*RV$d2ї_~_(t]emmmwU򖷼-oy۟{X,@Ӯ&GF{Yω#I|<cBj^UČK޳WdFRQMV<[:*x1d +2~I E]xګj<94'\7$F.Jy ~E}vo?/cQ`I$>'|xAI N69:YD=e͏rcib-te$#25]H'̈%{.F#VVVh4iii)yN86C.~Μ3B(:v@@Ǵ>8j`<Á@~G $ w7ak{z^1'q#`N 'gwϫ9vv5??UP)T}*Jenx}uѱUq}_aE+.+ =7 :65^!=C9]810x)uL\v{"sAuٙNOOtttԷz۷o%ɜFޏND:iX_G2щ *o-/'B! IDAT}t"}`0H(?F/u8{61v=5AX?)a֙v;u">s[|qSܛ0b}yDau|-7J#6o7Lw srd #uN;BeĒ";ʮd, >C$m,Wlƈ"z{^xcĐQ(5dX,pҐs{cz=3e|q>^\\haa!UC{?TմV)ZD9H/ƈo61 tttMU*. fnͦZV:2 I?^Fn34Cwgecc#kX_}nܸ?cYj5onh4ҳg2X}>hh8jkkK_|򖷼-oy[ꋨc uߟшgrp@9iLDh81ývv `'Cz ٽcUӸV:j_;9]`c(K"ft88cBg /c p+XqRn`u HW2Q)b8)1wߏhH8czgA\3@w _IWp/nE"144~_|?=Q?ƾg&r))a+Wz=㼟Ŕ;=U.Nt@YL#_KtuۏtLLGb26`4/n4w`{ĸC;!}T<@qd2Ifׯ_ʊ={s5 z=ݿ_/_ԝ;wa$|ܟ}s/ D}b`٬-֜}^5E!I\VMkyɖi2k q>}RC}3d9WޢA>ޫ69~Ŗ!Q ݹEδ3i4"P|{ h8~/0_<^CKd&g2\}o8H}nh4$]GGGjj6KHbcuBZ1V*%ʝ#{gT]wqqJur]6e'Аtt1Yu]kggGi/..ϲ RRZeIя~?I/v;gl}sbc!Vud%iq y 1n0 ˙JOveL`-!Gl]7ܗu=\fN1[=I_=aD\Ai,:8?ᅡ :n,--issSO>ϓ\ONNV7M;\^:e =^m8ͦjjFvvvұH̅@s $:k 'tL֧'y$-\.ic{ lIn:==UNGIښܹ۷oW7pb;:vSt\ f} Ch<,Vv k 's^-,0<^Be3Fb-nUxG6lvaRIHϫ^߽xSlazq[~Ot`q*o>v 7t/t|mh4T,ӝ)XlPxXxqL=됣eƌb6QY wѭ[l661s?t*j7zw()F#ͻ ^W׵Dx"9sskFlA j5 YelLKjzh4JHdH1`,k|2ؚ~3zttoF駟XrYw/K}j55TU%dż-oy[?6 q_)~]Ks8cMgwD I<;Baİ 1^_ x3P=֏?lVH6;d;Plc .79| -;"xqWȚ( 3*Ej9 {|'e;j'|'Ht]RȜ@wϫM<^ !W{݈'cw23Ѯ8 7ѧ_g{okY~_] g ^s{Ǿ :z^ 2ViuuUFD$ y~ezj'r$}M%-..juuUj0N9Y'dn#v5{c܋=-}s@m㱮܋y:;;SNNNtT*7b rZRd=aaff&a]±H| 9ϯw?#|2D3@R.:C"{IkT*%ȏ,Cc(D#[|/7[t?EKWf0N֫ |}^#҈!5ceX4ߠ:s]y3Z&gIWGʏRs%!F&13>q1zq!71imtX,귿?M7867w؈Gql}T*2eϜL &˂@F2ښ矧 %2@<Ǐ5;;˿L_G1e zKmoog&J4D?ݻwD L`7Lάdܳn cYXXPї_~nU2SSշ~統ok_R^TJ/Dy[򖷼-oylHij{sOژTŴl7g^E@Npo2Fxճy{^H!&pg\f3NlKF]|ws%@@FMA:ô9qm8%ψ`Ĩ7w!\ϩķ |@ `p `lI;5mC].#8 'kEct &qy:v*Vi4[ 676SN3>8 n4)icmmmB9,|;6u@XV!`^Xw\`Coѫbwcݸq#Ӽ*T* ovR1{yk4w,IJiF[=;tL=N܏=_p?KN'tU{1Njk}{K\#>^tb0'q燜#[\|mOz a`<:𳗂X2 q2[&al,vdJ I.w`1@p% 0I BȒZ tŌeNUW2|,"uM\J ]a"eƋ .=KȽ\.iooOoF3޳ ݻZYYF?O3!l;G:99~3]~_wpp̻fffywǏUT*кnfM"/tpxxz;@rYfS7oL~_f3VKO>dTlvJE~_z=Io\N#0o֖VWW&F[nΝ;Z__?&}Gʑ9{B󖷼-oy[[=hEb%|LN qbos{_yLAZy}k^w%_>8D0AdNjz#s}F!tH-l'|hGrGg ?=MiLґ1+z{ߟ0gEr::?9D;yn,^uoc䋊 ,b@(_ PtFh7.wW6UR 9|&Z7NnyĄ3BdOPL;= 6P(7X͍&,92W(x}<Z*ͪ03wbb0ʊs]__ZZk2ѣG;ޛËr r^=zwc7Q׫WB::: 8E5M}Wzvww<(<ːB纼TV kmmm)JիW Ŋqot:nkvHېM)Ʉt̕|>ՠțGr9E^kt/5LtZꫯt#lB꣏>榺ݮ?~Nzk2l6%3N`cqfq38#]}n Nap#AAcb;q:ڝ(qob@wvs7J_Ɣ9=MB@13>f>?Π.GN1Tu]}!P(X:99 ic1x2n.S1i⤅KD* |hDljIIuȮ~zSwk ˲k8r9x'ԣq!~睮]\\n\v;rmuuUZL&>oۉ^R .WH|'Sp8L1|M`^> rt)4w}{h;uqx/ˣssW>O{\2A%^.;<߉5+೎?㔾>g>NzxdR̍$7Axc!x֋d E1C"  fauj>:NN D dž+=I'tYltuZ.Ϧl6('X$ ODSdxG@ 2Nyh2v' JK5 e2ݹsGΎ$z,dTûh+5tl65 t-//ɓ'oB77I8\ƾ IDATvFX,JRr [{HU[[[d2ziSѽ;\>S6z}VdnQnJazQB3Qm^/-(acS̙d\.y}}'OTJ%, a#GE Z[['|ZoV?P?Ofa}}]JE[[[t: lcb^e^e^e^8AR2Rrܟ;Þ΍| dQ^yZ pH@9`7Ix=ʹ1 O}Oxw'Q_{I>GCw0Ýt‡i>cp{ v $:gsԃ{~,^!Nxm1pm1+Jo1XM"k-\gf9?>IzYw9}.3rcNjw ~65xG !T3ky?Ώh(JvwwQL&zY猻eL`vuPA @_@ /KKK8x\6M8=8 zxXYE}wu4q=xor Ӕٗ,--)ϫZ&0M'xf>%k^WunkH)9x ^>: %gE!Lr~nJ[@g}ԃ6164\(ˀ A:NxL&7A^O;;;XzJ:::ׯAUշpJfveĽ0h'>&y1dL&>L+++z[2I /2&/.,nU,/_Rh}}}&R*4 t]5 uݰtZ...trrn"s M7D/~ R7geݿ_\Nz]gggz ky&so޼I]^^x1P+++srrw}7x.Q\Qp)e=zHO> ׯFom=zHzBgV:M&e|R?VTҏ~#mnn*˗O~P^7OC=䚗yyyy}.؇7N֊=L '*ع9P(h: iO{sVJ.roMnJ76(UVKx=g2EJ%M&_!WQ7>Bb?c pJ^:Y3AR Bk2Ǣ{SO&Y2 fUVXi9t:dI k~og` l)ݳl@ d@37"vRwa@HY4- ."Ȃ2N^OBAf3.s;tS6q@Z0߈nZljj%e}L&aNSu`ۓ1"7|"%kv:wE8?%>rm] εM:;RIZMgggZ\\TRQt: ޣ-Gl Xc !ݻwu~~^brf_SV n]wO\*!8>,3im~Zϟ?$gh8?ic,t)zEI:|iL&2<pr5wl D??Wq]OwyynN^3* ?=|POԁyKڶR)}9FY9AR G8 d6MdǚDv|? yqfYD*`'F51ǘ$9Tu; IcoBO;:wܟr5\v, {RPf2#=U=JWO1˞p2ȼc3|/LحeR>$(%r4 uN2y=3t#ipBy6CFLr&/`O}8 l7c[V0^x\.p@*fMO6^NLFim^x?X :??Bd`2ؠdBݻA Ep{顄/..t||믿V˗///C:?\AieAet::99w}pRj5?c 'KR0NOOCX0=%2 l6otvvt^OˉyfUVCą-2t[6AF1G3 :!:...jBׇ~>HVK/^TVvzZUjkkKzσ!Fv&s^e^e^e^e^vzr"Aiֽs [%>^z;<@;}⟱ݖv[֣lă.OI8 )J]Y~B4]BGCs6C#c()aw1}ΒV8#6xLZ||o\wt; #q[Ȫ@"~-Dϥ\yw\N|ALV8#_KP'}|vTRpddwX23Y>bgaw KyMSmoo&Iŋ@2{{y?kg)y;9*Bp"v W^|gϞIVVVYլ53..3i:Yu_YجfZ<jjZzArvEB!Ӭ:Y <5^!\ tSLhx}7g>zV/q>G]\/!t˱GOŁ+Q7Wޯy4+R c{Y-'Y["k syy4r(wOo] Ä3LC=L7^/9Lvv&ܙ_?haaAVK~_ہ`'I8 WNN"P"^/1^H ,ǽL#'xo4`CFTX&oi?)T*KbœnkccC@ 1xR)J3F!s'J sl658i1GqviiIRINGJ%DARb5χt|UHkXKKKa󷸸>Lϟ?׃ Cy=x@z:/2,D_}٬$IZ[[;C/BGGGaL7d,BoU.SXT^׼˼˼˼˼$I;@1@{{F l$ ܃m{zzvñ(-!,CP&IHaq* gbp]PYpp[ߐ@v=Li10+ g!ONt]~DIGTa݆=uN9 7}yO eܣ87R$ۥj'[O6'c9Ş8BńW>7k<fyyYjZj6!ςr}^ILD{rO6 g*ʳFRNNNjtxxgϞP( ٍ 0,,_O$w曟oik~II"^yG,_ 5Cb )z^[zr^\\ e&sJp#"&V|0AqK /c{('}cw9"r.N=xܸ_9}mLnvLn$H#7Tb8W.^I+3=t !1B`|\LR\x<NM&p Cө;E4Zcvwð бy#}_X ܌' e, \ɣN46F޼yzf+6rsu .V:88^z0a|!rt&Z8\[[ %χ38ɍwl2b֙z=~ZZ-x|-..jggGNoRyFkkk!. > C܀NOOYF )z6;0ͪh&0h0lhVP/R~ӟ4,zzODgggzzbPR }ݻwO!˼˼˼˼ܔlt[')cA%bGĐzMR"mۇ Sbv4P(huNlPl!s\;rm p8T:N¤#y؋gҵ{pݮ$tީT*~0dq'5G}8@܅B!؝1o|Hˢ;؈IJ%.@~4&rr>'=I9i[ls9}.;2qtŐKHSd xxL̜Hp"xXػq7I#|vy;H2z]nWTj5솴{^wѣn&g]rLY* ti岖UT-;^ҝ;wȪ3S0qcRARX,"VhP~_o޼ O޼yjyCN#dbx`^~tuM1Az$n^yWW7b5 u:Z-m---issSwUV Q6Gp.pGHL&*(xAI>AгpV!pIg~EȈ?HPx<d']} s͚Cx<}G\@zqcDŽ+1$>' N3U#'d]\wz|Ox}}nX%H# MӰx9{bRasN byb&>ds3Dsaj=2|t0&D H}Ï$>סEMAat>|z%:NXhyqqJ >vȊquugϞ飏> }\մD(&r7lkRT=fcr^|mJ%Jvb`;NHqwΝL&^}8l!ߋ/Jn5UB.Ir#1 F|oPPHVMNNN4NuI l<6J7>J0ZGævě; qΔJT*Uz=}:99z^^^jccCVKKKK DӧOnuΝ`}8jggGf3)n]ye2p>ּ˼˼˼˼ܔDdv qI F|4Х]i*m={G-*6Gq`Vl.yz)^(YQP 333/ᓈ[@Ml(̽vg^'d@<)Xv`gu bIDyKID=9;#/l:VQl6π.IJ䠷~`{hb~0of>1icnpf}80F $ yx-cz<ZjmmMf38/,,ݻwз>8c#)``zaw:luH]7?k 7ݟſs@ȴы܈7c OSrU ...M{ |n!1^,n~A9Qx<zy30Xx_t: <ҏ'qq<ɘYkɇtOOSt 0^\KbNO&pm38>1L]o{Y߹1sޥ%ulZIXR@c& ѥ@Xq" &g H0 0VD1ɉ4бر(8QBװ͓G (L&u{?Xl6jtΝVP M_}Jӛ(c]\\h{{;xd #@]0D`s'L/玏t4Np`d2ING }.IgggpVt:ꪾ+U*}A^ q9pgsSX qnUJg b]+Nӧ0ʽcٙ˽|>l֝sCybQ~?kܗtzzZRr_WoU,駟&6xi"<m\ZZÇCZ@VЗԷhhkk+xR7^aPzNNN4/2/2/2/2/SHIg4 TlfWxc"?b~`l:oMb^l1 &ϡ~=Ř?wCA)'x6`Jvwg#,P43~g^ y9'\b 1_61`vG1 {m><&S>]s,ؒ QD!at)]t:M>'dμ؈1h1T]uUO3W>svyg1|4dH?D:Nzz^H+xB|[}tb4~rrh!}3Btr℉kVS'b{g;L@ם.>>1I46p8 gE~?Fl6I]I<I)y\3`Tgz;eأ@h3H+dkÈNd.}_qok]ޗ.GSI &:|S#_|!oco'v}_j_%AyڴYNRx=fb!G:?=| y7D \]]Z{<=0lvY!rٙ\ MLF0ו"mq‰/d2A|ӽ3}d...*χ]2er7#ߌw@^RG  IDATj%ȍMEFCGGG@@@WGGGZ\\~rA߻wO|^NjHku]h0p@yYh4қ7otzz=bI ʶjiuuUZ-Vć;k:@x8b~v @&=[G8r?.N28 FNЏkxcG; 75a -.NL?}̎|q#@L1Eymp q-w\o_>x;!.qLjd|`=cѿ0^R*B~R˗:== 8^E9mook4hR~∊ -,,~\4 ].X]k cr=^g2.8;r._p0k.2^GbgxQG'ɺݮC^κj@5 4L^Xs#AKqRܫT*)χ9ҁh되 Ev@ Bsy>fZsL8*8? ||k ~&9v ƻhXw":89iQ_79@_^^DGGGo9'H#i܏t@ddapfz C\1SKA{5&p8 JV=+~'E螦b_YE~z&g^sY.WeX\\T "X/urr;w$9IEؓ#ģbVe =\&QիWp}}   &%"N'Dz|>믿V.ᡲlH+pn; PN&j:ͯɦhaaAnW}&CmT(Oƅ>uvh\.' h7Fׯ#Fp>z06|~#.n\7y!¼B&wg ߯Ά^lIz>c=x-xyy"x}]__tZJEJnI!LrjyyyyyL#xױt@ "Kd2ICt ?K d @=)Ruݡнw}= (>shmL,wTߋ;98K]9P(B@PƁ9k 8rŶdv8!+Y[bm $ <ί\y'm'uo۴}`*^S9PuE7'7qY>/ST ;bLj'|q繤M7ĮosNʼI+ £hܶGL&mRg\;Xy,w!dΘe\'˟ˉb"~ZUZUx<ɉ{}!ˍEY{b9$^< yA7XDc?\>q}:,}dL.NP\_13u_tN%'  j6sG{@@ >@ăz?c0R/jpƒ;k-2+7cQR,%)\ZC[ k<8r1GdqG~F?\?C.$K.wck,i=~_,{@~L}_\\L:99ݑF}ȃ,S9(ZCAȠDP4vWF(gxd/ ohѕ@RbsO[}eCqNA0y)KKK`RxիWF:==$Izw%)ݐ5LBj8f{Xf]x[d2ݻw/l;7. y@J=u:Njzi8iccC[[[Z__ʊVWWC;+X 7^s `rPRU(bD?rW.1uMz;w{կ'$ >=l9leX^|/Ʃ֩yKxPO½~Zө~CEYyǼE%Ν;!Z-!h4T.2l6j׼˼˼˼˼$ShPbAl&{rC v,:zb0w?;xPϳbR' O zl"<V,g-@^=8Rq ^/ʞFYX,Yp al{#wP/e}}1倛/&?c纟1(08vvߌN^;F& £g^P uw9mp?\穘 }z}}Rl׫ >;yםĤ=%؈,ZYYɉ~ hZ={G%n(;~F.RV-\G"r"yg_Sfq ǎ ȁ/9&b}P_c79G?y5M5@z@ommݻcI ]7f:&Cn|~3 }u&2n:άuohY{t=C=pM}^wާo}:le幱н0Z-(DyIb5cDg@cpe;;pyޕ ã`$%x'K } %VN(_#iG Z*d0Dh4_Z};mnnw>&̎1AJ u@q{ROCٳ٬ݻjJ7z^tqr gÇztIҳg~ZTwޞ>#o?!)l ٬r\0]RA6x&4dX0\Ys8r9{{{:>>ܹ Rփ7GD~vaJO&?+<.x{7O1(q?jn:Sm J P4ropo`=EH47&n3rm-ܣ+kwT^ds9uBW_zyq^&j'n  7=efaReIG>b2eWgc;ҭsj&IuIܟ\.`@X>`$#sˁd6)xzT*T*IzZLgeP'L|y]g^>/\W{ìgz>vkq1l6l6lookooOz=`YN?KbQJ%q,ì eUfndNHJ T]KzChU-O=} mS?hFu:# A,--)˅hDz8ry_oQɻxxo_suN<~vv\ I[F2ơT&'//,WN"u!}Q q^L&3az 7{MH@7hv]h\P(h;N|C@Qttuu͐--Kj8!M#ONy?6M$<[drRp?ɧO˗j4* U,2r%)c#ߣt0@1P.l:r5LyYT|ᡟ ""6;!9ښl6R9BgQ5nCݿ_r9ZVKT**aC?>3'HSGT‚KP(O?i'O!8"׍  Utb|x+R@4˼˼˼˼ma(}x,A7=B܁zCŶTpҝ8'S@`FPO@nQ'PjHoy'vbvP,&)c;9d4 B3vEsXx]-,lv Sptv9XK:j8&>pL%!C_J%M&5;0A'WR2*&b7]ƽݞyȱ$dțe'uC'fYmnnX:>>6g\/u?/g9|pDZ~`nsmmmA-F* Z__p}indN=|>o# ZrYusk<U<B%P]]x6k)kY=rx/G8W:Ϛ>g\>]p^~vNM=x 1˼xo}C@$r7L8&a'{2)i5z b;XQE]6RTA&a }r@F#u:DY=l|pqq_n0$Jh4w8bšNt&snj>ZXXaH'x㝟hyy9 b#/JcXo޼уs=~80;;;VWW5NCyGd2.ͪ^'E4Nh%q iho^WV 0Zs坅d/g꼿cx<kwwW'''믵d tsN 3s-jmmMᬟVU($ +3s>dI7D*9^WWW矫5&oPoR_h4issSjU'''a>ZD}C\NGGGA˼˼˼˼7=*a3݁O#-綮SThT~Y==? x<d6.v290Gnw#>_IQq,#Ye\?`w䣏#mw“k^Lx:?"7笿q%s: ߟd3d2 H =XzL,0U0 ȉ'iyƟy푞sTn }NGM8O|9ㄘ;;k'2ǖcrX fˣtz )d19o r~uuZaXot=U*ࠀ38q=|))ux( A~\3\/B2YQ|^ؿS ϡzz}68& oO55uad^R/S1p=\c% g1gKc}ho]60vNNNtzzp {q/~ˁh*q $P-^ci:3:p1|3r}}^60gyH{ \/bbqrO#ھxS @j5j5-//k}}]Z__V'h}}=L޽{ÄrV;jcBtb81ݮT.:NVF|No4 w?(^,CC5͠0x/ഗP|>XX0Pl WxV6rxŧ,tr%dT,G yG9NCnr(r2s}}=;99Q\V\^{99Sxt:/_z葤hjdV%uc#2r91'R|7d9/2/2/2/ZtׁJ)y{?t0tFu#ޣ~$t%n[hd@n m]]]iyy9tdS=Nl:z݆̕6u|?~uI@M@k'3h 6<'.lrH&bHF?jW OB\cNqw-d ԏ耘@m|q, [%666T9E$IdJt]\\nHD4@11) 2vkX y뜈FG梫?߉pg^ϘX D/όAy<͆z𾋋 e27g MRh6uG?vЁyĘЦr\.ߙ8d }JR44y`\aoH^/8'uw4A<`-dn#C"؉xR@^O@~_v;h[Zjb!s*A 9bwn;)[' {ΕPO_'X_|.=]cMgϞ,{noC] QYg.]y;ע(_WW ăœ' !!zO )xH#߬رIv{O1o)nIJ=Jimm-hT>xBUPz {⹛崺aҶ\.Kj5E}裏R mFr9m-,,R^k}}]*JςFׯ5jMs\ #Y 9$2N IDAT+=~XKKK?您Np\6!K?`J%]\\gq6 9d9 7e^e^e^e^~ߋەA؍<µ n98NVv,2gz}})FF%"HgaCz.f?JqAa';sЈ~g6^ X:ngt^/8ԑZۘ@ŽΊQtZ-$891(L?;\obcyu{m wtc w.B8gwqY7yN9|䙍FCV+nWZ]]M\3tA!kb"5!|s]^'{;W3N=ҪaSrt=E@9c3]zx;Cq>1|9vJ `^tZv;PjUz]'''! 奎h49} ;җ0xJRC---ieeEa-qL4Cc]z:p8eL8̼R~\>׽G1\G8A|eMp٢< '=p'} }Zh4trrn766ƚ\BpN#śc9br2 r%ͪ녶 B cDҭ3ed2z@zX|\g9fΘ]]]g-x>Ə!Hѯ"{\\uSR H=d^?*)bqq1q\OɉN|}mmmBIFI"(bn@q0~Nt, !$ 8.^V==ؤ#Δpޏ'3:q©T*ѣGabƋŦS'+++bV1]ZZ $h4 J_J<γ3pǘ_bf}wvyZ~va#d_|?r餑c؍1A=XFCN'L^R^O,1&c'))`5NĄ,uD&!"[0¸h_}.mCU~  ?N:⸥;ypGyaLc~9?\4Iu1eW-湯#dP"(N(շ \)t:wNJ7L@W8€"(3ZV`H/..4 h4T*Bz}f27L6tHI c7x>񁓰ѤCX|#IIb: W'P"Nx;c:8&\)r!y z>'8b[.NDwяG|.1)$2)ye 0'/..T,U,UTT,t|>FRHJmEqFct:q(o4 1 $uxI>Ǖ01b>9ck;8qu7w 8cN:"9˻qVAW>stcO8ǑtwpB{&xL( SXz}s .>}xAţ(`˺vvvBJT/oi,X}@(XI3χ r=|DY$w'ܰ$?VzWXNx:wJ,X>VD.)d2Z^^V\c8O2&|n,QC0?Ni}}]4A1ۂpFO_<^'^ TyWWW:<<Է~+I*ˁ׿ֳgB%:" *A)yb߀]__'0EqAj<0NԈ 7޾Ħx~dJET*yp" F:NXlel|Cs^ }߿61ٿ*ZYY !MVm٬_?y8Q*JpX^}BZsxBtZ|銞HyyyyÜ[461pw䙟n[0wŎlMr~vğ4)E`&gS0qIY1pw[{ut:eFtRp.0{捾{?O'@^@t:U0h}}]z]f3D?"7 bǗH|INW?}hc1@ou<7;\[c8j~8˥nkqqQBvj¹q.w̛bw~8gj:s9'!yBbxbQB!m2 yJ9&q(/rYuu]a4FcN>躓1k3{b"ɋ uŒL6r8FAE`t}MS'@t:"mLf96FaF0(u}}HR*jZa!Ħ9Ji___k:1˦ndn1Bs(KOA#+t''ؖt||8xןXwdLp`0vf7 w<MX|NT[[[ͦNOOC?cK7DfV Ds d/B|>}G^__^EoQ7>",ZM  ۿ[GmԼ˼˼˼˼$ϥq ӴL/ZaҭGl Rsh=; H5=Xv[zPn,s\@pܞ :`6͉YkzV8t><]XFiW@hȽN8ŘAYg+}ZN.xi;"lq=~}>ߑR|> XI7 s#369-Dۿq[q9};xV0 G9.̻Ssx4⻓>\Nc٣nl\?V\t: hmm-{ONNٳw2?E_plyb'1# XUȖrSG`ҭ3}:NX1G0Ʊ\  Yjl6vJ{OZ-_};vEXPkdiC9ck}}ƺ0 tyyup^Y=z&ZD[jۡ]F#DƹC d' y0B. 8]T 6N׍XS{wyw4~f;:r9GEO9c<:: 1Sִ{)"8S6 ge͢¬/,,rf;IRT=&/X4Az8%׿FݮFQȗGDeeEk{{;LxA '/f~ !?jy2_V;::RL(uVUe%}uuh} 2Aݳ7싋!ֽ}1L3]^^o g8>}_|)BÄw:7mAz{ j𘵉cpAа86L4 B}zsd۽}Fz}"z^KZ+!x7px32k}}=Dr1 ? \.Zt&ommMgBk:9; _5/2/2/2/2/o{n:#%Y7|Id^3& b# [(nb gNp^a=x65֌,q/Wkqcсo1N×?[E,yG<\@,> K*u{F 6K z'Nx~Dl^+'bʝTH^B 1q"K#gxpu2=2ʼno1G8yNLNnR7tBk DZpY{SO͸bYt2`V:rpVx>eߗt9ptN~Ȣdy<.7#'2t_%b70DHT-YOs# R\a໱w=3o _<ǃdrS^Zhji<6޽{ % w4Bi;AQʌ \os0B!<70~|q8H1n7J|^ ˌÄaͦ*JDiN!`!caŘ^\.~B651J?]+}3CX˗/g8=9&duuUGGG*azBY﫧qHZPT*i4r\.ϤN&իWT*ȣ喗p"aB;g!a=}rkts|gvHoNlls&Q2y֚󷶶 IŅ2N e2EUUe2 P߁q<&H_o9*g||. X'5\ Y.Adl1.-k\b} p]Msu]΄Y//>I)W\$rw@Kkɔgұ,+MpC#pוNIdRfSac>6|{3BxN6Ca׸n6_a?&In`p_]]immMd25cij{{;e w’7 }V/5ww31M_GڻrwP *9T*a,.+{wwW~_N'0;K$njSIJ^NB9AC].Cj8@eߞUuxxx`^H7oJzⅾ8\K}7:88<ɄhOdch4f.#5g3 IDATT*ܫT*_T*+=yd<͆;mmmm~[ 1=v18s& cǠF o Zé`@"E5NjACa a< |>ǜ;qWZ0 Z0gNA1gz8G|}^}wq^g޷@sp#Ywhn #_CT?} }<|Cǿb87gƤnd߸`?z3Dss羳υ?e>3O/l1|q1r\Ȯ,Z__WVS <@n]vyX s H Cf&ļdِ9gɲ߾}r4o;7;AИO$A@z= uYZZ>|/}qǸu'K39Qٙݻd̓N9)] Cn ؾh9vL&oVU Ydyw}8 %߼ vvyy9и,]:;c щ]G,rك~xsWF ^$umllhww7`~z!8Hbd) \J]POse "enjۊPi=yD^OZ-ld2!q!BL&ka_^^ٳgJR8X`U̒"!|\(."c‰ F#moo+T= ʄ5elA !*+LuF{y+ 3)_T1C:%cua x(`7HPK0ap8ԛ7oBtJ5T*V3c4HI ъͽD(d w B^nLRI3ʺO?T 矫noogJұցaiHt:! vY 2<:$!}>ey̫hN006dx_vי/ eW"YIJHd~Y>w"N,wzL@GƲ`~:7~gr]L&3'91rG$otiBgϴc9@x<xϡO$r;NCt1AF $_x͝e9v]/x,9:;; aTÇL&38rY3ۅB!)0vu~~UŐ[%^Y]]!xgVm0d2ٙ vwwzZe=} ׉p#dy9Y.gA7jdw^ӎKϳSx<-QIOw{߿&yywW^Ņ...h4mnnݻt[4gHX >F E 2.YN? b&ŋJ&obu: }Z: D"Iymm-DףGN D?|YXpyy~DJ aN?Ӊg8DHw}GF{<^l0GVx)7k/ʺ^?O?ǏCJ.(/Ÿbu09 7z>WH$<< t{_3dr7r&.!x' :~}}|(;p/}C\1f~ xD"Q HtN6Cy cCO}M]/ȝCg8R{_~>3\O'Ouߙ<Š/?kCd2P >P12hte"qs%ETҫWJB۷o>N3XsiFfөZV+++>J%]\\̜X/c?;؃= zurן=48ey2nbGSTЃj6j4zjZfNpnC4GMpJ2 Oa-k=~XdO!xŢŢvx.$_*2yD"V󝝝)JR,F/^Rk;؛ 9&=csNhH J?c=rhc^R:{"̢}ÇpWJXw gs|%ND'AZe#ʏ Gyē;M~qzD"1sqD) wuu+u]5M%xvV^W\!CQ'`uEu]R7C;7W6UZҒf8P(5u||rtO?TZ__Q|XfSz~_heeE䃹P^R4kG7t:UVww. !ĩ;0^E[E[E[EMm6^ } 7¡S7s#?ĤZ`GO{F<_R2ɳrAD摭f GȸZ9 v̿ͼ?:dmbHLJo^10b~oWF8Mr JA3J/=ع0sw/#F*B2@@:th1^?ƚib>,k9ϘY[y ih|r*ss`N1ƟNZ[[ ?b^YY %]G'S$Cg]ND>u\;f\!C`)LF:[ Sd9`31nT`ȏAM0q677Ccfv[Ap^Iȉc_ ȸ:[$Lt|-Ov!I?XZMzj*%Immm  gHRInWV+^K9s Vl6l6dᅳL&j6[{-@'&Ik,{[VK\^^VXT* Z]] X.>fc' p9g7Y (5}ۜoL~16=W{n1s8Ch1O{|.=߁mBB`fLɃ [>vyvY0ϪT**˺{U.J% 7L&uttv:_֊Hg\&Z~ˍrqH22 hpggG:>>V.  /ʄGT?diE]__gP(g8L~elVJEtZjU3.dl1臭 8#p8'|L&}OdH^OjZ:;;Ғ &>}B r8yUU Cáwi:?Q@V+wU!~uus~Z/tzzZ__;g|ו cbZ[[ LD>`Hmmmmh1 *4{7 Nsʁ"ؓG:`LpgށWV@:|xq"0J<ӿ[#;>ns3N1'#?0d"s _01qA*A#XQ|_]F73\wI+$,k/?Oax*@DBJE3dݷc7,-Xr{1HFHNDz"x<׍>žGb,Ljuu5Ȗ9!0qz8)/ܿL&oK;X8///5統 LF^O:== :!rȣ)!^aCsyl'vmmMlVhd 率ќ`>t]sLu\xx9ྒྷq6lt:T*:88bI@@8c!+ u?0\. "%W5p65]týo^/[f3T` DzlBz=$Ylllh_!̝cX]6 {,Cߠ3KD#P1c}Ƿfp}￿reTUf7mFfb4:2!aH$$Yx2Xpf@!H[rw^H/˺ӧO[{}}`6# x* Wo߾驲٬áf6>GcEy: +|I&Wn(/V\Zl%˗ BOSK$lB{lH sqqFX,$14Rl6l6b8cL0ݬ#k$ )^O\NO<۷o_WgP!fQjA8,%R`iP@ ;χD.cDȔJ%Z/з~Y<S= k}jZHܤonn믿߿p þϞ?'/wBْXW 1HenIoa).k"8\.7sP,ڢ-ڢ-ڢ-ڢ-,>с0v;n08<}Ww?s#~?Zs'_}At/Θ{nKCІ̿!9|,}r#N9I 9q3?*NPB:|f2!{3C| 9eA#N8wP8܇En=ؐ9&fh:hXTPtdB&C }ͫLPJVkhrb3?1e rJm1`:f,$E͘kR\tbm}IVCPPTٙG-'fgCP\x P_6Hв:9`sL]xcNĠ>qP˪~/iF0k@>k˔ j4At]@R)`7;{ ;m+xǜ)~BAwUl6uuuJr9M& ֊>t=YTt~~ZvDx?jfXgggi\ÇUTBNE'H~ kbI1~Yݾw?\\}_dv)?>AvN?﬷>qC0U7(\Cʊvww[[!`,^7'\Ki|zgٌҭvF#|Pq vBbgY5M|2U=L:Z%RƋϼxw wߟakcF_tkK&f.#P,,"7: u6}sN0rC!&VpF=7XI4NCp91hA˺0?N#c}GJFϤ^>H$ ˗/z>3mllѣG* F D#ns`i:ꫯ ?#R)g?jNNNZ>3|QsxyyYۿ{DNG'rNȸ!ݔΣԞQ Ԭ=99{xswWWWx\tmmm79pQ3fc_{k/1i^NbdG I@ y<8g:@L\5N`٥YB2ϻ;P~$wF~o |߁t_kN=ѣݧ1y+c:f}QIx3/X|^ZKx>>ہ}DY0#馔ZI3YRw'10߾Ygb=:+^qceq OG|s۴Ӏϕˬ1{]>|/Ř$abqZ"{y{wp~c!(k2 >;;SZUQt:^29vCr |xd:l0^fZt:u EQҜ~TP cOTRXT6 A%xZ]N* JRݻwU.Ý]rH$f?spL=,!ݒ,Df{7W4~VcJ&DnWF#~_JE=C)d-잘0;b.>ܐÂn|$o*us u}(ݲNg P IHG9%sz`l6;_ !6Ia| d=w;b7( w @@|'35Jc#.䦞esdF@BƧf~Q IDATn͝>^O`~KR{oMϛ=FFC~m栢uǃP͎{}}m\zⅶteYZ-iz@tj5}:Nu2vpbTvt:t:hZM?CA:֗_~ƉO~mook8PZ__C?...fM---)imm-%#F#`@7>D5A¹ Q\.RP(p)ɞ2z(i! E[E[E[E[Esq:@;7y`;jϰ!4C3(廴x.c>`+sƣ59c}bbrPkJ`ye5G$t19؃s *xcy9peN;OXH&ov@5~>:V~2;}t7s=H$nq6Ĉa^u5kp]ψV@~'ۘ{' ?Ta5x)^c~S. nѥ%u!KKK zw4׍w}GFCbQLFjAx#ַ\5Yf' +"E=|P;;;! +N'cgFswUX +g^K8d10\+L\.k<3:*Eo߾URQPt: EF#n+)m84|O黬-֧1I{=:.e;A^ 6!?cs(yⲈyBL&C=~Xjj4!o3 d<'q0mmmm~恌DN{9Y~A՜@^]>~|.*=i|sϨy|6)bG; r;rIkd>%ߔC|8۷ "1͆~`6Ay7>TP[[[Z__Wh|NE|9,`YsO`y,K |G?Sx|sP`0PZU ݻ裏T,gs^p0*sr3g\d=Q.jt||[J;Dyo{y*3ӷWTj 5JnFd:nDmET-[V~P5u52 .- A< Hڕ~:o:_ X^"g766k]__kggGJ%}lnh{-NU|1< ~+ng8D 9ܸvab~x dl|2(d߬t?UT\V6tÐ "OQv?Prd2l0PF5d3Z-I Ľ^ObQw^X7}d$7AjpaeP1Ǝ3Óa1h4BOVZ ?s_ժ>}䎻U?ZV8̼<qm}NKUZ}'R ??DQ~z3)VT*P !Skcc#\~dd¾j*:88d2 [ԝ;w1ޑQO'^E[E[E[E[Q\fiu_#.ךNv.:gnG-2L=]N,qge=3tz{,/q< nt%>& p}y| oƕdfZ ֏>;/〨]zt˭7ďipytPy2>ߥd]p >ye2ww}o/;]}n$ {G"&|ńp=$/@EbQwQ W_T*޽{t:de2Pޫ8@VxL|"JFCt:=NP~f^]by'W~./8[\4yg*W߿3;hNzjt]ݻwO3r^"]@%T!/(Ͽ3b1ps=q34⻜|~v_Tbr Ғ|7Ro*JO?!U.BY[[ W]lllo@RsnސSTqK18>'np>mכʿ~},z^+>#)ɨ㐰h3+{g|SQL&jT.Æ" @6˕+ 1 J. 5j DJ(O[v@T*22tgޮL!XtK %5pCK aJӡl>@/^e\Cc#NyԳgf.㻞FYhܱupp??f#1d $;;;X+JP&J#nuu5C <wiyyYr9Pʊn:IRd2A!ٓv[nWR)\E[E[E[E[w3%wfyZجB]9(9~88h `}G7`FóJ;_u}};|g:# \H»ɚ/"0ͫuxe$wR.&#Yj;x1N Z ϛx l~Gy.k elL"I ~u:,O,fx) 7ۓu5}m9进n'9`h<JA.[q<=;9q#?\b^3>t~h7>`&=j\zD"*B y:{VVVNU(B0f9q}2? 8{ L+Yź9l6uqq~ts5 Z*$){g@8]0;>ǸJRŽ^M/7fgtd'@~Bmmm+\.]^^^+Ν;! ^N3Nb!k:v׌u3/_r'I $OƿfY+=m˙30f,jT*==y$TqR,8@!,&FjXxUun`{&_ZZ vwwZ62(M7ܘtqBH hV B狎.c8Ud: |d}}9Mo!NӧOgP(()x0c>Qϟ?ם;wCZԛ|\yt"$F`0Л7oNu=kmm-m2LBtF<ns#e&Hq36џ:::驾p5c!W1n4)1sQ̓g< G?޾}2SI$կ~-A~uun\."v >#|RA^u~~{\.ZN'siښf ,E[E[E[E[摟13s|;@'>my@{yqC2? cIN2x 1 c}^ϖa#^3~`>;~O [8\} lv|X0yXYy^,;_{ysĨBU 馊B\V2TTT kWt`'<^ߓOJg8}~:%k2C&(U}0ZYYQCv[J$>qdg''^?eǿ!wώI^uIvrmGK .saCYV~tp& P A jm4Ygǰ!*JP.rer '>!|uuSRΝ;3J۳DI1fHR\]11>7]8# ljyy9,d,--;vDHXd>yD/yu!)(,*܍FCZM8r5w!.Ol]m{ݻRF2 !nఖ1JR|^J%cNܽ{W*/q(l6!r_M4A_|Л7oBj+Tw4a|dYmllb&+Еq,AXC䔾7M:??كdők=IDN::vZ]]UPPRz^2"Ec R)uݰO|^jZL&Lnj6ollgmmmmuݱwP gN83i{ν@<9'اU<˼y9uѸ1/]AG{?aǑ9?^eҒlȂH$j6pZs>x")]O728CA1 c Oྒྷ] ` {Uȉx؏@Qޏ>~0_+ѝ(]Ͼbӏ8c\ysy>p>驲lx?e[:h  ,hHRkR)^5>/8{F\vϮ8eW}A'%M~?ܳ~_JE1?;9*Y|>!&rt:z.f}{ mN+'A|Glg(COJģ|x7D\..hcb%/B 4^ FCu \y)ӉLT(ºͦVVVfu~~Mx@QnSn>Cx*ZA_G\;=j>om)ϜddX__W{@lrq|Y)kcn_DF neźńr YT dRo߾˗/wr9eIR Y$1V/˩nk<{r&jZ끸rlƟM}}t '5ɺ/--L鶺FsT* Y^ew V vˆ{ʱFQ8WVVT(H$B7!lV"DD"}u}}=s/=x <3}%TZ]] \yB-5À-W*G dzbzǭ= `^se]z:kB{v#SՈ4Tn^U@!Lg'h33a Cʊz[y}J$%u&ݟQUr0 Í~ `V+kбiГnˣ9Q@4wć+fc|`0PX uA![ ֚7HFj:>>;8ִϟ?!$0 IDATaɛ =曠aqr;:&R)]\\׿u [.//uqq9<3nhoX +QE+٬2*J(}j4an\?}TfY5תdb V* !eHDgbPnkeeE?J&zQ{3 xN<P YŅ:BA~?ܵE`0I.( Z]]Ņϵ,j</~Žhhhhh7́0ټ;8q"$Al/-3A{hk'U2 /Oxn|}EZ8=Z%o3\^^.U5=d2D~.`eRhV=*02DNzx<3;NSĉ" k˦#~ħ a?+Dd@9؈|by-1˵Éx?>?7yyq`iiIwr\;?~rRI 6눾81@)x!MI\.Jdw~w&Ci.ʙ9c%`?rYd^cTlV;;;*\Xo:'([=3b{Po||wq}}j5]\\ X[[[wȘ=p]L&Ch|3*χye2{4$XPmyyYZMJo⾣bs%ɐ@"3whpR׫D!$/jmm-^O~anjzt}moo++ɄR~~?Nbq v[ }?= ~ vE_`8OvZs5 u]y}G{ g }5C9s$ h4+%,*rsłeR!njͳprF?5}ӻ!Of77n ...ڙL&(00.̌v8ÇP^`8N&kZsrPNFK%~ZO>?GdRVKA;Al!NF?J&GAZp iJz^xȂ Ea]N9ОuJ'OBQ[NfB oɉ֔g+L \.RӧVQ6 ,p@DTh4R^$H][[ DzhL0Gk4* j28 7C$ѣGa+J U[j5~mmmm~ӛ(|986>.-vC![/--;x|B I3Y'|6 T8z>ÒnU.U(BT:V >6\K*{!)T1:r@Ј x#8 00@F^tG # $tq~=!*?/!3#g/+;}ΊbfY'@N*{`0eCRd ,U%4f"0d$l!dx<W~IR7d=e2P]V֙q=ʰ17qoo/d30cH$Aρ΃ `9" P]]]!PnG?O*J[{fS!r4i8C8 B.w&y󽎼9rM:}s7B/&߯`6l6 >~1e1꺼P(ݠ XB.]"Gp^VZzȓT*>H}*J?MǴ(v=C\}H$B%;@~{nX,j0j">n:>>:J$AU8)K:JSv_k:lŋF*J3n qd~.k1k=yKdm9~H^ LR;sF>'gNr} {t:}̖ee---L/_ZRvvv239Gb]OADo3!t>C#0TY\c9+G;Ba`HV%}g!9|ٙyJ9&"̉8 C>`أn\6ÍOS $d 633ƫ׆e,d-oV+0@Pz]v;.CZw4])^悃l9+h4BF8w9߸DV\H SPcL81WWW 5RfdSY'O=nja0+̑3H޾}%} D"o߾U sEJmX GPqX, n Sn\I! Zr=4XE[E[E[E[JNLcF`OByt9v+ecwrգpxqC%ncj:~9$]D }nU~-;Q(^?8r> " x$8sc߾;Č/᠒gk͛_ozp1&࿟gW}NzϟX>ˋϷ3.;k}+t:\1:֒r2ϝN᝗TGyv?<O*&P(Bz=zp|~'.l/ |z:>>V\VX $D9){qSו̽كYWrP-nL3a}}]Z__jl6`-^s}ϲ.//jT5LttvvT*2 ЇTa-*~>~`_AzŚx2$;ttttݹs'LqLF[[[t:!`Kz ~AW:9ˊ=ssR?[|ד \f|/[]]g 2~dܸhŋ:;;tzC2-KG`{,9;`Y7p]ax!glRT2TZj`=)6L. J.KDw@C}3; 玄G "DFigNFB$ ?cnN $7|`r9mmmٳg @H7З:::RX榦i+h̡G!1єJtxxzkۡOD ZTfJxM2 sr8W^wByI ?g\p|'~c z$v.c YN?/Av/‡?2 rFdYLhlǀ7Dj,|A<;N,c4 s^zAv-pbL`ޜd_DsI2)u<+x\ǻ?::`2tBAPӧO+J4TGq8ʊA%EfG`Q8?03`tC']'}î/= -=$F duuV;KJ%=|0Bvh4RP瞿RI;L!wH_;;;vvvD"Z\t,Wo@'0e7)qѳ v;TqJ$j4zuPlNo2ځ5jmcك?r;drgw9LaKn㐝 ? 8>8X+UUM&}'ׯbQoH昵qύ**{v;ˇ~wj_HO>Q ck7e}z~_ )b ȞL&!e{{;(p/_jggG> i(0666AJyTcWTRpwwWnWz*[j5u])d!{sd2!KC///Ý5L&vɉ߿?x!iMVׯA)Jt:P8smmm7nd1?ǁ@$r! uԁiǾcVrͦ鴊b(e ɃMtd2B" >oJw]]^^R@Kd9~N8h::_qӿe[<( ρ3(p#N>Y$1ꤑn3?]yzd9R r뾋˽sPygXg @jHtm3\.\>?xNxF%&X֑}FNf1nHZyk0}qY`\D&!_}q9;vsy`_N!C7@Չ@5Z==SictMs3JRL%@tPekk+Ct5 <3^{C|X/?ud*=xO'J SR:;; vwwɓ >2x^_etF5 fJRpLRֶnnɂBZ-mnng=̜"7h4 wV  VZV(ID"NNR?'\]g=r=0U(=EاȐ|-}7=d jIW^V@?d)`{ݎaO{!( _R6ޤ}r̍7FS"qSw2@<Lgp*`o@7 0x'u|ɤ>|_j* :??~ fP3 #C)s=PJDlnnr' !?\ǎ'lJR(AkooO;;;qtǑ H$tppO?T|3>Cٸj/^\.0jb 3}G龼j4իW/~Hwޅj!uouCYBԏ&m7n?d2VN6/:;;奎4e*bYBYz`p8嚳6k6k6k6kv0Wb018!YbQp"X-&h>Ǚ3pp`Gē("ӟ pCfN?a$g>p~yg>8. E! @m9?/>ǺL_c$g(#'h^ß\Ae?qR?N@r*{mÁ,gcl\ùϱCdx`oLDDb ޺}7~ G~3ﺌyQٱ 0nM_`/řL&@W%yfxV|m. @d2u]}&3}̞ffD"ӿxu)ZYYQRtKDcB]px={ }s 龒Ďܗ{@7:9jBNHKjooOkkk~Gv0Vum̿t:R\.{ ԧ$ZN` Î#/+ɄF`0J%U*ptKC&7MJ%---2-NP(quuӁ'vct9aP>{ァ'O0[J 3Ɔ~ӟ?փT*͛EX⃚/jq^>-//joQԽ{?Oop8_}ꫯtuu>L/^۷oU,FXN't{фiZnPгgςs裏BZ7 u]UU"fnp7oѣ " 8Z--,,,~RST2 $ih40瓓ڬڬڬڬeYS$d?n@@!߳'Iu*,n-H_c>^agy8 ={̴ //6;b҈9g9=3>{?/O"3?g^<1NLx0!cw^tq80 3o<'3.Gd'G>NjNEp%n}n裗ߙF9&&?1xQ<׉}>:ۅ+2D!!!g\b#qpwRɟˊ}pgcvG9ct ̦X,P((JpёZVCEtIOPV`IoMُF*rT1Y$\quqy%'1IF( ㅬV|VX[6z}}*#wHv zȾ@8gm̩Pe| IDAT(X,R $e]c0'LjccCl67< {}}l6]a`ӣ(`&X9z=(،;=a'}c?Ƴwyg08(S{88ߌ̑=F(t؇ @v;$VlnnѣG*aHvՖ5iiD&͏Og` /^ObQR)Kpp8L BLg%톉`(. ϢBt~6 y=0AzX r֓f@|dlN\)d8=(&yPIq; rs.f':()*VT*ܣd8 >.' s'Pb$`ޗx! p8)oLx:Y$~~F̒&Jy9B+ _nnn&*90OF39%ڼls Es4An\"r;gdB+~R _T¹wxzct$J%-//^ٙN8~chM9}5+2}wa9:-Py1X=.&{%uxxnNBN$pGߟ3< 7v6$0 e:IE_%M86Sn ,xqrؓ3Cz/37f\_=yg\ΐ[dTasss ~;awwW_uP6j^\\Ldzt:ztttXq/qssVmmmi0h=1e2{4t &OŅ{Do޼Q)N''h4YȄYZZR>D677'>;.qpP.%z|x}[_QjqqQN'ʊjuxx?XBA_>}:8lbȨZ-U*^O{ァgϞN??kyyY~D n6 0H^/lV;;;T*aD`cIp8 Grz,.bQt:IMOӪZ^^VPɉuvvxogmfmfmfmfm2sg;H<8sٔ|mL9; "=$ NF,6鮺F"g<})?\1@.NR80sPB2^]F"wy!H! :A_Ʌru@u6X;N8ebG}|w{Y)PDD @'xIt:Nb~G3z0-:eØ]Gc@@z\Y`GNt6y06O=dY7xp|19~b6Ƅ^lnÉmpX c7Z[[/ˀ1^tw]H^و;K;))TsCDt.i_d~g:ɜ8tI"ߺ777kj4!{RǪT*UcI'88 h WzrRVCp8px[j =J.޽{eJ%]^^Lpry:cczuZD"ljaaAl6$.d.S2v @!oom9gT![5?t0maSI Dº f˞2G|M ܠ_d]|sH$B0T|>6 T=}E;L2~---@94Ae(q3'͗o͠{4.v> Y@uD0^=L&oˇmll(H`777:<< b,|?yDϟ?W* dσqzJtZGGGFW( \V!j -\.7}ȂNQ;˗:::^~^P'{ssjuu5#HP7A俺?P~677U($) Z؈/8n8z2CsםyիW~B^ׯ_+H`B͍C RXP'`mSRQS A %V3$VWW'"rU.u~~>qA.^lxvvb.O=g:== E{D! 4YYYYcਃ|耾كyu4$R~m~uQrޜ{Ys9^s(Ip: ?H8o}@bl󾀽0g5?SO +,qw%LqOq9uٺ\MLH3yK'C+b}rB'&c8=^(&,n '8C Y !C'`v@oLz:~Jl6l6}zjb.<}k >"!Ni<Lv[WWWrf* TS^wλ.|>*2\'~c,9W!C9 h4t~~p'dBd=Xħ I9_+b~CDblv<-Y >z~~b*{Qg8Qr:Q]C'*Nkyy9cĬ*a_$ D?saȄYZZ 8A|xqGpivFLj+5\'_}D%YF; ֹ}jF =zX///wqq1qGs$7&H#$&^p0|c$uNFTSńxO8{t:=mx}a*Jjal4{>r hMOcVZZZɉZտW6ަq j6zK5Me2M#%@AZj}}]VK*JAdI ܣp8Ki|>LDj6zl6uppVvqdF#U*=}T>T2 iP\Nj4ap8TZ>#=|0d@S/M;9d7qׯ_O$ Gdcra0\PGGG|"YM8__T*lvnW>YYYYYm8:޸4BiY4bӁ3?J1g?,y·^JtݐDDr@y6 LG>rvqA#$>O>x~by>O=@h!/NyC=crrqb'&KF>Q;t쟉 iq4Y{sPu@]܎Ww"иlFRIDBǡ<#z<;pY3cq@r|nK1N9@Gy_Tg_&j>d ~gS {ɂ!BT,bvocA#sȆ,IG:ͯмz=e2p6@e(ݻf2+n;+;o~iqpwP /:w8\?d7l)Kpsq4k#_z!)L&CƒgXbs)s1t{=j5dT,'KZH2TTtpp M46d¸]_b> n R\z(ۘL&uvvV M &]Ssssjv[D"Tɴ-;8qH H}k:5k2.8pMl^P}t:n  ! %K_g4boIe'MG)"Xd'x]$,9trtcyev`†̝$G8㻷cH'}E, _nd2Jh<88Fnk0T*jmmmazfmfmfmf/9H=Ӏ[_mrho9;ɞ-}_1!abqFq\<5777 q@?n\:MDb*8{}>713Ɯ\,| 4dg9JST:p8L#>(tvs2Vʨ{Ր4˃njB>|F{-JX| (xWqY2⃰KsY^ߕE0:{u{(vm9P0N9O1Ț:Č˝3x|{W ƷyG^j KTzwn#3;_ \) j6`j~B p@l.SDgYv:y k{2NNNC5c֗6R_6NJ• nk?yb| lUx}@wo_ qH`3؏ӹVݻwZ߿0 dD6dy2rX&H#φ@1pl|Ⓨr(Oo̜IHLaA8Zo4<)q~?8I,J ³D}q_ 4(FLN]^^hhaaA$`u_^^͛7!5jBXONNB=͕mooz0 lXȾ`)纵zd C͍utt |RFCӟRVY 8x {kkKaA!Ku]{OA6vA6 1a~ؐoD=7Nxpo޼M( tFxFC0~FJRZ^^% Vښ|(HB\֣GNnwbւ0C*)8h4^wǏFB:tw h>WPvU4??-P!X;<<ӧO'RѷVoɓ'6k6k6k6kC6{^'!=AIĞKY̊9= :Tgq Á De??S8}sV2 ?2sIF~Y\7&q6o. @Z3/i`KqɁ0'53%ใ40 uA)"菓nrY`Nx Xtp[0.ysbp}+sPy8rM>|zz0mzk(+7Ą`N8!Cg\N(ٟLf ԣGBixޗX !_W`k<&ޘxFZxJ2? -%Wg"_k>+^c,hZj6'Ç O' ))Ddq&m^;9pO.i(Uh4TTt޽p{\V"qO:_ ͙C|(g6ϳxN6UT u|Qݞ 8K^Cdo`MtbgqFCiOq5ȳ~3z+SF{)$d!}CKi$̃0|gI 'ڀ25ܡAhw%Q 41d 77l\(!L&.5#22pw }"qI")\L& c(%SPp8 Q9D"#G^{jZau+Z-{NWWWR6'0NRdnVIT**iC<@\^^RR"ٳg z:^z7rna A%ʏJollh}}]kkka^!V٘vx!7s2Nld:=x@ǪT*!z^(dj5 ݻwO.qkkk*} uR%>#=bA IDATx@կT,C$B.r"\.='ZMl6s=\GITu4rWWWVTX@>P^ܜBDrHȫWf iD$ $6k6k6k6kvwoQV+d_\\0k1I5&rl"{#A$;{;Ia38с HExGF wk%~8K;e,|x%A~9PzƱIT><C?3: 8Ayݣ$y]w=k2gC18 kA6ձW!H2~.x:~s@.҃0vr$zu9:/:K@RZaiiIN'T2 L0x&Sl`1j D2ņGՕ>|FFT޼y3Q 1_#gG烏l) tqҔy( ^%)lm!km2rn{?⇘8(uiU^W ժ>@f{18 ; +P'{a m܋-JRӟ[>|L&^rKwTt?\\v dpBOpe8b \ D?! ;!`#N& v|`8ATo'Լ>qyƧy9$> )vcnĔ]=yDm%O!c?%cvٙiX}2Q,FYDgᱹ @w`$1s7t:,j !ٝz>NNNvbc sd>...Z gqbQjUolZV75ctrrb,o~.ܫjϵ. \^^VP>uW#$U(BKt Y]]h4R^W\ֽ{3=}TNGt:aȆ( IMJR<:V[JRL&3ArUU Xp*:,^~Є@ g̹>`FȆ0UV57w[m~~^NGrY|^*:??WRQRX εX,*Hh{{[TJr9lwvvt޽k<kwwW?Ok pB6rEݝr? Ţ>5M??0{^8j5/ZD;mmmRTrYaF'''!iiiI|>lnϟ?׿7F6k6k6k6k6N#&uBX"X9E eMٗs<@980?i!`i}Fpgq @!0TyDjZgj...$ݞ nl6 @ JtD@™"(b׳Xgx~7NR1n09y?dN'J y)*sWhD"_s>e2>]N$}̡>o?gWs0_sdOg9t_饰&]qқcAiZs;Yn~ϺgYcx.Bq4!j@| VVV}VfSKKK$j7LN\@p4J$Fat̒vXeXTet::::RP<0cM cj=d!r!\:xº{ w#X؋:!mzJF#֖vvv#O;|6H~?E|3nyg`<:'"Lcx~p 2gC7n݁Fqf2CYv:t:Pp .eD HFe'<狟{4@)U:8>rO6s-^sG;X{e~`5Nb1yC}ĝu'&xWht< i28N!8I$t2YFLyEeY-//7o5d:Ċ'鮼g_p'u &FyӚiF`nffQmgg'dIwo{P2{Ϯv ap 2t鴊޵%?V.SRQ^c]]]Qh79ͅk47c%Hq~Cl &=s x]YE^PV)i*  G>g$O @ا{5@~ͺ-ľ9S,9p{8;]ߤ{v1MB8. 5iqY`x")s>e=2 T+pF ]Hdjv!cw;Nz#,ppFtZ|477eUURoB!oD%zex7|b8[d,//lX,l t '3 gLLF0Rr4b|#j0hee%\49Nb %鄤B|j}}=9CT*8DodBQ :BÅd)=̳fM1g絻gϞlV_"1/|>dr9z=}Wk˗u%pƆRJ*@,//k}}]KKK!;Qb1,ȋjA_xUͩnk_H??o[uڬڬڬڬ$Q@4h#-}dH {_3gHτ`''`b\]{Iûy?Ĉ 46'şϐ-q"X3gT@ƀÜ8x8`4 svbۼ^ πcl0D9CZ9t~~8p\\cIL<1 5#wvZ c>\<;aڻ: n!i?'/ y 9ό 4e߻;s<&9s:Azx#& ?/Ö)?*)rR4fdTCPx<˗/C=~80O`wzt?`di۝(a N}:Dgn>-___Tčx-`.I 8<???й(H#pSCށI k9$vS ]#;2̧q <:6D2T!t?ckj~,//k{{[۪T*_dzQc,^6m}!O1///>駟޽{Y{{{/fU-..D"pt~~jΞZ6xWWW*1!'?2ۨ|>w"ܳ3?XlV}?~H6P2paaA^/a!w^ 0'N#?d8SKUO|9엠Gcs9۳[\>\q?8sLY?=+A`{CCN_׫XN(>]Z*H"l 'c+2zv5^ $U*&ȅp jۡ`0]q,R b9fqE?#K8.dz@=vS^Og'mQi0(iwwW!;ܧ/`]1 夑}1z0F?ON}t:%NUbpZJND,2ucx<XJ}^O. DdKTg wv = Zdcg.y>c<[=b?K妓5P(J9wns샤Y8Mǽ?z1Aŋ?7ϩmcFǀM ü?K"W`]7&.7ݦRbj,D"" GɁ;ɤR(˗;=}T~_f駟(!ԕ=??WݞHvZ[[ Yfm=V*JЉ|>2 'X{޽{VO~ ׿͍>|?Pkkkz޽{{KJDJ"q[-iuuUiyyYJE@GGG!, DBZYY h4T'" /J=f rH߈r~@}NϟC#ܹs"5YِF#}---{;(KG˯*trr>@n?Q:;;>S_KmissS/uppT*Ks}Vo޼Q*R&ɉqڬڬڬ_zsp1ُ={sPA(~pg?:O@?~ő1I!/.= uc@@G1H "+] ɃʘȐg x@)gLl &]H(݉m ,%|N%2dYUY\|nЙF$:1Mb`tec42NR(8?ԽPS6A&YPT18IxnpY9Xx6?M?ܾ9w̋> d@e~۱ @U]ƸȂ9# 3>|\W4" * E՞p_ %ttt*uIdz@g{ |Nnándz3c_d$H̙I ];^OF#aW.#vhtw蛗oOCc=tr= S_/_vJ1; x*y}h4 8~"톪X̋y12:9 WB3?dx$I009wxr>ltU-{{{A)?\ގ7? |ͺ/0:Kq'%HrAyMWo7[|G;ߒJlQ&6c(= OXh;b<*'fSt:QPٜ`n0F#U*/_nrM/jtqq ܁=%v\׽{4kqqq"{DZ\.jnQ2HXVC4DV$n6U. DO}28|>gzvvvH$h4vv+-,^LH$]II:(DBJEBA8d$p $M8d2x@* Sڬڬڬ_bsǃ c~&kN|ϾX+%a@ߍt~#ϵ7-usbpsgg]ԑ4> LFyAH]K\pyRbqX~l6x|>-w`ybIŘL~F| H&hlRyme^?Ġt' jv;}>byNxL6Y1 =]>LrpB$MR)𷱟uu]`~XEZURAAK텲z>/(/vȄ^͉~0PןxB XY*x.2r],4y;ф ZiXVaD96yH5Rmܓct{oroMҨNܜ*JDpQ5N`2d(y^d2%#-~p҆nƅ]CZ,3]pKu:)Mu ?k1x?y^v1C\T*5a}K :== e B0xo4XXpzVVV u:e2-.....4 _l귿m`6A\N2L+8ݟJR^|OX,nV*RPήX,juuURI » heeEWWWj:88PVɤ׃C#;;;jڄnpx<ё絲5_ (a'8eHLA? pN,cFoD"2ؐ!}sCGB$<|fI.2xOiz+LX,/ļg2iv?AvP#O?p8T_|EmnnT*immM?Ow}ׯ_^#W _8YYYYN| GM#;A p)@G {)i}zx^E`k h<|+w: ݳ>g<t2s:X ڐt{&dnn.ܯJ@>:Q~s^:V‚|$˩hLy1'o 1O1pss"}>`qb2ugTHD"Jd U]δT*`"cG{5,cRF.G@E,CցAZd}w98y,Yp{ 4Ʈ=} ~vɼki҇ 㧟k<i,--޽{jjݮ={<2c@]8$.&1|A.-- F/k&Gl$Cјm=|pb!XO.|.j8zkc򵇟{%D"H\.^qh447w{}^WQZk\]WJE777:==UZ G Wa,N 0v'FQH!tҎFB!d5̀=zo' =;󻥥%e2F@ASX} ߃311U$%_oz/^njڝmmm|<ȁ/ ȝ x4NO~bb 0~uu2VWWÝ7+u^aLtyyyR'OhkkKLFlV''':>>4MF#}TA wݐH$B!ׁTɓ'OT,UtzzB0e)sժR:N+j@Pj6zNNN\BJ2 6 +BycRA"[4y8D"\.&H'''tՕlX,@!,Q2o3HDߠ% eYyFDB|;ABdF^|˺ Çu||Pḿ p_YYjvppD6lJp+rAF#r:s=|0,X?䓰HGᎭ?O:99??0dqF~PoooOnzfmfmfmf/900 l@tLpc̙ 1~~{ w:4%?;x:p,+o_2bvh6 M8s8!{_^B{H8K;E@TG{Y#9$*)TC/'H%<ׁ4b2۴d<-i{z뀵 :y0$H\ʎ+e-.#;'q3?cul+W_y>`"a|\܅g%;NbFN&xxI%\u!1rRf G絶C5/^X,vL|| sщxƈ r9@ )7 |]3rxwHTl6CEp8 Q餯uK360Hxwۊ4xg\]]rbc^ymmm믿V۝҂K ϔث0<v>{# .//N*ޯ8i؋4xmvR NKw2tYr1h:::~5Rl\:p~iW>b___j}4sBal]ڹGĠEm0{DEr8-Bk2nR"}'f<VT6@f' ,훵YYYYKoN$8`ucdH{-r&vapW~H?_i8h 樂]C%8HxsN8h `qi>J@g2d[AFp>d2H>&$b l9ptāTMbݘL# Ϲm{L 7 4>V O x9rҎ{&N-z\>L:99ip(g_"p8ʊuss궢 n$pnpϞ= }4Sr\ [pSRޞ ٬v pظM5h4tvvX}8@&ɐf~-uqqt:B3}ND /^d ^p?~)d~ @jn7uz~,X4Y tq$moorw0m{{{!\.k_mJ0@{toiiIRIo޼Q\֋/3j5UUjtJXYYYYY,!4GPĉ@߿:: &G45 B9,'^^,E0$)1(g}DA6/<u=-){s䌃\]]/o'm;Xv5]]]ZV۹;ǝypGSNp ʃ-yvv]|5>9p%U3󸰰Cg^xv}g59& Uz:??VVVg:PÎ!_Ys]'5\w rr~F{v=ϫn^nɬϟkwwWŅn( 7UTTvCŤku:U*PCP9Θw`k$@R S3F~(Me l;:N( sVOĚ~N8{&AEsW|)@_/i]__PnWmeuicc# pŽz:-HH#~cg|jX$ta3gDXyW>uCQ.)L53G8" vLb18V~HRIB!D/or!Ca4iooO!B 9WUt:JϞ= Xz]}ɏqkߗ{6M3CEI2b@A7|jnblP(0qr8gz}_k*J`0Uʌ|/"r \|1* ,Rd2W_}o^/n߾xRVex1z-0E"qZ-iDѹr{gggˌ!2'''s|`0֖f-v5+H$lZJ=Pb&A,C4E6g(EБ`N t:ER.|T hԜ0+n p>'7EXl6v/tΝnkagggxN-qcc۸>2 2f}߶tj?ǧ~FAMSz=spe -۲-۲-۲-ױ)u 0(@! `h $}xs)H$|>?yPw,(AѝN+++zR)ܺu Zm*E\6z'IRcg't_~dYx^j5t:d2ۋ67R\Sz2^oooF#ն- ~/ggghZV2{={h4BzCR)H|Qf*#9qnsҶOp=~cIIvU:TUx1`sef1hPtgÇf(JG8F4E>G4#TU[X,nOxWCS"J  ݻgen޼]a8Ν;vt:ƀ9#l6  vqzzjw/x)Ajeevw % lll_6)(NOOm<!nggz+f?%)[<;5R-#LZ9]sܐvQqvvfz=rjg`wwX gggX__#x<> Ý;wP*t,>CZdr^.LZkQ((J m-۲-۲-۲-׭Rq%Eํp=.ß8ô F3$ϝ%sf#1N 8b_"<h ̡H#32S@\e^ΝN[螵yV@R@߭zVqWP}u 4'fuI$gveJ D;vg'ʄDfW =]JN%ؘA:KP~zad2=Uw6ιu]{JFT\? $NۢG2$$ ^s- &35#DKU;1/}n+hRNx}?( beLƈjCoyr9[)߿+WlLTmʚԙP(d8 p%qH1èIAo81ͬL߷gmx$ MS?i04%5@dc_+R}B!\Hdak_DXӱ`N7n QV1_lV!J!L\.c0/D׳lK?dj_|~~?яP!nmu}fw3EQDQlllڵkVNnAg#\.toeIƟC$@Elkkkv]˗/#ceeV V nKʈztdҢθp. IDATL|d2otB Ǐ.($dI~oQQԁPhVǏJ);\TG~_??ptt4whkלF-1UcMq$ao8;;R~8"Ro}OQ,n Eqܻw?q]3 \\͒q˶l˶l˶l˶l_@LC:'ߣA1FC>)ni~E/EQ "xB O3xqy]_[eR|&9шZ ٜ%:NO.Φ:1򌡤~=p8R4u>%W<Yө2'fr D"Bv{n}i5hV3x.dU/5%_9.H(]s.G=ṍe{;E0]SusKzWrOk\HKK"ؑXU k[B_įFR!~1mgU,v$# F@sB;kZ}kʖNCR/I|q3auuPȈbiR JŰA]8@ `YueFMZNOS!`Bd_fU&cT_snRphuxYqN|Ϡ꘨+bJָ{;X,beeLƂk>%kAt_PPhN߈p,wkb1 ذ- ` b|hۖ5E'Ԓ]R?aPIJu}_TN5ڵd2i}FT*8>>q,C>G&1ِ/8ɩpݩݤ?\H#SP_:,Ith45h6st^El6ps0`ss̙fP Ӑ(J%VV~ Cخj:HfjX[[3柑/`rV!Y#4~6`8899Aᅬ7xx2בNQTJa>J%g?__c:޽{F ulnnچqzz~@ vK.ab*4ܸX B ^$kp=+_J1҄zx F(JV:ƥKpu}?^>Pլs^aeehJ% ԣ+YG]:==E4!CNY)whӧf$ihPLߨsz5ɣGB`Dz">Cgx<6ƈ(ArF7QAq͛7_|}]z=.}@~e1er}t%JhQw)2Y< W+G Pd >~K)x꓂JPz}8LCA x˴q.8X ^N AH#.``/$9_/ +ѡ4EBe R'BAG!PLP(h4j bU%]pMr^-eg0VslEoO4cQQ'AUՊ!@H=*.y3{.àX!Qo,*Cb+t|/1I͒#Nϐpk.+}r #%y݁fN%kJ._l ժzv 1#];$(]IJ3I&It:+L&M>5g\tPHKĪ\@cYk6h4L;Ŧvl.A uUZ1&tc"͢T*Y_4( ϣlb4akkH2hf)P0FI,v 5:U]  zTʈ)fԨSrY֔%]~w9JPk&kgG1НfZRX,^.尵l6k8OC]_J5[T:j{H#2yj]%RAXL2LV9qvv5!C04amllXxsFu+#z*yA|s͵mSD"χh4X,fwJ%ܿ`kkkF9FjfP(<{W;.L7#Q(pUlnnbkk t``kNFydY#F9 $Id(eoꬱ nE-hrGΝ;x1rRSJ)MnzH8nNOOܸqVRᕕW"媆a LF۬<9j4:n{{v "C|DF`pSu3D]@Q1 SPQ>LYhޱ. `]X=*w碪 Al>? ]wo.悯n5P@fP8r]l6k x8O`tBOh4pxxKrXڮELK2Ӳ|W^P(P*z ~k_4W_vβ).~?q5t_6{<%)/w-)hTt.Jr?Đc\vBt:m1?`^tjل\n1צhfSYf$V!ӻTӿԪT*Ns|dO+/~s\{}SL7Wwj5TUJp5lmmY9ILۥ5_.wי#\Hp 9ACFe2HfJ2 ={fBH$frcSE*ӧO-E)0QN9FP ˩qcdDaenj1^#Agʀ)hۈh4QVRɔw5:jz."޽ χW" ;/WȔyDD²tL޳g3~L&٤FH>p4}7'O?gϞawwW\A321~_"H޳H0-860m:K@nooV᭷ނ>#j5f3~l6CD<7j iv1"cٖmٖmٖmٖmΛz`_.v.0UT%UA,,v=wgh42Uf:(KWp2@H Px .9Q@%Du5B!yD=G qAuxfrfzV3S- 4ջt<`όLVg#αCh,]=* @@;W:'I20hxS,3疝mЀJ5 YDJgn=ưđsQ% MίhO!^f:E*, ]S׃>~?&x.}bΕF[C<tz^!$ Qi8^~7K<G<p~MH!NhZvCijgp-d2D9"B$󧧧I)i5?uL= A՚_k|ѱ2AɮnZfVb{ *Zx ܢ}SQ*pnZHRe:^ʵi4Dpnt:m͋gtژDa0Mf3|嗨T*uD"x<T*b}}Hvh Nxn߾L&ZH$l63˰V8;;CED4C"mHZ^Nn?t̉NfXɔNG ܺu l.]B$19Bvs]zjxvcXM_75uyuv]yݠP޽{?OQ*#p'?o~xE "@n4>Sd=0_%xV㖟c\F_7o%PQ䋲 \3jc d7:Ǯ|퇒E@qzsTY\(1 )ṰQ@㙎zI%WEE %۔t:8<.1{{{h6{.P͠ C4 {M‹#pm\rV zx왱ݻ7G5M CG?«j5G/]dHPQ'c Yǃ\.`0he&K=8;;7p>to͛7*n,OlA\YY<<@^G><9I8<<4L_ˢg9FM4χe+Fq8"ptt"Vzu#Pkt]6nV B^(˨T*FZ5Xh4o~/K׿_|a͈T.QV+: ,:,۲-۲-۲-۲}]=^ f$i'9/ǿk?sEt0f?Ǧ sLV%F܀-ws3΃zPC@:Xuؔ RRcha%=T.';ہUXah\.#  0wn8Z4,_s_W{eg󯀬~~7^ϕ1I#'&7{E^m C +)>i&LHy0ff`sI/kb#Ȍ%sXB\GGGzH&h4b>][ׇ5b|1vmuAmwʍkXA2h42*W^o.X"KKZ-j54 +W NPNSWxIL8s}_46Wyh,?5`|ј#?f*8v|SKqV*|>r9j5۵'.ڏөQ͜ f |Np^%4$Vt-/חtI']?*Ok\ v8991D(B1}-O]uMNPA0zFAix`Z`^ȋ XI$ Z-666矛n6D^Ba1{`D Y/'_  "hua2Z[TٴDFzmf3 TU7n\Pkkk~-bm+şk1\ʘt:W_}=l/.Jf?qN8o7 RVVVL&QTv1 tLk6X__7H$,**`gg.]B"@:F6sU{WYϙ>؇pvFd2i >z뭷pU+eKyocG^ǽ{##`ŵ!=e^ry6uܨ@%!݌#ʉ韁@}pK.a{{Od 3fSQOUV]乇X~o<ǣGPp||p8p8D"Eɪ=9D,zF,jdh42}`t]Ye[e[e[e7De9H|Pl(J.8n79 ϢA{xdxn^TDKP H ->8Ge TI3&-F:Becusa.`Mm=)@%T*/%ArVCatx,o $֡}Wש S:Wt .ṒGTB\,Vd2R=߯ZϜcSyb`1u@?$K}{~){WVD IDAT|>' \*!B{NVep8lY?X&Q7鹛x y/53Ol=h_?J JP( J^js\v /:."V䒂._d|jF( t:sA:-6PtaiL&7n`gg־q%.u?^kB #?q QjwO׋t:B9kU666 ԦsLSdYj5LSܺuLPOt?:>u"**;Db93N m6&/ׅI T7htnu}Gu%٧өe;qٮB =͠l(˨jhۘNd2r 윿}"1 :fٹErb{!u9vY\NH;2ds_Vꐲ@t2GR[ &h;Lh4,s6r%ȑԠ`5: `?'k:Zq4A׻b2qGٴ$+h@kz{i|>>#z=+W.Sc+X__)+NNl?pM Y:.f%Ls$1>|hi(i;Bz%Kh7NTgT/!žpT|(UU<lvW\ᅬ}+{"!ik碍:ϵHXĭ[lN2T_,Hoi9@Azܲ-۲-۲-۲-׽l^k/"R5`sAE \K`}y!Qd 4_2og} h$V!pCЗl:Z\ZǢ}p^\"qA) ijȪ<~எM7=P<[CZLP~l^]~ࢼAVo1W)'`IX]'ĔGJJzjժcz=} #93%uKu |>hƕf(Y};]!H&v "͹Ri_(0\L {Ϩ~߰8&γPx+ &g\= f( fx霼岕:'ȫϩ~@bʅ$^BT+Q Ԁ-FNX,znvW]/EIT*e8.b 1;J0fNNN,pj͕uSQ-? 1,$2 4:"$jo%#I&z<y#ȔҠsm8m"oc龥(6zjp||bvxT*K.!͚ٖMxzz:%>ٮ˴jtW?w˅܈-*fmd'eìul˶l˶l˶luo3*?+D O)q%DKLD `P LV8PR?C]3*g=@ y%E?<۸2WrB;>D ` 0I4~^FYH %/\GS KHKNo%ܵ*;mSf@2@wDxg+!fn| NL7ʱPnrJSVj?RSk]H/şJ&vW?O\3a:ΑvjfiM&$ \|suBp^9aeeẌ8~ݷuoU"+Yĉ HһJFòqh]j 7N$zV.Hت~*ML8+^08ks$#)\TGx-T=Xu~*ys:I҈ucNQYYoOkUfu>s'0#=&x E3djbd{{{888!>c|WH$[l6N бqTH&ƆGQ3# f9I|nXD>GRAVC4E*B,w2 4eQ.?67zݔZbwwHM:}n[y{Ĝ.:d4Z:.χr ߏx<ϟO6~eeon?88իWTI= `  (?c: 3=C#ŹvD"6H$g*jx+++L&fHRICA+ eJ)e_dp8lHD"B!AD"ܹs{{{w|>o& E 1Bc]!g:,zVu8TVmN":٬EPsPGK_yt:{]wPf3REfvٖmٖmٖmٖk\b? wLs %r(=lQ\-)9BCfQiE^E\@U ֺe\YY.0xj|‹y>rǣ?z@{R% c5bѳy V*Ȩ[twi?==E<q9YمùՒ7zoꅮ> F7MP l9JɥrT}u rXl@iSI]ӪgS?L ՒJt5A|۽h4B7lC7%γX*O9Z$믿w}n+++HRH$D"fX[[[(QVj0 h׳Toʠ?!5$Y&1V?Ӥztp8?3ov.KcO#˃n9FP0Yz^<G45XIk׮a2XOncI]TN:R,OLjࢾj9:=X tDbv!(3Bs]fbvwwQ. zHRsks{qEuFp8t:aA Ν;8>> " nx<\4g1j& bmm^gnXIJ-۲-۲-۲-۲7/Y>f3 PF)h{ i*`pu$Hn 8K%fs 1`@:0 42|xHIJC?α[S"1*Aߜc&Jm,fPFRz9 O,>%wӬylWGA~b pqnC]/S__~Fsq\ZM-~ZrF`0wV&FawdbwrR)9p:4lK?q8 *2A>~4M C|>R){6Tjv%QG@ ~M2|^׮бbP4eeY T|]1wcvV\rS7)+%]s8zJ>Q%d BfysI^0ߧkFqrrbzznsx7H$DON&q e"#av^@;usN[> ~8;>Jqx^#K??g0L2xi*B2>& ɤᒭV X|%\{JRiӴ %\NGϹdt:E45C}ZBQ译D}\WPl*piԞ*nI_/ !VRɮ|y&_P(dwBi >1nLK]rO>I2ÖYҍHYb5J|lL0B3_0DTB.slFX2c6,ڵks~HCS,~`zu5* ]G妃nP@ADHz=eacD"\~v`0@ 2F\sNEX79P> n޼h9F/_4.jC#@Q];;;C*%Ųhvxܘk~f(4?=f˗jk! HȜh4vcDQBw+F=O'FbV0H LI:N5@XCŔu3c.,l67%]LC: <FWٸN>}HBX`6*^Pu\Am AgY#Ӂ[e[e[e[e[y`+pGjT^wjԲ]B@Qn}:F}"ACAh4zT_r|EJ(!$+E}k1h  JgU|h](燐i_[]We>ǿfD~W @V3":vu.V2@ꬫsǥ~Z#x׀:m8P#\Y AW@gi5 r &xYGrtITPP?$FH) xu >>(nR~+9_'󕂅PȲrN)o"9kJ12e ff3r+Vh6V G0b_Ah Wu89_6W-D^ > ^@~o&TUa :9_}~~Jp%N=q+uSҙ=\K6]Jxh`\pI/Ώ|6Ef@2D:6\2^ٳgn!'݋$n6Pܽ6xܮ!Ȯ%\2rQ[E}t:˘F7\.g n^y#.ŪCr{}սHm*#U VVV,Ӊy!ë$tf5Y9QbO^fL&cY&}>bec_ܽReL|\OLV=7}DϺՌNXAn辠ٙ,64%K][@PMJ~Ү F`h̘&FG$ DAhOGNI [N_`8W_t0El KeX,j5K]- fbdau#`\9͐f.`nD"rm>Nd23"B!NNNox<|-3ǏqmsLYYE:C#PWYM癋wuJ%@>G65njlNtje R VHN&4 8^ur9K;Tsut6/UEC#N{t~P' )]C:0 sumfL >99x_Tc6;O٧^acjK0iQT+79ųgZn[l˶l˶l˶lum * $.ISߧ[s%x F~-g.y깗d kVQNvwe0HVyʊeL`QW.g;6S=+Tgx.rCJ+VesO\vDǮ[vFbf#\GJ}m>!& 6Ѩ{{bf+.@Ԡ%It>X>VquwdjZuWڼ&b2bp:9,"}c/p$F\ ?;zQհ6WǘYO:ׯ_G:FF0DZœ'Oc?)1K\r.aKD`<c8"Lb4e{6߫6%>ŽB3LLQ:..]h4jdr^ɓ'H$hZx3uw6 ܂AVa8"ɘQOD|>$ [ڗpjc|Wt:VFu(5C-7$ kU'asso&O>۷o1U se8hبEʴC3MrQ9!Aa4ammReY_$5Ots{8Yn(z\"IAx<!^]]`0@:6=x:bccX Z b.]›o>v73ul <| ~rr}s*Tmٖmٖmٖmپg};;3ҨR6GV:\B9GLyx666gOV`00@<G}d5|vvB] J$`0ɉɔH$NI,MzIǃJΕcMUfq`;-#1Nq||~o}ěo^{ ;;;x <ɤ:*GDXuؘ-k֍34Z#]uj\gϧR'SD5wT#<Alb< ɺz}@]w}vn2"[;c5=X O>&p]sD,ܩ.1U l6H?GzpǨU*2w&0ˎ/_ƥK lC]KT1+Xԑ ^n׈DmsuSJu$sWVVPT ܹsX2lhP9!/dHf Jg%mdO}Xt7;ncc)Sm<~?!AC4gf:en~O6S\"999f~y"J!RZ֭[&_K0D246P(XD<;zu ܽ{w.5ey  dy Y[[[{.*vwwkZB\__:VVV7(xWVVN![p|| NHR`&!)QWDV/Y2ذ _$ A0p2ӛZBq_n"EsuX~YlYnݺUuνNoϸ"XVQTܤ"$݄޻wϞ=÷-lnn3ܤT*ٙ!@& Ftią.(`jy&2ZySX'J5jz-O"J7g$Cg?! "NN}bn=B7}t]˷K/phc&ɓ'1qi<~|mM$UeQeQeQeQe OO܈$XQ w(:HU&?楨r >m'*+%d2=s&px|WB6+v"iQ8Cyq,M0q=_K `F(H "mGr2rh֑<ꤎ;~( :{M&n4h4F*BXD(B,3\X$jĜ(W|QAQbAVtV~\pfb?p߯z<qt:emܹsB-\`)@1qPwS)q'qhdQ]J&Lޚ8J轼Nwc= B}j`i4ĵq*;Δj8N󝒄uIni48zWϛtΚqsW%hVhZ(F:~kBxSN(&Eœ&E!vt# ZfH#w8ަJS0tVCP  ^_xMbl<RxIܜ2} :6VWWgcg)U,-ʄZ"HZ"!HXTkwzzN \]]!ɠR Rl9*#DmF#dY L&F|b1qH渲X,&bgoo(x--z^ ep,Hڶ&~?vM˗h6F&cYdB`0LK:l6HRA$)>^Z bF={ m‰ +YG!R^18=2X?wד'O|l? 9m Zss2o-^r= TM{ y ˺wq~v}ޑ+gr?*8H=F7-HA4yJ\Ӆyrg}p\ WI j]A{+Q`NdW {Z֕:QTWKy`ƽ}}3AN'Q+t &߈Midžms|R\3WM `R'D"3ϦnB!R)J%nlZT2uh6 H$vNx>>*RJ6pAál4F.u|>lmmҖ---^c4!Ja}}_pvvv=1(dPլ5kE,`0ThFÔ+++A&LnCf H9ye3+++T5;u@#\Z2 b T*MD"[F#\d ~t:D")<6^NP,S|G5/NVV /_~?>C|FpXu?;?8K4R|8==xŽ>3:Eep)Э ]K}3766pzzjDm(^zXtHDЩ]ڝk~ӡS{E*B @Z9HnۨT*a45 w\ay 1Zf!q?//K!4ʏ7]3 *8t\F>G&3]g3md4EE۵\tntSH:q,yt'c=X$(&(S:1tlLI;@ X rOۣr՞_]]hT*\.<;ËS\SNˆ:HPϴu\6܈sP4"p.8a\^^"ѐ GIi<3L&D"X[[aF،cR?~zR g^Pާ khdͅ.BƈcxL&Nci1j2 7`>)ZD"vL&c9 I |Bvm8==O>΢n=]hNNN0 db -=. Dr颜 `AqvvfLH^C(  LD(鱿3gI1( T!=t@($ t:z=E?y\SV1\.NuE|gF2ei$$2L@rSŵNj@eٙqJ1CW__:^|i:bdK)vXYYA۝Y<3\-U^^3er9B!P(ؤzg}D)y sʎ#ޠFS)U``OOx%XGi[t}B!s`1F]F^^-{APMlc،S32>;E-.a 2RIױ}t X~L#]Tf8F{g}ik8.d(3w%37lfuLjHk#q}={Fou& gh4Z÷c3u Q;C)YOSgz%9:sr:R93x< YZ9_OS޽{F3Jj_z|>S}uʠM]k )H}|پzn)H`Oә٧).//fiTB~ߢx|K)gLu)ެ2h$@,M,6I>0Şk51m6-K\Ͼt{U؎F j5;íX,P(X61%9~`00~ !o1pzKT* E׊j۵MO4+C#@uxk肆J@%FֱP(\.{auuI*‹/,훒LX ggg?1.//mAl6Ec<3LLS^ZTnk|!K{rrbuhhZF(v +bt.#Ep lJ&vCitp8+|Gf>'bH$bNa:D\p 6 븾FTyMSTU![J?wV*V^yIDx m .mFg6~8<Y.umz_0VBU}]-} yZgM'$$9tpG xSVzNtz[J(X{y\a]i]C.Y;mkysr8% nO&#I;l?֋:Jt Wt:VVX]]5nGq#z΍}lо>s' կP(L&s,bh.RY ɍb(5f7ʑ$]_5ʛlZ$Nqqq^d2i,cb1B!#n k;3G E%TvB[~=YCK:':3#+ę9]a4MJ%;SO =l7|;I󰷷{ٽT6 F&D"yD("N[Np~rR/dmǑH$D5igiipyy ggg|ꪑ@nT.!>ͦ ,?b࣏>;w,~3\.[H*Ӈn/_|~Gv0N:P`nԛL&6Q}" "͢T*˗@Ѱ1&...d̘iˍ:LVZR ]dҢᎏmR.\J$fx#0NNNQVQ.-]\V3R352 666켥xp8H$x e0 (_zKaj5ŵ5˷(((((q5$(nnADPMm~V[QNPst/sU0j'[QН$@gBl:h)aK <e4h4P(lͽ0͵HOl,.%X= ]/,7<{U%^i)-k3ez6J=H FRuRUZvl#Gx|hN;D Z&ʏi5me4M[PX _I;30Y wz}(C{ٙlT|>oiZ(JzR!l l6 8::2ahQV֘v0D\{s-3 Bժ Nl6]xg@5W,SMSxg@<# -_|bhvkp8x<>̽걠D=mmmd2D傕 JNKnc}}wE65 14s<q hL>AfX6Mek6E(2rU#m^~/_&z#|>0dHʖ(P(>  yFaƭh͛7‰CyV<kkkFDcKjPV1ߤuDqqqC[O7afj, |>S|>d2bۅ3p  a}}D+++zU R \B&0M7pi(((("E=W/Ge9?%=̬ɹWNE9 *օqov&ā ƪǬK2(s Jghd`9%g3YxtA^z8eu38 z'n9z:*:r_=D.VgP3eAE8I%T.3%N׻:ʗxYۻd*e5!un=lGR 'UTYKKKF]]]Y{͔#(O!33' wʛ} A牸q]cױgh;|CuyTb<Q]UuVe]\4Df!>FV/W)cܵaJRb2_No޼GH$l Q)%bIZQoG9.!N Oz1j@ll"LP( !Ν;H&W9gTm:;3:J{tS{#=DZr~.97oWAvSljDD nTq ĂI-ֵ#6BaUf"Lڲ%U' uQA)mWk4^? [ZZB,3Lb6rGRh8tlP3ǭKj'fP* c%ƹ\.gSLbdiAU5y J+~Qp8m,s4mC0jͰ:)gR +܆KKK^^^Ig"';w`{{Z ZR=yČ}$1A`pA0fiFKq$Ȭ~m~z&7#;\7ZݻfiQ. [^CN\ _󰿿mk"0x& S1V24n3¸[7ITe CEzC>ӧOqpp`*(^jR3hH&Zygi9vH&X__G6Ž{Nmk0  ځ4J$ ~?J޼yϟ[.s.//cood2&db@;hj8taG۵ܨ$4t$1K_~F1cdI,NSF Gݶ4sfju&[q~.+yѦ^qH9^*y$Tf6DdhԢVuL&m<2K7,pb^BVâ,ʢ,ʢ,ʢ,ʢwJH(78r#󊂰.a^GA0q=}[GEWRJ<-}k9kҫ[ {>C ?~4t.ՔK.Pqe ))v q.ATyR]#uFY,TH~(Ai4*jT<=sZ%>Zd&b1 kAHu{3vXӌ4Ј,% rOP142N~J>UtK)٣r'yQ.u񩎪KE@D=$fgt |Q[>ts-3etD"1LEck4x sNt=;;3WY >2;Ł[aMW%U\Qu:ƱAlZ5],L&l6L&cd-Iwk=vLSWO͵A:^1S_΁%MWHĈʊWUxqNQ'fm7!ۑ(MD#3_i rt:3y϶sǏ:qmy?%Fn:.0c]}&QFLKtk]CuEٙctuu lmm16u;TG!3b8wo%yN,^#u ;|fy+҈a.cU+qQ\;^.]`Р?z9jqBh(JF@>~?"~ŋ/i~mt:xVea|NeW_T*mٱ>޽T*emJӖ;1H$PVn-Jd_ՀQ|B`i8 xHt:E\F2LCL:R666P-I-''IVl^o޼A>0Lkp@O(e.rRY QPTfm?x'Rij( BfnF7)*ժ]n LS;>è3zmXHd;Yقdvvv/q=x4tlf:~goZ5[YYA1O29,4JS Z-8??GZ ԍ>}xo$lh̤?gl#6^YoU.])`0hR*<JH)E{RYw?eDBj.)F}5*uZ#jݵgJPjGY7AHH,ݮa۬p84B5#iD4eI[z±>`QWۮs^YSS^@,t@n`yyd(7͈,Bb<ʄWеRVlcees玽Gq=H(kINW{g؏ pt]j!8; ^*1.xQF IDAT2xsJ֊։P@xL& L&F~jG6ŋ/a!rX,uxgjlUxh4phč`vE>6n%& JɄ#M79Kf.yV(JꫯP,maGҊNcJI,g)ڨ\1PJ;wkU*ݻwmsST0-g2W,Q,q|| 󰾾>CeY~T$!e=1 ÇL&cyi8{fU$IK[G]1ǫWp~~#'d2iaKKKVFz1r /eNL&7oؘ! yIq0 a<<#8 Ie4ev~rӀqBm<ہ{H1]ߨK4,<߿OT*gϩc!4Eh4Bp8 -##\^^bQeQeQeQeQf S ws9{O,P`K# wYO)qo L K*|sxL@}*P\:UPI#wD} 㾄'P`Gf*z!N$z¨)%s X)APG '壤YhA "zg^gF._fUyrf{{{HRv .x:3 :Nl孩04Z5Pnyb“"gx=P &]?ύc;, Q6@U.d RGE$IL&s5}΅|a1(okk d2ơ Mʹ:M}z8<<4|Fr8.Xݴ]~t3F SREw$b jd>?IJC/rt:mi{{xGGGύBX]]Eӱfl* u]YYAٜ9I5m5auuȁ~Fa ~o I֘ bo ߵXZZ šlPt:58Ǭs  >+i %y3`3c׺ Q9i'p?s/>նQoT#DP]c?(}F \r[dZ* w*yKr~}}m˪.PVVV j۶WgDF~0ew3{lޣ(ZTm sc|OndFH&Vo~??B$A^7{yF>$4/M}ni LbJzvLw]~t̶Ĺku];ܐLGub:O8* h;;;}JS9o ˸n-&#۹I|6eء.6oVz:|!J4ppQjHv0lRhi`>c[5|0:|gg>pttVe={ <@(.//m`U<$mx& '&5|Ftdtx&P(dXTƥ% ,// h4B22 IJgggaA0op?Ν;ujl6g&`I& ޽{G′fYv?yŰmiږǑNfQ.>Lhp8fD"d2iy i4Z$R]H1FJ%<^BZ5€ 2-]Ee' ...PMH&EV5CGþ1fIpϜhI&dp8lh6F_.uçB.Z&iWglC6...fMͦ`zuuezO |/>#8xآ,ʢ,ʢ,ʢ,ʢM)H@JܸL {]]sA c\kMMQ..Ȯ:Si5ZD<$z+(<gG= d2A*y6'0%*WWY=:t*^SI (ܦRG6!MSXdPVq~~eiKkZCqyb:XNgLHRv奁JZ][yʊĽ5?0`+?>ו;܃,,V?߳9BiԜkc :w)Ĭ96\7%?QωB!sNoSdUGPsbtJ:*61'ۙrCΰ??w)Qe|.̦$Dv-Z`|>OIKjv|ٴFP'ŵjܹHueOGIoBI*-bD7~ɓu`mnSza-9]QT_;dq LK~߲{fb_S_}>E0!Ks|S~':ߤ1B!(:!n0X3^u6Q"a8rlMmakk x|f廔|[ljn[&)>3Y}('m?mފ4*3s$>&Oh6e#P\]^^F6ӧO{.<3@5G$ё=p٬y{n 8O$FQB)* n*2=;=`yFQ\ {-xXz8k /~5dHp ߏ#e#(L~ (*& MXG?Qg&P(fxs: #{ByqqatoLqvvrF13ed#A=)֗sznJ ~޵ | Pg>%|uBAy:k;ollʊe!(Ql2دGA\X:|>8677q~~n;$ #V5$*Ѩa]Ĺ4 FnHxɉ-误oB}$ J%Kh؀a=;%9|>ϟIXNf.^r(D"bB<G6)*Ww:b1dY gpvvfiX$\H:qzz]S@%i#.ӛP;z5almm3?nk2yrP/_D\6ra2`oo2[[[6{F:.L[ȫWP.P(p}^Lj<fϟcoowE'''1q~yFrl:y$.-:IQg(SSo TL& N9?vb]0DOH|8==E\1l#umR"cfooqݙ)yV'n,;<ܓ i^G^IvQeQeQeQeQf\y輢{]CHPHI!p@0HR *y{%EX]k4 Sp-0 Qӕ+6ERwI ~uJNBֈkzݺtBOS( ̽FFP~MVI0;"]OkW)FgdbN㻉 i_sJ0MϹVHRN4hOF\P.Q:)a?j5>KvudYEKgl6l6-:1߱.^ H}FHR3Qw'ljݞHMح>g+AEʱQǒkoOIyMJ󳎙xJ{{{{.rjvvvP*y!"b}ݽ6GY.QF 6WЌR#^Gg;3n*:eH]ϫ>({Ebleag)ͦjfc+eu j ~TU\^^!#Lbss\n&gz]__[ʼkz=~3 =F-MvY=Zf:k4J{P:γMk+++r( ,OB*W-V)g绉@ݞK1jUxlFiN$r/dtIqZ-匐 բ/uTW^wGAt:ߏB@ Cu$ KHGY)G>zkfpZ3$(v^F3PNϵlb:z= q,ÚJod^ƆC5Q,?ϰj zHB`DK0L)*- J%~z|GGGxP,Q,mz=bww?O,UX0__ᣏ>BRccii _|߿L&~Oe[AsTT*vL&x1go|Ҭ|z\f^g}uV"Jw7zΛM&NcxũvEϋoY8>>FղӁkkkvUW%^bX>|Iҧذ>zk#dbu+UW)xC>; YJϟNNSu,mF-]jۨT* ܿ[[[vTIf2X=d2LT*J2CR箯a.FղT}{f---ass? ~)~_//Ga4!HXKƯk4MEa8Է-˙{/4-u;xz8'?cH'Oclll`2`kk nh>p8Ņr4&Ç8??Gрrxn|`X ~+\\\`8>><ϳ4\p¢ \nf|>e[1Ǐbh2Pjzqz70b \.g/^[({iN<"R<3M:六 ^8x:.`n|8vس~j޵WϞ=.|>b{NƆMna%X_.tpuu-` !&$/ɡRdy_ݮRw'EYEYEYEYE])&VY^Sw)>NKi6ߥ?sQ\:Txw{)+۟Ie{]kZo̽)VmXXGς!2((Fh>|6\ RSzƫFYM5Hhs!s qO¾S%@d eλ߭{T#A|QB yS Ln<#LY$TF"٬cZϨ2䕒jha{+q{g!uF]={'sm8 ]wZC1:މ1zjjDw*cĺpeLklޣCtzQ米F@(?SZ-!N[ʯ7o~M UU}?E @.xܜ*uga;0>>SF}6Z8ދ̔@ۋJ4`b{2H gts7Q;J;܋bәkfiبꫯ솞G=⦯^z(eGj'7^^^X,FRǔUvl ڠ ڠ ڠ ڠ7#SgӨ`ypmbV9Ї 脁ĕXb[||#MMM(VՃ y_\NC³ik` c[*"+N\}\z$E=!u}6==6 rFKu:m ] {e./2 d(2ǘ;C_xNןNsc70/|{Є,̌NOOuppuy5Me2HAGq=8Ɖ SVjt-}TT ,Ty&+XhՊ Cdlff&cyP p)^!ɄLHڵkZ]]Ս7䭇4ַ%?C7o*677xy>3j5`ݖU(T'(jqqQ[[[ѡ>}f8n޼1裏V,/JZt277}mEaܜU.uppW*t~~iL0PH_~eL$3Ԣp\.k׮iff&5P(j׀a -긢4X'aN;>>Z␿LfޞFGGok}}=Jmll?Tё ]\|Jv44<5Pf3\.kiiIjU?:馈Gxm~tIYyW&|1cvmT(I=548IƦFyjunܸ'O񌩣kUUjkk+ZK/i~~>Hl6XMLLD)>2C}g{'Dz6U>WՊ=ɳ|FN""˞ACCϳY'&&\!Cڰ֨FGX xwpp d%irr2U+0=%"1nåX(><CP/SkeeE7n~d.Akkk~وr &&&(ۇ"{:N003GԔ4>>MBF!{}]UU]v-Xn=MOORDZN۷oF}E!hN?X`YTΎ]^{Mn76ᨏH <*EFvwwo,QdIkllL333VWW566`>i^EJ37 =9ӧOU,ue5M5  FFF =|P= 8˅3Gj;x諧3]'{seU}f.H"w|CqV=kk<3+7oԳg411X\po*jccCF#ۈ УG4>>iD@B8aayǠ ڠ ڠ ڠ z{涻7g g]; ns${ɚVPLI!$@*PTCotc|};22di .φ9vHy@^Mm;*= u!}ttz%bl6 uyyeݺuK=צ8gJ::u<3YRvT̚Tp!V+8CMOO߿bUBgiZYY/_ZMwT.k{{[F#&?nYZ-jii)1gY(ϫ\.kqq1mmmE N> K/ׯkggGw}{ߋ b{{GTUj5:;;VWWà888ޞ&&&tvv|>J C>e2̨\.*Ţ~_?~~/g_ߏlDU^TP"k91* qAcQP(w訖"ËTF[[[Al)iĜG3-..r'dv>uL "-2GY?/̕ EnwwWQ?jZ__`c;pr73;Ɣ~/sD9DN'rF G2 K|#(R_A~888T"qnpZ-}WZYY0d&''c^__LD4u:0}^OOO#ߠ ڠ ڠ ڠ ڠ}4r;уۙ=J61>5^+DJڟ\pp{"~^^_z~`0>g7~DLvo $IJ ;R;<c{ηˮ]`W*G)(~_A}^w r5`ߟF ...ķ4~)i#)H cW/Zo)kn>3oJDG?㗂N_~qtYkgd3@q;=66qMNNjaaA+++Aɉ~_innNn݊!Gd|nnN=>8 ,]-gq=u9 IY8xVZ`U*ݸqC6!Y k J+x6M\>d3\gkkCnde2HvgA9y郬Q!=(q\ߛ }3L&ޑ_t[뮓e:N~QC#2%)y'˨Z/X4 }ܾ}[7nHC vtOO\mI\@=422YEׯ_vvvTV5==q^u]~]CCCVqfFzW_?n߾sSMi B>{iiiIփ4778'=RQϘMgxxX|}ݹsG JO>կkt8!T*̌JgljffF˚S9/n`!jqP7t~~sttP*Z\\Ԕ>}F׃Q>??,.w*BF_{Sݼy3 )y/rdqCč󋋋8 ,+ #errRB!guuUwEQ ʼ@d rjj*R%G~Aj[pR'\Ϟ=ƆjZ(*O';Н- 6B7һ"JPĹ IDATy^sjggGӡ&''#0uC5??Tln` }su.I_}FGGu-=~XJE*Jd2zףNϽ\in`QPۋL[[[j6:>>tlyKpmmmwSU)6p?~"I?&rϹ6+sP 8a<}KDLx hs}܇NB8ļzIJ ;gB;U<|}/AwY0Mtd9@.pyr c$e sMqtɑlDZ lJ'dM?X䞤$iffF݋ T*o*kbb"tcgՙ]z$iqqQ?~Ǐkee%0#:_^^ Xe'&&ħg Ky@BϞ= LSRTV{{LbHrzfXbCQ2k5.$אj9g Ilg^s'z3%DU.r\jjQri%'+k_AEd*y!L&7"ִT#3}|.#l|^$ NpNyZPT#Sg~~^Slll,2%pƠfwwW1|j6FGGl6B*x-..F4z74??w}WLFw T*nAО={ahllLHRbmmm@f2zId3ݿ_Z[[ښ..._D +k6!;;;:<<ؘFGGL!6dTV&ʜ!dP/5XllRƗɲJʘӨ7'&&p*bT^K7pEC666444`a=ʨP(޽{:==գG"i~~^׮]ʊϝ? @ AFDj0cccE0#(/іUUV,=99E=yD_~M9q6s4%7lp r& ~-ŹdCR4W66C6ׯR޽{V* zY)nH{?5IWZ4,zqRjGɹ$u:mllDFbZÇUh4" "8h74/ 1h6h6h6hƖz.R/HK)i u{fjw|j~v~ѳ~=ډkώ/5j-(v OG4>#A?9?vp{>fϥ9A `T_D8o' ( O/sH=<?ʱ722BG"P3mlNq ?An7ƔnnݻwC!O<|;wD z=Z kee%0*LoJC+O H;2$ Ș4nC~\߰&''C..*@pt{CAlllh~~>ε!rS,ok{{[oߎLD}U*r8X,jjj*dSSS='pldg0!HjUj4qZ__!L& d' l$r 0G|jxxD ;>a?y$=X}mnnT*iiiIB!r711ϫnl*Ŧttt#P(DV򲖗/\.@41)GZ_Z&B! Q+ܝHZ-궒vtt>HO>L%6F?M!G!Cʔ75ILELP۽b$&8_/\\\Խ{0{췉_6T Zz;w(˩ƦfǠ5kǝ b||\~C߬VZ$b(1d`\AAAAmj'z{n;6h?ȣ9 `1V?$~S~>?|ǎqiݏ al| HD{~Q6҈{;0V\[۟?Ʊ0r"l`wSG?JR"52DVWWfSL`rB]'qeM &{||kwxx8d@iMV2XeVKGGGꪮ]YpNRA> (q411NOk&AKHq߶e~x=}<]bNQ= Ac}}]Z4#DBS[r~WCJx903'g'B`s<"kq~pdtTىr\Ȓ$VQH\N7o۷s_W3ku: 255TLɉBp ){ HҨ{L*?ʚ/"2wؤXfZYYѭ[JޣH݇{OwCmmm׿޽c[[[SۍB Drʆ T9Hw]]!BVΌ;Ln9+66'OT*BƆT.U.f&)d =z(fܳHoƍvZ.daS& ۽:h(ɨ\.+:JlƂgP.ie0A-,,hxxX{{{zz<3 3P".~J`Gbwuq]bt:AJPmE߇u ΪVhqqQzz2\wd0QPzW4>>g=x@ BۚWEl(d?ufޞ~_ldY;odEz uӍxmmmw/愀4>Xˈߟ<\ۣS`{9OAQ 7> _1Xigd2=5'&&l6vlkN 9qcco/s1n3}; ^Z'BJ^gggQLsrr22P31P>qAn:܉qe@㚝 5e̡/3A֡}\QgI#t,٨J5\%*wtH8>>!=i=Hd0~> 2hnnN\N333*S &6aqT*-,,j4Ri EEyUU皚y7GޜIGCq=63j{KIDN;>>j P(h|||ǏG&Ҩ*osF.M~vRxxx8#4!h#ٍNGLjhRP~u]jzw_Аͦb詳]O?4c7Ly(944j|ΣXS@AAAAߕ/ ~SJHτIwr;y޴9_#?x@nJOs } t}I NH6 r\$\xF?2<71nOgqs;N:X}!=;;ȉ CycjXGP|zso±$uFo:ٽſ(_cb|\zDrB3Rh fj,NG\."AJ: AJMܷvr sP"<Ӌniz)DeM3H|=W*@ W^yEVUP@\@ܕ7~kk+z/RnRX vQ 9Gp흝W|GGGj"ǃ}/kZ:882ݻw]-|W(F\.AFCz=ݻwOzJ' ?nY%8IdryYHD u:8/nʗYS..5 }ttTjZގIU%uL~}OπLx&x]3Ñ!rsMO]j r ]EyXÑ6Uf>AO:Y;8˺t׳d־Vf?55ABŨIJuJQ\BA󚚚 FGGٳHַ@H6DO}NOOUT J?XTTlPnv;as\D^B}2]qFz~ oLdOtad/@7fߟU577F;;;ZYYWӑD]_~Y;;;ZXXsssZXXP>CzGb>;99$ʄfz sɜ8\lIQ_gVqm^㙈qC~x\jt'Wis2#=Rś\v~~HG[L>0J~7ukyyYz왪ժn555u:襗^W_}U> {WQ^`~-5mmmm~W%)ДV$=?S`_ױR3.>d^)0ˡ3RA vlO}&~qڟ["v獿"߳ns$|υDƉ˫s_S2Ӡ@5w'  @e@fh4zjʐfutt`>c3dx9vkQ)kkܟexs:N3.ϼ<;KCE)%'LuߡE!;'@|LRCcH!"OI5)O/ -׋;nݧa,[fJ-,,\.k{{;Ȩ3mookss3.8訧l訊Ţ$EFO>DZ]]2cn7΅1䉳p<11JQdDAqvv-mll(8t92^^ ~$Q?< wL#H}@{^N~IzуGFFh4%ٮu  .`_z] o?=0~@W{Q=7>>ymnnlAj5j5s꥗^‚txxDe]_$=NZ+ 3ݬRݍ9/`γo4i=ѓ'Oܹo~A^d٨w|| R3?{d t}mnnr9moo?szW"HӉCr:N(CĠ2.TpC?5҅.!ɯ}zz|RJ 9FFF3̄bL+ymׯ/sxxR|>1KZ\\j 818<1! ]e ԓm6=BH%6pdõhhmm-VF {0ӣX0BXND::vnLL0p*ox(e qJDP>8nWQ2:@K^Nގ#]9%}~? j0fff"C٣>;'ܗp$?,WhN2@ϩ-;@CJI' Iq|O&R ~25s`!MI ~C}<뤋SN9R>ϡDśl6J$o9FL&!<[:N!kԱ~c7Wpzf3ƣ=k y2- B_e+DV`2ܥ81zt]քktr{8e w, t@9U.U*\Nv[{{{zqTq؉]P||\NG|I$5tvRmddDkkkz2Ld,ᡦ{2vHvV7n듹5<577z:ݱEMOON()2nFO?+899QՊćZJ/I IDATٔe/9]|xxX|>(õjZCaĄr53YۍyENjI&K~#f~n$1-,,h?LSSSovvvݻyN@uٛRTbH/s7:: EJd999 3 ,C:Z-aŃD=7qCQVacuMi|GZZZҝ;wtrrJ9q@F<7jȦ#"l7S2`qd3ЮlҚ8"$N'WD-% =H ?99Nؘ_7絽J3kqqQ\NKKKZYY ǥnΝ;{ndMNNjww7qԝ=88cpC )0)f "UUIWVƆsh+ȅsр3|߉Cϳag)Aɸy!NSS">HkxBF[2pR Y>{/H5ȡK}o\.t\.Pξ&<7g 0=HOy4hٚK7h=ymew"emm~7G{?cdd$ņ#@ͣ2%Op辫Hs0mll,*" (:::qsztt4ʂsJ8`SZYR\.tv&h~jj*>pPHn|>fejKxGl~2(~r8|C$E;JR/@v@&QPytBu+<[n7J0Er^/.# _f_g q6 1r 522JB!uu MR/!744|}~%V+Kʚ@g!vvsęVՃSySչ^DXT#I!EY.M!(g=*ϫRhbbBvbU))/ 8\RP'X,j?`'9I:==jŭVg4rCyjaaA]Y<`7H<66\.{Rߝ^k{{[F#}zz9U*",Rs\B`ldHq?zrgڜa0}u:Yϳz!IXjAj$_(zMyl/D?T(b';hT*d.{'i3sMLLhaa!A:缠b?$'JYȬ{ RӿxeYJ%ߵZM{{{S+hqq1/gݎ@6x^=όab_YzNA2SZr~~^wލ5tE8­-y!駟^?oůES<~8RƅaB:799\.˘,jzFe0;gggyaĵh"6L&,> +62 `4śT7XQ`(D] GH.ꫯ'(W_UFFFT.v533yWupp9MOOX,\.6339---0l6 vz{b1d*6l6gD@ rVh`1 ^{]- B qĦ䌳;܇w܋X6$0='\yAq]~=6>S5H?wc`. Nţ˜T ڠejs$?Y[D}: mm~wۋlqۿgv{ͯ>4(*_4u| JD&-略KƤ$i)-Yto'cCJ€ĸߓ//(,~c+`syaWK2.T\2bAf#HR7JB$XRA5|<Q9L`{/>n]JXAiyxyHq'CNx3 ,-P'S_sgo}tv||<ξ Kぜ"qT8瘡_9cd  dT*)jccCZMFCccc=5MH_.ԵkAoS!‚ƒjQ||z=m5MmMOOkaaAsss=i˛ue{1;;糐a&f׳: V'L?C]Ԕ&''{lTút"m0t[\[[[Z\\I2@s~~ y: 9|3#_pxY8>')t_KfENJdۀeSyjrrRׯ_׵kׂ裸&lrn@Fx鰎}J>8h9p}W_Ԕ?gz뭷"EO~?ZIAAAk)횒xTTwa{agbߦ}a_L`4ܷs}B J9gGxZk1ftS,N@:I=` -~㛤gzS=1LHtNHA _%`.u[lhh(>r@_@ϿgE>QP\h+Y#q.K {yyd|1w܆>lCNrJGFF@s6 pngIWHkl6qfggC]^^}#)֜g%35=tuERԓ)Ya?3 ZdC*>q q9b5==-IQt ]AS1GI0K={,n(9)H&<+ DK14[de=;<8cznU >6x_m#׏IYWͬn,Z^^VXI a]gS^OJ֮\Pբd\Z9aX|>W_ZZݻwO뚟\d FqΎ~_7ߌņp BB) U*8֗_~gϞizzZv;uddDsss!v[|^Z-&ann.s\6}ppvqqqQ~!415e{DuXR)Hb{ÇsMݿ_8pPj?l(_9::ҷ-ݿ?Q*z2 nnZF^sJ%m---I%=7z)5G-S2~$blHd&''Ð))R0 tc"ZVw~r=;2qGȟB |q,U.{n0ubQ@:Y/29To~E MOOViss0B͛ԃ/{q6l$R9,sLm4d}Ⱦ?/<"uuPoMտ/~x߿6h6h< !K"=76MI&rkQ,'w{ `RjO q/$w ppO^wɁaQo?b9/{ul@HYi$a3Q|l\}P9X\0z`d8 q҄2We+|>L@f)dX֖NNN+dԔ|E,kZAa")i `N.N,@ sNOmU_; YJ'R<:`}wbKUHx6Y ܳWwx ւ>fhsŒ>::fvҁkrN0젯{dYc sd3|3fggc,ru>裞/ )BBgt] G`.'&&f7aJrj===UXꪖ555ՓIK)I8h44;;GilX,-_`[ǏSq;վޥrt"R;Y}d}(jv>Jj6Ajz 5ƚ@8)466Ybd>!>|>%J}vv׮\}̙NzdBQ- P*o666}moo+ݻ= jH88PVSP625 fffTTL<b>`Tݔ;.5db1P>VVVzYGGGGFFt-U*0P.//5558(ϻbO@;Y6hZՍ7tm?Gd O ~:YzNOO?S'?~_ikkk@ ڠ ڠ7lF|/D;nhmV+Kܗsůɾ=4uHܔ0r@~@|L^Mgz'b)8I;?a`G:W=ĥ_~{vC?*O* 4O`gs|QzL+Bx 0wbWLrX__y޸J$i?ŸX,F4>r2GR/Ss].]vqM|Q렪[:WׯG`Od`@Q N՟f<8 >OϿ?9== b 'A2/!vqq龮ݏycn )Z3]ǰReql8==AWVd> ΅Da^گ4iz~~dl=IR} 8=}j8KqE.P$d}|sDljn+~;*47R3n钌;ޮ999Q>eG3w9AsJF*I(F(mõښи:\,ˡp(@N#}lLmw6uH]ݻwO{IeRdttT?PLO>տZ?z:֍+AA>QHg}R%&wp Ծ:~~N_O#HDS?S$%=sZ/kϛڹ!=o r||\:<G7`p&#RI=ȈfggU*.aj"( fffB(gggcc={,Ǎj6ɋ(%GVŲz޼ IDAT#ʈlqJ%mllDmmmW^7M]^^*)c|{cҝ ?NJB΂,zImEv>&l>~BNGEmIq#x t8AyctQ"=\ARVd ;ĽFFFTT455꥗^h ]!;{2z7tttjee%:8;;р;ZQsc`gKYO˖֭[$)O?{}{?z2+wAA{@Ödp`-SM.]9d̶5[{y--"f8!FӿhoH"`1s #!!xs9xsyS_1u? @('Ҡ:'8r,%h`]\T.}\n=L"INX6/)0߁*IqOU^j5JJM"{l)hc wPPzIQ޾.SDOe}};ep  гi sGGG5 z8g'T}a|w#]NCx6͐ϖp%%#|󷗋rt#Rb"jHw]R('O*rׯk꫺sNd91nCCW_̚pK933˧7o>k0#ƭ^Ep󿻻U~Be,!v~D?:+zX1N>D\p?u]t|vvvz ]KI񑑑EO*pKK| ;)x#=/+̺r@!7j4٬fffztߧ<k}<`fSz= [q r9jii)2F$TmD|>rgIf$nG#KyTM\i1ttt0&&&v/BR`&''È̌ɓ`(vrr9yfO·G)꥗^Rшg-p!d`!Vntebf}k"ʊrO:[u ˂xvxR,7nhff&?GDbLMM۽:H_}tttV)MOOkoo/XOwPi`j9? A湙{wc={Lv;2RviW8b1NhMݒ:馕64WZ'ƍg5MǦru=}GQ...7|S}Y(8\Mo\.PNGO>Ύr_վF:6<<ǏkooO|^?0FHAÉ͜VcD0^,iK_EA`Kˢ/w믿沈G)_B{/{"REN ڠ ڠܶJ}Dl?94Jd|ŗHm<?]uTwG}W>3{3xÄ ˈ8 >#$w)pA."  $I$_!H%3Lf2_]UES'ĶjKGk6a_J7 9 >bgwkPuA3WR@ ~kJ9 tSz{〜?O}~sg8x|t$i |s_'K aOUppG/)q<2=x''#}ywϚI T'^v|R)e' LGC3 v LF#4н{8cbNkԜXLrʟ6og ?|pyyד=87rz.#5~۷o,bEu1p/ ܅Z\x-YB7M5 ꫯD$4 3P [ץ%U*!0X }9Aìpg^{#d1vrlwEx_|_u8k }r9 /l6z4k]\\ u{G|0h2ҬHFEq$-0n86LcMxס< ydRҭ4ydKg{L2уnt::==id.Fvbe]\\~uunEƹN\.J>hm4* ~nUT"L/7 ̣0Y$,666UF dIFu0YHB(+G& dj#>8R֭[!-Jnpx9465Б a*_|QwL&^zIzWO~2RY0D1fNnar}d2۷Sq<'ʢ# "楗^Ғvd-QL*~V8Y>xčf32P"Af8ɓ'zjZWl(8 "B~b0 sƩoxxOwG)1cx; 6(wXYYs7//~ZZmj_M yyyi":Ԭ ӝq [$}u;%󔀐KmG2ʑ7L@v`9ݷS[4}v bතc裂s; j/ئc )sع_8<}WKo'@Ҿ;)<'h冸,N&^ZZɉn\ޑ5:,2 >3 ?ta2/%R9Ck7~>z=u(vHOT':zӯnJ!z  ֲ&Id;r4p}du2czAt8qipo障T*URQ^WPPZÇѣX.O/ĸ;zLvYFFLF?}e27%`YYY 4).՝,s|u2^].5U.#hD$2k׭g<9Is\"/..*Ef((;t'U|}>|d Ⱥb[V<72$3Ydf91u=#xb 6ր\{>'y/y;Sq#2ER~Ӑ-1CsOs#(YpV+MDtM.֖vww|Z:T*"pwsa:6%(W_}URI^KÄЉBN`Q<X,h!L&wvvv⚳3?~hY\z`"|.Ā:88>@o~S??c]]]vzzMX@I CpuPHd2*ouuUFC>TJ Nxs3=~XoNOOfJ@0KץZRTcܨwtE,>隼kZ!"]Km4k8Rڐ 7; F2^(;f2(kNY@7D `=ō}wHbv%uq0}^ymmmb# _ԡ8n;UFcV"R'r{F?{=\]^z(|_[^^_KI?Pߙyy怴9`4 ?*"< xjc?`Rj{Ve}fe3i2)X~J@'R҈~:+IFy-W2d\[i0nܧ偐OZrlܳ4J]'0]qvvb8Q BArYlVv{*01duq:--sxs퀮1@gggSeT![} P\yݻX}>}|>|>`/׼+^@VKZ-2_[[/{T*kjN"`ji˼:.\CZ ŢCf\R,<Ցa=u2q^NZnp? N:4B!X+ex\.Z(ᡎuyyR<ŵ>8t:3 :gz#F Jr_S*bͦ?~Zƛ5 X( qWTBSdQ,.Ku2,%|@nh|'|hjqCyt\Nz8VlOojggGqF#y={LGGG:??j2oܠs`4\.kiiIB!zXج1Hqư#kI"_[ RtzG[^^ֿR_򗧀)+I7w,S`} _4տY~>}:u__s[6o6o%<˦E;{ txPqI)QCoYͫl8Kpޱǹk]W0p" uAؽNp0>Ň}7w9,,,L͓FgZn3>EH>٦ϩ Sd2'g;s?L@݁,ϜU܃Fe]\}bixy0$a982褑^1XMF+u⠶>N0Nx-3p8nN9^zws8ARl \ssSLFv;d2SeH5xX: l6 o/'Js)z$'>O>PшuG޽+IAN0. u`0,ǖr\+++Aq|wLI4RNGf3l 2kg{{[{{{ܜ$SӉ5|zz^7_@0ph6e]}Z*J e +͆`eU*`9Tgggj۱Xj:N,GVEM["ᛖґ.J,e=|PϞ=Ӌ/HV)( &ʣ\@+x&JdU@^e2,cZVD1 +Pae*jZj4u^xᅩ(' 2ܷHKٲTYZ $swsMm;u}.s=>K(kk<P#ܛ"Nи~ii)xJHjz˸hsI*2cLP1 \/hOyKsA8єҩ=(::d c 0Ȝ\>Q ]nTGt])dooORI^ojݧIqƕ;&{Y,'N}TnF1rYwލ nc}З5s,QޓpW zqw^W \lccC/b1,_|Qn݊AiZ(?jmmMB=M&+jkO~:<g^Iw0a#v04UW__RPPVAu$]ojI:Ip n6;AQcrQBAz= C鄲bL& OFS̷tSBp4ɓ'؏F#}p F~6rh74$1I kޟyt9_\\.!6]wk!57wHN7ԁqW.{rˆ=sg*w.u~7kaaA !0o_ 2O6vwwcͻQNNN4Ld2Q*9vΰWdbLxYNzscaa!@Gd@GzqD{g_օGϚD.XSf3tM`~D݅Lz&GV" d= =:s`<$:9y5 3d2p8T^a}N4%Un<o_*|#G\N|>DND0׌ r*JƆr;QYIR0Umhd1{q~g^{8LH<###ǚbo~^T~XklT۷u||F{`orq y"RWf亼J~.slz>MPH|>}}_Ӆ('ʉ :99yaݱH/K=>q^/ \ߏ-lƜ3(1^b#ScR8۷um0{']M& }vbb%YjVJEjUVQsNNNj"P>Ν;_ gLHLB4"?ve`677# jwwW݋{饗c?cjtmV53u:ppHHeSixft AAXZ^^VORwȣPD}:Uz~65MC9tttCDIl6UtB 񱖗vO~2ΫayDfJPSrtR*9e׋v#ӿdFtꤡ <:˙ZWb :Q9˘p6߁`'%0l4X,F~~{?3 B:x<ohqq1\.NښȌbbҏEę[gggv,`԰iyz1N,%Χ>y^6',;_/~Q˿__ ]<_w6o6oo4Kyt4Q ҺM}p?<#Kil~sss "YBzoo/*PR#Y9YT>7NDM&8 yd\3Z Dj}=zHz]jUv[:<<Ύ>C5͘#'^{5mllݍ st0i{i{{{2sqBY]eYz%˰RLUJ׃zf  .lڒtGg(wrrL*yv ҈N(毮pLϟ666l6kdu֚~\rfp[n F?^)2 =CRr6d(xlF2˧>)tZآ˥#:v<kuu5vr6775U*`02tkPښժ"x"@jvvvGt Ob1fK i+Iu;h2T* áT.//^NHυᖤvN3 `sÊwy~_wǏuqq F ='M*)%rFG  [}vv]ߏe<0Ľ!g?5ȶecC8X>@gӨ=c|G0D}sv#ݤ}Bg7R|a4"Hj'H>36kSspB6o6 J '__Г'OSۼۼ}4rɉ?mYXO8xCwr{.{S;}i-ϐz|&~Ͳ;m_Q㱺ݮ٬g4?>DxR=Ғժ677ÂDZfUմb۷o{qqv=q2( Xϗ 01v:ljmmM?z]^yM&={,⢶oݻsF9ҒbTtjZA CW_^dޛȌd2:'m0LVD>lϸ< Rpayy9dj80]mllIiE2yNJ DL.pWB{WIqfiMdF3)D0;0|ynTzF`P*//$s6k->,26s=}ToͦNOO5\XE ܙx^z WkG#HT׵vP Q&((gts9y^__W?Sks(I yyykn9i.}ԁ|q@7R >ۊȤ=Xu ~>9K n)0H<26>4q2`>y$P/!X, 8xGM3tCfDsV("q}}=!ŧyOQ4Aaƞy%018~55Iw9(% ]~D/OFw?x2D/5W- |lq)J}y{D*xws_¯vk鋃܇7<ϼ/`>o맾Ɓ@@|~KI\.`tSϣ%M)>frûRD`ܯ`g`?/> G3~8!9x yJ/UdqF}eG?滩o¼蚔bL8+Kϼ:YEweHOOOu֭(otqqeYlj<8q<t코uLj7WN'@Rpi<Zj{{[\NZMAio,9??׃b#1NmCY*JBF:筷￯_]j5Y3jUg۷oG; ³ɓ8 ޽{zW+dbZzr2%R'E Eu] mllɓ'~||5ݺuKRIVK 7AlrB&QR =:b͎cU* &\YK{jjẏiva9??7Jz]+++ <;./GƟu],d{S32rqϸyŅŢZuD֓'Oh4$]c+++Z[[ =r,.'|;V1vuxx${gݺuKj5t z`jcyݺu+ <J i`AY0\g!^y`5LT#D``ۉhI3|yw8X,_={Je8=:ÍJōx`CIɱ( Fׇ.`YX_㐭dvc{rO>D 6 uttJ R_zaHސ4nl.S(d?1)#d|!݅pxT)xJ 9~qLG?u˟ƛ٤ʊj8cIM6RiD_89b@>#-ͪR1_饗^C<.uՕ=zʖᣣ)%:*FXM7Q=y?voo~Ǧ"wWWWa Pe,S%8ѼۼKmjU=oVפ Cz-~4hld|8&ܖf*طz i//-=s%џ7h>x6?xIzr!r0Q/Ľ.>&A (VfA0@iwΪqR)~OFAƼÌq6{S*3u qJt X:aWrR>_8&NG Ggggz$W^+XsO̫E[Yn\.V0KɧR,˩Z1Ȅ(2 _&=\3adx M;666T(YQVGp$p8Tш@(4vqq}2H uJ"L&Sc߬toKFpnWV}dp P=:)TTts.JuuUz]~_r91e\i4 >"<S9ߔ>([ ^xAq%Bq81QQl :Fo8(bjcBzHD+W_3B(٨9ңQ06ؼx>P.vww X. 2GQr $u}5 PaQbH#r1~&%|̞ = Yrn8Nw mJpm/SVC<2 L;Qi!I s neeE^Ol65_qBp޽{7jcu:z6dn|LRGr ;8Ŕpytp?_7uYO6o n?-տWCDz>H6kHimm~PZS0 ﺝ< XE"c;)wz/Swu}H$",&}w'Z|@^ "^}y3w>;; ߉ެ|pr< iPYK$_T~9H$4gpd2A Ҥ ?kwtq3 `#a@~9j4at ]<%{3gDc'gl]+mee%*jr;W'x6BTr|3'ŢUi٬ݻ}wl3xo֫ھ]I71:Ӿ*J"`=A @Z+ /%RyttMq۷CΦdD9`y-$I!G9Cf}O""8.5xϴcLx 5$ěM;??WN3晒k1%^(p?dٙ}?Cf^wc, DFY`/..Th4YUba& xfƱ7^߸߿7|S7_SPjllLc ?Ct3܇qz$Iz]fSz=>'''ى7HQx$ʆ `oG `|;}^ |x@IבSl̃3(B>Cq%[luuUF|`̢l/n̸,R2dpA7`Ssgk}Pf<$OEq/ƘIMM+X9N1Lp(Ho6Sp\"Uո cɵa,:`Lw}|szuIoo_b2?R5kxqqQ_Wtxx{mm޾3I'#࠹R{luln?xάgF}n*d 7Ez>÷4On|Nہ&8e O|n?;a{Dx>^#|('(O ) s;2{Fϥ)>7G/ MmH/y<;` /r^f*JSĝWO'k|uH1ǚƊs}By*s_]lx'kwnUvvvVst⌟>sfkY /^cqJFC^/qz2f!G>~8i?~X!p8Se;!4 nc}9pyy.{|>2|*h7<Øc(kFFѣG~Psr!}Qs _~Ȳ`ӵܻP,hnGop 9'gG/Tq7cGJ:s]GSe<(>.\\9on~N@J^l6C_Jduu|@-2E(Gq||C}} mmmi?H \r6:kL$WL!ݻ!쾩yPӧOn N9á5 `rfT"&ID-\Ǵn)A0,%5JQ@*88l܇ژml4:olVلL&%6zhn"c@ƙd30t3w}N>T^;shOO)__2^ЛMIz~~J^dۼۼNs|HA fVTsN O8|=흈??vo,iQ }癀(B۟9O }^Rsl%c>M7' U>ŧ^ .˸J M'7ŷYE,L&<1t-{?xìrXg܏ 5ý}M/g1ߐ-T߸sjV)2|DZ2+3\Sޑ]qgp޾}_@/q<ӣn//)~D}D2^cs}ݸ677h#d#ԬF+Lh4R^x]]]El6v=uͣGb9& ZaccC4YӉd`ZwZU6g96۲F+JR[T. d/U$EU|>Q ܳ"Tcs-m$if<60LH*TMXT5von2x0Fy|fb(C=yD_?j4;zOr`>j5 8h4gϞ)(O1X\.HVhoo/D6=5 AߏCpqtqqQǑj fU("enH!uE*YO, vzzgdt#RH߿K!yDb RD$_"p 赾q$X|wܙ9Dܾ};g-,,B%a}<4jcMsBMՕ2De2H'v`oa{ZZ~WUښ?'5ژE~/Q7˔̕__տ^ַByDԼۼۼ}7!n\9v!4hQԩvՉ#|OuPk~>G`n2&2E>r_Ax(Z3]ISS1i$ds)¸OxW8v@wy|0'C݇teyp*k>NK 17F~ #>$?spk\ wvmȻt#c" 9`^'cx<|L'99 <'iDžw[666"B2lDs)T. k8F$?Bw 5|^bQ!?nO:N]1;8NP5:IQ|xFQ.r <3[ӧO[)󎷱nE2 :|ƝNGbQ ׁqp$;HG_R:b'/..eI$ \g/ B!2i xl6twv=b! I=I  'S ~w2R.˩X,FF2kK!ÝP$L}+t ; xf{&C9GpD {`0´Şə\d{D][[Ӌ/)daẬvz=j5i<kkkK$q#QGVT /^\\lF?? "碳'SYYY '`&Id!K>F0onnhh{z ob1z?o>8 Wn9ɢEQq#k_^}UDZ-67z҅L!h򤠲1%h<^^nd2;CCF#Ŧ̰ aȠDPp@ww݈r#j2np fq6IG7SjD^҇8ljH$}ˊ 0,v7@uY\sgID WTR~Hg ֗d#c,' -OEQCFMaR'ժ^z%;SNbٶpcwf5/BV5?*$ME5ёS19h=o-uFi[I׎Կ7F{{{_haaAɯ|_u]Nd]6*=R)0Ǒ5C`0 )$q}ܱ+1wdԱqT{,\qzn7ATַCTߺޥ9N_.gzM:T^a?}4v/k}}=tY?܏ pZce8q"=\z]WWWVRQ&s}D|M\'Jq |>^+++qd GiooOZ:m4rEXٌl72%ߏIbsE "[! (i7t=[č*...;wT*˺ԳgnA'`mnRXJ!JшP\NZX.vè9<swVKl-qߏ)7&RB׍p(% 6#wNMN_SN"L>Oy/c'Eq< B@a=7_Oltcvvv"k/nDFbޞvvvtttAz k7&h@?~xWƊy9b`Ey1yjF#=zH<Лou}_^8:~>K#_>d,]go;7'mm~pQ>AIpGon'!`H&#>EF>nk?$T<hJ#=b܁_''R} x&q2&R ?Z>$Dr@āw'_ 2LDzVȃxϞ=S^yȾ8en< 4c...b@ՊhȹW\q"˿EX+Z`NOOh4 yj832k<\p2X,F AzN 8dnHXt3t֭T"[X;|ߏH+4LF  A)Gd0*2\g YNGv[XSu^Y&`R.r>CT1i >бHd2LZ*ǏiPdͱp>cQ!x >{Gp83$f|ײϠw$v:88PV{{Օz\ ;whkk+֌Q5 l?T>BQш ݻjJ^dpÇkciiIfS* rLDZ^Xߏ oSfǦbY__ÇBqX(dJ/_ T?Wfrba0s~'~B_khJ7q3dA8yuqq>@g>R87Z܏tCIԧ>۷oǙTTZ"0 | 9@:e0,T~(1xj<89{bԸsda醢ܗ{y}hv|#L7"n>426 $w{P| |á߿;wh2ѣGZXN~@N&_|0. t/..T.u޽pr<%i}*>)Yg9i4oZ uhqqQVKk٬~W~E/٬>J>WMR-ZJ6\wm{D?ۼۼ`4XMtS,unӀnMmy =(}cO P>{1 - <60&d.I7~Hj>C!XD{FH_w_ʼnE pT xC,xT;$ d\'Yqr0t2>k^i~GG0Md >qO'*kB\˙ `"rɳ=%O:A}w w=X/@cf09r&ŵcqq1!"60rU^?N.,,L'%u:kkkK\NAx0$GBYC, u!B߱?;;SZȊ5URp?R:n"[yruT }dQnc#v`vף$ f x )kqyyYjUS덵])+rlL N9RGsl@: Su:*ޞʾ" 6dq&׹u =|f^972sESr$@Vz)e|36}XFnyGN|`N>Ǟ3Cfքj zzU&۷|>) }KY י]GGG:<|8E."T̀gƼ/>)`u #̰0fz>),oF5il(56INNNT"w}Wf32zSF#XoYR\=R& \NB!6.O3E\AƴlF E.3>ngϜ"M7Dqy_gəo |L97dA_t]p)wZ'̘EoGɓ'*J:99;CMݹsGv[GGGZX>+ɨR$ Y,-7_ mll0U&S&wuaOyĒ;;lVO>d2HN)pf2X,<͍}+_oo~>Iۼۼ`6]2pRd2SV/5:De aAdh O?qὑ5%ŪR.S9vBӉɝ̑ՕnݺuI rIY<Oš tǝg㣣c}KU@@;lq؜cN?~fCnVIUɓ 2sGx$r~rqqUmnnQl62< P(hmm-UdG~=yfސ{KrЇB_wݮ..."鬽y'cnVE B`26doq+z)0k3ϳORӱrgwR,%ᔞ pܞ"C6Le=![>[vu(V sܳ8۷o:99ݻwU.U,ܫZg]^^Wn믿jcMAj3?v[\N[[[Ā|P(h8>FR$=4d^^^ѻ/z=oR bczhto&FpvoS_ZZT(^8W_UZU EqCg|^B!P7Y ٳ@XqG0xD|q5[X[oEaɉBF)1NPl==Cd=}r_t9A|]\\oN@@<£ B\D; bn t6/u}RIӈZ'|`@4/MN]Ey>WؓzFs>d2^йeSB?w`z<.EGg<&ҞwFy#}OI#RB w>b}SB(:yb~GQT2< 1K$MǑUjjlf QN ש)6 ʈnݺKzX?"kq8 ƅ@Bg3]trr2w^% GQ4']Xr2*%lF*1wywLTVl,x|v/v?/ rճ!}YNa-y6n3-tw;\.P(\1G& *={.~.kٻ؆s<ɳ|_Rd2QZU6uuu5HpjޛHzߑCe<MYMR,WX0ƀW]_-lx+ذ`Yed쮮9籲r/zlҟ}@"Pĉ<qt:FaO<-RRu{S:v–Lq[{[=1Y*|,5@00ªjgV~]6W^Y^wM%G`h6cQ}jqEhY[O "Dvvv/ '-*% BfS$i4HURMS IF\S }-1G?x95պ9cq kxS5t=3ԦVrA bl, O~baGGGt... #RQHJ&ҍ3FhtU綳cV*G?c;??~QD%c6usuFmW]4[*s/OO]kԯٝ~Gʻjeqzvslڦmڦm7E@+ @(حMmm^hO*QNd2q3&uVqOटQ:R1Om|y ~V(zDPk'|w|*21͍j5iuLב?%f^Wl_^:zxU@YU;FnkC+u??1>Wss{mR^la=ͼB|< g~8bi?k׳}#EWǁ#˺kf2?d[:}+ NEyJ CZ)I0?ь]7Ahi2 \c4Y۵``^Z5Z+#ʹUȡ~}CV LҐG kZvvvDP|NDi_ȈBX^׳4j0 Ώ}H1A^z-A:NL&cFut:u@V#ی{h?tU[z6`$2D-~)q2 @GLS:[.o P09Ot Cf.onn奝YӱT*eO<߱j8Cj~o}6Zf;;; L5Zdlrl^$y~JAlB57v<"@xRdZpP pdiJ(cc LO>qL(]E U5<ɂV.Za8DQR1F*] KW2CآqBYŨ)[P:5Nus?Ɔ{\"p̛F5 ,\.gv=RRȁU#*JDa =>st0VR.M8*'otX+ Ѣ!OƌZxj!g *C)~=(U_vwwm޼yc_Gybt?5\yUn ??G}qqav E6|R9+%FڴM6l6k7c/kŋjޢݴM۴M۴o_SovWv ;6|7)hԏv:JUǏZuAA ϩ>>P Vċ>>:J\<3xԾw3_L5O:`7s ilqٯ1ԬuԏQRI0U7ͳqaEyc@58q}i AT( u`2؎Plt80^lQ㘬YUQ7X`-`^D#_KPAp*?:]fwg};EU h~>ΏDi-?b^G檋G%t}ȤB.D,#>ts/KgTʮᓓYtꁴ?~yz >ȚQ!^WY [$0Ykt T*t:H:M&wB+CpN hbtk_OFc:\qbou >"k_ 5|#Ӕ;dBz=__rJ%+6Ͻ|h4=s9q%pFb ؜2^rP^Gyƣ&@Ѯ`+knT"79xL&8hXղ ݯ-O JAHX/CmWpOF3EٙYv1 &n<pcga`TBFSMFA4u+g5K'VMLh(Qܫ\.{ZΎG3l|6:o,J$o|?"(/BQdfǰ׽BNy_9kFV#^ g|:D:GN\.Q*fѩÌp66Ҙr>3{yl:h4J Ӵ?J.lϟ?ϟ;ZR5VuW}.:曶i߶v||l/^xuu2L$7m6m6۰ <F`/6)DfHXZ!ڕf&E@UXP2+re$E@O_\.? 2q@-Nccf2шu%x ߄yh_'4F,o~ km",2O QUbBaH+دϭ~A)?Vl#| N岕eN}V\n枬ux6rL1tqޅstԁX@F#+WCf:g.//RyFdۑAY>~6'{*Jc?"qC&Y'{{{VT4$t:+{UU E4>\Slx{a afqhVF)YTZ奍FsYP:.$5uҫQ8(HӣGS1K;==u@+5P>݈030fPF*Gj@Ǎ_ M6\_ Pe ;dH# ycs+ Iy5 qPAy^ae3QP?9  >f37&y--F#O޽{Jݖ1?;;;T*@ӍgUDi}|>o/<ڦVy8e H$5:o\뚮H imjȽ~ѮqlM۴M۴M6|= A؄j*:&DJ8KuWlH j HVE;ΠEBӾ(SP3@0_11VҔ(7zM J$!`Oq| p&p8wHq%L$8W>tt 3yO}MHu*f|)NRt^l6YRqLt [Tr%Ɛl%uc8^t\qMQR$2% өmV*޽{e- 'ESZeQ%#kD&8!K+b::~߮[ӱ^syj4VTl\:ql6XtN{%"ާ;??xlVV}CN@=`0p3Gݝ ~o_vv8`0: :x/9%ꜩ@vڏJ>Bw-W'ro%kџHD_Auio6j}R{V,*NRRImd.ce ~T_RG3c RcX&[/񮘂<%:=>s$#ਁgće=Ծ_}DCZK)q @_,jT ԵDg_ Z_urhH<4P_v2l3Sic RϋMӠo;s/H*s:T}njX7!99`zz6{Z~+v؃5/:$ϘaG"l7ߡmc^ij-c?jĶT)n%2?}cv;w:[]S}=d"JY߷^gԉwD҃@" >hFѺ}u><(yE\ }U?)^TCXWrGyQ6JQT*֖ CO!`fNT>kZ6O?/ϭXXGه~{ WT*U=vcKf+=ZFPi/ W^Y6ulT 3TlCGYaZ+Jl^__[TB`b̃i`uƜdlgg:.0 %IMOP(XѰ|5nD̼_#3]-qR) ֖YqŀU.}@$hЙݥaM^"xs~Gq,^C᫱djٿۿY߷>l4Pq`Y!1!JK#}uȌG{{{롘hMF ieɦV, *h gtXtl4%6DkuL!gf=yZ-{sٱJbRt㢛)8EߍNFP(x'* IDAT `&(Y#ȭcg;TWIY :oi#GC(MÇZup080w"$<3DоkȁiϬXCC=( +LEckE?mp-`\kWg[[[v:u5CLg?z|׸ q)wnY70"]*k$ךL&njz n}ƌ;]ߐKJ0.fcZݞ : Ώq³G3NeY/QaRl\c:Ze<^泪$|#%@<2s'=(=x +f{5Ȋʑy%@ $h4m{'O3+^2tݻg777v~~6`ȞΙf̾n,ٗT&>(jvG()f- z63UkG2_{-TTj4o cJVneB.K7 W^}'+N^"93NDﳾ l&F#^OX!-YZ\. ĴXf!͸R5@b3E6c:NΠh aa:vkń!͊Ţq  nȓAVT= eafzz;"bJRqR؃"BbRY7CU4b»  5u|9\weJzy`@|~4Ŕ6*JJTn^Ϟ>}AbvD^"r/@3%tpFsXTku}莛Γ#ky65x0-D9*9`ܜN'e%m"*x䉝Y6Rd'''~Pv\.'u2Υ>5>==/_ёm} uԀ\7ZiuWAtmڦmڦ}:fog[4EL}hFS?' 0N&zw]V䀎s$K4cMhUHdOsM0JӠBMAa[q?mr 53k,Q~llO*Qo4‚qrH!|YG0FlE2^J(Ѣ:CeE9f3&D!k 9a̐%G;;r\@՗U77j02}Xï?4ADR=:k ܇}1n4Z U*?š}%4;M8Ou ;fE\^߅lӌ0БJSQ dOS59*zHQ fwQ]˳V+K/+KSur{̑1}".Tw:SP]$2tXKdLS+Ui<giqaR4< L&c\.R$i.ʊxZ٣GbffJgg`ӺF8GP?zmvpp`[[[^#>.:>cvC&3X!_h@+эO21Xwm5ȵ>sb1Wc̜jvVPVE/smQ3=_aGYum|oڦmڦmڦmڦ}uӈL@T*奿էT[f|Fj3F?-W pM fk& Q^XGJ)P#@Zm]J(`Zb汷|BXyfwgh$4}?lԀPpxjBΔe+'5i||oRJ\.[6NҊE z~bҏ؏^vC)D xa^wrzD[؇-5NZȔm~鴟uuue⮯ZZXt:l`]{賘.E;\r0N; 5U֠ctBG2q {^V^ b=Or*ZjrVcOTvrrT'ߊMƽZ,65{i `Y ث r,@2VB.g#;Ywc,(զzݚݮ|j#6Ύ}%F;cK Nu8,h5FK)"63e0aܹL$BȠN7L:bG%)(2RT*9чB`pQe SF_AWCID^2MJ[wH1X,ӧvttdLkMPLNϦN`0^gf>#/xW5*6ϨT, ?Y"(u_ 3 uy> z5YȀ>#2>6kւ{x\XH8٥YtRՕoX&]]]SJuuՉLN>#%l(g}F~?j/64 '>/`$%'e&~}gf|?A&H EDs "`zoaZ~= r%z t:`p8t;r  @Á_Bzxi g*i-^gv1wp 1_3NwXxp&qг|*UU?:J!oTlJ%?^[* Jp Jv2. Pj<'y``_'oRu][RqgvqqaAS3KEAfPj$ry{cdQ\q].S.?fr"`ec2gY'\ kZ"]u fxx&w87GؗН|>wKq3HrS+(qe0l$,HjJqlr-% NND隢F'13?GIRng% U0a~!˥=fus#X,&Z=yCv5 936Ll{{ۺݮmdMjFHDfN_ؽ{<;d]9;D :>+:DjV+vvv晃́b$0TZzNNNlXZj5'`˷& ud4Mf@ib7VU+{kw:ZRZ:X][αZ,nd2^Ҏ=JGg2N,ޞ' r} n F]LF)(M' F7~{r`G4jkF#BF@%z]__s@#YJx]Bd͒ moo{";)wJ(wwwm4YѰtjggg _`y~ϬJA Ih}rjn[-Mc$@$hQ_/e/%ux-~'V\W*Q %#Ɋ{O{|O xc7^d￟( 3Jٙ...˗6 <:yܺe/_ 95zAuk\:7];iiiiljG$뾫ouÿR@ѷj?G;PmxLt\5f~m4-1.`5|D5+sw! `yW)9>2_f@0wk 8_$`ҠEw/_q3KȻ @-1xQ~#NsTUk6NlQ? 'H C^uYۚ" 8^S[׍Ͻ|jT"Q@w" #o>-ZTl>ۋ/l0Ցj%OGR&<9, A' ՕZ͎j%MUul1ϊ*٨<̛#j,X(q׏cjfAb[\t#N'.&H /KX,!k0U*7Q>ѐa.n66iiio5U(^ Nf2wsOH-E6>%O_y--~ -ژڢ_츖vzM;YJyKy<Me}a )!c+e;;;cwkg>[\{uF2X?C$8b㻪s{ ({T^uu>kZkYוEKcu,"V &^8[Yt-P2=-[_UVhd~O63SQPq Ot:Y4] ݇X,ޞc* 8QA l6$ j4OtV[OUJdGmmmYyםNVNNN< m6ٳgٳgvmw+$KР;%RcW]׮T}?N'QLcb]<Sg!j ƞAiu#QIAzQt|> P v톉H}^ k<laF I8t JNYqE"q`l /<6[VIˈD|LMcp5]tER}/w%xZVx(c#s눱:Җkh sմݮ syZ9>޾ZݑL,9fwYˬ-%U2V=gfV.=\V%䗟nk_xlVG}d|ygbC(sFIK%N(Q6 Q|X,`0Hd)<]+p/N8sͺTb BR[F9+΂eA 隙+r8A$y=phF^a_YBqcvR ZDiBŰ! IKdVwqEh4|kRLlXL(up Dٴ]xݵ|>oEjc(L;,<8BN$ O< ب1=`^ShψuI25ks:NϠeȑc&˹y+㬁 ":Oc:NYצM:ʖ-KǂW'ѵkg][|Q"7W\16|}M:̫bP|N6MN8 @jfQ] wȌFدk m+ˉ# p]V 6Hgݮu:v2Ij+pf%x!@ i4^<3E]X#8'cLhQeݏ_`^D&9|h=Zf_NV,dW]I_,U%p ܋uY.j9n`-kffO8sN_%H#6|6bF!IQՎe(dqlRl+Z׳ێ_%XԼi%]elv[T*oJGGGN^V+ONRv}}m&r}}i6=` E[.m0$VXg@VJ%7ҊY 9u1,Rl6v~~n[[[~2xfΎeY{ݻwNOOأG\hvvv? Äp1G$ӟ\.b,պԹTy5[4g9*E5}G?J)4JQ [7uЍ0*G3J v~>8rϞ=m t:ͦs۳Rd_ {Xh4O&j_oooΎb vqqaʣݮG`i]Aʏn8Z8úʦFnڦ}@AM۴M۴M۴ߦ)XAVK! S=%SO5ϬPqȔzF\*) fp}}"d2Oy5D곫1ƀC_1_777GggPGgit:hC :/#%:"3kw*$Ϡ2MK?J.o,uB$S F!f37nre|nQ(T*y\O)UDͺ\(`>K+g6yLJVphvvvn% |sX6L5ɾbޕDX,qyGifF(6nqG'AnԪ8}uJ;ދAɴH ȥFC@)\֮F-}TjeZ^xa~?f{@43;<oj PƪX,:Q;l8Xk6V|CA& dYNUټ[i{mڦmڦmoӰo!Q́ &VTGKB)Q= jĶvSWmn% 3hĨSɕ(ݑ (OQBY5V#oWsof,c^6uЗgSrKJd߃rt YKkغq4KfCƿbul>|WQD} ƎEL ODkJ4RL*4Tn;88}B &>ޓgf̓|(ɣ=L,'Tw}VuNYv wKq<;;d`C02N&,H*Jl6\.;7='|QZZ\v:Sl}GdM;J]C)N"l\ Y\瓩S݈uıJ*eT+5ŃB2g5x3*Ff9Z̻VzmHu@\phWWW~4ёAHMuz?%b#T*,9!IjSw\|䉝xtjgggX˥YVs'X,Z>`ttöX,xg}fgEm۴M&7tُc3Gii۵mmw%6,QJ,9Ctұ3,]S[a˔,RaR)qG+T03(PF5SHcfvg=JiFF}kXϸ́Nwl 3 @뵔T#ms 뢢TBD{j?_!b6+:JZݕB#z\"kյ,Dyq1i4C;<bZ4 @kg2Ǎ5 P*|vݜ1ޞ\_`sXϊ͎F#zܪ~7@1rNNNSԱգXWmMнnjO-P$Kn p~3/kiIbe*7 5Ҕx=c|n[0&H$L#?өOF8Y LywHD#AT~cl6=Yr' 0!e|e9V+FbqHgrZ.sbH,Ƒ̤fi;;; a:OC(>p/k)9娹;cY[GSY MjD RqT pFFdfs(qu dzq-ƃod򨏨3wum!qT6@wooώlggs1r9߷lfn...Z &OUϻײ^qR=XcNm2ŅgBV+ !7H0\__;@46:{Xݶ~ovvv`t:rkѰjXb1jrj ›ULcvpp`[[[6 uGH^`U^bU.tTB 6礃Д8{=Z18`Uan4RG2hȎE-ȼj5'M3syFg&08/5#u`5DI^9lB`t͛7<{ߏVgCT*vO?_n` + \fhfýh 3"9ٱ{]4t:,!|55˥u:ڞW#k0's5T }׉NM\,-sc_ܻ(ע3UJH}褪Z#ߐ$fkl6\.g/_];<<'P`F|X( '|bo޼13s]m~kBVsĆK4sQC BTzlڦ}5M۴M۴M۴m{j0K10f 0W"17>zLl`WyU"Ѷ5JL0 f3  00_ /oMB4$ΡFBkK]@yd2 IP~tǽ*:Q&! n-#? DT\b U&dJ?35Ec\u/^ jfRfeȶ=C\.[w?Oahyb@ߔӵ q%J !3zX\L|R|Mv0rFeԧ<|\Rbe8z'|h4S?RxM)|>UEETʟSu6%=/u~AޠSS\јi[u>W=yp(i m0'T* Lgk:curG˗/=!Jfęl; 0!\.`0~$_=4{U=hEțF GffLNAR2Ñ!)c+1|?b .N'JV1e`@X@d?jj$:ljL3[[[Nn'a`ӷUR䊗H߷C;::T*eNߖkZ.\[ HW`S+ YL(3b$v&&5ۚt#8xgIRh4X,Zri}#I0 9Ā!AQeew F}SNE~4.̚:ԨUV"aRa-]__;Y(\.Օ- /ΌFשsm$|''=ynnn.;jj z٭m6Ǐ//}Y^+l2T*`h-Qdv˽'ё+M۴ojS{6m6m6m $GGAH--#WR$~y]~%S`o\GjZ؏\Sv:0Snw%4ڗ@*9_<'L2 54q‡YVOTbV*>φo0%4+ L&c}\ 23~- :@KC.N%m(D@ypHZ>=s Hi#yZ4 s'.CSٴCKvyyi_v䈒dAo =G3J:5 3>6&8kFps4jK*qo- 5Yu`+eĉ&W3ixNr9Vl6"d^{fJW\f9*^xss gz=?g^rdM)81 Gn33>>iFl 3[LZ]ޮTO#RDbl(S{bQܳQ[~cYغ!k*%ƕ9UBS7EReseԣ Odt!A& 1[gD2ptWgf4ڄ^,n[>fpq蓎1b٬ew72D1k?TpjjJ%?߷vFFa$"ZzYH d&Nl^]ͦm7ۿ[76Ѧmڦmڦ6Mm`EHI)শ65\Gy|*CpmQbc ZTRѷ􊤔G#>2^OxW~hUkx)#3Ls{̗*J?=^@űeBސ߭-j ߬dX53׋77gGY}eUݷ4X7ϵ doJZV.5t+[AtTT4 سL& *ͽ0>RJ֖MSOd`t،`t5L >-ٓ'OٳgD_|a?ϬXڲm6R21ac\:RaC(|Q-BzУk/V(PENՅ$ڤT*]^^z75VUC7O5ƕTyFM%saJ %"Hh>G>O8Ql*:Zq|u#$5(t|M}{߳W^ygiJy*h;ʘWelֆá?5M33O}UUkZ6N2zl6kZ#mvvvlXEJŕ`0^m7ٳ{iii߆ d<g[`u|GI ît:^bIbWݵL&^L+ :'/cO>M4tXlcZg{b?ͼ >ۊ|'i@QwP{ߙl_ %CP~,dJdvkq΃>0r35~(E_cPZ|M < %¶rW?JIL4+Y$ϩ(8Se0؛7oV.ֿc{ߊ_.>F`DSB OA,QBH*+H@܁cm(O7/"Zha8h4yiNc40] ƵX,\;::=zszb0J+B@,lXX\jLcbX Ře-!;:ɾnDиߺ/j=:yWrNujOj A %#xC'[{s^kFRd;;;^J0:wL*cz<5m Jp8D_*J>{2@[Tj6{=Lh~_о ;??[.lRdZV'󮯯D}-G6 l"xD~+UKV֖F#۳3;??& q8ߍuB&VZOz,Zōב>jp/^U'* _A{Z Pu4ff\7C dյmk%H#g>BDGY, FPd2FNF#t6fǃTjlN:A x$ CKR\,[r|}}m=SMhwwv0 E bq[UT\.Àh LwwwP(ёݻw7']J~u:rv]NwlBGQ( )uvvfggg Rwp4An ZdKUIGRI'QC%ed:jh=8*ZMNFh7EI2l7ǧ~j~ݻw~>{[Z&*߿߿;N?wvŋ߾o~Wm-Xu;NP%:J98qiMnQFcb6m6m6"0{Lo ?{jQO~4DB@}lt}b@uQL \U[(x~Fީ}l^ޫ^{_/ AH~ut|h`?p%u]!_ R@tPJ>Gi@ߐy4uQUև+8Nc>c+~uMPDv8Y,| wvv`ѽ*x#M7Kf./%6ujQ st:m;;;2Dk[HxVUY? ̩d+Ngjc03p7%  C{ybMOS/ysscϯVę;cl/֡7[$.?3ʹC f\+v‰7} @#kXGc+@uIbR௺T*%dGyeYjs9+fBP7SP%b %UTl3e2,cgQV˳yx=|0AQID D 231{sscZ FjjsAG*(l832m8zvp8Tc|nnצө_,vvvfo޼bc,B6vx}U?J]FfkȕaTcEϪ#2Ӓz=# oZ>ka,?2FfF#.z [6+dɳ3k>tڕ5...l>1rttd\Fe+3ܷjYRn.?nWc[UWU9'%AI2`!J@Ba#$H  H F2 (N.}.wg\oU.߽^{Yk]H6d2QZUT c2|8fSs@$јB!\V#T.۲̴Ee[e[e[9*EN >c;xɬ:R!4']<{: `wttP$X8@mw4 $~>e tsJ9س %S]ypq<;7Ϊ0e?cCLew`s|>>>rw?T*ى2v>_##C<*@*/H`;,Vٳg:;;sKcWWW!#}`Y"*p-_~N`Rbq+v1SB+Fpa-7IG8FXJy9?@^RT Xf=S766R3?1_㋰td21 C-ϝ~*.): *߇p"Aeӿ!\u|m0^k u]z,Ľ<( |M`,<ۉG^d6]'>V>^dz"zb"} vsgyaK@Sb{ }[D7^^^?++wٳd^^^ᅯds7xgϴgC`హ9,‘'ȭ)_G= xvvc}8؂8@ѭT!C9 jϼZ߹RLn7*`AܔJh4#VsILh4ŋ (:UC *T*s3dd2j{cd>y`w;;;zT7T*QַT*Ex6;+E,pΞ ʪ">?>gO|pm@8X$:;lFx0t:#{{b0Nn4x97Y|,b"+R₟6FSMƀ&nbc)CQm<i~dSIR\#J___c5ZM,Z,rc:(A u]kooO\N/^ӧOuvvFMlovvvb`19Xa1dz2[[[:88q2|A5W*l~s_E}ߎ1`,7II kWt ? _Aj\.㹧G\\R9s0ǁ"3>'g8/i. ƒCB|8I0cCNB0vM:88PXёͦ6777vTX*js\8!|JG3[T@w!vp3tv,-yzv"v2{9Yo"t)N |!nooMVSPO 癖`܏&IT' z\[)1) ټRL %\/΂41rΟO]\g׀mtU B=_uD'a/Pb ˆu|\zk2L`ĐQ ip `0Ћ/EҗT%V;dcA΂ŗ]e9fSnWWWW*z7cOJצIh6LmmmEr4ϛ|,J(}>#ki6ǐG֖ZVH|$O+isB(c&ύgR(ŦBJ7YaGpoG 0FCV+H /50c)!"pE7RQ6B!', JBX,sFs}}nYI>SɆHYR0d5M˿ttthYFF#Ņ*>cr~~#mllÇzy:dh.//烵f8lt:999'eydp ͈y>Oġ#ʀOe8P׺wz(mU*DFI.gþ#'/;?{Y2RI( @60O'G:! q,ˡP=q4Zg777V* zTV,eC 5HK~H)P([.ǹvY6m69R/mAiGtJR Ǜ~Z6GuMZj;d]\\hoo/6e[e[e[e[e?DyKr] =@}!$E@@ atX8@9,)|Yckܿp:q_,SGk}mmmLq8=%}<:}?$S*?wo{s} V;m)v9uLhtbΝa~=9m)%ݕCbmU7vord, w9rNR8HR85:99ٙU*B\uyyZXİ)Vǵ<Aq͚:0'G|y3}Aͦ666/gk2 u9܌1dv^x8?\uxh4驺n`2뛌Y{8{3crkuI3|l]/}]Ig|>bL&b1,-x @rYYd@ V#R^y^,#TaYJEѧlvV =vct[T }AR#c4DjZj6zQKr' ?n1$ > 'gO_kHmO>dm,{{Wr]8//..C8ǪT*L&w'%)>^Ҽn͑F42&DxjAc(\Yp1 T 8Q@D?9C0y"Mfq1Z=BA6PH];CL& ؑNF:; T** \T5G%PXǺ{=Z-]\\ݣ 2L:YX tFYRF2?W&3+#G3(0bǨGZTLh"K~?Φ?F .QTd@dzy[1gRhss3PJF<~8ήVϵ^7c0qv ޽{%fAX\FV bgD+L&8@ ҥZMQl::eQjXg~[dኃ]iMnoot p%8.dfYM褣T;::MzqlXl;S˶l˶l˶lj9LQO?u}شE;iβGG* :P ͽU=zګwL&[YYQ۝i_7ұuK#dy?cPĕ\.Qg2XӇT| 2{n=:\cGK }5~/ى^1q o"&nq t:}jօ;p{YurT{ށ,˺w>}\i^+l IDATp(777G*n[gϛrX|6z߻T*E90{F#Z-Z-Uq烩@ NTdwq.+++eOhvk={,0'dw]u]FhR L>Y`hL&*ˡ `9Ls'@!C;Ez͟z{㒒YAΘ?%<ТG&K:C8c~ǛF-X-/Efvٵ VWWfS*~Ax1=zt@G"<^Qֽ{;"(hD>}؛4KVQ~{V }эkkkjsd~KwՈ? u:u]z뭷tֆC~= 2s؉[[[o9㤑Ck;- cTWqPUxfkkk'>l6-Amn7>]\\H^0n``l0@zj?N^ %o=qnX[[a(DEhlt:U^WWZƆFFKJ|'Wɼ(F4x}U'{9@i'D"`8^BBPQiN𳃗N~0~-G ;|%;0SJڥv  hLC''!Der1 2>)a5ZUVnl6upp[Y^p)R`K)>N-L&gmSjwLu:=}TsgުFp8bV+Ιrz\r嶶."︉˓GEU >APk_ޜr]G{?N޺N봟i .x[ziv=58<bQ.:C7 ]Yi9x>^MU%o39K(=`N$pL ?Yg 19¾:L"  Дp#3qTJ24uqqgϞ4*zףTܗdIS yښ677ySYZn0eL<0|^XfSl6ww}WgggG?{>cg1m4f666" k:; w|aP ^塸lA 0OcDbS7RÎ9'IzjZ^se0< 3߁$EF p@'(KK]^^ɷ iz{!5Lɕ,*=EH9Ƽ=Q3cbT`#QH95ñf5D,ZLP'cF D dd^^p2#ګFMMQr֫(+Ƞ;Dxy<<@BDtd\6]+IqFY8I[d!dollZ|#'=6β(oNb>HeyeYn>=ғ'OOР_e[e[eK"]ZEEhuIz̑sI=gH38TMKa?{`tI0S`Cr=ݣGvKcG3f;@;FNOO;eB>|\RpP2ޓw3N<E8ș^ = hi儆ˉBzrR;r%d2O ??~g:ewK{Ov[NgJGΝ!9c'f eLVb %`u/^P(D6ʜ 9FܣW_M_0^z=z4|nurrQFCNGNGQqhL&3W.tπLBt.32pS@&Ypb"]=Sbe5\QzO)O h72||?CF]xwֿ;!qLa}Fyd‰0'<+0\?(ַ)}\xw'Y,c+]},s\N8s&&H tdKe<=ZJEkkkjZ Ş\ CXf3txxedɂѣ؟$E=zb<G%%|RqLm_KG}l쩾/`?y# Z^RhuuUnWFC#]__@n'RL%҈˵$UR,#Af3X0sPI.wWh06 < bA@yØhV3IR)&MCIuŸb8s0 |eC|>yz6LTV5Nc`dYӒd"e}}]:8Y=$E2짤NVaTV2HD6mD9 aÂz0s_p@(eIGcٳge0< EH_2R(bOH CT4nr%  s!0惴Bq vOEN76<`cٖmٖmٖ퇵4sGp!27< <ۤ0%#GÏLv=`E\H0Мwy_|OAL'|1On;*rKg&̥58:PNn?1`9x(z5|-8I+>.)a`i|8rBd#y$v]`X`Ţvwwk0D"(,Nd>ֆ@8p Fə-|6U`0<|Z'_GZؑKـ``" zuvv ɉ}] x  ` (0xRQZA(%7Rr ߟ@v4rB*2|X~S!oNX\5Xlg_0&f|pdC\w:]'CvYN1ܛz݉4ߓZ٣=Rݒ̂]_J0u;$js ^F:x RwJ ONs6 NgI&Qŋz}Calpaz*?,\+++SmT++w6osNv ? ^r&IkkksѐAuyys=}TO>U Ui_]>=)msM&(Fgx\i3]6 i7d!0*áͦ&Y @x<^DU@x}y/\H4lHx!;1'Hfq j0@C Rġczjax-YJ9 3d^\^^jkkkG(777ZB CT)v#P(n-hgY(K7(GV.#]r<t?L&ǏrA;H@7H'Éxv2Q ưIBL XQyKֽb$dw&vwwKz0L39YKNj^!d#oYU,Y7.‘$bz䉪jdxat:p(j^R˶l?+ $I0* '"٩v菟}gdJN'J~kD1Nvn2/J fҁx:`<Ɨ̑8L'3qqc6 s7ݮUCp/_<3/1RF#\_"Cnw V0 0I~=-Zߩ^fO2666eMK &%7\0Wo߅^Y owwo>f[ϔ+X()yࡾ5kΉ-'E Zf_2{)D)?zq^w~rEs>4n噼/X1UJ\% $8uGrY(->dMTVl6C~{ͦh4=A;8YD7͑Foj-B&&$ϫRhee%2,xd򒀢l0P*J P+++}E,`{^l("`ݻPۖM$Bd %Q#2-JXĮ766uvv&mK8Iv;퇓bpRJ%Ғ#6+3ˆL&"•"[Z 22aHy6E],pHB6keR "ʜA|,AxŹ3{ +}?jbE O!砧'c$EIlG.չ2̳|x&B1̻$Qϼzpsy@7(!ƃEޓy;/i_ Ey//>gr}J#:{:N/3һ⹌}"93gh4R\榚f୬_deM1}C 2x ?ݮT.#˳c - :88cLhd2_Vh4BW!'f}}Rr ۂ3|- Ͼ/>~tiv:]\\Dٳ/_{5=|P;;;~~9~6GQbT*)E;*EF"w%J념D 0#L.fSR)orfzAs78DDgp> Q^5˅0f E!2N#YZ74t=jxbCNSA¼{vPx؋Ps2JoS=g4aC{$H `/_ؐAOϢpQ"p0ȧy`&"8 ;+N llqƇ{_?(KwFFQ)]$4>~J]=wha !}B?c`/Vt"VZ&ϳ~l#;, Júp4VTf3V-W q ۳[t.S=Fooo޼ O_X̺dͱ\^釗r3s_nlDǂ݀dNDc];3% dߐ|v}#D>g9i⁨NI(̟SZh#¥BM7 !d4 "H~l70`sȦ|xx8w J"-L0<O" #-DJܣQB~Bp (s熅!πcP\|> gHl6ܓEF RD& noo|GXz=^nI8,^ gĂ#%ܺC2SґB؜*JDr03ߜE4QE6:ѡTDx:xE`vD ⠾G;V]_K'9 sAcdfyG' ʼZ3b;9@O_=l6\9$ydB60GyHKtl'R\+VN=O n:X'ݕ=81EL1?Ȓ:!!1v?)sQuXl63a=uI=~/z \t,zԽI(YA{"{J뜤]ٌ,SQjb@Re R w uqz0vrK!TG`=~?#% y"Bp_0b,j5\A*/ lcc:I8&[("cknooB'AP>\|^jUV+WUVS&3ˆAGb8 E6']> ?0Z0γ3jkk+?2|Hd/df%D?2vvuvveY=|Pߏ}^N)W'aco_RδRzӃ\hzu\*l>}j( l6h4}Osi1IaƇpPfSkkkQFR1 HZ& R{|^bQ,tDt=&I ݃J%e`w=:EqTC' RS38..?^nxRٌ &">බj\͙ wqPD5!C,cx2C$ӏ`}EJ" ppL|#,JNlڤ2;l_VV G#Al68K[RѯگL_lMSOO9O]mٖmٖ9vy*W"ے.߳>#AI@Iw NFHw !8˃<£ѽ\\@?:p{!!cA 'xsr?mkk++}|%E&4OA̘1)a3.D !cy"BtoNxr)eNݟE TJbɣooouyyg]E@.N;9i㠵U;P/?uwvv_%0t;3<ᢆ,A\__RD5iso{#wy׍3@8@ّ 5"#`,2UD;yennn x"]=z(N <(ItHz 9uq֫H]>;3佝sY=}*U<{҃!\gLd] ~C,c'Ly"gs I @o C ]]] hI* ,g@z"G_kҎn׽d%99ӧOBvgK F!|<;AB$JjFN'ٙb)'wuuFJG X.=`0 M4˩^a|uuVэlX\Arj}: 'Ӂ2h~$p~7~#tm}򓟌kw~w~d {gKjE  ,_e[e[9P "}Mεb۲.}7#|e{w\.} ࠈ׫ `XV&+/cM&liہ ?uNG\Nnw" Q %眻넖W:ƋT>鸦MJxmNY@38Q/ 5/H#WG?2Nv_y > 絷qwZp2;q$2j y}A TދLC60TfUVn< `.)9%@xbQRIX?bݮJ޽{*ˁoc[L<H«9Y`"Ʃ 0x1J?\A80"7#= 8LΩ͒He3 &:\A ,dr/Tf3MD>ܨlFAx7aa1)7"j0ZT*-d%77783D#Q (b:By'{2$vl6%Ő-jT"ÜiԼ Ө<ƃ1zsʝt@k }4j)??f)M7FU">G PrPhD/}6p wfVr]Y,5 ˜E>+M gXܨfblDKJ8w`Ax9K( M !6Vd|b#}=-۲8 |D7tppymwwqE3Q~6lV_W#-۲-۲lصi$o89X趜ߛ\ w\'M;`I%hpMZr8HiZ|.Y|fggb&Y B)@A_Z`!GqR ykk+8|A0@dʑ(# )xm@&."J?N&?yw0עMDZ:.#B sCSt|S܈b5MZp<6ON5~T/..oK3N+O+~$ +DSIcKa'`+YPW)#W{p"F'ONNj<j@= F&>.O8t93)pA>߬Ig̽9vh^g+ >jE({Il}mmM݋q[__y@wQ;Xx z|M'v˴?JQ*0Z eCt}ZRuˮt}J%\FC+++a#x U*Yh5f ; ~^Dvi(ٰ`&=V<ssJ8 ]J p8 ;n2|Kދ3}4)Ӓ^gt]I+5Fy_ڊ)^=?/"xRMvH2⶘ %۽/i~ d/As7.קA'>Ls ɝwQAˢF`%_|w{0-ANҹ<xl6n~J L^XC:@ /dˮ8k=aB?c~7ctAyGX >:l*$?c5 mU* ըfDŅn)`{5F v!chM;׸IL#\ҷ-F#}#Ɔ?~'OzرPb؈H#Ϥ -lCF1PDg2I {'Id8ٳ`l50 #4*('n3 X,FlܘFQVD]BFt:H&NvF 0 ydK ɰj<gȗ+t^'@JEJE777aPFr9qÁp@ggg:88"_2ؼag^!RB9[YY p=| ! 1)ٙ$fz1KzdmmmimmMj6qțoG3{يAoari31H5d3Hep_/o3Iҽ{ѣp4};ܿzK󎤻@-r^qgֿғ'O~}>OK~m}_[o??}>w\˶l˶l5~NM"w%E[+ bV)ct: .$@>Vlb>ß^& Z݉4;^_ 0e=?v奺nd#awuJ(QEtp3/Dg̅J>%d/tgwRiIO /L[~}<ق{`/~`{rYrYj4q̼3D`J9ccZ>h4?x /ޙuH;Is2sv>N8\?F8`e%99UnEke͉E:uN9?'%qq܉Y?%|}o9 rFVLf|4`4u...} \ \ s z)8l6{wԅ~,FFfg:>=og `mq^cGs^w 4, Aё>T*D0׎3r9`~|>d c Al6uqq0?|P>v~R]k[`J>kq f'x%l6coӧ* zUT~[z=H5Ɔn Cu:`*tP'2\1@,`fɢv4csbeEg-U뛛L&]##~xg7Ll6U(Λq m`0@d2!D`]]]RDa d<3ĕah}z| E_T$ ,7s#iiPՂ) r9 eYU*^aLSÔ ;`'"ȔANY4o&l>$\w0a+~c=zH4Q65qFB Y8(wZz=懳nnf5/ϫhh!vADEz !dzH?8QwAN6IdӱBQ e"jc8niegkn _З9U{_>8\/z3׾5/bq #o6ؗ}aU}ɓ'aaD{A@w}C׾g=J[o}kg>~?.۲-۲-Oy9Y8T:p~9դ2Q|?=I#{$~z<. 3u/86h4 3ayo!!á#J\rv>kR|bQS.3=cZ@v4s .yo|1Zgq^B.S0џy?#Ȳ8ɔEv%y4yRښͦϵ3Й'N9UUuЦ}MSBu2y=((ȻuQd{:>z}BP.ol9`a3C^{:v|eu?Nyұu,"^E{&sF91A7$ɼtWƿX, bq=}L| ySrKQTZhx0g.{ܟ,RjӘT!F!ed2; < |=AZ?@;Bآl IDATWWWU.[Ӊ`i T*Udge ٓ'OTVkG*NOOo%`2Fctqq^z}Cz״zwYd3xoz_S\wG~4r"E_]];#tmm-wvvTVUVþD~Inq}79bLsF,f; uQc\= NL:?cqtv ͍_/ŀ@f8 FY%C~{{{Nf27'PN]2L0n7R]ظP|H}rs!f9޻wOښ#;)Ľ p֘30}dSQ#I"Q\D!j1.9Y@<̦b,,d`lAYBL"kdmmmET?ʣI666b}B0=s05sXC㼧zQ'''sfuvvV\.tussse2}ߟ#Ţ677u||eZ|;%Է~VZ2ɣ=K") >ud0hggGkkkQڍgȳZc,pgϞi:\. u$Vh%D<9Y?OfX$g)q8s|;yK'&O)ك&#[.|? `\P~/a!ݑ< 'v16Q͘s>O4wgAKHzx|W ^5Yt,Wk 5I'|oaZL=}<:|sqO''X @;؊X|ν`0twve\dt~~>G@|,!t$c.3A^@&vng@e2\.VŅ?_]뺸,ts١`pf(D ex yE.옭?<<...lArjZ9CYמu||su:^ӓ'O9?N<׍ltҗe}7 O\2g? kr}6W}򓟌='}AAe[e[ev#0S kv{meevP5p#bW`u{ˣfT wIsw cŻ`;{}dzAt:xiCG,\.T*EV@~tG.8`Ϗ]^} jnv@JA2<:tp>U6IA4oޜLJ~2bѿ{1}}}uy~ A|O$ 0T̜2N3w28xgWq _%Z#`}u~)ޜNs4ѥyGHZ^ c;z+A,eYz\7f~YXj53EhssSZMbQz=0eσΑuGK8BBNOOu~~skzUՔd_ \'#C#Y.//U(JXCuCz:<<ښ=zCU9FH\\{NKw 拚 S0H)Ql 3rYWWW€ f1X}_Hig]9Q1.777j4xėt¹(D`ܓ fv-:Wb#I:>>{C7$6jHjO 90,˩^:{i lv"GA.?fs-!uE2R'HN6``ϵlZD!2 z=Xc6[`^U.(C;lpBis7bl0Vjttt+ŋAqơZE9 9C!3ϐ]lv.YG"]c5,)6wooo@N knG; qŠe]]]ji:zNRbR*m|_s"~.vo//Go6~Oɖs;Q@`ӟ677mkk%Gsݢe[e[e8:=RRpΣG@v\>p9g'q)'f> --aN&jJERWWWQ݃\31%J9.W; v@.}>?~?_*h4l_t:$e8䶃X25p7>F.@W=כȣ<Ӄ =S-m\{9hx }@N8^,x}]u]mjht@|IǮ3e{Y:lM;_]]iccCZ-J`)bIw㒮I^5{o#iv?1sYU=V HAY/AB,,aybĂ 6o@bXɸ鱺*+#2r"9?pqRUeF=9\/]^^N8hA~cdBnd^A>Kee Is&c7kKigqtˎCn `'bicc#2^|E I(Nag9وR~pV ؟ȋC~^=;;ܹF!b9Ŭ}鍀{N.//Vi_(3?{^#ljƏ_eWkdH#JeAVPo!@F 7ir9zB c!4L83F::+sKr@IqM FG84(a]n08 ͝,G,:0vBL0V6TtEqWVcB`#B\__kkkKt::<PϛǞ@@yscB.8pZznlNd8$?|8}>kdH_\\hgg'C \+Ir9r^{ӍFCKKK1j0H\.~k2L"5=E_X d6%pfoa d5\g' ; 1'Dr/򗿜}׷-U*?/"pߓ5St2~gV^{퇖a嬺fYۼۼۏ<ƃzt_ [#B$yP.37)ǂ-H Gzv}R~wi ~S +nO/K\ʗ"jobnSJNxv$<#W)c+(~I7#)՘>...tttj{ݻ=r~~FsRuoiiI~_b1ѳ>':A`g0^&z܌+ɉ=z-t:zj4'~QC# P$U0n"?$R!jZP|RGPPyݒBv23g1@1r9U* "Q4(iĸ!h^HERc|R)S 8VWW#n{{;#Y@1\PTZ__ Bu# \.̷P9o?#0G>nlS9[ӟɟhaaAWʜC)臫+mll[^} pá_~16q4wyټۼۼx[ .:FW;WsytFhҀxsmqܱAG'<[IQ>4xĬ;d|WVV"pt4]wb0^:ms_^^lF@1s5)?9̧ ׆$ ҉yogX;H]xg>zbyq,#2Vlz3}$.}5`.<"|UydMVǷo4:99Yd Nf.z2n<.).//@ށuup|{\.ZN;x5q1KYfL}LZ-HX~z痢!ݻY:r|ó`ܷy3<XI"` /W2s._Sd} O߮!@?罽=moog=%H I!)Uo`v~?E%3RݻXQ.#ggg'pNfNw nmmR(\90`0P o4 zah4/kss3EqϺ$m2c`&kcۍG?9dƔ2I?>JP02:z9XH[E@ZpeΪNYwo)!lLG}Qe>#e\M}wh[(>$io>Qn`ogk!\4FQJ8Hc'~!dz8< cÀbpaX\\ԻᆱcKh~.2/42(;bDh&dqǸ!VDymll^fD=ʺzo/$SMMll}(Q&A↵,ql4 A5~(ʍDy ~" Em+*JA⠬$Q4FaAܐ&{!KD=ų$eDLv~H]#ZCn<QFY\.uU rQB=MQ|p!~>,f1^R3YҭT*ۿ[} _d2QVt.ȏk|^7gyyymV~sRmllfy_4 s!%E<1RJt b{{ࢿn˓xNX{C,WWW)Lf ;)Lx~!>\⽵6 8ex?;p|vс_Z8LU >rsK gS.Jt? Yt}}]q 6*wtM=0 Rq-,,G"Yv@Jv3Ķ@j.$<֖`XÔlicq[XXtR nsn+7|3!%Ҝa sJV sncZ-  vSȖI'Y$Apˀs~vVKKKKj4x^`ǃ p8&v>sVFNu]D'Aؒ_+ X,"lKFW,UtX,V@P.<`0PILRIj4:;;UQ$mooΝ;jQTV+&r#c^k4@NGrY<^| (!Wgu6DFQӐW}n1&2Xw,S5M޽{Oi>?ȕWn"yee%Zd½WP ՃcG^!'tR?OIG9ܝcH|>ʳ(a~q X`GH6=6} IDATFv[~_F# {TV~%mgxJ<DId.rj\O?ݻ'馎%6ܑl6cl(s 5Iy02z+ t3E~a&dIr_;;;LVVV2 _RȲt]V(QDb1J,7c`.%qCQj}}= F jYK #OdOzQ|>冃V,>7)d 3cLS( b+\.aDDEVS\waPФؗ\Gv.,,L0v ʝ54ow>ɉ|͏i$ɜ~nll_I\ ptã~-_pyy$oO$o˿-7%< ‰7= Jld~N_28xsiT0A`\ܿrvvmǃʾO&܏"vv .ݖ Ɵe+S}ؔdn#q=ӽ\>wJFꏗqRgJ _yYunc!zҌtJ-=x@oδgoy#%|9 2TX[[H|S~z.w[Mp,OEO˒0LvY3nv& F6\wF7p6Ţx.Uw:>N1X,FyC>JI@hdi6*o{{gk6www3$ōgVs9=7/e\OyD?w ș\cY^xA=h4|gh4RV{ =+dx$,1# gRݾj;~ ><NJ̇t[ߑ!hF0B;O,y@p12#z:fj>O PNV!M# =zϰONNb'2F<˘<ڋ3ndG+ˑ ˘]YcjC;c}\Tt^.=zD'"B6(X*\6K}4L2uهAY^T*>Y͝LQ5"溄z=z5yy$5?#?R8rgIQ M&oF9,t?0=?AyՁq}ev?A] T۠y7s@t@I󈶆yF`0d1$)= ؉GsZJfĊPlR`׿@p)q`y|u.|dzaE|y^ JcrgGs l5=99Q ߘqroeĽF.In;Vjt~~9^q~5>xF W\q<}NB 8#۾n)%uywG~lr'=...B/zi@_@lI#q$r$uxC9P O" $H yrrrA;;;ݍAEZO?N}r҄Z-,fO";Ur;Ί ___G3c?N Wٌ+ ߬#MI+w'Ef OYJ---immM!S#>)=3 :<7؈0la 0/..2,_ݎM](BZIDh1D`ñH~!<1 Օ*J@r KxsYڽ{P,/r('xoxa&c{OvG-`5kkkj.lh7pFZ\zXրpaIj3|q:DsI$+++AxZF'܀G~cϰiI?d yFL,kƽd^9@kZqr pSg ](ͦ^xciebDckuuUQo:ƅ5uy+J PBGLU6߾w?ƞ ="vwwKKz2 w_5ΜU}s 臁;6o6oRp|NN>'l,uP6\' "\嶲ts~+s x=f@$suu ,Gs2s !|sƆ}YRG\N~?J3Nc\Nf>).S.)Yj}:dO"@=VRyM;!_;ެ=y]}>Ǟh>,sNsp}}wG///Tϳ=+ݳ_$?W>\8v5z=ۂ5qt v\x 'si,zxg%Rȃ)]q2׎ FڂEyT|||30C!?SBμIJW<=1Nn2@B_u&DA 7=rpKvׂL>t2]RQ3|tU2db4i8f֗sn;;;QWW% CF#FAo~vv dq.rѣ(mVbt޽}=x@ {yl x;=Cm67bJ`.mM絲FBn*GQH.O722y2%;MHmZ ~H)xv>p~͇)枕 5z7جX/amBjJR|ii)?9Dlj'X,J:diDɥ@,ow8Ym,G{p8 3gi;WjCOV*kɃL߻ ϢTt{Oښ666Lt: pU$J 4}:6ݮ~iimm-l~2 :::F8 do04Y3NtpgZeO(X"f*J7Ƿ^XXid$uw锘9_L a~,?TVSV Zggg;h:V^Ɔ?S?Vɐ4dא!8WWWN tKK͐|^8gL N9\ WWWVɉFu.#Zpd2榮nwۋ F`'c`2׿ <@L&mU $qjq`{-Ĝ}RB!x9΀!T~_gggqH&z=Ø{کGldNA d#(&V`b~y" /2N'`{s03{O;TVVS^<ف_k曹wJĝ.{?r33x%˪j믿 >oLfrH r R`# %9qydސyx+JAR>]n=g_tzϸ1􅃭PWWWDDTO~>u7} n |#sN\_mZ[[ Ll,2Tj4 ѣǙrdnzZYYQ A@4xL9ۄ'kiM9hu:NL0M%Su:yq=u?g؇^OVKGGGQ\:N_TV}(}T*YCj hHRΎ#[`# .sg'~H nhw[ƃŗv#|> .盟^Ys)Cx2AJE^/2a`aGs???q(E&YERF">/@k'0N%m02SY|L &KԸ՝*YsߜG@VaQ./#CYe> g!+1DxmX\ BZ8 Ph⺃Hd2f?GH4 I wn^_eT*o.4HK/ȁq% cbo"DoݘgVVV2QfۦR^2eCb}$,z]\NV+?.KP<ŠtÆW#ds>_;?n9q%[2o]yy7?;4;(uz)ȕ\.AO!n !/9Nx8>v#/6 ;x 5qgq.kkkZ^^FdOA^&7iv)_''VVV>v7?n38))y9KSO{R Gk^*1 oNIMxG(RNd?AyBS }Rj:99^h>F&|T'DR`/#q;ThN+,ziFgMJ;'|5,πX5vNA,|R^2 `ṗѲV./[kǾ0 0=lfT~=3f0rrp㳌Ӄ,iEփ:>>[E5Q9??f}Iqvc!yBqd9Nz= CU ^ёө3I'ϞN\֙/HA+7eNWOVxz1eb-cEvᾉr^̽A+a|1FQ2gy |>P"S$CP #;^1T9,X7jlI8QflZjz s(2nIb`Se`~@3g1f@R:p@2RGqtvv;oҨKsr~FGۼۼۏ9)N褠fح; >{H#leP9?dףRlL9++e.{gVoKKKQڙ@</岁_XCgF"I  9+DzR-Ad_TSr<܁j;b]YY)ʘGe8pI< idbeNBggا`).4'ط+<2浵5r7J(vzzクo}=3z饗lZq7{Pr} ւ~[v ^G3/`Uȯ)d ͦ=z~}ښ9Y:j7BP?H`?dҒͦ677~Vz뭷*DyƲ;Yl&I<Fd@/G IDATuۅm>Wre= Ah7LZ\\TR`j:ǯ9??WVPKсu6$=zL5+Tl40^!Oppm_dˉ8S?(h GW\.RH)熒DXIC32I!((6) PP7*Q4r:Bo2%de48a 7)h(o:==[o5]\\hkkKJJ%4309iX6Ȣha" L e7k1/~3OM䕗{+Qӕ2A1~t1pҕD X4z=]z\rN)^2uGɆj6QH<X;? ݸqZ=J5qC~#Oj5kE)v||sW o^ooww3t H|\Y 4j Ir'o|#Sfmm[JsKL7 N$`ا YĊr^!qRH=&̣(Ɓ_aȸ{^(j5]hss3逗́Lu~P⠞ pwS% - @4=_kyJN.8 ˳ .4' ,s~!a'L -'J`#NP8ʘ &+ţyGȰtN v, PV`0ϣt"{j^!.n;a2]T.nuxx&ZMW堔: @ȸȲW\*; ]V|rur!=YG`x<*F%x9{ުժ~fO8^'?Cxf򗗗ArxE!x@9vr}.Y}O?<{?#I',g tv#\nW Xѵv;GZp<38+&`0Wt磯3s ;pS ̟H9;;;{6Nr~?s<^bnoe̐I];%X[~n7#8+$*`3bAo%UըZz! #2ovO Cu:(WVu޽KfئU*{ D%w;sx1uJ.{$M2 ceJ&2XZM]ry Dh *YjZ!XzxݮVWWSOE?܃2Ql&Oڠij/|>Np'CPM@VSvwwh4"e9gTZyݍ41qӛXb.{%B d 3<"Уx/{PGx7!Z(kwH(9pw3rbdiuǑ t}}k2*,9I9`:<= (r8hBA?oog6o6oѶYg FY 9 9 L}>}0~>d .<9`ഏ#43 v? %dYW@xtNj0#NrʂY=gx8zS08RXNJ|+p!2ݣωZ?`G+2 *{T*hD `ҁ=?ye<^g_/Ȃ5}^e` UJrȘNX{!%\^]D/2kZ@.kk%!ЫNX]]k'``{'ee$W,p 0J.)H/is"-ˎRY@`̼rd`g9y<vg~g4@Bz9YA( -fCn~JWWWىpRc J 2cL^(g ij0N2ZP?k;d*g(Mf, s9's'(R[s8H>iąmlDæqMvc_]]Ez )WIDrc,CnxjE",(uf5-&)?359H" nMq`xOyxJH'+5yNNNTc' Ø#%IQ1r" #SDa3H:`8tC$~6R߄ny'<=`z&*dS!0x[^^:dxYB HLYD'P"u#?`|qqv/nZeꖗ#]w9l8NNNb-(5?(AXXXPM f} d1swP8ǐc}}=qyy\?*J\;o܆#kG׾tk"kQeP~~M2 ۼۼ} C=@ 6ۆu|FɁ1x:==UFT,%h<3sRŁY>`dBOv:`@'y=~2^τh@_Li<'[џMtS4ia\ț\,..h4'S23أlZMn7Wpjaa^z nf>2宸`Y|x;7N#hq0yU#j5tDٙr,IqgWraO׍i '@ƐU_?(-~IL`]AF[^^֖C)? ߅B!.O=;ƞ`{2'UY zWwNy̻[dq29Y|{::1Q}Rh}}]͵>!IB^?y' ̀,ephw}WNGJU{ 8qbeiU'=K t/-%]:)>A>{R5kY Fʚ3>Nnq9#ۑn5L++z//z7#Qo0kD_|` 2t:^^^ዡҊsYtJ\jsR e4E:W\Zzz<5Oq) v6L'YTܗYM*Jq'r/O}z#ƾokcc#th ޑCGyy͊FKŝ`Ezz=^9P>QNqy?u0H-{1<p`8j@p.|YO Xxx1|N*&p8t/[YcLm8@4HŁIdLz{9IigꗁP }V9&TNIsLhHrRɉRqϚ:1x\vFwݸdss3@rHP`Ћ(F_c?#spHuHHUv ]Tb3~ $%v8؁\'={g2O[|o.N@>;d(%&x@N$+XGН;wx$q5T4xOu]oqu:ۺwݻ~1>}{Wȟg8l7}/v{'6DX{e<* ~NLZ^> S"t$8*'hx&!c/,//G&0` vvv"h[Ó8GtḆWYs~CF<InWʜn~mQ;$LFGp`xnZ7ʖl"T.GGT\.@^t{?KT ChĂɔ@ F!RpM'Zؤ0!1ߤ,=NFz]j5"ؘC^]]Eg"f dpi]/es96&ML?1yFA`<<].rw g:fL %YZ눢8;;h4Rٌ8T BtBRI#&|() X,^ɽXAF$gZLY[H]h`B y&/dtBRI!?D/BֈL&A b$`i?e5莵5+mz3/Pgͣ /2~Dr6mm9@]odwvt{6lu ~sG=& nf_G/~σ?.w|//f_677zKҝ;wcLIF3|D=y =|Pgggى`Wp XAYe>ڻ>_.Oe EXǏh4"|X,I$Id;Nud̿>\>3(%W9`e{V]~!7xo芔gY 9uݹsG t"f)>S2zh4+_V^l-u-!?}}e#=ȃ.5~?k_-=fCD`d` qBBm³SIլs-> Wppp\.Z\.00?TW{ tXJEz=JkIZ;{Z&Idc?2iコR׋3*PDg: q]KQ,ǽeH#2B`1@Xuf@N&x*1ʪP(DYDo bLJcT* q1aRښ.//nc~H[Cj8]H1=DB|3eH|d $E6B0v23 T!F:N8Y|>d3Ub1{"0t8== P(n$~Ǩ]ZZD P677`PR:==jg% 4q?#z =-JZZZQ{NWn#YHć, K-(2 ;vTH8s@_URsNuz`<^kkkK:>>ID= suu\_GE=PglGGGvZYYŢv )PRf#_‚z`ggg(x;wT#; r(^g gO'dqg u82ϰO]gkq&rrN} V'=[\֖^|EiccCZMGGGqFZ- yD=({*;{/^^^*Jz紾s;%` ?=>߾c=3=wR;d~65Ǎ]}nH)AxyyM}_TRѻk"Ke Cok$d2 ܛsrB!0iz|9P^̙V*Ov\)M9LǓ3g qLnJ|^̍1iͦz󮔰 T~?!Ah8qyy NfaI`arp2xwFQZ^^ZH,fpsA Vc#!X8p.9~ꩧ2Id0rc1u+Gk)Xggz[lmm>@qC3wbqP_XVE|/A 6$Et+"QfEҭM;w4N>ȔJ%mmmEe0 3 8К Z z9DĢ‚zTբ~<t뙈F8k6kOZ?e8sH >*K&)Cp;N L IDATvvvt]ݻw/{jZ677 p2,eqSp2ۃg>ke|Srʉ5o.ˮ6Kyctz{r1NYލ/ |ϣ!0KnSB~TUz:;;S\2pIxwijiaaA[[[Ӄx>.@{Ne1 9B[qw6~WWWh4B/Q. wT*A|\_'"o" M8Hr$뉔8u}@‚*-E5NwQ>ݻw3D,uPWכo^FC:<fY-e)9ﶁ78FnH]}vutt^ yHN}̒T-<9XY-ՈWJm2j Imne`WY9[2M@r98v cR~"πuF#ܜG`c\ z(*wZ4:yWQC &ƒ̈́NʘC҇e"*J&(_,:Ac%NLH +`=R-#~nflJH2p. 8ZFO]^"Gf;Fru&r.f8$Q.|wf5lE>YYY$ʤ\F#0C}yy92^X'qsuc.\WvyHoĝp! N v!˔reeeEw3<ɓM3:;;֖jT!{±47%R׀;sNX{V{ݻQk{9LuŽ~>"k'[^zI:KL >'izg>cs'aYO:gw8WJRjaTVw |0^e.\K~"@MMx{/>GVHA&w:ٻ_lq>Q.W'h2c С$8pdP“zR;'C1Xp /F(5ܭB*A7hh4R^.Flu`%;ceeEr9NEmnnƅYdH0tم2A0!Z O<io8t:t( .dA!P{8@vXڥlH@CVc`q'j&FRc^f̀Є//G7dWUJ%u]=z(dPnWZ- @MQH/g!C n7QHu4i4iggGF#)݇Ӂ\^bOV*/Rf3T*E <b0J]PKY4Jp7 d)0o? #ܘL&zUV$kg{*6o6oh|) ab{A;Y$v;t[rz2 is 1IF+Ϧ8 Y 憹 'Kow~ItQy?>UP\+++Vj6jL3ӹNAva?R2oy){Bp\%x0t{xt}g 0xq|hde\~T{ァ R&^zo$Eu |d@@* QEYY7}b}CJ~rx AJFcLn.TӉ`Wd| {yQ~"w'fQtg\Oi|>Lױ`+:~h4T([^UVT???icSsr~)!{aYkc3__* {nT\D0̫lZugŻ}dw[z?s?ݗNJj Tğ|>ocID'tͦZVBlN\Aw1fP1D;N`NNN]zWs__utt=SΕrrR CI+Z-mz=hmmMv0 .#‰2X0J\!L@1`0Pݎڽ-(盝ю>{<&~<^3F4 {4eH2惺8g///j4iww7]huuU4!026x`x1yB8 A3Yt:T2 )h4I$;i8Fd.wSvqqQJ%￯Ǐr&x\`0PTV Ǡ2xe+ BVNw7.sh4B1N;;;NA X;y@җe=SaXQC#Ieaj0d{Já3w)q,'r~2K/|+/}KzW2;57mmR;ɳۚ`u``}WWW#xp>)B<?"x̟E/N ^1^@:2i4AHLD|~?ҿzn>F@'La~$˩ϥc8 loT-9`w_eFX:kf~M?{V*@j^B e^xa9Ns\ggg:==b`%JDt:d6^LK]s}|uP %'''sfIyPiJrw (c*n :}? Ltt󀮞tsyDϿcdHƐNnurrW6z=5܂,9B3Ne^:s ?Vz;_c+|Ϥ5uR(r8e>5U9K3|VnF:ca$ͲjGfFfVUW34 A qF0 !!\F 1433]U3F;w:{yőJyks+A >B7o"d:;%N2sv[J@sI ^3G+)gS|``'r|>\:>77l6d2 {{{[UVkf΃=< 2p8JAt&Y(*6Ȩ aO&]^^fJA5 -!IZ-Qb x M|@^C ø8#ej:SÑy}BDbCGGA LnXSR 0jP0.(|>(ΰ\" zww$vPKA}*{yȘ3ݮ&&I\0ۍF#R+oFJ7ʅqwvvt}}yv;A[2;Z555TՂԫ&6bАB1=%OiE`۷IGv=Z~ܷ1phh8j~أ !YLp/~_J{ lRp)g۶m۶m۾;A)~qi) $^T ߆;@RLYgK?h}q@`m ; M"gyuۉ3PցK z6^tr3N_sAɧH#WERvY/j=iyj>z N$8 %}.]{ Exq#%Rpu)HlR-ǟa;ӹ~jZMVKRITzy쭔4J GفN.rr9w8D|`'@1ߗf灱3"̇gt2^s2D5⧏dL&ȤAF@CyE az=xcD'-7Gg[$ ,՟|9,,^ׯ_GFd-;9^qs<%3nJDIeLR&[+J۷oG&,Ygm ']RR<6 kY. )]̳upE]붃WὛ&Zp0Zx]^xo<rFf:bSx0ˊ9>,J&?օyW\*Hw;T3a\^OA캭s9߷ZtgJf:/?Tj6Ϟ=Օ$S1זv iĤLGnS^R] :M5Q^l&Vq"L>J86fɔC0^1%] 9;N5Odicj55 ܨT*lN8 0v;>˘!F'ɴ##񳎽^/tD> #a~xJIOGngضm۶m۶ 3R@A[l'h)@ "`; d2QZݝөөA"WyĊp< )N>*)9%hA&*8h)}80[Q:)_39osݷuP}\V }|9T*[)岜6>%%{~[*yTp1}gHY{$>|+A|>WV 6%(isǼL8'իa.۷o#߽/TD|6{ǃhSV׺t:￟ F.'\R1\{ァ[~:ŕ tBsl?'!й``iB=K -~,g* >h0ije`}7Mu]IAKb1'07n+"{brP8src?cgOidFȤ ieٸClbZCx.aDcb`X1W*В`Z&A/e9d$ņN7 T*e.ll4.1Ep8MGN@4 Jg (ql ޏP]Svl&IzgJsa2GDOf|lzvbpww7J瑥孔JCY{J}I͑_-*䌏u j6!7( ~fM\l:+JMŁQЉS.(0s52iXv===ٙNNNBNR.|8O |.ݯo> >Gݏø]H?MȊvZ2r|>+)wE<|N-Ǻ0~_gMg R xR` $Th IDAT9ztuuׯ_s\B==/ M5zV0|]OD,JA@2EF#?,Q3 ;uԤXd2h4`0PP_t~x"sX~q <d³Izg+, {~( 1<* +d+%l Ț"p0"ptc1:ɘS{ϳbn ?gN'pXdWzۿG>.~?`[DdIB;D7d"2vq >M ={lFYш38 Yf\!D.>bi,xkk̍(<YzxQH 0.RdSK!U("6 ܣw`p< h-E 5rFCt8j8JRJW.#YTF2ƞ].?ƁeN(SFBw}7ϟǘLs>'GSH_#~5J7Mlt1N`:;vֳ˔).P\N 1.0MwA^Y..QI W*.tA>y&eu?g]n=ģÒaf:Y3TB!StŒ@l'd}X)`0́c*w p|3u&pF~fgYh4xכ7o4 4 Dtcm8Y4c fμq{N|ܨVeR\0ieptk*Ȕe8A| cBs4Nuxxآo22͂s\NZ,j4A^Odj0X[ʈ8Fl$ ؋LӘpmpssQ0#:@!$~w 9eJw†!DA}+l\&B]:N6)cS8㇑H;;$]XSv6tyy㮣!;ߠAiu:I 8P0y\q3rzzƔ5::: ' ua32\.A kۍyl%mw#wZZA[E1'N-Fʚf2OjQs"B}ss<{LZZVV1[Edqh4 eggG8Mݎb9w~%US ['؛4Y c Yߏ2aT!n,l۶m_8#o۶m۶{9aĆ{$Nbx$;=wWn6p[el(\,ŋy&xKz/TE ˖w3,)9Oekn/2d@ZJ63xVz*~ZZv=cv3+]>N8=9k?pL31u(zoAPDNďI$rѳ9"P(.듒p)e\]}Μ?~g_:w:}_aܹ|?O發/\;a^FTl6UV31.^3 "~91X ʚxf^NVo|:IXciE>(5θQZ^hg,F;@ކnEQn64Yl?k!dΤg@r9c]]]i4e zWl|NB_.L dc;;;S'3KUss`:(J!P۶m? M~}L?(Fom۶m۶mw9$}ұw@W<Ձ Z-<99F)]aNB/RP>xĮkmKtg >nka7K4R^2W{:k}uJDe9t@ۉS?WvNl=J {Wdǚf̝ϋ/젾gm;)@,㡟dPxX_WuqqKu: XCh_h'#AAeAoہr>=Y>;M&pʞ@SG L&~dM:l+g,كx`}~gIǝyzL !J?؞i"~!0JI!E\?z$a>U|`&C;==ʚ~rre>%  R e]/"# gsKd*)g%$D!Ita`J6|!K0<щ@0C~*@!06#FI"A$L).+.SI[V\B'0|>0~{C2VރE|2F;;;10^Aq0xG(GtnWXFCN'0jd$e9.@,˱Q18٘Lt~~p.ل,Mh4Ȁ;}FDaz q#}T,3uͦF8( m۶ƙu'>m~nJ #Ӟ6xzbooOy?Ssn۶m۶mF.O}ROs! @wݿU$ŋ:??_r)/ |& g^R'r2(g{𑟇ܥ 72pWʒ )>@L> r`INh3Sp G>xʷ}٬i ~|T|+?{f k`*9yKtw\uvv2YTxIeq9>`# 󒢜9Y i 5 7S`r4~zl Ґ`s2 o*ҳ=:jȒK|>p8';XRBAc9Z1L"k<Gi|\>woR~]B㤒j0h6Eωg U~r>a۰`YTp"xWT_[p4wy}0Y (g1QT˪jf+yy~K@?Ww8V_D8|BA>')ݖBogMfY+C_*K #BUtkgg'|{$epT'X7S{ %ѫ>W*S<{b2P T*T*;SڑJVgf%-ܣv$!07|v>jE:n(ͦeH#6L,)鉮\ìFF+X# Ru|7 W JED s((#w@ht!`>Y $Q'᙮\R4w vv;C g/gy9毓!. DBl`JڔY*|p8fGY˰sE1pP SS2퉽 #!gRݟ@G"߱^|2}vߘ1;G 62F?a- #)x23ǧs\yqb.c~g8hvJDso?gzd8s8Xo?t0OMHYω'NQVurrW^,h6Ax)d2qL}@I}C *u\ ]QSПg,_]G'רr 3%nnn4͢J\qq{_zo=+[*){us uwe̹KF#ro3o=z=F+h|/q`Tq}Rl6ޞZV!rx*ekssy.C~9P8r&M& ffF1~OnqB}{`d)rϒ3>H$A-^S^2~v. v] 7gTUZ988jLރ2777+t/-<E`a#qN,K- JEzJ\Nz=*Ln x 3 C"bQqx:\ iBr# wfvA@$ خYD. Q׋ 碤n +&:crAwAAٖ`I}c\lJBuu1R$OJ%2r t0&Fc``c&gD>p&Xz0dZV(ԩ*ˡ0i$JRu(B7_KȊ[U*p"3kCpL=atգ0taә{YZ4D(r1DyM&czO4C0wvv]oZH1J=Rp1sBD sCBF!{bNC::%m~К;E__/O4,}Zs{A/j8w~w"+2mnnIm۶m۶`i|MAmT=>8N:?_>,N #$  v04re?L?v[ 'A(DSsJ 9I\].?=+§חgX~'_A|LS9spЉ) 84wOjxR|Xo޼.//U*tpp?8ej 1@kp iQ} L3B@D|{Y擵wM^1  ^G^D CAhIX3FQTOQYdpF{k IDAT s|nR B:yc@DT)J |{Uը>Uey%Iv[rYJ% fSf3֧\.g1YH <'ܞN>'{Pߏ>ڒYw~+ d!OZ/_e0m's$J$ERRQ ݂`{G6'~pf^^w[4"ʼn fc#>zdm_{w!0Ep`>3aO:#sW'aYExW฻{r=Swz@wSf^w{ァjh9X.#Cʖg~}ns)/QF0{l0D2ݝ~T<*Jv?<ϝ\.Wepk9 )^|^ :99Q 3DRTSӉd$p40VONN2c/ h?{Nv;1OtY[ ]Ȇg±']QЉnI <}ޘ3X8zU*(=yuuh{5pYw"uYךfV:88$eWlFYK02nqn:VOĝFNaR^ʋŢ CEyZt&{Y1Od@,wm68 k2D_%zC>17(GJh9B2~ ``;<<ԪtÇ=ьO$rqR;:8 }#QΎudepىr:)c9HeL(m۶ ˿cT[7__ɼs?sҗ|I۶m۶mt@>%5fYOIJ%l|( z۔.Ja8gS_ H%5G@9 <ץ, /BxBV>E=$^ӈʂ 4) vYI8@<w['J93K ( ?{E'NxF~ܘ˒ Ng= sϐqp87_H'iS<jlF{DlZ p3}Y <{`. bX ,gB&8 ~,Tpbuc~6r<./ Cu5j6p4gYdSeuv0/HNGϞ=ӳg4Lu!?Rs}iDLBb~!?rDք gIql6STx4 tuuXu||[]]]e#k8mBٽ+]\\6'o6Q2xn7pv|BBG4L`,Aʀ?$ZP `%'z-pKCrvA`Kƙ3L43rTRϟ?t:UA$T}@q2Ǖ#9`VeMdpwwWPJEj5xǸWU$Gx,'POCt^CR3ΦϪq2K& zoo/&3!p/Z,>Z"ҢfBZqP\!`4 CТ`=񉁑Ѱw!l~ܨT*r:aBdO*{ 6f ,_C7n!4/C{Z=/rs9 aQꮨ.ueB!cR}w@8,P!rE456ݱBf=0sg(Y9kBy zÝq/Ad`00eCNC @y-v.F >e?g{ > ޶m~iWlIY__y۶m۶m۾w/$ P؁tlFPcl6yDW*(}<# ȟO%=W22{<)038%\e찭 pg3|<7Da33f'5dMV J_Ht^^s‚gs;;XE:˲:O|^r}=$ /Hx Ab_`:w(Aߋb?^#ݨdl~ha ͘1>9̉"g"R<@tb ALrzXxdun𡈉A7(dhA/ C! a2"ٳgF,3gISZ͍FQF 2(4\V3J b?Xַh41BTjUqoϔvt?r 0ͅt!4i5:{aqkdJE0 }J#v _׼m|m&|O>6`ꤕl:ඏ$R*)Qᥲ/L!'Ruo?]K>d3W?̫JQ7dS6 ֳ.\>ov(=pɉ.//5 |&sDZۻY99ws{9a{94 2k̇ @COts~{{|xV>z1 Oio1@9redvbFznnntqq #(;lWrj4:99Qɉ*ad'@Dp]k,{9q}dT)Jjzw#ދ 24ĝG8z.Nk:%jFg1̮E/r{{fjp Mȶ ;_NOOR^y`K}y&H C)U.UtssFD^OnW(g?x#KiZ:z`x"\'f1'bs))UFvOV;Jz$z9G/=X,j<KR*d|ҝ|RJ|A|م!\.^\Arb.%6͚fGdQa3 2vl}S,\.c՛7otzzwy'*)Qx9)A39|) a;Q#6#L8l&ex>;/ϦAH)Qr\\.RH lZ.Ar"}t:*b*tndph/K;2r)nBԥtrÀC琹[Sx>·Yi3zlXg!9(l8%B{Z@pWN?2L2.#ϬLll6Y@s#!Pel6tbj +ˑD6f*JL:Fr...2agꓢ?\Ǡ8<*™od!3߆a[6r\3^dJЕgw m۾_;^_K$Q'ضm۶m۶ƙB;qvvX gفY *fFh+O`6 \.#p ['HV-'= GPB9jPu+$MEw `/?,LxO@+ߥi!=V^`!(ÞO^w8 l*JR loo/Ņ+~>=rJ? %ۉEt 뻻q|d}xЫ`yYOd>ip1։Hf\9h4l6\^/ *J*˾ 2C'''J58^ÚNRP;qB lǭk}GWb`0bQ Y[}VJEVKǙ֊5fu8)z=|;ǚ.%|RF_Iz>Cz=]__۷f1n{l6i8&!t=מF 4 gWAC>g:.N`u||ø&}]ץȸgԹ^rYjSYS݉i_G5/Lo߾u/=>'[WWWZ,:==rT׫Wܸp8 2>èBYOUjo}[g>^~^x\.vG}xl63״(j4ZAHhwQrL*[2;B2@yN?K9뵦өϵX,bj̹kY 9 އ}ΉoE#Ul&#YR)>˜8tM)}H i3@ <( ^]WDt a(1-l;J,#9Bxh\FNJvHRD" IQȼ$ Y 2~;HpLq&.5Gzf :2Ú';sX0!3(m|(1NX,By`S &|Xzi'aX;/D#͍urr|>z"nW|AHEֺݮ#C A{ef}&gjwwW/_T^dWaqK=(>J#}Ȝrq  >PpˣpCǶmkkX3/K?[ȣb޶m۶m۶)XBs?||>lZwr?j42>I){D6П7t҉<AW'7e>Go$Ij{p >u}ym䂆?E9H\"g20erDWX HS';qcq8hgU=RqXox|=_Pf|M߫Z)=\S 46vT*yƷY{u֛݉ _!M|/)G O$}=<˝39d}dJ>D;~-e\7@QG!_q$G6z֛7oWa`QWjrdqxeNAO@1^.D2sh4t||w}W:99I401?#uvB^}Joڦze=" g໻;fj8HrNk:իW[Vd~8σTr?N\]]E1Jcu]  $}}^XjC}DZqI c$9ȃg:_YMtK#c l{#;{ dŋnw`QKL/!b}C lGx,g܎n7AKB- xzx<(Νd$_(BCG r{.%I8L7!ˆÍ- w0 '¬Rh 񝝇 #gum<7 ߉@ sA?ċ3Npat1%<POó<-SNΒ( /8udA, 5OL9w81'q$NjqM&I5e(bhzdAx?Ico20lL 3"'}?rP IXя IFb8<~Ut:h4  =F@^tT DzmlidA'ه`/}K=}J<W9yƶm۶m۶}5wV,= _mH=A+'Ex-Z,?q/ᄯp?{ֽWIgaO~ѵ 6 IDAT́-9S| %R@)ßg;_WD\8(w^51HYl0'y8kKO*eg@^|O9ct"}GO"9S?B72/JR^QcvN.7Na;N?"8pl:΃rEd0fD#hEZVANI 5y/?`-_Yzx5"#7/N2kNuQ5bnE`Wâ?]j5nnk\>c}I+zN&zwslF6H.e>'8:sg^Nx"8(xxxj yN>H^/gϞ,!:nnn;kM&=jUGGG\Vd9s[4 ,J2$fM5<l6C@Lw[RkCBjZ'B&}4EFcj273`}\.5 " };;;!Kd_V+ȍgs-CM37BMAE'DH8LHM7_RF< `0$ZLR>l6ƐFP @=A%E xZ6ƌ~H`fKA5 Qdn8CiC=*jEڰGա` >1/΄wwbؼi: KC95 G a9dt:rpd%-KFIQ9t۸O$c#eƋ2.x@I.sgdK BD^7QnG}RʹK+lg=ګP(D&YT\ױFLQ.ZfJ &=e$8x<2Í}%DZҠra$H3S sm2{/ض^v dk.=d)9$):_l6rQ#I0qʒNJ9Oio0d>k^(rqw|L I~z=NZy^T2iytl:^D# :T.u||c- gdzJdm3` HnrDs}ۍ ~W 8OVxxx(;);xttN"g>v}}纸T}"PH#\qNƀp?3!~#v[NGGGGW~ |yyBAfSv;2 ֤jn% =YywQnu,DIc Ai^ ;1Q'<^NP(D Clt,0&g":vn7A*a+~޺5%7܆-C |5S +|R9Xqzf ʛ@a ɤtT @ڝ&҅~#A6L0Cߍc'<ęFJ3p~(aqzz!Y$EF ?;xHnOurm`8O_777E;((7ܠi4w68BpPw2}?D7@y&+8^8] &>#~y`ժvww͛7j6E Qn\ZR`!Ͼ璢/ %UDPC\.{pl̓r|E*9i8_ZN'һn7 $%!`M1ܣߘm۶f"'%LJ_7tnK|n۶m۶mT3;ܝylmhn! +=xE`qɘvY'|UlsL>mgc{&oP#sOuRKf\y12ݾ`>6NHxZxC ~I Bc5vPʼnTv]+hw1 |g~MI7@sk3H#>C;jNXIYq/X]<0 ?F;Dݻ1n6 nVFQd 2N_> 灜9~c"|>Ͼ?"cJ \ @"`+ݮq|q|#Ti:>>ֳgfh^^M#ˬ g\X-gNv[GGGzYW 15{p)t.I< >@nH Oh0#J! =щ'Sxr͞fkZ&C"Ӎy=::qPU"/dO}dnc?Y*"A8q>^Byg!:h\^nzyFq .v 9C.`dl"h4E9ٞnx¾oZQ v8Kxe/"OG!0{F BR'ӣ(nW6A(E6!pC y$L> >c FD |0~h0f;58\!a2$n,%=|&%<# \1v?@\{9>>҈4bƯ\F=ʑ`>CD I. aZçy"R(S 2 AF~$ j5QjSRGJ33z(0)9o!#m۾ QPПJW^F۶m۶m?\qGAR''ფ@? Y] ـO~zwO )=F;ĸEyOVQ"v! ,H_9,8Ht3$A k>ӟb^VOO.+Okbd`R_ɉ/GI'Drr}gqpz?b1>L^{Cu]ܟfA" Bν$d@@^DJRd=GK XX+JU]^^6](wA\F!dQƼ9YF_K\PfAN:/ս!nv|Dؙg]___>N5ϣFg ߵZMv[VKNGϞ=4 bD F'y>?;9( l6uppNgXc'8RR=Ev>$uՁmd\4tBz6/yI#Sk=nN@WWWq:yNzmstZ{=ۅB!=؃u"1xT{eY!r*n~;i `ws9K/E֘ {pvb$]A$@Ț?]>B=@"t; H/5 øڂs32z=溿hnk4 Y7d~}#`CS'CV))J$`8q#IDH9@+ 3 /J iZ9ds¤z↥3Ƅe.//Zh4x( Z*yu:b>7b06ChY[ luQ|< 'É9LWN i:9~0 @<e1C^bCr0T1&3sR%dx<(h4ŋ*bm!gypp]z*XFϟX,"zO%E`F;@V">r_5# 1Sr %||t- vҴZ|WU|bLN::jyd>Y%M҃O?L, 廹eOx?˗/# O\♀ʬk۷oUVUT"gYt ♆o {̋pbl䊲^Im#xS9._777 l:J8בQ()jjQnǘ7MS^Q]`FOa)x,/^Ћ/T.4p8) B?yV-`Gы" qJ#\u#%O\ Ǘ˥f͛(jtqq;f xƘqf>n2udȲ qY_.Ü߮*noo먪'P(D֏g_Pbe<֬e稸>@>) ; h4TT"ۋJf3U*M 0k4}')I`l1ݮa&DFRVnZ43;NL&qrfbC8AgYpk?-Cq1IB! = @=k0DXt tlP7н>;N`rXSŠLz(eټ,F#/E 灈?\aڷt:̸Ή92dI DC]z AΎz7l2l6[9yqXJN8x}MӨtIvdUac OɈ.g:7@<g  Uո}  9@c%c<*)첞@p96?q<>|F yWWz&IA[Q,b:;;rIR^Oϟ? %|: yReGGGjZrq_.V*T񔔁xfM1 McS0ց1RnHsI`]WN?_-$fxp8;?ɪ`ys9h4t:d$cE``+~yCC~U]Kx"4OGpr`9"kO`t/׵~w_l2= \A^9!|ݮfY3"(Q'g|On.?<< 3ndYr{Cwzߒh32+Ք*M$AD&z 4s U YTF7w@|<8:U"t{5lj|> + v[ 9܁QyfORѳgb-!%þ6`)aFCC- 0HVg"U ~8 ׻.Q >3 N5pZ\s#v'f^FO^}3˥vA`wI?YTtB8Yhpbt:UOptVC$(Q²l&XKH 2dP4ƞ+Jw H" h$'d$E&DddIQS6QicԱy\9/Y^lhw,12?u6ZC"%l=2F@q`|#CF{ H/;I5u~Jk0wWբ`իW*tv;oyaOdAvSpJ>O&  zK!viәxi,\d`h:F̍f؀Nf+{ճ?:3}u:5>$3`=KN`7#'~Ƥu.Nh~1uc<TpU^ G~^.5σu}$*Q\TwwwLՆ>g묇g9Vs |Nb$M.'^' 8XZ|xr'jl&'q?sģOe.I/ \CLCKJ$0L4Ʊ+J4kԂ!X m;(}va/D8)0:y?o%qm0H枺$b?=`&b> ̝0^RMa΀Hژn_n\6g8x)((\JjA!<80\ЉG8eugûapa"(f֙ pjX?Rn2r9 m6*0BRlTAC2@~yl׮ HrQpZE)X7ȧf\.뉲tV^fkF#-ˈ }ɜpf^3#Pj<rZ gZNHw# ث{T IDAT< /'x%άJDKGnyd a빏I/~svp~>@ד'TVK^A9SK|6M$F^5͊y5 5N5σ(pgJp2@`?s}-p0 _(@p>ykZ2OQ6Yݯt:Gz j4gq`97 ?K# ˩zfR_ ?z 8?dۣooou~~:" Θ?FnKf)I_urr3J%5eB~d|@.ͦz c-Df.rr2t1g|yu14>/\Y_3c4Odey6DEuga^!'rӇfflp#<3˼ҞyQCzc^Or9Ҵ+FJ6h4de]f"*B";T*i\j6ŜEwZ@_*]s =᝵e-kYѐ:w |#NnFsBERc'ztHR{4wՁuH-GccE!)'a_J,~xNy }cyN0&Y|=?hȩV>~3kq{{fU\ 'Zr\J3HɠEH@$ȍXZ4YVb:yhOd6)B<:M/5N5Nub}Y<xvlK]__''$ 1 `tz_nv 8d= 7RmKdF_z'x$f_? 'OqW驞={󏎎"AZ"c$_-8A\@dȞ˕tg AW~ޛ&Jy~dܦ`^A>|H:::w_3F{_=Q,5C;vOÞ&3])Yg Y?^g>8g8=<B椳|>w?A&Ju@ H c # PR5+b4r#EDr -0׌tV6{Pb=bY7RXW cdy)7H8W@̙DgxF ț,.rDٺbj6!<#̜t:az<#Cvx6/^,49uP9H+T*,C8ߧ> i#v?ϲ FG`He ;QdO ?!Bѥ7MG (LwYZֲwhnǧ{)с)@o{,oH}|?1`scA~6wwwjZ=w󜈑ʨ˻ r A-'|\<$-܎F]0~.}18pc''u晬D%{xv’QFܿ3 l/\ᏺ8AKH--/i :tp؃&qTa^^P yp1S=GdyT T'k~F^3<{3.cGs{Zu:?OrzBRsnwZ/Jt{:HJAڳ?rs ןEj}v %K*6 ø]~  2Ç ȃ ddwwwm` $EЩțg&9t:Qm2T*E%bn:Z~UYDIT*QhD`D6:y$ ~~DwN$rRӳh8nZbO4 u(wfq! f}(l$(1wwwj5tqqׯ_1l6F>x9 { X.n\.J r<]5Č;m [Ҏ5W;xd"n adǣK<⒌H>ÜbᅬH"FGJL9!}ܝSi@$kYZֲ tH @;(E4W"/DVcFQ3<g<!QS.)-f,*xp@|>9F^O4:I-'!(?,S`dۃt'ϫn]8 YI-  (5Nutt$P.d2W_}pexrӉap Dzvu {ZA1Xe ={ͱhh{l\.@.B2tN'f$g3=AJ"/,'|! _cOrCt6O@t=n=  a9q| qyWEj6dɡoW_rrl1sb^=k }½|MZN9λi _z w^|cۿC'9=hdq}J u]Nlƹ>RWrFoduww e^WWWzOTϞ= RiOX+gcj5p՛#tكp*<9ȲD*FdbkG44م'gAe-kY<́tdaJOv=g {ĵ#u؉^U@=|)LfL|R!?뾉ѱުj%>'ҁ/H{} ի.OZt$|/It)8B|=(mX;!UTz=AJJk$ ?3O>cYJ|/Nzv;~Ç*)ltqq1$$@>\ ' ONNȇ"57O=xx! :]9`eI`VCt]"c/"G0Q'9_<ǚ~1os~2ODh4...=]9yz>`2*쀣IOwo6>rR]-kz4Lbqe^׃ h4zY$%xP8c??Wև4N D 4\q5 J~~oj۷outtg?.//se3.=Y 'Z(d&b=};ʼՅ}DSWk*y zA>;7{ )G%H|>fl6 ɣPtՁac%(Cg1`;p7pXbxB9~QDD\q::C*mh"x#csy6~Qk|k ΁N$ M@F$ϫZF Jal1tD\A^' ]B;~`N $EGAXf0NNNdϰ͍ud,xr*砅p@va2w?|T޽> d/qWkQibQF# j(;#.w㰰o0@\x&0㱶ۭNNNl6CZֲe-kYZ"6LN|}Ms"@c;`ހ3،9o8cƧ^&/Řt 68}Dsb0tT@"Zށ_ Hv_{)1#.N2d@Aֹ,OO=},4~?gwh<<0!#sȜyzo7g.UX&!gdQAFxu'Ȅh*zmdt݈wBu0&> dDXqoݝ#.2"Y3R^,-qȯp%ZW*onn{Gԯ ''\fN9ΝNGz=v RY(g >f7DFv;JzcczS |v.jBG?g?s;9wB3^0=),<99{8w[V{?WKЍcrX^d r(l|bPAbHXS;==I4 ՝NGrYNG~wE2zX|i䕛A W_Dw"&N1J_~>Ӑ*gOb|-j6j4c=yvj4l6q|>W׋3#$tw Ǽ<@dvݮbt mG7MUdyKFI̯O(DuapB=zOJ+ '"p̍nt)SƉ'xO8G~s#8l.Ji8J޳ 0<2 L$I C W?DyGEablz&}a}v">6Ӈs;*Jd2@0X!ᵖ1Hr֐.(ZV ˥nnnlŋP/x=y!{L;NBeN" % L nxhbv+<%.+ zY{'IwGީZ*vAVwх\V ^q0Nxf-kYZֲT[t@}.P~2q؎->GL@8%qi()xd,~gK8B?C8F`W۠Vy@;3yT3:I? ̇ &O. xsny.SiAt+=(f\vy.N9>tvv&IQ:X29 BN/Rp,e<ܧQTtvv`kF#u:𹏎4u}}2RVu||blt@;X]ܧeYCk"dqLrwz~O;ks3|>͍ibٻTL!Vi`MחD{5/_&yͦLl)}E!ϥRING<=5MƵr{}O>sHi$ :tM1N UAE&eV%Tr9ux~U*b\.Gq7~_ZM! SP0! 0 zKOVKv;)iZngϞ7h\X|Bܟucs7r>X} .v.vY9!19Z,j4*JL&ѻwÇx>:uy,U׋{X7|> x <'IfO;<$qM&][J-~qޡ#۱ai೔=WXHWWW^4b:JO %H70&\)#< rQ%n1y< 2O IDAT ’vFxc0B&EBG=͎Ck(asˆf!V0|L#4/iy/K7@hQsv:!a~I &z83$G$4d [Nh(afz.A76ue|v[WWWO"un'a#0n_8J B:R_Y,rT >sHއ0 Qҏ1ȸB1 YCp (S uV<"ځp8跿me?Z(:>5F?zcQ{ ܝĴe-kYZֲSoi}F"z>6[98?~w0 ܗt;A0/+_ߞ9sj2~KRﯿӁBs'w'3#r߃ BdsxߝPo `_^@ל0"<cX$|-uf8Me5&0/T>9M]UD')J/MS- F(z(|2i (q I?d" [^& 4%rmMPbZUћ7o4uyy`$`d@02F/*ޱ|pDg\`%E4Ϭjt:vXj49dox# E'=\NWdN2{ :N"@@=: v u܏ryֲe-kYZ(g<GZaI8|9x&9ǦdžMc/ǸxWqwy=i1ɟČ lb|qyq_. `zz41ejU$ㄊˉυX|P(, BFI$N-ȀTq~8}^TH充l`<<3-K뼋@RC\.YÌ :~ =( $CCɪtjzVRTZMT0ure0D ~,wc88`fQ^O!xo/>CFYw,׏X˚cfUpL*%4#C}jH6EJ@^1u*8jZTal8kFʐQ>žt=qq?暖 үĦy VFo;gy}hǼy`J+rx|T RB!0WsJN ggƵ4Lq*Y D<\ x6v#:ٳg zmcxs )9kggA?d2۷oB_|_T* s"nX(3qz=$x/&}p']c\i>dE!U =Oy{]]]i8FViDgd 0L2ʳsPt }w(/snсxq"02mal  \CIA2yt6فkLx ,;;5`Y  FmߏgE,1 F:b_Ã눎9QFAV+ Rc?1CDbut:::rUs5=K‰[;ooodj P0d(~3}$42n2.jU4LR2fIY$o$˗Z,3{wwwq19A$EI!NJgI(~}ޥ'\']/Q,jED'3vW{yRc$qot:t:Qbg  kjQV~\!QQͦ~'''`Q@iqV0_{ǻ]\3N)~f ]|&M,L?AA&utt_;>zsgqf>ȗaywXw䓟ի:jZ.h4i1Hsq2Z6Jd!c~<)J๎*1}]7Q1)˅S.hzN=RW_i>͛7j4L&xp8Ld,1dl@w+zs8<o6p "#ACb! < wdA@ʣo^ 'g88`1H{5& D\0e>p~'gXd0DН:ḇ>(W}~~rv zJ:;XPh QV>4R`6)˻.Y01#?d! OMZ5{jiX$")`Ge :}(|>"j8HWRY&%Rh#ua'CCR}eQ29}6M0quceDq2Y`SbTke-kYZֲ+i ! B;H,>)~? G'T+ќ, "iAJs2ǁ $%Á렪s؃8? x&!;O@JLS̋G]N3挱d,.\.IAK'3<'pc9sYb\hNQ6>C9aHF)> fS'''E%$QϽ^/tk-Kƃ12dBޛ;MӐ#b/>(2=t(䀱Df/k䀴!+x\ZFd`ENGXBAA۳4uL@8;;Sۍ/NBRvGW^2`i` |f0@ 9~c~]C_k!rO+ #^ ֵ~Φ3t"r |9VR݀ d@2'`p|>82/''' ^j@lg sR7oިX,FiLtz6ǖcN3Y_ț3TNKk26;N5#IR 9IFN@d£$d8Pr_G=T?ׅCo'ƠZ2 ijT g݆AR("A3Vxy\. WP2~pj1fS}JZךfL&Z. ( ~8;Ad.sC%QAV rs |DxA&}䊄y#J~!)uFNNN S^z~HL9== ŊA^(313 f zVI4(dLr9?@>~yi5f܀=Z? ?C><l('Z/o.90N: }sY?}{/~g'HO`MߙO "<s*~&@˗jۺ bFzT!8qZi2: |ďvw: sX~___b_u|>۷oJ n #T#"yEN(qȏc0țIc$KtKcvB]d]u]Ɲ3aduqw^V{kяBsBYF3gC$ nHp+˗zmz^~lTС.RIFCWWW1vd |2sI ViH\* 3/-ȇsdsfLX,b.N_~ϵl4L4Ne,if -)2<!er_uASZ,H IH(~('PFT*AJMZA@h  +dB0 80)krgC`YZa 0`tDV9p.ʹA%[;KQ sd BiAH90d0^H(2 F׬fb@f = /ar 厌~' r"uwZ^> #}1{G9rhv;܄nNu%2 t:D:Y:c6 fP7aGs=Nc 8da!R|/2l6q^P>3tÃj0RMN' e-kYZֲ=6 4`%~l~$LZ^5#}dfGrW A4y=栿ȧ >_i^wy+wyӕ/39(8Qu!#\8 8bQBb1 \#gN;hwYq&b[~"v\>z9a/f3 E]d !蛟3X!8/M0`VF#Ѹf/*9Ɋ:؟U\𒍐̫Q:I ~( ~w~~ՠ;]ЁTre7Md9y8<] )ʭŚ;y\Frtt`~bni<G9qS =ɱFn* vZ, pKJ |\.-'̖c-_ ;pMUZx<@aJ9^wJX|L7{׉Ct2u^RUPMTaߓÞ!{esy|qYFӍSBNB:N'P+.{\n6E" ulq5l / &}e tzzOu`~X;ߋcCkfne"Kk2ns:ȃ%<(?3Ϝ@7fTVPB~h4#Ӛ.L&* j6!ϬaZgvss#IA QO? ne0h<Tb أ|>5Ca ]^^ƚe}E`^1H#^?fC8(<es :er\2#Ps0QI/IQ#?\H7Qb:(S1"!PFl IDAT0 jE3oL0YLqÓϴ`٬+ Sy7 dؘ 2pPnnwו!弸NdsZ=<R[<ÝAOc. $}w2P 눚Ȥ;g>sY3 frH۷o8~1䐬g7DPc0~%^(d2 CIgަ!JBk4>AF/Ck}tRԣ0 tĀHq{n'Ɨe-kYZֲSoiPӃܷ@:)I8X :>ƳL t4W<`"ܷyPc^8]ƣr#R$āS (:F-W5gϜLOͻ'L̳ s yF~ţtRY< r u8¯_;SŝC6={;qN/X[H";C䄩.NJIOpQ=f6i<koNָz8+z=m6өnnnt??/~ }g2R('2.G gFQ+c9r#pwR>xuR3`h;'gqIL;Cl˗/U{~{4Fr 0%,duA<@L9FНK}8{J'Y&2}DfY׻Ğt #IB׷6`ޜwXlr\wg.y*\ٗt:a?\ߐitb^" ~;5{kd85M~Z~_nq:_֔E9+n\}y.0j A9 T*E ~?ׅ{No"[ J3uM&fK-G@: wz* Hv[Ttww~[>a13rYf3l p[IfAV+}VWWWFNQ`0:'gRe84۴! I(Aq%GTG>Tm v,-0\nᄒn =MikZsc&2" < ޠ+gp;MO^#|Olo) bac3w(GGL>DDP~rY2e=۟b='6=}ÇZV1O펟ygGy]/f1/|2" É!cNC|h韔[˳Wg>>^@T|Ayd_y_9m|"J%.!d C?ez据V~i͛7/gF 2!o̳ԔcsV,|4%G;Y<^rp @>FW8^@ćΒnR|!{dw+yFϞ=SÃ...l6l6sh^@y>tC+z4 }J|?TZ`0|Rap|>2{~{S+F\<0J0!G'%?F4r}댛w;d{ ς19ZV1$} b`;M;>VĜsnoo X|,l6B.mTqLK`j5X qZVsdsGI 1d3k>Bb s8X^Q믿Idgz|>3Aբ؇^gvcM8ӑk/7N_A̼u:}grԻw[EαK#$ ^C yG`l\I X ҆FL, 60B 8a\+v< b(B3<%w`yčR<#0J%lơƂa3cx$߬-N?iX,F sĦg ;cp72R(Yp 9!Hz]n7ue5Dh@*@|@wZ $JGIy+'1IfHwc>]2DAbrPc 8߬ s%18d z8P>F2!+D9 ?DQc}N<1X,:Hbz8!}e-kYZ~ͣOdS g2'7b: = Æ $痢{P ~:P $%RNel(:y- ݊澜E:g{7|4ٕ& ݯtP4 @:Ƽz`#}`ID|! ;Iy7@2c,ȧKo ?@ BD~{ON`B?Ǿ—tBl 7;$8\䳳3r9M&@^_wŋdW_E6y>3z=cM=$^Ƴ&'gπ9)[C"ՐkN( Ȏ=-JJ=8X,tqq!z^~`ѓDbQϟ?WZɉj*ˠ;<QvypG|} jUVKv;;t pB|8 /m:/uGj~՞E-(\xZr3 `;DH'\W} s29r2/'W~GD2g?Z} ONLgRAvT(4M R)Q A1,tt\.d/c2^z=i+ y//FqAf3`0}2LW_ٳgcr;gr_|`MӘl}xBϟ?\.hOBr9=\| [fonn (s^_ךQSRpF"ye2D_5c|N'j&V*SnqCA60Hh~gc橃 -Ji=& tl:j6<{ߥR) OOEa4z$ eP)( &e(D+_'x^­0XP̛6pH8'?7JIsÕ!0N<@Iy(7ʎ110bܻrL`~$xuTv/# SJM@V]8#71<yw'cNUJOs2̱B"i=LjvqƯTwwC>GiMT !ݛ|>QNe-kYZֲ6ib43d}l7l\Uy_l64u0JF蠐** #rDvG{ l|MdϏ|? ytڣȟ!=e_oYIF?&K4Eժnoovc ݜ&el` 7rYkj\:$2 1j&x. 5`mz%%J7EGq=/>25~_FC޽HBVϟp8GJZdf3MSM&#iG8>~{@y g4:i3>/rq Lw OnY\E1 tzzDcC5jUA''s?p/9 { ̂"j/jȐ8ngtBF67ÇLʬQJd>xxhYts}O?gg1>=I "دƆ@=۱03eg[Mc| Iܧ|v~syyTԫWB&\.gϞNWWWL&VvcAI'''fA|7 =,߿Ņ~T0vQ&57@|ׯ_4 vF$6t~~\_ nWS%Hf`6lFl6MDlZHhaQV+aJBȈt?J١j/, 1fϸBQ'4+ERr79${=7VU|"w <?( W ;O3'w;('Ozr= ᇡGyHO; h օ \36s֛u{b1&4Ygd}H\9ihK2䌅o8P- 9Q,c: B\䆣~fOx707ltK ]iV+ ] jJ$ke-kYZֲ'p?[O`,lc9=4U#(}t3~qcqq0Q>gNBz@f|&X7W%ExgjN:o9(!HtR -v] C|ݐQfzg>vMߓz=E'^f;}YKa*Jׂ 2x2G C\N&w$ _\.k>xȫ˽²'wܰ̿v,#T ]9n\z&_. R EwB/KY &t<1{5p>!}`>Һw y} b@yc}s{}]o cXh6%d ?YNˌsš:>粄N3I:;;Kp= 6GI.2_,tg' $lg$m'X|oi<wi2\.k8< T.駟B?2Lt}}hvt:~Rk=<<:W9\M)EGpV$Ұ cE|>v gǀeX,4u||nPОeȍ+^/ Q-OD!XT`., JM~60};k]__+{ ;LD121HGqA]iQ1B8qR#n>8ظ8(|q|Qdh?\1e)8 \,}8N1G(X;2D YFiG%M1SMg噫ĥw<—u)#-3uedXQyXQL&\ϸ nD9p1L\.C$Zn<||ۘe-kYZֲ=&8`S_Nn X;b=Q<{yx>l6 w:Ad6-bG;ل;T~пvV`f~RpzAm`G?s ^A)3GGGZ,Qq:A2|Xu[*ydJp_78y ͍?C}qbn'$ꁁ\WBp..%R>  NUOz=*x|{,KFYMdp8]@HzlH>0*Hu< 9Ea1'R: }8\y0GcߓtbV<=xwv@>?ٞ)d0v ss̪PH{`NH:AȞa/Y#X 6@9xϹi ?+=#~Wh4\.Р=8V>f?_?~X{lj"LHѹlZq=:v^oCw jj4Z,QH\4*RŞ^ggg'ѿX,h4^¶v!Cܧ8ͦjtsslR̥RЧZ_/G=t~~V~ؓ ҈DžO(X^+R!7M,;ABMk02ʫ1ffRĈAy4_Ŧ0 _z$z 㑃YTx`b 仼;|Tx5}d2M>{ý^Oޞ ;99(kR{*ES0l!!\-m@<ïA?֫JsJr9>\L&U[d2iE|ZtjAt|W),QGf?|֎E0?PxiMS[ZǏDcϲWX/tN!3]uD8d ex=&hJ|"KKKt~_ h o:J&*Js ssd7< \g~¯T*57_[G` IDAT1|~ 1^,q#8d0e' OT  /Fڥ)(cZwy{qo+$ Y}zC} -?Q ssxx8GZ2n룏>ʊPWDN[j1_&r&l }A<_]rE8g766ttt%bDM\./~w]JE{{{*ԎHlkW^շmz=R);{Ţnܸ1>Hչ3B -B -B -~RăXTA>OOpx/D #f =YCmhO!S`X>h>so@{QN %@g~<@x?NfUVurrl6k<:$Ϭp{z"*Vq@c" #}/'IFVH#BW:1(( `is<W&Q.S*2 sD#`beuXs@wQ"PRуteKH2S,JR\A@ÈX^]? YW8 o{d2!K&VUJ`45OhB^PL r9#}0'A=Μ0YZ=yG=$3 VS%`|X!0Ff3E߳s->1v1 A[hmO`xȟU /޷s=o~?};S/>g!^mA3RI|"QhA<[w7Oa c|`(\=!:Μ•qGdrl'Hܾ}9xBƓkmVpJ%R).\˗-b*f">X,Ve2HX,h(NJQ˗mDX^WTR11 "XAx~&dRv^ mZhZhZh)xGɼIRJ T{ @ Iֹ< AP6Bb h!] WH! i /GN{уb>=u@!cu^_d2֞X,fk#v^?6S`8c8E(\ p a|FGǵn㱪ժ ~m}KiĜ{ݏ7(Ȟbz=k@꧐(N\G No}[*j4Rؘ%kq\saaA[[[+J_wccCjUv[j:^^^V6׃߃ 賠$jK:_\>VZUl6S266x?x)d2a6'}\{pyBqs ĔW@pNxM.~+ zyg5PMI煔WJRӱxlY&+KKKF❜(ٞgZ꽽=ʕ+9P(ڵkt&a3fciaaAFTAUOjssS^WT̗߿_?Tմb&y5%i{{[bQ?ttt``%R Hu!iZhZhZhgfL> h42G}=D_4y‡uI狴< #_)|J)Dyp~PA3փZ%c$+P ʫa HSp6\! t:5bZŤVQf2<'`β٬ œ*BƓٞc}9IsR :ك5o &Q}2JznAOأb>hp3/^k{2ޟ#A2ȿI~v~OжFc}}ʠPC_@~'yrkHE%I[<B yIdN3&O׶*YC*s?$^<Oݮ)|mƕZ 5{.)JR_XXP:s#J3`T2?K.Yi wQ\qD"Z[[驲٬.^h>O?t:o۪T*F68 v|֪6/z]7=xνt:mnHQ4 -B -B -B 316xrEOU:Ԥ " ~iij@^}V&եKM_hq?CQj%wnיmz@5уk1>} }(t XroI I`=|*?Z ŷ~zT0>>U9DR `{=ϋ / :==UݶDSHdK[H7F7Q+++4s^g7>~Vh4l6l6t:m`(t:UQ\62JfSH"U*^my(AD$:+`'-WS/T*ɤQ\?XS ?U#z@ݯ]OiHtxxCOR t:AhNYwH[_1"Ut[uj{CQx\yOh= J{~Ӝ9 kkNv|$^] RbKއ l⺴f~/yU{gC6_s  )}l635O)g$)inXݶ6yupVqoR5 R)+ujhaD̷1G# BJ2'(dH2K?l-E_SVA}%SY eYz=+ikkKtRnBQsuUzǔdlH zzz^gi=!!rVWWUl6wp8ժj<ʕ+xzzjdkfDT.j,^x_; oy0g!j()99G|{x?`NQhnCy`)#Ci\sf g';:A_7   E)7J$l@1QLyX׭nTݶq罬/6A4͹3 $ʝ%jssSGGG:88l;*F+ C#xFl YweeE@ZMhTv/2`~Q6-Դ$hn[B`PnVښ.] hhuuՔcqe3i<++)ɨ[ׅݿ_W\1E;)Nk4RX},H`U>ժɤ5t:~Cj5UU *t:fX bښgjj$n+a*iFFZhZhZh}FS` z*Ik}4: uj㱑&^Cj<Ɔ/Jj6Fb1i=! T=t:t(>%@.OIӚy֫.|ar|EzHdv0IsӢ r wbQ D5S*8id2=OOC# CiC2D$Kgze\___+FZ)XI?ӓBZ#ժƜ DNnmmT*Y9˝NGjՈ~o#5v&ݮ6664uxxxh4jD`0l6SPȀd2ikC* F"IEJT+*sykGeT2(H>b-@A.F#qEQP((2+ wIs!k6L&jۺwf$[YY$ ;[ =${өU^c/,,f3nܸ-?h8aQ柡vݝkҒaTw tRzͦnCx-O'TPQ#|d2b]6gQhZhZhZh}PΗ^ jxA'xy,]zX#" ^7 >ؓA)< $! <¹ i>Wy [(XLJq}>B۫ |`$NPDk,EAI}"}N{#2(LX,*[e=w+1P@A AO}F@v6p3k:__%_/1tHeq-,,_׬K|$1R+[קcm@Xf3u]^̜cP@ { C|m#>=J!5: ^&)o޼RdZ# 1DjlCRzuW>%prrd2ib"g| ׍FVIFkkkv!G愹σ L&cV٬b ŏU pOdRi#8ݮ*l6vmoHG ܘO;~?3lqpFaNXHZhZhZhOE0@G yp/ $H7k "Ւ&'DR15MPիWTYtZdR1>l6jG1LƀU2gbQ<0h0 EP(T*J6`-d2|^Rd*JV>J'$uGŢE^=( 8`eeEtz.<́eYZPQ_^Vz"LZZU*j5j9 BmO tTIFѨJ677@SOj@ބ|$ d$O4tBABWerfA}*3^u>>o3zsx<-..*{@oTH kB >ΧKk?IUBFJh^g5$O1YfTP2T,‚ݮdf9a@@P-//X,0?c=]5M PV{=T |?>MfP1< FXvFs2(֘OH#0Tlm!u#s{cu:[uM&;ADz]}>#5MSmllgg%ɔdL$FQW˟#>9$~Ы=S WUR4 -B -B -B c(%jJbԇyx4_3 !*gIAʼn2*HT}^3 Sz ? 5oS6WJ%z {ņ|guQQ+|.sk[gg|^:e flBǽ zOhii6C93̷'KO^ŚaXjSJ%]vMDB,M.H-u p*W*a=YC8dkڬ >?Nըq IDATh4~0Ԛb /..*L@Uume5 MS 9E~_vΠzq\.FaF}5uxx~:::25Ғ}Yzꩧvvzn0Ы9lu ׆áD̟QhZhZhZh}}~nxp (=ɧ(tIFd/v6_v{^§㽴ǧQ뼗h\@z'w $0䝿n0!A#HhO$Aq}~&!k(ڈbM@L>*z#dRhM>7h>H< !HD&'F 9e Q *.f t'*k@ >>㕁^z"M2>˽~{TH†áIH%~z՚e>x:E|^bQRHUY_fic0gc7كo$S(y~sx_H$E!8(#|8 8!U ʁ/k pzxV?lϑ^!9LwɉI^tt:=99ƆΟWn=ikϰ8k > wѕ+WTVc*@ I7~QE >Sa 3^j zKhtſ~3T/R -//VìP(h<iaaA|ވ%EEKk8 jS/3U)wݮuE"Sw&Immm͛JR޽;7fGB  <)ǜuy>kC`R)eٹ IB -B -B ->@ 4{!Fx<6P9A"dj^eq#kK ÿ>*ݫ< 6Uo_0Qxg|zg i{/HAxrlGCLdqe2Z*2&Z[[47([|ttʢ!b?Ja(ۋqd],//+NX,DfSJR+3T. &b*2P(X*"k۷oB Wu:juuuTp8#5`"t:5P-fQ@t:uI"kkkFh[= $k q;/} 2;o"mj4RpOX"}"`>mb_IyIF\*hssSBAbJi[() fJ1} iDE@x$KTPx_g즏+pF'A׃+5jL&3SߧDWmy@@Ceb|$gEK |PiD\.7:m6)k}}RQ\&X lfDXTR3y2 2(k}}Σ#[ҠziOf* FZ!Sxa$dY---… VS+ɨh{ЃPދu)޽{FyB:"hTUݶSfTS‚@n%V: @ͣ^gj H]9`='~z]VKjUN>`7"*X,~oByb1… FNIO~Pr5ZފW!!^ r9AtnޟHɜCTvoT̿'qvnI`7H&H]ϿFtju ,=irO^zX:Y Io/H޽X,'|R|Hzxa 3>??SVM@קNd xE=a9p~3hC< 5scdg-]NG7|>ou鴭G|fZ]]իW͏jj`}G*jnW'''V;ݿwZXXvvvtxxhAwSR1)P^"?>b.d%TXHZhZhZhit4}5_v=Qow!y`$EYӒzRPE"9pG@!0`}0m yB(H#gáEJ H6ꄐ{ 0kEw'kxh{Š1s1)z)>ڃ%>$A᷃ xݓp|bO& ,D_gS]+NPA495Zݟ;x^nk5h|]_GjD|n0?7O? |}@d*f-q. {͛ol>Н;wk_@?w9}'S׺'9I q޳P?cyoW,SPZtw޽^^FӱxYJrNv}[>(~}rL&jX,ի79W97u$_?dSQhZhZhZhAZ|(L&s0 F`'<x< ſԾd6Q(wڱb'#~EQF:DD3#uϋ,DhKFjlmG{ 3mEQu]S&I"srcwȧbd4ǟyhAj<7;s br6YڃO!M ߟ |_ÛW qa H^]L{{͓"T]x +++>BЅky$J0miH A-Oy`>|4@RGKKD$<? F +AG"1@n4NAB '` Qr}aaj Cy@կH$bij@)! ^^PuƔ f3qsF_[[[s7>hdmz,7f3u C%92|88)H8(';)&BiA`0- `9P$H8%@;iH+w||bh=jb*%QRe2ah A @ Y@{c < CRdjhOĝj>1=>o3{2̓A:$$B,Lڽbh^YYQTQ6U^WU"PX=C$DLpTiM$bfFt:f~9q6;j6$f6@|uuՈv$Y\.T*xl666l<}f\.l6M( z't5eY C5 MSXQ v/5TXקAD"pႩY7LF~_VKP+++FZ-Q.2d.j C:V6ښ YIinMن4dB'MqrrvnkDҒ ŢfrJŢb柃0ANOOLMĸC.0J)~ZZZR:6%#0 Fl o_9P:?J'S{b} $ۯ}5"|ooOw55cRV[(v%eq{e` uK;}^rrN}]pA.]Ύ<*R n!~W~Eۺw>S]$_c]pB= 7yY§U ZPJyR+,|I}1t:F_ {)/YYpnk*&ΔCM&#Y|?L?go@3s| '.B ς4߃f &C@'({uotzVsu_pd|:<`tZ\NLfx\Yy?)~{=`I KquE-//@Fu:/>j٧W.H{bύB|PX|U@ط>(dbZ-Dl{d-Txky kl2̱Mqxx}e1u޽^}y$6yB#j{{ß ~J&3cʵwwwNd29* \q*ŋS2iwwh%ag&?O2>۟߾~ >s'? ) :bܤ<{]|sN zs56n,vj6Vݮ|̧)e_<̍}Ř4 qJ-=?=Qh.SHZhZhgd!=>6i6C5ɉ666dtzzt:mtZ :::R\MVˢeONN)$ٵBRE߭-% F#ݿ;RC> O$O #@W}y0H1jMmnn*EBm! $B":#4M=dTVMMH㶴c{$cH$vvdi(N:*FB!Tմo ;P|~֖ՠy'0\a [[[#;#Ғvwwl6utt{XLFBA/_t:Շ~t:m 5 ل=E cHFUT׭:{ө0"HbA(H}I\>1  . ~ P'P\ޞCL&tTVj`attt#U*"{<[ N O%kd2Q.h42>I~o>KUz 0xKnWZh?^u?fMZhZh-<C -4o FF @ ރ|&R$1'D$55J'Qm`ia|~~ H)Yg⿰yUA/m0̝f*K:֌hZV񠑟+idinՂs)]`4v0j~ʓGX^CD2\%OF)@HRAprt:m )y@? x_^^V65T˪}";T.]\n|>o>B.f3BB`n=SRA6R)߿c]pAlV|^O=A 1H&FԶmWH$lF#FA,~...RꇴHJ#'XfSY-}r9Z]Hv[DbEFvMG_b QR)R)r9ikkKrF Jl]B2WU_ ͦFJ&VW+ OpxU-ئُxۧ5čK^ H='9 M3_?7HJ3 ?CiooO@d2iqKKKzs7WnWwt95?:3ʢ Iz{<|z?=w]-^d2-Y+W~T*sZMrYFÞͦcD_1E$3Q(ֈWA{AzHp@<{0<<'b!iZhZhZhgb 7:&^=Edm4F+++zQQ{O;Mh4R3GGGLS|4)N[ty*2E&Q45~%` (w2P?keeEZMt:<0&T(Hy?*M97 YT.Uմjjiq~B>16;A}pi}< H.]RPړ>H$@AxXZZғO>/}Kz駵}{V֍kV7_=Hz/f?MA;o|} kM&g#ttE]vM nk)Q!-:<<4e8ix%Iy lW~|_Bugi -B -B -B fA \Q> _=D$j3u-iVWWuxx8S{nw R4Υ0LƊb1S*b1 K !OtQLКS:*(spQ&;=O?~'G{Š DmX/DaN ġ̻OC4t7`ۛO  J)Zqn8|] 7775%IFCHĊr'IKGM%ȃhxo:bAul:1_N*T`+kkkYjlj Dh4˗mAnx6Zh‚$BQ*lijuuyayQ_I="K OX] r9s[DOAA ӧjۦ.C *I|ƙ{60iIgiK3S%IS}W~ժuppZ|>oW"pF1_?V'( wiK>\|򪐥% nkssS.]RݞK,&A% /x UU}3(s(B4t5PI8G}Zɯ3xAY=HIvz=ۗFt钮\b{n:99Q^7dgl AdT.*E|)k̍cū>{vG ~} W_?|,9g}vΩw}W/rm{{[vvv~u_zGy{ /^xU*OfRoH}Y?ң_>q4X+^{Mdf356nF"Iý=p$|9eTѧӳz9ڲ4qBA\^ "ͦzt:j6NGXZ̀}Z-#<I<_Gz%)|TŘ7PC41@ avi ,!:r}4i0X[IEl6nv mnnژAx$Idb\WmZMfSH=,*,gQX,… Emoo>9~~A??ٟ?MK)l!'i.b_C~Q $yX[j}}]*v_ֿWz¸AX=ݻwO?PFx_g}@Q%X"@GosPFժu=hggGZ;Jjw} Q.̫Sg~<ޟTg?3Y$C>M>ëǂ گH$c#Gn߾cS]ta؋k)[Ry?|*J\ IǟA5Sp'Kd5{h7sZb W*m;{H{E~ч~w;w\.[Z^?9k'zw*.==$g??Qk`0w%LjssS=1::YƯ>y-ݸqCַ|ErYL'߷qGZhYYگ~w~GnR\V>ׯ3<{{=z>ڟ/ſP&oojgg|ook__ sF̷__[o}?7|S?[?u?Ν9GH_%I=o޳z{җr[nW_}詧s77K?z3|s֍7\7?&/߯_ȵ8cݻ7|X{驧IBj}i0%s :\_ |$zݧ r @ET$=X: չ諫*Jd2ZYYZ;VVVƈvY5 =x@fSPFCz]NLj+gg:\:3i<w {̓6 +#}sŬ!{$`_YPA<ٶ<c\|-fǯ}?CT\Nl SoT@cHgHl6HNƆ*޽{L&*R24_2Kp6I:==5[FRqSr9#^Rv=~TATpRIۺ~~i=cM4U׵Jb)Qz=k0T*)VHp.kj0h2h}}]W^֖ ŢJ\WڵkzꩧOg~gO'ԕ+W|>?Fgxb1aRRҒ֬WvqF]pA?s?5u:ݾ}[ߟykD]D7FxOjYt:M_ԂnWz]裏l!Q'Ju]_}Mz].]?yD"ag30G{d~|P'~w~ >7  ׯ_[_S_Ӌ//}Kk}}ۿ򗿬V5tT+zwiyz7/8kzܱZ__/_~Hwy [n~>?yo{9};ާꫯk_vvvgIq~%O7o~/Ͻ/_w~w>;;;zW7?_|Q/^{5}_ -Bgs>/<|M} _o}>s򗿬خ[nww?tol|gqK7| f~\~m=S07?^{5yg_ A7oԿ7{=cY͏w}Wg[+jXϗP}x} 'p<4 T t"0Џ/>Q0ٷo'PXT7J&|HHEt6E*G"XQLPSn[_ͦ4EB#Hl ׽e],4.y5JR L%p1DxB0Z,lڽâ^++ bBƶH%5~Kfw"*T.{~<9Ǔ"A%V$' eO$jVJgDsh``@*>+ꇂÀ{ o/H/!#-Q"A1FCCCJ&dy"{x/Ѩ5dc-ˢc<ruLF =}r&''ɤVVVe6]*R2T65^\.th4?A|^DB|^VV! ;,l0 '.P5MMM)NrkppHmSj5Sve"r}}&PJEa4!JJ4C=xʇݮ".ymzEժjbM^7՜yL}l95N{kXȌ~ ~eOm4==9[Il8:t萪ժPem<Ȁ1\+߹3BOQ% izzZKKKFFonnjeee%IVl6k=(㎊qccC7n߯C=mnn޳~<'dQy'<$$ϣ|n``@FC=Dl#q/>vi>F`o^}վva:~ǎ?'N>|p_ jyyYNӧo{M:uꔾ/%%>)z^|E+wsΩX,駟Yw)?uI Ӵ_]gΜћoyǹy\7P>}zW0k_Ν;w[ڵkgyz/Z -Bc+wnnNuGJ:q^|E]z/I"}MW\kv /r2엿e߯~G˗/>|{[Z5.ɨR1@ !z%REHjۊF9S:YvFwƆSBKelc ?/>$߂ -GV~9@4u %Is#2=DL%pH$lMx \W;X, #.(HRVF\.n+[iVvLHRJAdJNc*Ѩ v65 URc 0Pc QybCCCfڻwf!n IDATggpFvl@Rʎl(JZVRZ\\TTR4QNQ"/˩n[?#ʕAeY#XA"5B,)!͹6HZx<nrƆo}l ("X}4MSpmmmillLĄ)l 6xq'loo?PFQj5YuA뉒C3fgdOD-yi-*~J`ILwq$?8`7#fddDJO?1HAeiVS2ԓO>/}K]feGa^~eٻly]pǻ,s=~w }o}wL&r|^ $]x:|nd_/N?ʷ~v#)r }7{%eY.]/}KD>}#78|Đ]vY_XҎ_dY9sZhe 볧ebߟ/Ⱦ):`=_Y(T|ֵu/|!6_Zi\<00SBK$E@wt:{j{{[ j6bU*kfP6eA3ɃRsMp`?O?_`1< A`x3ZëJ,T9DATR\VV3F0ժ*>4laD$Sr9Y\.g=pȨX__,{LNNHx _ZZ)GĄ&&&LeDJA@5 ̌b&''577={Xi{jڷofggu8p@<٣IkK?3&̯/P{J%e2khhe*D@NO,CLrfggG.3R\ @߉(L9}udd/^ J99/E,jTVnGw^MLLX,b *FƗqJӦ2b==W97VN;Cw}WW^;Ck׮iaaAkkkvU0Bc7c,W4_j5n{@>9 ƭ~kW>G}ś/U(JBRhdd3gpA=t:y)) YYp^v??o?σv~f~zKW^ջᆱw}Wׯ_J .v]ɼ~vaeY]x8\pO͖oS/$_WHk׮#W^ѩS/|A_HB -ς=Ӻx6f2sss*wC3$Hix=%[00`tT'RGB Z-֖}٣\.ZBFZh4Jt:m l*Ғ2y)VfV3odë[V6LO3%m.jnk~JO(XÌWRyR{rX5;g}@_BNʼnh4|>}YuJB4G"Sr5{{1Al+)YV8mamr._bx\ ~MjPMLLhϞ=򊒬sݝst:ɫjH$b$0cqqQ7oTTRVS\VXTV\iDT^\/{{W1@#.?߽Zď{pݎ h[O)Č J%w^MOO>g ѨůF7oZ lGv=яW?!t;OpsO,;{ nIځw* ZZZRTFQnkttT\_XBkp}Bۿ9NRHꉅ?.&vO'|eI==$3ETѣGsss߾'Aڹwf~~^݌=A+VV._,Ki[oݏK/lY؇R&ѣGchmǎOXΞ=o~!ʑ#Gn(hΝPW\g#G(Y<ʕ+:u$3 /Y^^OS?z^ɾ?x:r'r==?/ڟy hU*dVeٗ_ӏ/ҭ/\O /}OI@AP+\ b_z^"=vR,$j5SRm``@333+dҔ9Ƶn\.[)_ !A2d  ySLNN*L*ZEzmllP(릴jX,ZC88|rh41bd.Sn b NkvvV<&''U,{3h4Fac +TTFz@iֆ2U}ȈT*73|/顇<`iKfA{ ׂ_y_uͬ%) ƅ} /_f Dvz˓4;;0Ճ@yŌ1 n=ִdD>9XX5_^}9--@{ᲳƓq-G>n1n*СC BOr$)ϬC!5k?x }'9#9'+k5Ab̏׃ Ehh||\=fffEir9I}R+Ǹ6~\?1bJ&v횺ݮ,Pk|e{}[S?_Vԯk}X=ŋﺌwI١CDŋuСtE;vȑOtJ6Tp/%Io]Av1;vL'N%}rZh%0k|>cǎŋz7>IOkqtS ]|YgΜѣGXщ'v%y=#x[~OP_z5Te`s>"`ZtaUU+AEs O>+_!fA=𸵵Jbw@zx>L&c>z8a S6' nܸ7nRh}}]kkkD"j~ .=xU gmyeiK6g^߈a("!}'HH5{~AlOJRA#ݴd(֬STRT2r,)OB|b{ $xJ>}~>1c$7O|\=Q 8xu^׏gǗ d=?XO!1\77kl16A,'>sNOr/N*/l* .u /><'NЏ{̕+WttE;}M>^OWc=vO,ȑ#VnҥK/َ;#G B O.\~[o^Ћ/3gSIzU*Lqc̙3>ѣGL.]ܜ?/?_LB>1|k0՗_[| gSz"e:|'$+pxPdYS!t:}V 'WXlA+i(AE;vZZZV![xccCjUVKb~*1 (c@1lx29s2Q7J&Vڏs=n(} "岪ժzʒ Y DzDQo&$o43АɤV=cCsղA;שj6n|ހ>#4ޏp=kppwQ pYTT,F511D"iR)S0\sd{{2L&cM-l0[\'|?Ox'( HT(=$y4FFFTVhh4t:,z衇̏C௏ǼrWxE `,knw e_>5 xq`ݯyz^`-p=ŖoQ#EheeJ@Ve .!ySTNl&)ļB ~OWgT*1~~sJ Pv grz)wH|E6Ą#>33iy͓S>_3s-~m9#17{px2pވgddyJ%!s ){+b``k"P2T2Yccc=wsJfkkK *J67>& Z'=ԏH~-z+1Ih,* |f{4{u;66~5??3?Yw~]_/DHgyF/>%ɓ*}˩I*{ ^G/~tB -?

Cgzu>;9C677M066r95MJ%Z-%@%CCCyVWWǵj=aifSZ^^}] {=+W, bP.@ tj5+eA@@?>ߗa?A ҫM/1R+6Հx'Y@P^9Ink`rrb4P{bT*YE*=!N۳'K{fPI׏5ob"xs(Γ_S}U%r>qwSɓmjmrY/_tIo}*Zhv?a?Ow%͗|G/x׏/y (X"29ƔJT,-C?kbby2e=ZT.jT4<<={)K`}i7 >J|D c 9F1=hI|5{# b---O85gܫ?HP!|?3O { Q:jޏSƒy`efFۉ9^Yq!8>' 岊Ţͦ1pqgTʈ,oF_V+hP1~ 8Nӱ==!oQfj_'{R)#K٬u^0dg$>k Lcn+JYT27nh}}]4::1{VX4r1N[5'Ѹ wH>7% b)87Aő+jj1hssStcc} qn+ɨR: O$\(ϛT*J(&Fybd$.*zJ7 |z~ӟ?^zO-'k'|R/^&i?s.\^{S>^{MNҗ%}[?n` _ܹs:x_ed2zG}O?'_U?tȑqY=?~\^vM/^ԅ t+q_ܜ?׿JK.}#ɓ:qćٳwz*,ZhYرcSOgUT˺p႞{9_]G_BsssַDeG?ҋ/W^ycΝ˗O~*g՞y]pAǏK/'NX~o}>|XN:˗/ƥK׿^{5=裷=V>/K}ÇuI=S=8/_ŋ|W?xۂ_=X!ː5Y Lr9Slx1x}>Ll@c2QI2%C,ӵk PGB)IK͋)}CThZZ__7EQ:65Ȉfff+ʕ+* r6Oc &Ir93y۷OVcommill.JEV*JVN^l\.[IRQ8b1MNNh[8V%Sw S(zCl6ff D֖ ~mH~-7>x"re$r=PZf=r ev=Tun |W*VG5ŏ/!Ջ\.kyyYJň# u 0LFPj'^T*# %HQeADU*K%0 !t}\*3ZGzeH@u]3S N 422baÿ^488Gܓ\7d$5 tuD JM/BRE0 rbS1}!4QNsݩT|2eߡ4'|vmziibYѰt:V.{X[[Ӎ7,X,Z$ay s'wKC%փWA7H#_\2֝N9j<7(fggDl6-VhdO?~Гo>=z`ٳg\~존nS cǎo#G}jao~={V? /Xu6j̰^xA_u:u9t萎=zOرctҥͥ3n_3KN>_|q}ݶw]=:s挞x l6{Lww[BW^\?.8򗿬EAW3C -?mL{7400wo}!^;wNGo':tH_tY;vLO/~ ;o7_Ju~gYŝQ_1g_ɓ'600#G׿ugW<bG?~="=W=?3g+_Ct '?|>?^u;wng--Q~GjOŽjY?>CyzQ%I@n0)p}JCCCVVV, xxxT&|a[/3G[TTt  M ]"vO3ʕZl6ۣځDYB 5zH2=H >snS_ޅa𤝒~N^WTRPC=D(P&yx\w:6RhppGn 6PV!9'%<!b_3d!:c@$/yO>l)1򤞀d{67;;k{q%$I2T"0R uz#vxƒ}s~J*b5bqAa ɇz^V{>Q w#bVj#UU[#c/ap}Ekzx0sh4zJl@L& Y'Ԟ={D7nX)X%zǕ{<4*F D"e2o1`WJ*8>gxsE_gS¯ZVi}}Hnf)MNN5e8x.zpdrGў~ Hʴf PJq%(DŽ8XZ۵KӾ}455ek G466fL:Ą鴪ժTGBAa,j7ŚJ51垆WWR0NXLKKK2q#EC<8{R֫K2ɤjvMMdLJu ٳǀZ{ )Dl942ؓ ܏?$#?=QGA},BU[;?C $ԂZZZE˶'u' }c˼ck|k`s>L ɔt;ZMtzUyv9{?}߳*Z[[4b|~w[wgF2Z Byo%4yEz}*B B -B c_Bh}gWzr<ᅩK FW*==X +F}`Dp\P: ̵{_}3P?~k )$=K ~9ibbOo1ⓤOLWĄ*4>>nqxǕdZ2445e25>>n(WVV,0v!.}_Ғ6`zrAARc^eeINy_o!i][RgQhZh_-//K=GBln+'|<hRVQ24 @AQKؖSlh||ȝh4jNF5 INǀ:ŃfPM&p@PS?L(  egOlYs c?T*6vT(4==}s{e<}oO R{c;T >qR^72S~#_Ы0fhhȀh_RptbP=xu* aʕ+wippP{{+HhttTE9gӱ |_ ?0GH?VI%־E}ټHDJu][u-g}'+>}f2_ @d _d28=ID,^PsCXƗ|%^ p8?{P6H/qx>W0'EIV* T*vL&c{jVWW{2 d2l6k)sN7l%) C\bL~5|җ#yU&dN!rdN×ӹv%ͼ _k{~Aa-KYQ+511+$U~ޏ~ע' $$Qyȏ/wy,2=^-ev*$Wp<ƙA.JF3lrrRڷo5[ZTw=K²޻ݮ=K>q#= 6Hz+nU׵Veq+XCr{,$B+[^^ֹsK -B -Bw:t辔%-?e_zhA @/gg477D"a`/A|0@httd2*x`7訁t4 f?8\HBAf }wܼZ9ẎW1Ns} pmITRZU*R^Q뚚̌٬QjDl4= #x\@c,*MRАeQ3~A^QZ? 9@,3?s)ӕ+W477t:T*e[="2z>^mS, 䇨kjccÀY_R˗4 m'He-0b12ժQFE>Abf\ YWw $p`{uU#ր'|mJ%-//kbbzGu0^'kP@,y|gB#>@x5߯I>Kcǵ/P^h4#LJ,:K&(w^[)U)%˅c0=XCʗ>h4j"HNۆ=ɏk0D'7<1}{.k9f1|L5"`OJq}HDX 'sX`\'hz'8g71Pb{\mݬNi{Fo{c|D 8`( q=>ÜWU]v''(}8F^u};KB -B -?t钞y]~]/ؗZh9Kτd`eړ tዹ$x5*x2=mll(‚ͦ:ax1Ǽ-hAE%vu)NkhhH| "ZZ/7֜4=cz7Ǐkgհx7GDQU*IJqF{xxXr~!UVy/"_@ `2S|ֶU)AVj*̌2eJ%J==dt{7VissӲQr9mnnGQ=ueѓ8)b, (ҋ U'UvOXLbQv&O4xyJtu] ۷O+++Z]]5p^P(Xzv\.gJJaB.--=<7͞&nWNommiyyYNGSSS֓eaaAd@cϚY^^V\)Pty/h/D"` DrO/Tsw_nj_bՓ@ @n@4bEZվ}^?jq!4kjWuP 5/HLd_[DvFv{ W_B0HGiD[45u[P=KUz^?l{ssStZN7x*Ϋ[ 5'x;cQb rj)3knmmMJ`bDu"PT2IV RR1R{Pu]U*뻄A0ƌW2zrؘ?!|yǓu^~~5ml}M>5XL7o‚*'W٫*~TFx06D皹O0Oq^-˾o|?$E_GL&>lg| ~79.F<pt:mѡ!S ޼yS|^ZYYQX4-J-\sKP5RG?AbQHD{5V͛Lt̏bG,b>vBbt:|>\.|>US/e?F_KTZhZha{s,XZ[`fI;dd 낥:|fL&T*[9/IMy_K>|>oL- ϬM\**J4V^q>ګ rVZ*L{q.@MaRTv,JZ]]5Vo0zH3~̧/WI5#|3 Nӧ¯XعS`i2T"P۵yb\N<RIf=ɶ~ 22H.|oA Wc[մhW"!*lPD^mayϦX8 1Hj4IB9FQDjm#Pz_=q~Db'= , *kOr~ Qv>&}:?v5{{^j.Oq]_|Þ }O* ٓ\>1/zwyZoTٴ2k&Nb1[WbQlA0%rhTҒJ%0a=)ydgppPB|lc\cXHZhZhZhR xa_Xy^  QE@8J;Y_ͦ٣!r9S^>;}JFGG[oj)Z:hxxXj"їd{{Gý4 +BVpVSӱrVɞ$Ɋ4 [ =\/-RdvM@2KF>W"ҒzaTXrT*j6=DY4H[# A ds~B1}2 N3`^PQPW.%d & $ϊXz)#쐄s6ؘr2~V)m/I51h=% <(}NZŢVWWM%8DC###‚:٨vʓk}$T`Ni@/z:xL~ߌʓWTS۵2~}RquuU=%FH$LRiccJl!% #Y+LBI<'^0>z۫@!rC)J֖͛gZ _!u7-1 _| _k%Rbc3)OPg k >76l.L'FC4~78gצWTx nkI-c??w}CZIe-X1~AIyTb;|(zJ%8p@W"IZ-j5Sv2&~?1${C''K,--iuuURlư4 -B -B -B S1˞1ľվt'4ҨnۗiΗpbH2}:[ݮ&''-@!i~~N\nܸ{ -ZYYҒ訒ɤ<4!Ѩ"ԫ"P0'm' ^PuTR&Q&je#u+Vދ2Q͡}f7eMP(R8G?.4ƟDBT*`["LJlK7zȗ(޶Ov[cccZ[[SQ2[@Ϙy/_ރ%xk'=\{p}|}$y!҂VzT3: #)$B -B -B -B )J000`=q^$D%3KROIX,f|>>dL9w^|{U'\e|>oGc!!\ƽқǯS|-$B -B -B -B,HyLCx?JVAs,N|aAݸqCF1EQXiTEdrgI| X,Hד}hƇ~nHdPښ|<)Ɖ~{h4e2Yd< s& {mo_#YaVz?y,iIFwhV~HD|^nRZ1FU9P0bt{{[Tʮ%d5+N+YB2 lVdCYpq5 ks $o~l& f$$}'Yi A|}a=I z0Lp%r'd2d4G..E&#<̓1sUP\VPT*iXDYJ}dá, en2YEYEIٻSj|R}Gb͍N`AbYj jDJ?@}XKWm6-(῿wmCj:99_>H~_nWn7ⓗtvUٌXaD p„zH?=1c k_Ke8/..tyyR/_hիWz޽{F K}pKQdɒ%K,Ydɒ% ` 0eYc r>diȅ|tϟlY'h~WnGD\eYmP lbʔ##r𵠇MN$sxQӟY$ּ{9kB@n9n%ifO? !_[_ףlx\= P(h4ESf@:> /o*ʦ݅*gŸ NOOUTvTUl6SPЫWh4vH CJc dƜH X,kFt "瞵 F8~ %w@w/,?rN\b#;"lQf ғR|d A%u...vzpp;>>VP\1d磖am?c$NRډs%/ubSr`c {16'!9K~ j!!yr'D''':==7|gϞEg1VE{zzk&iG15 'rss/~?ԣޯMb: DlX%}yy6|<,jpe1ha*' sg{1Oy} ,U׉s҅O‡_9ZEElqw0I32 RI\s((oɉ掺nW_|Ţ5\ç|;%˥b^vO "nz(Ӈhᅬw|8=D%K,Ydɒ%K,YbyAA'ِN`Dfn~> $Z. :::U&SpW2yRt ]\\씔 777(%* ƼrGLzSk,$)= eN^ 1?߫鄟AȢkAP5,|T*:==...byrYy(cA9&|#zZ-}G,bN.x4%i_x<} P ZtBe%'#֖/U#@b}Q=@{_0/'J!˥fَƉ5*:: |Rv[q̫h/ !ussCf3cxb"ق/Zȉ<~$b>A^HK|VJ "1q;y?q6@?¸d2 be%=O}[+'h9ɚ9!A|+Nw?L^*Zz]bz Ԉ먙Ѭ^)N:9qsBwZ-E(KVKY??kfe v*&9!F!jk6裏nh4 jPhjqFFU h IDATλcqKQdɒ%K,Ydɒ% n$= *@Kf880 =YiZOLc3mT*d3)u}}p۟y2(PPgdA]`K,4 [,Q1j;>ws:o޼ы/B9E5` F_;m?I^__8U*(3d% `@%_-KZ_M0l*fdt!H3NO $MXɲL4P(씀axnij!w *6Rʆ1/DXTbM ~NY)l9VE&4j/B 4=j_dQ'ZH<݃(9 I\q!kI8' ]u : ;I g^ׯ_.)cfQe:;;~z=JU(3BLӗ5k<ͼHjHqUyX,v`|X:JH@pWGuWVreC _ 3>S׋xhXfAV{/+^G Q ʣ~a\|+L&8DE^h4X,";$I@yAtP(JZM_uJi QNzO0@3/g/Al65w'=.AB\dzvZ_kM&]^^RVc\I< =:(m?Rq^ǚ2wT;5%HLZ׺P4͢G2~ !Bϫ )OŞM1Wtjs=\^oG͈_xOP?׻w4 T(bA>6JO+q1}\F穫b\ /II>s{`,'7=6i6i>oÃ޼yUgaXy0 ?_e-{ϳgs _) QY2ݣO>Q>I2ޞ~8W*vU,wy"4&I4J,Ydɒ%K,Yd(qq_ˁ<ϮG`a>{zQv`23 /)րdުRс2eZ)˲ 03xhghd`FF``_τ<<3*defQk:R `{߫Vhj\.7C) p8|> !///U(gC#PfTu3 J3x=HJǀWds/(wH#=LBMV,19>tT׵n< sU v;\.GK]\\Ę B1CO$z!9 x倸CN^ɫ_d?E:yI5 www;dvv@BhV+R}<;DH^1QT"FN:Ξd|>jRe^FJ%w/G뺏sI0֝f^JF,dسkzv:99񱎏0nnne,|Č+4 e11cM+'?d>q.>Z0xWghNw]}Wj4`0Ir{~}xd| .a|)r-;>w9sKw w T2BA777 A~P-ja|đz 9W\|λП'}QulcjfZ?XFCnW}Zz^(bo8y۝58罈j)KQdɒ%K,Ydɒ% ^vK. R^普9ᙧ4hP⚞1(eu^<@iWLP̳WrUt||gϞi(f!xIsJ̋@ZV/..tpp?\6!H;ǚy٠r!J@>9LN L_dxT*zр~0X4c^L^6Zwwww6r-Jݠ`_,Qv@:<BUB898"1?dϵZMZ-H...٥t: ɉ={v9j PG hZ(1^ u/SHgFlpǞ NW8d=-Ǽ/p8&IudJ%}rI+~@X,XR"{ga^oy^#u̼xc~4J,Ydɒ%K,Yd1_{T?@zt:vQ8P_S#bn攘L&|`PnWD\,z޾} $1JBQAMkU777Vs/<; Ͳ,)>y WbUryN3v;QKA@6 Aq:<TYog03xzatl6n9H˿]1d1v]0b>a=)QR:a!=x+OCxV+OL&裏~ s?7=)nߗ?e!Xü2?>n|S7MNhu]+ck{{A8kEe/s6U*V+MS]\\~vede!lZżZzT*跿m2wGNL$www/^oѧ~fnW^Iүzj2P(Z9Fb>2Nc͈"e6E祿(Fɒ%K,Ydɒ%K샘v0ȃ.0@rj*P 'W I"[p+?i6L)c?yXp~~,vvœA .0R JI; Z$=5 |ǘWJZ;8pOp2Y{{{ʲlǿޞfY( ]mE-_%۽X|VT''';d@ k"%v}}s%@ X\~R5[F(U /!Ezr:N|s/q, 7kQ3 Ϡ#?#-^^+ N _cXg>p Rr,bRf˗/u||~^ 8֕19Ae,p8Uk\lZիWk\؃H^ޫqJ>Ù|< čϽ OB: e3f|s3 rs3Er\.Z?VEg 4d3c(&9CQrq㖯v:ᗐ>^v '㣏[,zA*]]]YEbg|>bRy57⏔w{Y8+%An |Pxt:Vk?U.c9>Ǹ}IrcȻ- Fɒ%K,Ydɒ%K3y2$9HQd>G{Vl6!p> ᶷM={rBǕ<)ryb?|>۷oX,$=`]~ j+ !%E0nL Pt,4L\.Ca佂[=-@{JʹjI+J>=II|€p y.b)JRCtR!eONNl6HҚXI7oh40*Hz=n7۷oTok'7=a"jq2_s c~}O6{ dh\bD / 8=:g>8!:Iʘo=<<1wqWR0\.1ߔҭH'b@%]sAr>1k{" |Du|R \uݮͦ&FQqNڳ8|!TxsU5ͦ:VUdo{M7d22ăj>Hoէ~Qrye>xd])CN'=~0Dd2sKQdɒ%K,Ydɒ% ٽ*@79:NUף1@2yjLr0F u0cooOdc18YR_:F#=4vz~8qzz; BɌD⚒9|堡gJѷR젬+K8w>Y^ a)PlvrB~~~V~%(th4h4v>F0|"^lZL&@&ua^ Iv5l6y!)8%+4͛7* zⅺ:;.)J,ER.c_^VcBQg xpp$O8;DS0}s'4|m?, _uf0l`R2B"č=O bPe;=xfU<wwwZdN ~{W9Tq DoQT*z={N+(*'...Y?nW~_rY|iu΋vfr {t1'>\j'>Tfop5`L>Y%9S Io+>&O<\__ p8|>7|pϵX,4 jt||v2)u`'@KpjH9U׃L&QV9=zПc2PoF<ʖH KʊTZV"(?+7&Wyusk1ǔ\w F=Ґ8vUe;BGfY?/sym~?>< q3w-/3'O}=P1JDZϵ@}kOXO@U|{rNupQNo|^G %'O}sNLp'":QD*$H |M/:_'0OAYyX,(c{_E"M(KөvC=%*?sj5h6c7O,!g^zX_~eJm6zyFnzAP^A/_TՊD稃XEH#b1gc!QtF".Hdɒ%K,Ydɒ%KA-q|f4ӽ^/:\ x͆d/G:V+t왺ݮNOOU(v ,t~~j4 "#\.G&_~KO^Td2QST|>QHx*~S&y8W~=#@7/Iz"` sBf\.wH=枒6A>%tuu% e^7}s IDATj8)˃1Nc?Q(3+0]'?u&z8i*볯Bl^`⛃Ī<0ǿ00NWocbA}|qu%<juS5g V+Έr``lM:JEft: hވޯJK:,⾌!rW7!dY $@."^P~S>֋/lݻE!-H" %}UUm6zJ_}~;*Ce]__O>2Fggg">FP|T$psrvj6ĪG)J/?J:Hdj2,Yd߳ H,Yd-T?j@,'́2W9VAt: Q.yj~Sd;$`fnzJ|l6P^(B zp||BW[^/sqxxeVhd\A0D3 dnC"\u]=<X,tqq`tӘWwIcqK>ξpUV̏ucX4C} <^%NʈR(zJ- H#^OD3rp&{XV߽&_=3kz;zB j)b#'C$Bt2}\U*ܨhh,By? tyy,T,|駟~?*Jz.//-Fɒ%K,Ydɒ%K샙Q ]s%\fb=+_3z"\.GvEёj#.ID^d{6 ?g( Ǭ{5bz^[ C CyFV+W("/p ̥IKDk%qOW40g>'3Tt:zxxl|XX{IdO{5w ߉"1:x9'@q8{ gR_JRG\DςjZGɓz)&Hj%=dۋLqox pBuY˓f?>蟊N{yF-H"fzޠl`O: l?|$___+2i0Nv ǬJS6|;8zapI(rW38\vvAX~(HSA$oZWwZi<Gɴoj2P((˲^k ci޷Drա:iWC{yECu"c 8C=sסqrz\j2h8*˲)CXxxI&+c_sAdk|g$=Gп0V !6L$kq=KNsΰ߈O<[eʲ,z1b$H l^V;}޼yP@f~rBT0u||}t:$ķy~OwCfjt-a WXU'b%(Ydɒ%K,Ydɒ}xȗ|@> ^ m~mS0cQT*8JoI%Ȫ?88 44Fj8بZzl8 n `D,˴nLFZҡps{x:k,uI;tRM\ BPA@ᾀ{9a}(? "{g8j_NWVH-dF{ssJA'u55IdS)O,Z-G8yf/BP jP2XB@s7TVZ,Q2 s&k=\ y@65X*VMVUd}xAfb`$` 9=ۥ}EBȒ 3;]-*8;Du@ȗ}bmG)vcn!ErybлwZ4uyyYz]ZMv;AN3'W9QQa.!L|> }HR`?zP]IW@Ao{58(CeI{$LT_]]RT纸l6Sq2< Oqb)w')5&s/o ZMʲLR)JR(88뫫x(AF#M& ꉟG6lHཅG_rzy1yXn6 4|@>-j#1ϣ/}*:VժF$E5l6s5쩒R"%K,Ydɒ%K,4ϠG(&/"=@)iB5e@`+P/B陥~[Uad*p8VU!XFVuѐ $G cpz˥^zvePv?&>Pҁ~LZmg|mj5u:ELa?2.'wȫ*JYTCt:hnO_r/Ȳ,#d8j2h^G<_ l6w3WĎ8onnbϹ:K$*f^ǽ|pm1c1\.k6ZtY.ᓔrl8::RXl6m(z۷o\$qS*X, f8;x'ط\YzFYHdɒ%K,Ydɒ%KJ58#z2GZV l"SՕ88G8_J8d0F\ u<1FQS0dHyT=˛rwdKuQ߫FZI!޼yB2z@sp GECl6 "CR~J9r)<*fzd2я?=Y|23NtzBʁuh\j:V)˲g!vx_)e H>zV7ٙ$vi>E~^gs҅y7D+kz ܧur?{'ՈK"N)'̗$u]}'*JjZQbОjZ5dKUݩlF}>qU>P# v#6ʬ?]R Қ^EGfsw4(F)5L4.Lg Du]i_(Ewyyya9Jp?=y UWN=b={}f=!pI+jDR(( 8PfPupw JQVo]Sg 1Y]AE" {eY At\σ%2".B0FWI&N㝒r%hZV{AQ5PݾW1/m QN)?1q9}Q磿K,3hD w;K^~")$E&×88,Ꭲ-Fɒ%K,Ydɒ%K샘: P! g@r)I; 鑠zj |qyAj|ss@jtH'~\Z uKZU^0i8ٳg"DN@n4vhD?߉.zEʲLNGH'''z^#׳lm .|m )jotssxMIgggiͤ]}`ŵ)X:zo8Ƽx>))F5\.wlTR u+5<|/8Y~K j+z|3%~ X 7]!nu||ftNћ2=eөrrc+0Ɨy&WaHb}Q9xůYVӫWvzRPVXמL&fApz|@q}}xle )@hN@jkʑvPz?&Ub *gi!! 3ONNDY\9'Z2qV0'e|oP@"d4)2 C eYwa܀4z0fY|բPox…PAx+K*='q4A= LeX/A9FPbqɓFᡖ˥^~lg,=W3_3eV(:777% ‰sߥ{NH~( 8 :W8!zIhZƞ!1?*˥Qnj?TBgw֌=Pb;ސ4l14֑Ls"^ni,Ydɒ%K,Yd>yH<`SGzN)< 7pXz$ j@c}I?tvEe5 ڮxHԂL﫫+MS W![y>ݻw\*d {0v@O<l1y F[;>Ÿ>Za(PTy>YÀK+3BF9d"O %s!j|uISN@26'P5ʲ,H=ycuiD,{,qևt: m!N P_y"Dj5Z4]Su:I U ĻcN}XRQ^sX wPiRScg>1Vx>7޻Ƴi]@qB{S;Hj=RNOO$ g Pl@R{yqKeYqI7rztjZj6JR) ZY]c?W\{@ERqp}N.Rs'Q ҈5!?ύJO\ z?;~ Olq**`]x"uxGҝCBK jX,$)۔<<<ɉlBjb}b'*˲(lFY:[s]g3 K3i\}]-ų@f1b"gq(-Fɒ%K,Ydɒ%KXd?]8Y?)5㤑5oxp]/p=3 <6_U|?=˓~jd2tdzl</IQ"Expxj۷ou}}ZIlp@1pUXS@)Y̋Op8ust‡r%~_G'iP^T"`CR;rYfS<xs` Ã&Μ@PdKcoW½X<91Znd2 "q>,zM&QU* ^B yb dxGEĺsv@6C\([(0‡F:^xN\6r{^G<Ny,_zb:;;ESfzx~5d3otv=IAxIj15]HcʃzLp Ga/Y= _sS84a%E/7oJEg9}O`35ݩl(I/C_^mzrK:5QqM~/NR?}Nf^/R{{{HT {O8 IDAT[ݍCիWjQʷVEbυnHjxj%픯/ ߄<\.`wk\-D%K,Ydɒ%K,YjL^ʄ?rю9XJVQIHCܜ FNx&| C{13(l(w@ hh>Gvr<1qtLͥAqo߾Uݎ W BBgggVKVK8R7|̬%e ֚wIEfXIO} :^VgJux/a8Rl6Aאp5l,seYj~IA(J t_g?C$kj@+$^G?<|X9XQ^8{_1L& LW܏3108]M@"w e֟/=x$}!q˩>uSg+1MK=G Guw;zJ_uZ_}[5P[ /f$}V7})$E9^%1G>fWt"tVzzzet:;9oz=qV5 U荱^km[Z Y4Ⱥ%[` pp #(>kɕJ%oYO+GX+H ؿO?v` @%A1W1}P$g5>iɴzh }*r9@|CX{J(Ƴ/KػRI777:??҉p+O=D[ytRA}ϾfoF~?5X,˗:== sbc6i0 DIJRD|b-C飃M&%z iX(UQݮ={~nh(Os3*eF|P3HzTJ%-KeY;}{{+ Ø;b6LRPC@H`>^/'~NT/; iTT"{Z,QN@p1b}7{~')Ƴ)ס_dVAO%x 1ƅ1Orb:ޠ'wĚ_By}s'tdXP1ןݝ...4Ndgt:Oy0 H$i Q[ҎX;=,=^yȉCu ݮv5Fɒ%K,Ydɒ%K샙a;hp^4lg1)"03IN@" l$ѳ_ooo5 twwbQ~_v[\.uuu ltv`0 ]JEGGG:::޾}r|sކ D<'ٹdj2u3y e,9'q<`(〳|N `t;^ PCHH.>úWYN V@' 0(OI9%$o<kb7Gٴݾ11 `0rɛгg=lň>5uzz*Iu:U*={Lϟ?WÃfyxO@QBq>T/_Q}b%=s^/㲗/?CYyQDBA'''SG}c5 U*u:'~\gf =+ 'g6鄯@|n[- - MScFPRvdAsHMbνϖ/CXTZVj4Q 2 |ss= q_lgF@ 8-E3>tĀ %FCF33ڇ@`7I'˕yqs$ Y;x_͸6π6!Iӟg5 }O5F%V1`Ng;1FRv++ILi %vc8XJNHpI0e>﫨tͦji,Ydɒ%K,Yd>9pg';W8 *WP$son@N$J"zaaV.//c=S*f3~Z~?9?<>2g@^ ^K_I99NHYG$/!bbm0sYzB @UXJƀ|q rwcmpcqbNr @Z-@W?cO}~!P|y+XH,|GFwwwѧP($2iVşc2 旲z;TM&t::>>r{~Z{{{:99Qۍf0,tvv#j5wLJD6$V=$E)/MAlŜ@`@1wb1_}ZݮNNNt3A|"V nnnl@azY)$qZWWWѯ"h']5yr3%^x$Cpy)Wm̬&I([Y3WKx'N|l9W#6MU_c'p^!7? @T=/:cH %n%(Ydɒ%K,Ydɒ}P#;Uz,?'=Xc3$K@ vp9 qRK|y6" FyŅg?3z9,):u_Ud~Wzxx{b<k:Fٲ|,Qc"R˗L&:;;U U<#E_e^z'er(P0&3Kd뀶<Gl^y2ʁtIAG'MQj(FF>S^)\K@h^=<7 mfٯ|?<yrƘ<ÝiDNgȣ$e% ԓ$rfz??SOY|ƾ# ?*Jzm;4ɲL4jCP݁Y (m P\.Vj*I/Wq"r}IWZ ދ/U*i6;1a--^ _}9>GPKB}9t}GgFY)2]^^Jb,3^j-Onx)JJYRʑXPbPzx\:AcRpn|"+FP'*F%όo;x' &@U?NbxIRs'P@INrO_ȗyʟ)=O&xp Lɉ~G]\\R2cS 7áNr ĕ'74==8W)OZ"lFy3B|'cD%K,Ydɒ%K,Yb (j$E x@v Fhy2(O3\R=2t"AVP4  ѱnCN}K777_h <hfZpS\l6EP}71Q(N0n3>'o?cm  YJ1ު\.jiB`o}?*i4Q.IM@2QZH>+Fw#J[ŋAQ ׳!#Qƫ*16Y.<6VwB\ߟ͟>1^@)'D+u ?CJeXT}nnnh4bл 4(%uZj~:{y+J%Za=#ɲZk 2++g5=h7ࢹq3fMUv@B"[~7oT5KK ")8sC`gPyrh귿ͦGbj4L4N&I>W.S>WRٳJvjJRVfiXX-bGz=  ]\\ؘ=+J悼bl 9f1epVi9$sd2 iE ؓzZ5`y~J(c1u\3dEĺDEx̐fOgp%!}D*=H:ׅX{;!_0fԡc<ۭŢnoo?^5RV;4W2ܛD2^POj5i[_F̞E}k${){hH8N)EQQDEQDEQDER;Y%- &_`! 3߅Ԅ I l2pi+Fხ_ӧ{@WC"ʎT*vdbb1hJj6zZ^gp@T*ZZthd7% x}|>:2NBl) ٯ|ĕW+ΘF#IRP:=={E}w:;;3 TڷWe~ieB>9 {K&jZdb@1|W^CxNbngc\qժ9rfij =Ûd,ŢjT,VfZV- "0`"d2v|T?]~f3#u!&أFC\ U3Rj$=,'g<Ͻlhdk}X4 zœX2WH^?+ h|>T{)CWq#cD &{cWI1&g=/ "d+8Cѫ|>^Ovgggd6E umϔ$t]#Hn,En?ϢֈHjS<;L"XD ܙ{S~pDQQDEQDEQDEJy~ss+Z-PǞ._K;`W(HUAh/{@5c;کxǓo>P(xBK[ zrJXF>%Ef~V>Wd2:it:J&ffFfu CQoW)̸2|`93Y<e$5z5 G\#Sع72e]0׹VJr9U*R)sf( 9'PXآ5^6}f {"LX,͛7T*{jB]2z 1d/kwZ/0z I{-mg#gO݌GPГ'O_\.%vfV7'|ssc^2.I䲗`Ah2h<}d)ϛ 0{ i{?~oI~m{ׯw!HH̲Vg~ Ր!Cs3ݯ6kmUo]*=M=_Sx m'\}k~ĒoGVOyB '>GDl g6Cɇ[dR@~>}j{<*5If;${|>7D?4.7/=II⅟_+PH("("("(]"ϛҨlڗz~/n(zɸx疵MV<|^RlP0a!Fu_"[+a3m'#PX7cEh``} Eٿ0˩njhhݪyoLDBrT*ITTj l6އ@,}b2p (Jf|777P1*b"9"i/:JZZ2% MS CՈ U!P CA- /6O\>$˰C  !u YcBpn_5`cyj6L IDATLr/rΫ/~ǭITRP02Ddk/PVӷ~|n\SH$v5""'HN b}ۼїb S@֮kMS cb?j55MTVU.MmhK2`Wl+ V KR0򠫯MZlO&}H;#ŢX,4Coo(i@4c̾<Dп~m{5+#'`|;)B !:ecv_)> E<0/!$$ ~_86= YQ‘5rS)9?y”qb8:˗j4U1JuS hdP$3%)}7=yfNS;6iEQDEQDEQDſKY&y2Tы/ K @{xlnu:S >;M1e@& ^~jt<(H b(IF駟H$J%~ۭŢkf3zYq~7 xBV~6 $5vSf3ܘVRNjLp~~ @ƓvH_V,ӷ~( aP3" U thbbIV(|d";HpezW8l#Cu V>|P6[1~ 24b0jO Jёb\Ǧo~+#*<{{vW^VӧO  jrB[:˥>a-{#C3*:QO^ ܻ;{nBcC 1fJ45dAT*#ێdT,U*H'dlf@u@"DcOfU$|Zݝ_XGqMk^ b,oIR)#yC'W?~"E~ K环^EųHx\^h4l6U(tuu|t:msssjj#΅{fwF}68kvǏ35M#B%_S`]ךL&J$T*T*vLFN,kr(NrYo޼??hQ dёnoo5{cHB 1CddXd_a1i ^ђL&_,{8/KC푫+F##pJzl. *apzy ƸzkɐuBDsr}ݪ'kZz 6RH,X $dOd5OdQhis!Hp߇=CĐ'WŸt2N{,{!CY;͞|AUZFo!y@:t \S*Le=~$cm "PXǛ=}{#[x u5z_/ڀڀwwwV> +4"("("(W z$X|@Zfত= EJ,h{(H$vuwwbhd$_=A/v#p 5J'z/XTDB~< F>WL&2YFCJT$d5t?/_Gv7RI*zݮz} &o/ ZBjUo޼|>W^7ՈC |>ӧOk|RZVdyba`ݝ^~W^)p8~niPrI`0^Y :_e2eY[h4ŅGD$+am93!LOzuh0ɓ'f{p쏇jĂjj]Z-Z-ΘOTXȝpW>ɓ'g_r Ff'TJf&~MSu]oODD&40 +<#=AAŌ4cط!< Qo:Z2ZjT _,UX5kB&Cj1޲ "׊a=ж+Nk:۷fO=} |]3lSQ"BYaJ2n2i0:ï=Dc#ʷ9gC䐒ÿ̡=lyN:}Q9a{'vsdcmx /a}="C'y:td2gϞwbNnpRr`WɺPEFQDEQDEQDEQ*ȗn2M&"Y봔ez=}^?ͦ@t:5$#nooUVM- SY¾B e\=l630~j4iݪZ3 '^f3oS<+i\Je\pJbK}eYU*M&#32'h4j6Fzzv&HwޙB@"P~;}V̮Ճ@@WqX,j4B?>!|?1.T̄Acr+O3 Oo[H… s.$v<)|hz(ȯs~*1oϞ=ӗ_~iKR(5H}/kϭCf~l6NFhe{^u|2'F' z}R =XvLFvRύ#,ULs ۈ4"("("(%)0?5 >'[{\^T*ij2ݻwz*B|qd@fSJE\NRɀJvpfl+v"|j /~M ۷o ,>}W^^VYjjxգJXz ()٬~__bQz]\NFWWWf1nuzzjM6UW_}t3_iPa!j R>5}Rŋ:??W6U2OCLDB^5N 8W<߫W"i/ T*HL+@&ng3eԜX:??Z>T*T.v =k͏ s߳٬>+f6ϓþ ~5 jۦ怈T*l6纺R&o~n<[{sفBa4N5 =̞'?=xj@nqD1J2:z<jWK!(PE#i_~U!Syx(}z:M[,PWW׳:C ~oDP80͜L&FB.<8 mT-^rH oン!KAxr'=CՓ:?RIFHv}w/=I}$JTbxIV%Z'>!i8?B1~ b...|6d2iR\l6t:g:Q'AJ Q &]#{2FQDEQDEQDEQ*l,k\lZ&+fU(xёxlf偧n[2 RDlVvۀpI&VrEگcmYŢ5LT,5Lt~~wvM=(á㱪^{ɄtjAӧOuttBk0<__B IvR' b2W! !&uׁ%MP02Zh=_RQX4ݝ f_|Zl6k񱵅f\fz왍#ժN l6V :Hv8x܀v.@,[A첸|>tT*e6d-@$('o<8 1J$2 ;:6 l6SګafKy ُ-K]__k0FV=%s:ZMwwjTb^xkf9}lP|wޛf{5ȼ(m'JYBrJB`֙D^J[{g˓FN_+)~B0Fp84kzFa'G= jC#gR)& c6n3r,Kh*ScJLj|9DZR=I`.AnE^C'¾95gG~)#q[^1_T*:KӪ떸_契V8s&#T7>s{e5Mm[{iZr&Qg k,oMG8k7|> I+"""("("(U/ѓDMM˥2 z~>Vg,bjj_Ʊ]t:b2LZ,fƸb =H}H.M뵡(l6F~Ho;\.㜒l `/P(X,L(lTcs{+j:?s?aa 6㱩A?|~ouJbu:d2Q{~P*Ņ gp>ÇJj6fFZ,f63W<v>|`W7fNd2zݮŢ^|iI,S1+X,PWWW{@T:Vқ7o_j@ F?HfZMfOal6MGzT*l|v I( mvov,ɢI* 뺹@bQϟ?ׯjtsscu?i_m^Yxܬ{٬nnnNbd2d2٫)z9VˀL6)"]il"PWV5AF6X>7kG]TG?DӧO5uttbfi33W.//?sԭFZZ,8X~_4 ~ @5z5}y*RgC\Az`=qJ %$eu:0ɨ\.ټypԃO { iבf 7{D!uNH޽ٙ|>zn7dc3‹u5֪j{v^, WjCxu!€9鉠C3!""|=lϡ69Ӈ'5cT >!!TBHҮC+iX,2 d!v(rrYϞ=ӧOUմ^ 5vdO>)ɨRYsq<&JYm,aL{B\.[fxuE#?5`00kIj!JbOYwwwZ,Ϙ{iat(y-T q}RREP?Dx1H=6;t]Z'CD'|Cʾ_#SI[h/ܻr,x>[adtttdb*Z=Q'@o>|gϞh-$r9 $7Dsا7.d.Ͼ^to <FQDEQDEQDEQ*TNc}Q߿‹B\N?_} 9X.c ٬өkr9ȏ[}7jR˗/u^>}v+5M}w:993*%Hh8Z;Zf v[@RRI_V^CQe={fŅb޿Z$Ј]^P*hX(@TT,Z% |&Ym*J"}"P(7dR@;͚f2YC`k&ԏl6fmlLM4 j ` bLR''':99QP0rՓeD@byuss7oN\/2!'ZԌT*wdTJ }ʵxYΌ$o[F#F#ܨP(j8H#Pǭ^J j@f2bp8T,LQS^jyBL Ay)8j >^䒯9_gI_o#.K#x{uJ%|>o+JfLĜ^ךfgxKIRH!(P/edϡ\;TZ 2jNЬ9tr{5noo5m8pO9bo牥PYñRǿN0-)s!ACCx AYt{'"|{m`mԶa%WyB#ӿEC^=~}Iԡ-K]^^ٳgLufi5<}sPm<67NI} f5Mrϖg#Ɣ5I?ٓ3$šz;9#""("("(U,R,ժ={c:;;?H5M}UU[}n!P(H|2_} \__k<J І/ھPg6i4i0{}}~o5X-Ç~4RJLFz]^&\NR@|@\>C?d}l^ad7F^L&F,@PG1) fGv}Tr4Ze3ڬּb6}PBFk4)HlPQ;e m{GbjZM~HqƋhFt h`=G?tDJ@LnuG~L*d3L&rV P"kڳ٬z`bNP?[ ;I, NŽ'}h47%iXxO&y" sy^H{qf2[^eƸqd2l6jjuĊŢ'HQ_+k?S OOo}m&^cnwٯ$'c1}%p8t:5e'jR\.g6~d>r{mN=zzQwv]ϰ~Ta1=ytHYAx21/1:0a ; ICJCzBYזWy>/c}#H./Sm!!/caH$ 9<{|>rT39WT.CNͦ$<s!yw$qmV0F2PAZU<7:Nutt$I,yC|ҁφuv}:;FQDEQDEQDEQ*0>t:zzzeţYVfmX,$}|Bp[&>ztjf?ǏTV x0ձˆxszC f3ObjZ>:>>V ߣ J%j`5<z66yP0.S_B5 5hɤY1bLFuAPM>}X,X,m@ *j@z= iF2Yanm7EX9õS h:@V3JcDt$ezBh5Ӈ;VY!p̊r0WZX>~hݮR?T~O<7_<(/$~e{6 oC*\I.o}g $v`Y,h4trrzbhuH 9g.#1 .TP(1C ʫh96?/`="OF8K$ N~ۇzXQ3=Pq  {Q |9I{  UImGs O $'';I!!aC}*[yŽmܓv,`{=yD@aBRuhz(j I|X4|a (aO Ql6QǠxv9ӁA4 *˚fc>~jZbD"fi}z(pdfZ)J6dD2Qx!_ CEY@_sMt:lo/wl63P0 j^OV@xH㉌Cs1|` "!bk<yZvN\֓'O4uyy+f3|rt:bЛ7onUTjT(L&nn \ĊSZ5[;4CY CbDnF.$ d8l4 X*x%*z~j I2Pk8W%z=AX*6*Hf\ Fzr$elZB6l[ɤ$$H2IVC UTj6Q e*:|ڤuGHb{J zbǓL|>Q,3"ɫ 3N0rdc^~2'gBDCX^vss~ _ydK$6$*p?=~Ƶ@ImW^ DO~qH AFQDEQDEQDEQjAl6KSt]SdYe2M&ráNt}}meDK9cZ|^OAMIAC5ݪ\.+Зf͍fޒPPӧOvݮhctZNGj4rA`s6NmPAFV+M&[m2qAI@N:Ffm٘*+@8fH6?V=R(ȼ112=6~̹ϰnoov[Z-%I5MZ-JfNj<$l63BWpIcsP>cϻQ?/_^==8l2s AyK^el~9CDQQDEQDEQDEJhKҋ/sBq\.U-XPn׬~!r j$;7jd2i*V/^P(ᅲ$={LF|>JfiEtK<FL{*rxs\VQ^W2"777&BARio,*NZMm[2^x/^^pO jEu֙{QV+g\Օ='Ⱦr~o 8C@sυ@5P|}y5 itss3 5 K4N |s2_zmvH|ek f|V#@8>S>=en1fߨVj j6jZrrFE =Ŧ("}hw~'Ӝp5I!ڀf0VXѯ^!ǖ=z*xjDRDHV+#!4DA5-Ryq\,` !2̬PwH?ԎCc/a$Hom4!^IC'2{tRQR_!!a=TjB:'jGk...0zTǖDmT*fc_(mYbQǮ闈NHpQ_| }GG^->+TbI˾D感^a_DQQDEQDEQDEn=R>)&>~hdcT^W.ٙ{s}eSaׅY>WVݝ+4NU*nuvvX,f=so+f}V>٢2 ýBԦ!SsR{NO}תWUzJlV^OWWW~N&}Vϟ?驪ժYl۽ZFŁozh4R"l6SPZ5r'p7I][Styyil6=pΎNRj:::R^7L&xX,f* fǘJ뺹1<ڐH$P>֡ML& X|hZjۦ( | +J=T#2vjvS!bєe'a_ y ϐiIr+8}w{{kq&QVZ59H$bh$9 ߼jI@V:J1R=510cmCrdMԳ7wߋ$ja(`0sU-2уVI s$'=P x 9g1BP;¿ 1C _:[%@*WsGkJ˥i=c"P׎a5|{3Xuzzj<5c Z(jl6ؐ~_^{?;ְ[,0"{fx{pff3#cc<ӆj"s@F4"("("(WfR|>cŅ}bѬ}§P(^[V:`,*Xs,Ţy)!QFmQt*NC@h`zi\꧟~˴Y`? rvvD"c` {LlԖ(BgxPnZ\ }Uu0n{&7TdR^΀lfsNcito=+4}"Et\n ^0g(K~)k=GDEEQDEQDEQDёrǺ:2dubT*End2 %ϫZj^yr|n~ԩ 6.,v B`ŎN>;p@.Jq9L& Ǻݝ:bFўZ>F n+ZV,zlJi)bԫ<uxD6s{(@f~oAL&@- vw\[͍)h3DI[9˩hHng 3o$.'<!>DTc^_H5hc-@6E/tCArHK@<~Ov}h>~sc6U^WVncpM2BdeeER)u:u:% SX3H3Bpd'iPhJ2bfo`LE>hݝhWڼnMJdn3`XzӪj6W s{9qzfx*!+Cip ?gz1"1Bc0<}><fYךL&fA\`fb1K(g}὜=ĉBR){&$s8uT*-l<~k"\?칛ƞY~{H|><~9yG=_RFQDEQDEQDEQ*furrvmYփ@DRIf,*NOOurrzjj*("3P(㱒ɤ 0X( :^K;b05 dRZMz]TJn+x[H`f$i<!tttrlmd2Ce- ]]]ʀ/TVTR( @C?y$ٸ%I$fvN΋ 63|n`]X$m<`r v>HފpX]*t:5K=jt:maL r> ]Y(Øz M'6ϓ~xo 9JNRnZha}υBAJEv"|h]{HOR>JBgڄ`n6V\.eaۙꆱeZ}&?xC nuvvfjnkKz x;X D~ΆCA!ps>vEyB2Dx\ϰ^M8z ɒn0$1!|$JtzzW^ڃ/IH^$#MX$ =$'p8SwĔT*ehdJ``DIsvPMyy:#|-""("("(U/7&c?\.g_֏b* {EŠ d^g;,GDR|>7u%D84fj{6Xt ;{Rg ֍ Ahri` #deR?~ԟ'!w'e>=tz]WW9D'*`(saN${Pe~_cca|pړ }&l|SFj{]'a&|<|֫ *fq0݃>z4CzXL^J%q Cy#JөŢw6$#٧"1ao"R}\.VX|ﰾ \]2'ϽRX Rdc}g{f7"i:|7~boO^Zxsq\/GjOaUI"eЃbh<Ԛnd2vWy[Cc6~-{I {?z#w-!rC`kYt,<+IRIHJrjHme#nӦFajXTVRV3nXy = nYZU,۳ ף5=X,j y}( OݒL&cjAH8'* ;vk{Ώ_ØnF TVX C]]]8gdaї쩞uH C]s&N*Awfý!/{yH{uH&?G/H~oknH r'z`TfBPЧO$IϞ=S<7K3_+TucDaOa?&$ٞ'H]գ<v;Q,k'͚6%z 0Ä 敟 /c~3H`'O(͚e/d2{!)Y 0gCk=DQQDEQDEQDEJf36ZTVusscl63@zb pՕ޾}?r̀Xy0ؓI2P"HdC(qL&#鳚ă@D-K=e.//5 $IflݮzeN r F#m6!>+H] =u !It:gD &CY،Œ_Z8MxH`!B&Y} jyy X s !I|{{O>^XgK)O*RZLFz]Hj4zV AAs1 dC=}TJEx\jl?Hn@fH&HX,f$!~nS6{'7z`F&XsIvdI!ɒ {^zo[VGdq uOWFTT7s/(BRAVC^Gٴb:$@e(B$-'MFh4LQJA蒒ڨ)Dj\ZLjcȢ\$; {t&Kٴgp8DEVτsq_*%U(q%+\8l%s훻0zehI5'>xF.~Qŭ$a py&$+ %QPTԬjx!^~mlqwL=V[|_b0GW $Cb`X;k,}8 FfR" C#{˥/^N?9ǿ]7L!5tyyuz=$!Crǝk}_o;w$'y͛7o޼y1MqC0NS1%Re*h;TW`F2P(Q#)qxx(P,-]ǨZ`QEO@kI[tj)_RՈj6xNJd t]T*tpL5 i4eAl%pPd k *D$2Et9n$MQiaݵ+x]JiE=Pqn91)v&\ x!NOOwj _ tLaG_iTm)9~ T-V{d\Y}e5D4/9T.NOOQ*,#!TERS?ָqXw@ HM$1U\uu6*ɘ&]{Ԏ]LGHŵ>%QȹeB8SĽX,0MEϊt``i8|Xs\e1ךO8%\Ņ3ݿ>JRt}[qs' 3Y?tk'H*\_`cժ)#8Z$Kj٬3ֺTcΡac=8.3l/K_Nj,uvdh7o Ӈsja^lZjjrlA3i%v{}/uo \cZ* :8` }Q%f.i?:3ǐ'y͛7o޼y1S#Qn @U0Qۚ4M|Ǽ>0^ψ?r VˀE#HA`RA,Fd$LTX4}s$)@\>^|e10L alO8N-RWU%Tx!P(ց[#)m\4X~vRN|tI H\) dTt-l\rS%(O uqѾkĺ V7[7$u8-"YAU>f\O[ˬQJ8.*8$dTab0k~)Ԑ$S6+JF͕e)SV-}LJ +ñT&uml'HH|ATu!)Z.r蓹.|VE%>3tqX";Nwqܘj$|>Gt:EC۵֑s"۠KK{Fh*$]N$X#km?k~]s+AT0؂gMq+n1Mp,bN 1 ab<c8ʗׂ;l;cθNUqĽ(GP( cS}i>}> hlf)5W$IM&K4qתۼy쎏9>G`8bw#u>O=M4_}cI#o޼y͛7o޼yA I4Iz42FJh`% Cp3JC2#Xt:#8@j\b.o04B&x@!\.a\.\.[*;/CmXzhdfh4D8;;÷~k l60 _56 q #%y'w*~^SS R* |@#M$PTME&z.BӨy7r^%xFp^H)Ꜹ@F'O\pjf:b8"tBaG1gVj *"gX{rƫ\.TR A "#IB6x& 1 Ŵo|qHR@UiJIPH5SϥinY~ cUGߤuMP\{޼yc8^MG]C}\dt6'I+{Lѯ߹DP5\1p5ASQi:r=k0 $Z6Vu}6;0U'''t:\UoݹVS?k}-?3dYS?}kadK9Rr2X;8V+cyZ-yjQm|'KZ7/5 \ູAI#]Ҝ&_i9#I@UŐ,F+o &kj4; hzV' 3,\j.gZ%bJ~r9TUZ-Kᤩqmp/ OE'Z-gO\6eB\FD^7TW*2҄{^F7UJ")M(Pୂ4u}E;(U]{UvT.iRwHXPV-JXvfD1Wںgv M^]IqK xVb%3$Ƚ{>_1o|$RSqsߪH$I#IR{(9W^!b>c0z9`.ֻw>ݠ;1H'>rgggx1RNjP>?H/={R̿?F|L&fi%_=_{7Mk!a4a됸')i.9ڟq߳}̓F޼y͛7o޼y탘!Ǻ5X]w1N@ CȚrl,UP"XC#|ހVMG`\.[J+  ,%RV3Rlb0rR$8Y,FVD6JF rX4ȍv4~Es 湠F'<!O#w~ DL)FjJQT Z`19Ls=KF ZSDFkJ*@Kw4?O R5\P00~$9v^DƱe;IZ-<|GGGnFFe2T*SiS 1f8<"ݯJ~DOv۝}~@a_v&~I}nh $2D) Qf܃SKS}qV|gFj߸/?xRޅ8F( jhZHRG-^\F͍( NJT / IDAT_DP@Xq_AZbgϞ$J<wOw|}fdQtT2(JWܽFuI#o޼y͛7o޼yAu,!xzppJbk;~HA`1:"(B\pG4 0FHP(U-KЈ Swlbc|ڥmI",Zj&* >}/^X{|>7%C4i:Z:l60 KCu>RIkhAha HH{LII`z?Ւ}g$E9LJ6cw5ڝ㧠]37:KHz0u㚶G3pt.u$Fny 'AQET}CbȭĔQ$t\.fV0 t pV5 ɒr(PV!  0Fizt tF6.o$+u8gy>M^ɖ[}բ$Vj2eQd~~ƽccC5y$T:Nqssk\;lHi\e$KcU >c_+~|i;yLOH PrPU|TJJ5C%֛qmλ*36$[~LkJL-JK~G{V;Q(X.jl IT0L0h4Ev>t2Y,zR+UgJ;#xT wqxlf$+(V~, j4͛7o޼y͛7oĆá8??GE``9 ĈT-tQO:'Fk$$*-%hc2BCT*44.A`56!=>& 6&0Dt:5evVk31J@*ɣlfkpNhVBM$pN.YQî%}߻ok4R`)1#Kl㱪Vph;\pX%Vo]pV+}kIZ0 nQS܇nMTl6DQvm~>@Uc$;e$IVe BJ(wn) !I\)Z.Vfc'yr|RKr9;ҧQ-T-Kt]t]F#KCDKhzU}>1Rr\WTaᎡRBX䋒FǓsow~))4v7,K2?s%}[ǐD hFQd~2*wxTykIQ}L8&Tw6>}js5w(M$_JF8]l8Z'֯c9vk)<5ժՄ W@~w]'e2wz}dJs߳]-7o޼y͛7o޼y 6Z 6mju;aA2'l6a+ QEPMkhN|Fk.^G*뵥n$0GGGf իWxj>}Rggg6^;?d"(FDE3"&`ٜ/O0ĝU@*y9BHr&IDrhg4D &xr e;yAQ QU oϝ7mi 'dv-ՑGYh0{$x9-jL&(,]T6Wr9T*TU4Mk>?J+a]cKƓF޼y͛7o޼yr\.to bj..J͍`w1+*?- Z-l[L&0 QcS h8w@Fk4o1)LRqrrt:nkHp< qZZmVl63UkLq15y}BJ%\@Zh$`%"\IF`߇4Rs>6 @(S(r̀8-&kJ%ARd' ϛz@x<YJFt^۽F$% IPI?Cu!ǕNRvRz"qX!xA;އ\*" EbK]~~ondV 7އ-;^(!\Ru\rP $0% }yP۷]Ki[} Uq@>}i}MA)FI>q<4q Y,/v-}gѾ$G?ָn?hѣGx왽OiA 78 7kBxtNzFjXVVVJ=\\J$O9N;tj*)A6OqqGu%?$}>̓F޼y͛7o޼y+ ^ Y#3j/˘x%ҔtP J,N@" v# oTJ0h4PpssT*VO?jOBA5䓌Q*?~HhwdP.kJň"X{V>6(lEAb`>w30 1:KZ܃*L&Jb'UdU9Rm[#I*X6NnoowR^bCs3u%gRQTlje J .)&")~>#56ǎ $db`,UcLtzJfd=FbA'<1:T$\ҙg.),\޹9  5TL"'?(﫤_n>\].iMʿ=3c /z=>3lfT*Rd5/#^o']mq=tNyIzR:Rf1UcnQՐ0 pΘQB6Vk_E#wFdYc! w-$vU{>8?ΛolԵv_{w4Oy͛7o޼y͛bF Ģkooo < @IRd/A8quue@XԪQOx:8y_JR1'4d2h6Y._ Z-, SN M`M\٬]S`r (jFP(`6FjN+V7:J .CS}@}Fw>C2]ퟒs\0`^%͍`W F|:/\WN-E[.Cղ_Zh4ppp`d1}"ʺ;Zh0!P0Dxj2FKS\)P*PVM#*t .RBӹ]\i$TͧL&F縩rI+E)}I ~I ӿ37>UjkLSDQd>ټ60vx{ḨDtRw}1U$.91'sfҐd 4}$؝7> %dݠl0 Ç,QRcA:~߮MfvhFz25(BPVCX4>Nvl|1JZ:Ϲ݈5\յK"pE{I5 }+4K~~jHUg(\.jBU@nnH⩴ !H\ 4<. Cl0QבN1 GL)EI'B b6Y*t6Nwbڇ@ǎ pX4 FR)LxT{vXϪ*V1bjjpPh4q=x nN6&E>e_Į^SAsW&6$qU`K+Cu i{n?۠H"tlH(a3[z}Wg '0Yqzzj$ IXB=!N 1r|n{mA`Gw%IMŠOD [%S{2DNR; ɭdb>/JY vkNc2Z'k\o(ObFQ%1oI|S# /[$ oIOS#\R+PPtr4ΑTp}JF%w۩ͼ,I$",)kLwPk߹,TN` Udgz6Y2xH"ƨ/}{:5ϨVFb-K 4! Qa6gq/ա7|0 M-DaT*htpuu+t]{osJ:UnO i%=ٻLsMOy͛7o޼y͛bZ ~Q*Ph4 (%iߘ6|hhd^i4O x8-Rd`7SGyDQd)*2Qaߙv/^@ŷ~k֧Bid/?ghdHR X,0\UuD'p !r E`3b8}...PoACt:xM`:B]v !`Tpl`#ƿUVHHHHpzJqcv)P==:>\8WzeX:.anZ.4I(PJ(%qԿi_\@?G#IJJGmvU=l;vBGI4IĄ#˵奥Me<) t:E%2 z^~m6}vׯ8==5h4L;LsܔG$5$I$h%NIGw.u3z'y͛7o޼y1M5q5^zZ'OlZdM;&Ȉz-$OxދBR*QǚB aM*hmDZ{/_DGt:ݩQ,h4d"1CgZ%0]ڥvkۭBXs 2DZi" J&UbҁE|j101p}}mc@#ȵlLBuK>zRRBR<%ZKhSP~q_)ɹeE?y-ޓdmNmh^\.\.DEn$ N1Nl^$xd_c<# C<~j0\DXDӱ_Z̀SLTrܕq.@E } je**04MAzyG?W +;dt:l6vViCW,cyO%ajNr]uFoi9'E?h>yAғcǹO%{U%YCREUzR"G0vۮq>ЛλDꜹD] ~R_\6Z*`qGFkMMuu<+lULŤ0* {b0djQb0X} t|gH§~j-t{{sl[x :6[]sw$͝Nu.hJw5m}I#o޼y͛7o޼yAQD2M۫WpqqO>mi$g\6߽npuuT*eݔLKѣGX. /_`00X,b0˗vœ'OLBz_9uMa1znR BadI~4͛7o޼y͛7o4 w"׿5LI!ɤJx=~ "^,l6 j;d|>5?C , cK ~4`!ȫ cj*Xmbj ,0MY>U)]]!MU*l6Q.Q*d1㱍zFR^x|3LSeկ~ۿŷ~ ϟ?7ǛQ2%c4= ߥ4zW7׈Fkj<%& ke !` IDATZ-R)y;S?FQd$ lA4mv#U qd8&;uYS #d㱥$XNu^V/&EksXZ)$hԇW) I UC.$؞F FRMGSQHNvm\uY#ɫ]\Kub `Z.9&!_pK >%$CY*ucFRKgl61 $v{WR"1z|QozJqիWT*hZ( 裏h4x糤njGW^zܤ/U{MaC"PAܤ},ր;R NOO c=34%kz)Z%ҙJ#b@DL{\\\z.iB@ ۪ui4Sbm*Rb:Z[K ֓ "v* ^IJ,(Bٴ^JƈQ'''8>>FZś7olj,l6ᡍo[ӧH@ΓB`F;$ӵ$5E **HBx%%^ QMbJZHql%4Mh`8'G>ߩF\@#_*bRT ZAnTdb@'J `Jj\.ph"rj0e*Zܽ*} ׷K(y8˶V+k |^צDS?}M6%8YIuR1=Mm^K{\Y?נT+yIkf{UPp}xέ~>#S[JD%A,rI5%?IܧJqᗿ%ϱX,yJXbYK~~[냱oa?W_ǦV@nL&rl xdbIkCk9(*0נ K} \')Eк}}/;I"c(IR* (2]$Gh`2i k'JLP.t:Gaqn~tRCSo']@L&tj UEbTFjBaMe%$ `pVjjtZGV:6)|tKIsrr'''VϠRx)`J(\[#y$"fR&A1岥Yj48{za" CTUA` *B@!ifcD/BRq͑)pioIBUl6z=#ϙIxޯO(˖bJ/ TQi/8|~>|&pDAUNLƈ6|䞤@ /%tJλ~EA~ׯ(aI)U kɽKJ2%}\R/X%r3=U+IUUOE.J2Wc4I|w˾v6H2wΕRőӟool9==E&1s;5 UL_BOw02 ChO" &PQ*,l6Û7o br"]JDKLɴ,ѴoNT>gq{r"KpT*^T1Uu6E0]\.jR[SQ59.B/ C4 Anx0 qxx6>h6h4xrffAT*a0`2ѣGrB?aǿ3- uH`.u] M0יnj7oxB3Tq#cۓw*ZElt:uFe!} *<$u\ԐJM19f *U|㘓~NU$n& ڨXz~oi:gW7]CkUyTq<ۡix%t*\BR+ȭ]iz.8 5`IDK:xqN軁پ6"%wE$J5?蛎ڨ?d>Rqzzj1"s C{OzHA/E̾{H]k|4͛7o޼y͛7o&ATfoX,x!-Ӽ1*4 Cl6j5S)h:F.R)K%E4& FpyyL&( Z,hۦd€-vkinooScA tyKIE`T*h48;;QJ) 8F7F0J?kT*K C`I6T*d2 \/^`<^ZZ1_p8Dx &~Ϟ=Ņhtͱd )骈)J)ϸRe\Zg2ӧ(J8::G}`ׯ_#QY 2 ?'ƩR x%㩄l6/{X,b8~خ_5M|Ff2el6-5_ET*ϟc>#$b4-u_c#rfozpp8/\{.8*#؎tlfJ)X~zpHhJA`x<6_CU+IuBZIG` {$5t:0\3TtE#ك Id1u ZKJ%WwY>&b lB|eq%6;>.^}w>%:I$+ |>pG rs7>4k|%,Rd>׈Rޯ/08DUTAz\b0`X Szr9T*Ġ*^wS}0E'$$>0GXj$0iM3HIPac'y͛7o޼y1`=#Hu!;Do*5p8`0}4gūWpqqafzF N!IjBh3' HQ]D k㣏>noo %q#~` L\.1h4P.Ϭ[HrpB(A+y CmzT*a2˗V~VO>ӟpttsKo3Fi`Rˆ`K,)QT*L U* %Ep% *|>oĥ m&&a5 bQĒ r2zQÔRNk)jVJS%PwjRxSO}dwK6ԴI$X'JaX`45Ɉ/Ji-+ZKsWEuU<$9ñx+6m)oIs>Aq^ 1Y.L&Fk%WW_N9 uԏ\o'%5=1%H@`'A8JDLY㞓UQpRu46$,RӾ{s(4gJzpQDiTjp8DFV3U%uXk#F2^Wquu4 ۩]8Uu!}xJa>#璔z >\&Qg>_O%C 7o޼y͛7o޼y@ƚqI  f2W{'_zJxfn/_)r1^|+u|X8*ө_kdb``M_:FSZ-ѣGhZF0B"@LGCAZf%ggg;l6n-bilfz2 ,V:F7uj& ...n0 DJ^dzgfW%;'>}ͯUbt:m\rJUGm8F*uWIJޚ*W͂^Ϣ gYC3NM}CpE%pIұ-Zܥm>#0N0jB^7 R-yrrMHIriē #oz F;5®AH~EX׸0p6KFEYl6Cr9ZŢޣ_#y5餘.J#1C?Tx9W\G5EvRnr.AYM+4LmG?U\{\U L+IPU-(UK`Q3DR %ĵй6%TM=VWIKz/J+oDIz. ǞDZfd2s9(2cz.){s}%~}^O'!uƔBo޼g#K`>G۵qZjOR Rv<{ '''~f~VOF%Շ *//h4x)9'%k YM9i{B׳$^.tw?? H+%nwYF@Zt:Va˗/^-\3҃ SZx!РAw< .(jvk$6Z)VYD%!E UR(E\~ol6p84S~6p\^^Z#4 ugFw#@#`0?)>c x_}nnnP(vƜ|zn띐LzKⱽ^RK`FzXצb[IجB[* T''W3Lln,1⽸/ $H(s~UYe8g\]@0]Us $IҶFWBI~&1&=/FMS꽴y$P?Ľzb|691A9|~35U9+Z9*Ii{z%q*:wHki:F*f-Y|P=&?|_Ik[t:Ic}M2Oy͛7o޼y͛f900zFŷ~kFꇀmKGǴ!_Qt`[ !QLi,(QkRbIDp:"#o9ՇRʍggnV)nz=;XdqTB\60ժ)1筞C Sמb777бN I>z!<.//qxxOb:nLS |"5ME$X8 !whT:"EY-ZfHA "LS]|Jd;UJ֮*H&ѭI<T1b oȰ>F15jd2Hl6VDpt82 CL&~|8>>ީ!X\ZIA/q)N5s{]}$a4͛7o޼y͛7o^o0>IJU6N6`0:C.u RYD@A;iQ9%Red:V.HJ kh*b/_C,$A`ENw =L^ |' l۵2gggx)a/"XCB @y/"l6cy<}l=h41:z6:?~Ǐ? 8^LCnJSظgR2uqQPM]%A0wT(QTptt0 qssjt:V+D7PAGf}8o'\ p λd6N"jV_Ii4R?3ZGME%y][$&$^QܑJLRR`UD@.ǁ2m$)N☩áJ^WUTVZ?}WЖPTtnjPW˶*ѥ>U}Kp)RRoN"\Ծ6&)sMۥQ2WA ?wo7%QbPYհX,F>\bNǑ}&!ENOOqxxl6 #8F'0 +Ţ=?NM"6 O3gh42Lk)A\]ʷ~+eEt_( 8⳾^z I p`x|Fq׬w{Kw}{$7o޼y͛7o޼y V*0Nw"<0:1+ @lWgiDWe)K04/ ,S\HV5 $ET$fFL&;V} S]1mٌnh66fTT*#4>S[*PTĀ d*FFt:l6%ŋ폎_Z l1e;xv r +@*bsߋqi;&K #Ey o |k(>bߘ']FfiZ%1Dߣ>;v9~ߠ\.bS2X,b:"c3lv'pid9^.ѧi1%6Lpyyi)c>=?cj>2X,r|VRΟTuC0͌R]4 !i%5t?k'y͛7o޼yjAVO>Ȋa  'O͈STb3HX)$u?n;IH#CY.`VRHzq(&Zēl H K hj-fccAuT{MofQ* ,"y1zժ5T6.$*(,t:EZp8ě7o'E* *2JJ7*t6q(6I"UE~ rzjux"AA`/ C(29== ZXoSkҵϩ:r5[8-֝QP~[K}H8G ^ $tȹN^,f ;dbh*=FU!x\!R{>)Jo=FsOr9JHvh Li\ 4##ܮE[ޚF5H[*HD1^Z3,2^ FzX C K\u3 ZdYyj$֥j6T*x)+>;YEPb0N[rl6~BVe@=Er@wXotJ3 cf3LSl6CRAVIF(R xKpӴl\,c$͢(BZJ҄q˥RRAznrOQ{N ~pa\܋%)tl6(J6Vnj?gݶuW.q{{k~F :OJ(8 7 v~& j\TJ%y坚"jtqþ^W%K@MӅ)N*(UR%xj*A#&Tf [Eekd2漨HS*X\u>ۣ%k_W 1Ww$2' v .$%c,ksu7tm5o\n] 3M{KPU)1{~A|_" :\|i׿5KSa^\\{ X,vm|&fZm6WBqJޢqP.M%M嶒!/Jկ XHK|gZn\*P)n߇`}RgZk'y͛7o޼y1_V8::BEF: "MD h%S?$%H0TКjp&i"Tpvmjj|nk5Q%0DT2UFfY Cgl6kiJedd2AZEPёFrV(q||'nxF f3x/T]plܨ"d8HLED[(E114z5_Z8FT*Fh_\wH'FB?vHXp-f_H4c;(YZ׈#5$ T*eT-.d3%\EFsݓT)`'uwhۨT*`kuXc{!q5hH$0DZQnFu1%Pѵ8Io̹Hi:9sKUJ|(Im~c\.1 4b(ѢWb>} :>](j ҵıR5*Yպ~kwd:?V2Aېt΍^}vpɤ~r~ou-h;iTh,+mw)sG\#J%L&ِd0l=MSA8DZbػ UdRbJHc*JHp(*$HZ)5MrEf5cW2~k{>c0X-qz ?l%"Eio1S⎵X'{j4@)7IvpUI?2M!]WzQ#1*RNiI>AsMF0*IHr)vjZ9%.is]6us&7m$ɮORܒLJg0EWkv]˓F޼y͛7o޼yv54IK-#Ѡ%MF^?\}$MH)vL e:=Kp+$zoJj @߷uRMĔ7z0TAP5S" ZLqcX믿b۷o 0#upp`>Ou'Ib>ab~qTr9F#kvbq ,l7vT2?V׸QP <#6pwz]V$|x(RNJ)JQ("9s|挒:o\`ixMLYkn1=h42N>ᡑ+l֊kd$=ǂ~`HNSSrҹH28VxZ6 qz%exM7g:"LK\(DҌ/S|r\o}#hn4>f߼uKA5>t]'y͛7o޼y'Fa# < ! GƭdN`Qwx%}x SRnV*T/˦4! dz=cV++Aw!cҮ^Z"X]UOQEf)өKw&Qt0 HaZl6#+$d*:PW_a>R#+H, ? d KV.nv<'hvxxZld~(l6_sN!]ӹ\׸EEt:52NA4snHc"`ƹEfwU᪩$ӕqSrM=>80lP U@UMo٩>r:.F,rh8ucR=6~(yĵ3nu-%uBu .9xpc CҞ(X(w4MnjaDϜR^kTU Tw:{n*=~wnq=sVY:u~\z}g޺=?\KUqwH~WLd'-۪J#]{GKsc͝syț7o޼y͛7o޼}jFf3dY! ry!GPe""  q  cI{8zbJ0 l6q h)\Z,U p')$9~$F70rHrl6koooh4fq(0v hdU; #$pJuÃyX;"oc3am,@'ahɘ"AJF#DQjjJbW2y$k+%sXz"C\.w;l7ɣjjd G 8w\e p! k *Gө&IQp(ѫ&LIgJ#!AQűzh4L6='4NEQTq}Rg r(DTG4UZ*fMby <D5d2zAg2c1檈w]I-R%* ~UT\r(M]dۣmc-iđ{ ƍvI s}ՔLkqy[\6i (i>p G-|||Im+xut=b?:J1Y].qqq/9ݟ jqF`.:kS gjg4A\Rx&QRđvpxxB\ަZb1E?0.Ѝ2ݴt6c=5ܴӮ_Kyț7o޼y͛7o޼}#ѳn\F(JFؐ  <PG %9nh6T@'CJIZ z8<<47b8^``=1E$6X;.3D0VӠ }AZ rيb8dn/dQ*Ah~vh}$>"ϭP7cYV^fz#QFRqݢ^[ڿL&xlPT2)ө)G0DƫW+Z-Sl0$CIZ61o1$grj֠b@Vsb<I2j UssnXV\a`ȩ!;9O9$H̪J~;LScqJ: j-7I4k#g#KjBѰT*Y3*?)wd%-á幧r 6@4t%c neK GoznDp8Dxxx|`d_xpz}'m̴_RL Sl6Fl[x"}áNq#ˡVRX*jjJ%X&Mb\^`0@R_|fA899A\+FRʢZnCZl,Ir9}k#kڜhXnkr12 .J!W( T4g˵^11NO?^^n>S1jn4(FjT*j@**1FOgJHy# ^Np>{|=M8!v=Tß4nFfYK)շ.{4Y,hi#l쨉á*rLw`_bdqr f9CO)x?%̨pZ.Fz-$g~kR*zu)->sVڧ|Pؽݧk7f?FM3磫@ϸ0-cP=^xie.ܜ˜s}gEޓ3\GGG(.I6I$!umQ<"i1~ߛD 2 C4 S #U7LN'''8<<4x\"ccS%r◛=1(zsl:~PIb;/E=}sߗyț7o޼y͛7o޼}n`Uz0U.b͔c N&s/Z*s"MCprrFJbW(BD69ύ0b ;s%- q A('H( UTQ}TMTDvA{AfZf~>==vTSj&6. DQVeGXѬt,XO3-$ItpvvbDI(0Nw1͌ j;d;_Q12 * J47d njX8??GB}} IDAT( rݕNuARaXC" `,?wtSE S$Xn8oUUHi\V<%`k:'K0)QXi:'_V+դi :4BSqܔ4b\‚?JuIUKID%G=3Uc1掻k|v<YyMm>%Ճ^W@>$g'J*un1a{56(ϴl6wTƖe߹?ύ ZE* CZ-#U姄Eo^c:7 0-m%q v S1kTi5NՄ>K/(|i#29BиP(^)EJ0^l LAMvbab^c8" CT*Y} +ACR 톭릆Iu$Ibj g Vg0`>Pƴ4%nh401jF q[)$0X,zjCBa^~m*WH+o$0m4A{*&DU1PD o ܣIN&t:F#T*#27KH}@1OLAw k{D>kn[4EH!s:mHwۆ%}xOK>҈U(yJKpi3&K;V =>>Y1MA4Jn Ske/LD#GlfDA?IRmt]غB2vh8.=|M"+/l\"HtU'IGr 6*U) ʇHuǨ>P^ !8sde2t3*'Jۤ^1A_ *EU$*48W4s>QG# Ȉ/.?}s!cE7"ki)_2wr|ah녒ǚr$!FթJv۾!mx`9/k%>Z"iHFŚx:G5LRW~'''t|M1'u9%ރk92nbڷ/>f.Wc~)1I#o޼y͛7o޼yIZ>ͦ#ppJ#rǨj)vJHSriDI0t:5pBAs֩\d 8$q&pD]镸@bݝBL0 Mß; B֯jzzm2M&$SJ|>tjKNOOQ(0 -TA`གPv (fA! UU#}R*a4afoT Q!X9S0bzKKӧժ|F z=z=|G;xcI}^Ⱦdjjqt$+ ab:6w4B3hόTP_[,ݝ*hqw@AQq{hz'ƚ4t>cys<ИRG+y^uG>`.iZ?%'YDsN8%J2 j zFI3yɔy$rZKpےfJhrJbh6ED>!$?|P̶R>) F~lޡe>ONNvb.={HB>K2H3%0 !HݵQE <|)*F4f*>ݿ?>6>47o޼y͛7o޼y$ H8a=`3&GP\.[?z v@\ė%'u x }b?PN V *?:VAw^SUՐf|s#QQ0琞K1h%C;5:V:z/U_F)@SE{GO8J_zU@{nxǹD+'.K4%)IXxqۧYZh=ϪpZKPTP(0$=w9TJs#ۭF;\&yU(PTpxxnkR.kkwqL&x;ۘ1011--N.tbt5>5]8N7]R%>~yț7o޼y͛7o޼}?EUբ 8. F&A`*#Xwvr7jXl63~r6[ς'Z(lSQaVGI&axIHx]F<1MZx=&$Q!cX K^(/1޾}Zf>3Pu8>>ᡑ0l#2?XMfC)E4ɅFaGE KlP.ZpssnkIVh4zׯ ,vz^g |>7r?r1 tk4fFNny>[*J#`6__-~$HMLCZZ -|  #TBMŘZJJqH&I[@š ďS'0xrrbU;;1UXF#z=$~wޫbAFV\cvqn@fn9i(tK}F?+ę*>TGZI Nƹ յHqW8-My2 W}K-Ji6#/ię{_~D@O&zQݻw( 8::q"0ĺX(!Afcįd4V!"KϖdnE& .ժL&ҴXbMM=G%4M{Caqh4P,8Q07yF"L3HH*~P@Z5@G0vb' 磦TS$;tKoHQMD -IؤMSSI1];b4zz ZDTBA^W_\8a_u3Vb}=T=\]%SeW]P|qFkRDPII%\JA[UՋ4]Jl7XGJ;}oO_1Oy͛7o޼y͛ObTsPEDrh jU!`$M0s\]]h_6^RM=4_bH$tG4Z0 nQ`xNGpGS"dPTLUB(^)ۨZCIX4 ZvbhJRC7 jb0d unB` PTBݶY{pp`z;)7i"a=\.gxq\FLZ)H4wY5MRrphd'T #VGV.I#(;=lpp`S$$ #rWr;Ql\Z*&Bf\y?IT*YCL&d2ڡyO-c]$*Ҕ " 1>GVR( w!U.O o7\i7]E^O7!犎!dK_$^34-a\K$}2>.Fx4%ܶ1ic5ݶkDg)FPۡvF*idrO62P}xxx<`0ՠ㳅*Xn TR\&1Fl)fK6(窮Y׺^D*sw`dbbP7=$8n/i$ƀ;qf/}cX7o޼y͛7o޼y$!Tr̕O&8@EL9tsZGGGj~*50 QWAGggg-M@ e\\\*DwsW|>`0@R͵Z nGGG!NOOQ*? |9~bawAf\gggjbկ??N((J; &AnA@ p~~\.TU#&NS~z MdPլ݌?vT+hۦLFVw>GTw}voֈ\.gc8L<sTQi۵ f2u5TĐ#HP"8DL (isR.!D<+;D!E#WB3#|0Г, N1 1(볦SB eS.qLm\HQ ^=V /;>$9x dV+ KFO?t}^/x%'KKhDi~dtMS#c%MȘN#{8v>+ɥ>p͍g.$=&ժdu5H@EF$IbkɚJ%8n$8T7ߺY#wo4 U4J`2"z.Jj)0"V׭fǝ|>C4 KA$>F$'xOT5$ IŠW0$$@wwwȺ3p5$&㸮V+#T8g38$Hr*](;S1$ bV|nFRA^Ã8_)#$2)]LOT! Zr'L&x=Iqslb>J>t<:oK\y.Zgy\TG -"5Oy͛7o޼y͛Obb%`'q.Azm :$IvGBP`]ZD "pyAg̵`vEJ!@*MB¨RʢT*a:xR0X&٩1A@a^##IHimrF-իWx-:r86p%7ʩR5AVCu6!̾`)ԓ# d+1=$HniB'V.X,puueL+p܁gwflV+ Ct]j5Sܑ`!7LLQڥwldm!ʝ Sas8eM % J$' \]]T\;%U7cmey_ 1%=%,WcNPV"։SryMc4J谏J:0Kvw?AdU.zp %ޛ)0R"HMhGSmC ?}!̍_ۭ:o4Kh9zY q }}\*I[ZIk 8X\TϺ6',Kapu IDAT{Wz60F>\%ZW.<+3]nqko.T"204rDMKu*m-qϗ~R{I#o޼y͛7o޼yIM/C@~XX )$LE@ప܉Jŀk@2{?<< c$$xm6g{x">(2kX'ȟQVM h4^QwL $%l_}ө)Hx)B2J@9޽{B?߿!8>>`00bRh`2OIЪ*43 +x>s_TOycLSajj)|>FJ!j^l:ø% baXT*899;>>6e!"L4{Zw)H6r>qyIph7 &c'+_B% 0̯ƙl<[z|nD5%KS}c?/@T.@Ra_X4&}5mU /#>i;ܕR5U@Vp5wZz=$ T]SUIJ0USkj4cM0.G )/.vNJ4FVJ#nnoY$’Ta%v'=cI&n_u7ccxNƵ')W%̵]kIyEU'j]µ~ kSgT[j$E\35Kg;QqcOޡ), Wc0MI^"nܹ/p}Kmߚ1I#o޼y͛7o޼yILݵgJŸk400cߛet 3U=4^M>P(ʊ@rqlcFvk R\Fz`*f1>޿d/ƚ$ v8ZSJxbax||DZ5|j)xTzUkP yxxh4۷oDSX>c\WpU2y!)=^_ABJ0$Ԕ"qwٸFv($3-ISqޫkQY$d)ѯG軒1(_TQ&ccZAUr#J#xN]@6 UHI:gNiȡ)Ѿ~r퓎ɇ|!_6S@bk vBXkܹn7IWC:GTy:U9ia|O7,e& Ve4S*|xFqs%Gu7l37TT.rkS]{)MIS틙}c9K )!7o޼y͛7o޼y$) @h >eY#6VN%ib p+@hz,#O+dT#U*k?S!86P~y||4R'Ki2`\\.SMQEzFZE4m>nT*ڃLxitkCA2U)q14MzxJ#wuW-KFsm:BxԐƔmwRd5!Kk7㺪H' ^3i%^Q-g~䘺kn,'ݷ7is$'y͛7o޼y'1&BTw|`9 (2#,8`53u2 |f;EL=Fx4x< VFT% qw-}E ux:~_tpp|l5(nnn0'BRLj(Bd2p8c'RjAw+ ~_c6Y*-q# t:4MZ-#R#8Dr4BazRwkA`ipngp8D\F^G߷VS: cM8 k!o V% Tk~&yṂz)%u19fLɵX,̯$vJ#YXVV#KSȱOJkOI_.I**rN =WK"uU႕ʇǺZ+Pu^W. >Gӵ}k$ u"ijGSdpK@mmn*~}sgLzf&L#P5j$[j]SWETI%e%u6͟JCzF׳9dLAć)\y1TR4.wX"HI3ca>R3Dէk65uIgg\Gc;x/m~,9˓F޼y͛7o޼yw`*!As<A H8M $ x}oah9Z#݅N򅤒k!YU777f#\^^NLj[=RJr/Fv$IZcZ-<>>GGGrȠ0 d?JbDۗ_~k$ :~'m h4H/IFah~@vf8==5#2V;N\o~l6fX"<T*hZ6{Z!"K_1MBJ%&~( T*b^^#SP nq{{r;dY-w/KV+ql/sCk|.kƫ*8Ǵތ *{HU2zma$ 6g݁A4"bZʣZfJWYLIMo3z.%L8"%p~NN(wﻄ@KBw3 e% U*F\H 48G:鸫oӀ}Ҽd'sM}Ǧ%(掱bN?W$g2K_{s[.N#\LHKvmg[ZxmiI }ЇLczom5v뛑K@k׽OP!)s)J9~F(DS)pnT8%n EZ;Ƶ[^ x:s}?wwf:%3*G9&\y}~KH}y撡gd|7y1YIY_2Oy͛7o޼y͛Ob n)EP@kYDbPi`z$m:H&D0BS$1 j41?9^>ǫWprr8IZb^\.0 -[RAIMƔ[|888z"ͦx*˖rm B }}J{߿)d)!V...P*RH'Xl֔5Lqc6Y$I"x5L"J*o6$p8DV۩*HuZ7@ Cr9TUcS^TZRfDZ)ө WWWnVNqnJ5hRiv @߿G2hr$τbQ„s䧒Ŕnl?:^$2(B׳>dlOM6FF@8WԐ$H)7'H^k!CJV`dJ8ŹWAX*1USjjIs%Կ.F嗚۽zLHTŵ0rIZ? ǸK;5K)iC?SёtTFdDZkըnJ4kqIRWeF}y|| BPTnQ*vҊj(w7~YTz9 \?y~^4 vE.CTBXD 3w!}\. rLhyv?Nl# CmAdfBjj&V H";99AE6PT_ v 7`0<))].:G}'Xc18P,VTZEX4ՕgggX׸O?;Rڂ%E}>c8aZP(^iq`W!2?|xG D_/JPsX ˡR%v )Iք (G24tSi %8HX&I(P.FTV )xn-H)AČ}QkzNo$;}@–T]I^C=^ 3XgkuETߐdz5$4`ɠ1Fd*4Fyz j s w;kM.yi&s{Zpl|ѱsb>rz, .q@RDE4%t)њ[.T8::BRAEx||DEVCV 4V+#4{@$QN7钰9]EOI8=1B@}؉u˽C4Ǯyț7o޼y͛7o޼}#8|3XV.`j4_̮M`Hz |b۵6a<DwF4kOʐ$I!P-Exh4N  $m[LSsx>i4( R(hZiۨjt:N`GX,l8nq(G)04R߿7FS=#P\.r41IeV+c{ZP.lX,̗B_6bn?+Kz\zxxx@ZūWpyyL&ndxlnqƐ 9<9F2X,I0NtN 4Y.Fx|?Ui*{dNR₪/'U:Ѳ٬Jbq5]㼶U6-fVp0:.L -r4(<n9UQIs%Дk. ֿ(ks6`RuĶr* ⒈G_:c1Jp{+ٲw4=oXG_ x& . Fh$Xۏs%ϴ.ƃnp->$5U.F8ͧSژ(@BFf38;;CBc?QRQűq0w]pOrskܥ޾XvՍ8i X,( 3ǝ/'tǬGidKk!Vzț7o޼y͛7o޼}Gkf st]9 Rn ѝ | 1$G frh6hF&;_LUnM J}K>C>I#o޼y͛7o޼yI,K4^ ,ROuH&T;@p8|q۽R8F\FPuYJ"!"#gDvS\' P(K0r%St[{G5j«W,۷oQTp~~`7IjMSSP}B$ޏ?9-M"R(IW_͛7f(JT*HToTVl0n>)`#M ޾}0 h4t!(BAZEZhJZ j~- aJfp8DhZ%vY1>m>, ܠ^#nr_T- j5#hSKqGr%q$X_,;OA_60x IDATqsm$IMF d=::BT͍'(6q>ij"MaG p8"cS2iM䝒Lnjs;I<`!u&ɦ M kK)?%ʩ`{fN@T$նrqI #X5 ԕlI Rnvj4bTbW*f5]}Aǥ瘦 K#jOPXAL7}7Lg[x=SF}\G`P_jFܰsGs8'{j%H(B^=2 :mi4BcJrzn@9u yp-6>%noowj|geko)`}8@ rA?ESh4""ݮթ"1vrr <>>b<*Ȃ]D2>>>jt:#z$2cV3 I !8"~lwwwӟC$$IF Z"y]ˌ'|bh/n1%H0c0b0 %rd2~u:…`qu^jܥ N&< * j㱭!j;%4=e.tM?sƛBO zsp B? !Uk1$zD$_y0ct]B_t}g߽~jik>O~i:n#ҞE{Xq|O㌛ H(ikFZ{NZcN \Sq]єE^mY,H#XmW^ٳP(lZ"c ]j8nz$NZ}>qcW?Qz-]\_:|a&1rC3q$ *J}X,Z"wpۘW}5%bȝ 7o޼y͛7o޼y$Pu`ZXwA6Le*wKF4rM`?lӶ;]L4Kc3 ?bJ(LEAIX"XfFvߣ#mP(dTGLE1B`$Ɏ:dÃJ,͚"{Xz;w#3WEЈ$IP, {޼yz$yOO$G:$dH d;~*}^,QTP*,FL_r#xxrIݱ)M 乄;97 (BZzFӱ$ET}D׽?U No4RP1U:KTE}NgV1<_}ͿO ֔KZ7NE%XH^Jwɼ}$LZ\)g֝RR{l%Y\08sݘI#iDKȦCu:ܱw4_Uǹm5ڨRN*g!vsؽ[ #WEy͌}>Zxxr-M[[BtYemcuz=S9W1b@>`00rWDZm(*,-u״b_]ҘMiu^? <::9?ۚg]jh4npٴolf䩞K&Oy͛7o޼y͛OhfDwHH4E ( ;?g<'(<꥚ ٷ;C>*' |kjjF777XVYw.KhQ!ˡhg{0')UN5gSdA:MRɀ+l6)XKS1Ӳ/ժhׯ_& 岑=P.E?''']bP(XJ6 P"4 v5c5"iE2UWdʏanfƷ_LOxUW.k{AgggG1U[RD; tӉ9O ]ǧ2JU#mtmܷ)k?/.Yĵ%"o]5 Moid}f\mK>ˆ.1ClK*KŐB{ Q8EHB@ f(:=o {dF$ CSxB೵ǣpV:::RUU 7|Y![zyֳC-{-Q"E)RH"E" r9$^j`k-v @\{# ? w0.-%ٟ¹#b@^>{*1Icz4LX,RcUUUewϞ=K4CS %D5|@+ CZ-trL:4%ussdG-q:t弣vbdƏVTtO'txTUS @f2$g<c6zDJ]#Xy*DJQ!ׁUׅmq=u^'o'|/HhDpϯɵݣ#Y怲{$6/>GՉkN93OL[@E$q=zFzG߱usvG[S$b$I1XW<V^nS 5.[WUUST;fӧZVL;u:?L4LzQUU8ϝa߇9#rc|X]?==˗/SjY9"缗>!0QN9elyXUNҔHVrYKo{Qo.uX q6ɉyZ|maqbfOwO]mΑID%G.A iTH"E)RH"El6<9/`Tvggg:??zN@8 "{h@'HI34rCRIy6dnw^h4yDO*x"C )fFfөVS(OX,r~~IzD6v;s5 u:lvu||t'OF߿yj͛7"vՇ4 }1E%]]]t~˗/?QϞ=KAԐv)zhY$)3?l9E$#Eh'v<1wwɡxVg>\b}.CsGwԙm;sg!\!a> :q]/'F&16S zzHzi|/ە#~bR$VPq糗.9G4'?'xH$ȇZ';78iIH(oW s xQZ{I=??::Jg>o]Zss}1-K- ^F#'xss~ uiږ(ȡCP]9uJHpRJ]k(,nWt:+gt]{} VG?$!)Q"E)RH"E"a8i$)ـ(sܨ*n f|h4Rޠ{9kv[UUiXh.3vө:N:{@R!8j4i^n~X{fn4i6,njZ\`|l/_#W" CD&IZ7RHQe^~绯-@N$y T$5 Мu<>g!ܳ /;I{gHv<>sd9qh_=UFq|5hoMכQO\QmO=)s(Jh/qro1?FVh?;i?DvN}wӟ?FL VKDo߾z֓'O^l6ɓp1w]5H:]^O<9N|qttxG葵9C@C~ڜONCRH"E)RH"E)I#|M2:;;K=8: XDz:Hu :)r vY. @7lvr `n;0.vuqqVF|$Ȕ4ii4"Jeo[F#cݻX,Rڶϟj///|Q~_VKXv:;;Ӈ4Nӧ3]\\$\(ئ{ccr{v(ru)8sqbAX~tu}^בF3Ok[='K|E6GJ; e]⚧y;R2QQ>,Gz; d._ {DUn[#0t~|]w;b9#-rKu x0~/e0ynEy= 'Xhq̑K^Ngܓ_qQWNDRQYm=(}PH@Jl6_7bJ>FҙH'OjjuuɱQ#ܞ֑9{`drԻw$I_}[MSm6Z-I-ׇ8v}DwM缿1t=[~O9B)RH"E)RH/"8?= 3^O~?E+z=-=pҁSy@fi? $ E"zyˁAxT E{E E&KShz~!9Gy>6Ɯ1b<ت_i#)@#9Ȟ# r"pe{q6xX==X$ }n9N< k[3Jru~L:$Gprң0w{| >N"XHxu͟:0m\^^|j˗/tMdfM69,A8d>~r|9(GXZh""h_={/^${Q^.zft>딓onC9ݸž辺n-B)RH"E)RH/"s(1Ҿtz?DTU@p'v?zDz!^<=-J<|# Y.z=^vg"4su'뵎RthyT۷o?ZUUӧ5 Ҙ:qH}ƣxk\j4H''L&8Av "С{z;@y4$ cDஏv=l်9ۨ#9p:B1nIhܝ7`ln5 z@\bǾFDts oYZsJ|>4~HH4eI'@>DZG'p;vh/>˭I#IF}||0ra.(? CSnB'q<};v R~k}ꄐqX\׿իW:>>VKɁ[s+\@v;=ICTu y|qR\G08xߒW:>>VٙzuSl޶(*vxzT?m45@NZc6z8ÁTקvA2=]!kc.yHBx9ȅ2ǿ;CG+tR0F)3j D{Bىq"ǖoo$o...4Nh41u{{7oޤtnK~eCTRnv:UUl'Vrѕ}+7r{[>^>Ida: H0k=}4ys/0s?|6!F\#3jJK]i| Xx$Hznަcd׌;'r넗YrV_wq.Dsu~r/wǘ:Hz9v6<81u:GBkNS8!G}Hao_.jJuȳ g1ݻtv:>>h4ft:MAZZaZ˥er8q}3_f\9/?QIv<뽳YRCcO=4Gr~~ۯ9䷐HuK!)RH"E)R=])O@ X,n0ç!Iv޽{p€v䢈|D#ym򶐞|'eYrx/o{lM <VWUU{Gx\?lj...ҡ\5^ u sq9 n/5b$84D}E'vk]Ox?{LUUi6%h{Kxя1 ;TXWWW)Z }'I PRACִ ilOO˭I<翽H:xn=$>_c;#ܼ?Cs;HNCǶuKyhpۈ?F_!B-W- 1:H^.{V!ُ~'{k5MF#-1Aں~"yb)uthI8+u/Q"E)RH"Eb=GPە$u: ^5ϓ./H ҵP7d t?-TG*DawS%^R>>>Nk6^F|fS~_Pze:]u:4DyRHc^&/k=:"m>L:q}^k6i}.G9Y@K/9ns~K˥v9DEVk.Nx|. }Av[Pggg)"`+gG&1/g秹nrSގ0tgO)UWSM#ܞbY9 8<9:PI}z(qˍ)/빵˭W4@xloV.E Eh0o}>>̍?m;l6l6񱪪JŐ.HoMHǚzJ駟:;;fI{#BfSJ>|nK$>;;K;'i/FrDznnE"({QvQ??x?ZH?s:7hOlSB8I7[3Ӝ+9[kq꭛˅4*RH"E)RH"_DCR"! q҈V05X,(>]v ]׉ \r@މ(ucZ0H!k:~П iSAw*QK`6i89[kl&ofNcmJ Dy896upQJgD#ꀯCX")r2"9mսCaĆ4LҡNr1ˎv۷o5i]KJkvBHIgC:}[\ao둿 {'`bdVh(>b/#Jnnl4׎xok v$~EȪmmg"맯NOX!ʿ:>=*Dr@~ȁzcr>:Κ\.4s^wv}3fyv^(("'vz-Z-zץh>~-3^?\sƐ1AR76b94"G<Ѧ]b<ӑuuYgNHl6~#}NlQMzzXGD׵-EA=NL{OW.r:j4v[RʹO>i0$҈~rrZ2w=7(^m33+aӏnwV_͛a:t]Þζn];fDifyY9(D00 @'bnSIp\j2$;w6ч>'NOO<7AX8F9r_ɕEP>Ι\ܨǡ׵1 OFmLiGFZk١0^8@`;9]99w777GD߳onnϢvyWJsw VT|l\|"{\#ёZV޾}+ C=}Tϟ?OES7w)oiGFb=ΝеL*Q"E)RH"EBw 6E@ E!IB>KQP6V"msP`:u B p#d3,Hl6{8t7mY'ls*zh ƜI}-Oh4ҘZ--˔F/90\uJv |L"x(x@BzQrv~xFG t:záVUJĜ0¶s#sgt1޻.,>}Γj((pNO۽5l:!bǐ3~uEȟw=glUUi<>>NQI:;;K9l3ioOtp}xM>^\.NtFZ}E&(}CgFc(i'c=$9;˭ߏ]CWɵFE)RH"E)R0 kEϣ<q,nF) x⩢ #8hVZ9)G6p1=%],c;@O w/"E5SxǨn};J,9ۨOu=Jܪ W?zKΎ{2!bH8~!#7gXcٻ˥m1$Eֲ.w6ϛF^zZV璔ȟdxRQt"jxb#:ɍ[>͍YH?zDik?x^bwa_vJjdq^\ssЇ}Ho9)Q"E)RH"E"?Szo6 15 {z{E" @G v?"{LY~JL oOHg(^ȏh^G\^Oq9$"@ADuiDY9R;8.zG!BAF;ٟw}ݹ>ՁIQsDիD =ԞK>DL|wYiq` ;>>rLiy"~J'@I8/wnp=ts!R%>DSst=1c$ ~=w5 3/I{^olou}vAwvQryy15:Vdqv3 N 1҈i+0 InRm6]]]lɓ'zm{rrxxbOONNvuzzssM&IJkbzNN3}h{XݜuI*>IPJuJm<54Nsi:>&ggDt84m\=u.Q"E)RH"E"L{<rJu:t݉$=v:j%Ȗj!H|>~^UUp<XtnЫ*yBxtL} z='ڃ5P')]B ͘u:M&4 ҋH/SDqDy! vj4ZVljdvf ^MCDK >DCf) -tDG(N;w'r|.Ztsv0h2hX?ďC{l;銺pH~N7 m6믩\/^ŅzbH@|rrt$H8ρ()u=RȣM\vu#v@ s9 (tx\&\HدǴ( 0cW9ʑSXVМޣ>\b{tqL|]D-ku<[u.xrr1voX,4vtt~~ġ(r|кMD­nOw3S׍n;Wz=Lo߾MN糳\vE{#g\.9kCnrҨH"E)RH"E|qq@VFH WY#+vtzzRy#>ХiMHlr3}/Hjt%)E.zOgFytVv:=Ӿ !xJI*uݔ"fzfU|rr5и| }\9,܏!{e,|3`;XG=r%UĢO>*ig {0뇤Dd\uE$V$D&]c~3L'׉2xRnl#k$,_rrfnjO)\>H#7ε#901^sKؖ\_{bQGnˁN\]NRץhķ}==B_jtvvݥb_~ur6i6v{mJ:|ۺɡyzE9qoc4?ŋ4c\Wgюbs?ǮguRwcPH"E)RH"E)Eĉ4>SF,/:2CD01Ov!-G98D879u# \٫#&uSn۱7ucD=Z3Qq<#Aplы/R5PmnWB}:V~h"CCkDn{$M&/nۋtuBb4k&M]?)'9{FE)RH"E)R䋈`i? Gvty 6Tl5r||"q,~oWC9 у%p#| rNxHxr ]"+ @(O+tTUUj4tX7'׫Ņ˥!^x9}`#h#`@X%{#Z>Ǿ N2v[Fl6_9?Gt:EM=9#ggQRgu<bH~nT ĺY}{gB)RH"E)RH/"u MCqI*z'T@ 9P8dPLg);4FrҊ43G=xw~8NZ8׻G\IRUUz r'uzq䩣"Db`O>MQ_-sT(:2yn׺r=d>ڐu!`!qu~~W0Fu@;eHi$}6o+O5" 6V+^/H2hCL@G9d+g<@-:nZfn<8F= YVNIq4R1ݺr@wDh9"?瀶ǣx緂[gi3}W j)#d"vcAٺB/D@4 9KC}n4{^깾h>k2:;;'-pI?#>_>sӟ{e2rj0|ʈS\^C V|Ȼ\sGrKsnt`?ڔ DFc\\?wBlZi!bhZJiurz^w2"!umn{hݟ[ s޾}p*9x}n'|dCQY iTH"E)RH"EDA@ h%@A ?\R$@&o;yQ}U󶟜$`; @_̕@ qTzor0׽e=B̠O7s<̜H`:XMOJcýf/:k$rxfXq< l6N{`Q71>n.j)HD m3QϫJFv;>磣EҧO;=y$,777铮t{{RI1okns{Ynyh9c\޾}ׯ_'gwNrEYzwٜľVɭӿ*4*RH"E)RH"_D4nw)|$ZqɁ mdi":#G"A$Sf^2dX$H^d{wwVy?$ud?f"$9 c=k9<Ηt_dN_7:[/r{݆u>Sg; ڬϥfb[u: 9;YֺddagO;Iio<;;K{%d9ӧDϜXƜml$>s(> vK@#zu($-g䈭99t:uJ5G:"ި:G 9')+? R9! lj<ǏZIJth[U&I0`nE"ec㟳Ѩ >GT'~{r+䑷5SC= ؎:=zY?,kplKWn?bD9ӭ q.ɟa纹QN F{tCvKd2|>奆ar8a%btǚL&R: $f>~dg֛8QF-386ˈyd<'wɕw>w?k㡾}3|)Q"E)RH"Ebu(|vS2yKk XQ3 Hy:ED58E8g0fNp3?$u $ bIc#)El6& jn1U:hZjID#pbXB\T777*%zftCKsj$>瞋c~q:DD3?}>RA1Gvk]^^jX|Fz"u@ϿNOO\.h4yCg`+sdhMUUq\:==|>O`oy>$z@Ѩ؝I>!0`5]ԠIC>v/(:Rep޺zu;>u8NHsp+^?sw:uͣ<=]=mm\}߻n/gj:JRr`=ji\۷Z,fC"Ꮟ5ϓew-+UlxdfIN0Օ~Ϣrsy}:b3gqss1k>{=WGFҨH"E)RH"E|q9^tsB!:P"=a>(] (mħ=OOOS׉~C6zn(i8b$ 8ungGD.lFz9'= "pʝ+堉GG1v(:^l>xH5-^A̕:" GtğI#]^~6.//SF=<5_9#\d'(}t"¿cv}-O]>lԍ~1cMq}}CwsC$Gs3;n(U|9!G3szmq ۺSw\/bߢ~}X3>|8\.5~INJ:t8c"26n#m0#~?Cv_wrrOuDOLu:]|B)RH"E)RH/"d (g<$nk%Pu>Ѹ#dH9)l6S u8pl6S4Nx&}g!QjtÇDC;!5Ϸۭz^>jڋ`Hr9?9 0yk_󲝜{.jˉ}w=Qg9r'G2t[nG=\۹5}Pb^x"r#uωq' 'vphOq3cnl6v{&N^w~=LX,RJ9tYZszFqZ9K6Ɓ1Z,i'.ϲHJhKDȺCLac5b7oKQ㔃Qi*']9nN]c;y6뽹 3v?;;H!)RH"E)R'R9 Q{U:Ďrs'`X;Nɗ[ԟxG`ea@F0pӆIJ3b,fY.9?|nhh^VD,HJ*Qzts߁@r@rIߢ:"2|죯Y)ݑȬ:u曯:w,z;u3FfS^/knSyJ~$닿3yY(^!tq<֣!㱆a3s[]|oar^c8i愗օB)RH"E)RH/"O{:#dzԡ,i'r Q@.":0LG>@bbVDAxL*]TH;\{< g:hWV") 0'@ lÁl[VɆ7z,pL 雓Hnqsdwx~hh\J^/yVGrω-#+1IJ"^NNNҙ;BWDzkm=\v;)l6{r3J7fSjj6J_}^|v>m4%҇H:,6jTUU{$V$Gk<QG[f3qq}[{#hH$'IwuĈ+#reV(1P9PHB<;#~rˊ4E8w}:ԯ:0ε4z q7D<@JgϞӧoު$!u~~lFҕ ճg7$BZRJ)Y%2w7-/kL}zU6ҔT ԑ>.߾'>þ,g'rs#Ω_٧l!4*RH"E)RH"_D8qĽ5srm4bM&k:j,EA  <ʽHm 6 IDAT=ZoN@{;NHG6/ӢxTGx4QJK,j4 >"@<[FuK}<$!O;\QZ#=UG9q瀪W8Dl|\s]!9{ȝC!sy96˳nuϝ+>|tB(=J샏;p+Fz)ݯwύy^s 'IZ!'nwnoׯɓ ~ڂC6Mi6i:j2=__/=o<ϫJfSP^Rt:M F&u}s Qۭn}HK{Fp]gN7< {}zG_H"E)RH"E)EP=G^H)Y{=RϻSvLy! xQEwjl6#$H8YuR8==Mql Rڋp@{"b(8^"|\i'yu6GԏMFP~b]9@ oG\9SoL)j8mv|†ݮv]ҩ!c7"?V>}J&<z:??`0i"gX!v5?j05j6ɳ܉9rL_}f^zKt:{Qy1{^Gy!5;v`bv 1~ˑF9`%WCdc꫻'+4io]Y&?&Gĵ^~m߱M؆>r]P?ӄs܉-A b_UX8E 1okns<<ҐCc\;d2IiCdfbt>"dzۭŅ^znd"xHIv)jSl6u:t!JI3d2Ig~?ĝ4O1&w cJ[Q;2uxq.4*RH"E)RH"_D4FKےg}OA&B&NNNR'>qO'%z3{g?w `ئLR91AY/~vtMPNE֌~+)-D87 }ѫׁx#m'j@Vz zpO_rs |uJc¸^=nw4oV+(+d"BfZV4ی^ =wm"e4rok'ONN4%ITYt2vt:vL&{'XM2_NNN4 R:gϞ%4t:гgvοpI#B)al6Vǘ&#/h _\r@y}刎ǐH9#pDCa-ĕޖ#ꈓҖ.bYq+\anu͡qEIXQO9# M$|^񣎏5Q|o{ED\^__k2 :::Jd{;{bݙG~De&h2a~N3ѩnBO>UM5Ngot:(H#O=i_wH5ވٟi'm,#7=b#6yu׉qHcG",){OyYF?d)Q"E)RH"ED c \;~xz8wo\YʹhᦞF#$fYir;rdp==R;)mpЍ{X~tdq}!1q@ruIO5 ]GQ@5-#ir>Gdܶ[2_") xmߣ0(>(>lb^눵"t&GUU)}zmt:iNjƔxIGGGzf:N >;&^+:ڛS$DOv ے{;vg$c :~I{kx\X}Fu auw^_ n[fH,~|F~r߱m6/纾VI1i33$uy*rh۷))ׁ.%0)P=m'[ǚ{S1]Lo6)1|=ĆQڗn],L&YZV"=wң# x[=űG^ϟA,Eo54Ƽ1.7ѷrQq^i4 iTH"E)RH"EϱG ,;>z"4r3;Ab:Fx]֎( Rt v;0 m|}?h&%u'=\ q[dGЦ o`s[nR qQ.#!t/cr={{@ND#S8Hx:q}ƛ8El6HEN_lRaI)Ę9z v:>>NgIr _>^5N\.S ~IjZ, @? =FGvH.HGq4FWk.Dn^׭mG$Xrćw/sumCCsǺ iT׮xoq믏m/GiN_Hz-ZOu{|wh6FL&f>; 'IZKiI92=k$?6n[]^^ I+'o|a_fil6f{165ݾzz2H$?3w a g" %{{%Hv[~_UU3)8fl4"X'XE?2#B)RH"E)RH/$=n߽{$:Lʄ3V&^JȀ:Nܤt/JMOxU<Ozo<(s#x̳Cj_CEN"uDJyɒ:.!ɍGuGHpݟk;'C~#pmM"x~.Jqw X2y?L)_wߥ#OOK䌧kiFM|f]'Gh4h4{WWWGRz'"\.f)ժxۨBTӧOt}}D:iRJN8þD`o⷏-U8Jז˥ͦ48y4uyynRnuvvpSFWB)RH"E)RH/"XwA6p%> f~zv^)(="7$/jV $~=-ep`0H6)zu@A]QN@$vs~iáJB^q9 c`(ǔhj<XNyM hS> Bcv?[ocIr$%:=030 |1E{R$E}!1%m"ommp B>,o;N#ٓmTO[;}ն>خL8E>z]ap_(}0vfa.\{Ƕ5gn_dǫ R nooKfdέfq}}]H&2Jh{ۍ ^J`0hR{|3cƏ?XH)2 J$h5lБ5H5Wq>}zlooox'~jU=A3"yijGd}m 6D%goAy^fsۈLd=93 yumo#<2y"p$%z?j#W ??5m9iˠ0o16 '#δm L䛃xbE{RQ{6ϲqfNkIYg0r⧼Ef=^Ǐe]81px=1} bJ@Qkö=m>?ҥ{ Qt,޽{|MDD|[#MDD `WxG'8Ϗ=f)}K)9~,a-B&X/~ݕlVK%TRJ*UTRG  h3dgy}rH1Z>'A 2i풛L5?s{^Y;V0"$k;"d:>tܖγ ٣Qβ/LZu2۸sɪ21Ɔfhy_y) KY=lz]ٛLgB߹Ǹ.F|Q*UTRJ*UT"Ba @DD,(28a b@_ 㙀u驔wCd3KX \k)6АY8B*OOO1e@q7h^əL&y!W8ݻwZ)rϟ?u4" n9urAG3" u!'DD&)u3 YG[ Z"q "+%|I$6R'DvN6}!R!^#X;4 V2ۧ9#EȐ69LlϿEeӴSmQrڈg?umfD&<Ee&?c[6mn#!X]=~eCM p x,R.vu6z^!>r;??/kyQv M&3;] YR۪4yl;.QV5`C]|q}{|w%0p8t:-k>=,n+6o{dF:}4 d o彊FUTRJ*UTRh4j|tvq&A F4AI˵hPf8@Pp(g t@a@75ۉ_'G)g gY5]Y $X$o*gt@q=A%s|_GגuDOEӉpggg_8Z f@3c[r[]j6#[裏2xg8==-.O}y]re @/>eRzNK>}ek,[/mכ{F~ nׄl;O[oT&L f?Czfl)>|>nnnu<@bжu>~ WNOOO%ƷC}\]]l&BrZ`\.Ͼс;هZ?.ꒉyoٟdodTUҨJ*UTRJ*U| pd"xגh&ھfR"GYFIW29z^)E]v3r&s9LmQ1b@ 5} qDF7. _f]_nvx<6(xg, :}6U\]]`0p4޽{NP]BA4mmr$an5CaL(9d`6^~%"=3ƜDY#} %d ln[vlz|O>O {L&%⢑a1%1nnn\ Z2n83mB6q/l۴~Ȅn]7w!~Daf ߠ lK2d#yωۑ瞝I62Pd\|܎|o[^oB=^ '92AeĻwsnʹ~gux\gdُǻ_8s!}oo}"GK샜I51,uPVxAβ؟P\]|>˲bn4RJ*UTRJ*_D 8 g t4R` IDAT`e0̐3Dv$<~L"}9l%ϗw ~݀9ĄVы9͙+ΖB1HW KrvC,Mcߗ2XcJGx}|Wݻ%*z 1N9GC"tB[ n3?LPdh4l61NѨAf\&@-e hMqokW.6M!)1h#D3:N,2q<==z.a~":qǏc^Gߏnqzz@$g1Ig5 d7;3F1].0΀2`xv;u 62FҴ1oVm"_8h#2Yȧw[?g.O%ˤM&܏pm!Lx,~ݤe꬧}<7d_۽MشZᗐF~rM6xNgb-8k!k?7[j#α]y&vYndsN6Rә&iSL.gRs'=={.S)+W_Ry% ݚ_1dB_ێo6KYR2SكzI}#i^7257c9}rěeyrq0AiH%TRJ*UTRnQ/|aI`h&Ex/ːюגd^.-gR8 HaeL<6Hfdm 6Ah8D6`z6S~X+1  pǥr A\\\cꫯ1L@F?!KIn8%('D\o[/>ۈ؝^ X<'W*e=GC(b+2e46)h4*m"bcXM>kmFχkI>JZBT0m |3 |V}/sr{ϱ^˹omA}<-$ܷ}dnDRg[zns~w>[Ěiݘ$A3ȓgg@@&?߳0{4F9%۱ۀ 7%1@!9_s'b< 8d C!%a`~~띵0hm'oD6Y>@FGd4Bgg=})~<&"5'.cеet:/|0҈>_%}̣#ާ`#cD6 v=Bt_'g/E#CO fp׶n}<cM6t:-c238zxx(HgQZY>О$q dmDWXEdt/YS:uV"rUg'p%9__<5^~gb>"^ Dg(7ΘsV{˺W[}a&T g(+y =ͣ6}!( ۾<>>j*r8;;wx5~W34޿ ߟ%KrdGD{{?CY) KB&L" L&L>=x<.NîQzn+lV)gVҨJ*UTRJ*U|1 ŗ(z\qZxµlܟkvisֈ3#NOO aT23$9ȑgYeйgL~Pp,Δ9"#ELӘf1NE~Os{{?C,fn(9dC $aK؄#i?"ۺ6K4ߙz[#^] {"s8-D_]]p8,Q]$|dLƝe`u)hb:g$6A.̷N*Z&b=/ЅL'ڒAwx繓7m7:K=3Y%ۜMLl9+ܾߟ#=sk{N[ik[̿rM`LiC[n뽇#~$5gy9=ygyMLV5m,"^CDx}Ɇe}dz c\ff%{&T'֣6?:5aD'$׿u#p8ǸrÇ8??/ㆯvlf8 `a]x\|I!LَCfprrR2G:PrK@yX؃FUTRJ*UTR@GAψ'rf0`#rOOOYP0F&q;C@3 mB!ѳ& Gft:˴+s2 {?J6 $Kp9` }0x&>~"́faÀ@& D:;p8÷!|6LlbR~,_\.Ҷn@ 4bnFb8.// 9K߈٣(mt _Ⳮq6} X2Fb45iW*~9H1x{ #Y4xj(k7R׋dác}quucy4ۓT phdi{ 2idg ~<4RJ*UTRJ*_H 02q  Pqv-φ0~,^<шW pflPFOM2CIڞ19=;cg*vvȪpAd ~3ddb7:>~d|~x⢑ ޠkG=؛I""cXL73\q8)B;ҦdLv[l}r4S8#ӌ17~t:-d^0>pNS,?g"ٙw>O2ξO>S)F6?9noG3X-%*P m{=??QϻLw Y֗EL|M&"ak^fRZ۹a[6h2?g?0n봍3F֝:5)׳m+&777ŏ~1~|75yA?cX.qzzZJLY{H, OOOcXDۍ~!uFxzzWUt:cy\__zbt:-ٰBlv_ĎFFld:=a3k9k iK*GTҨJ*UTRJ*UJ" nX0Ӗ=]m^xZbY+d7SWD` w0r< 3RӶ^Ic `Ҟ@ 92Rג2܋~_ν!3 ü;N9g ƧOT8;==-dfbQ!1Hlpf['}R5ἛdR1Ǽ9bgOOO k29 F|$j{71]9lГy\(+`-9ۊRC2yh3!n7aB\eҊ5p8l߫ժO>(}9ˈs8B2)-{ٔ{9a A7&X˙؂3y&ؓag0$A&"L֘,|6 'tqyyow[NM,`yUL@/q6]̺ϙC6kFK?5vym?_"LHm}c 2lyxxh{l#K+ɟ3Lg;hTe:NQq{{[|IƧXVc:ƇQn??bIDۍl޽+ q~~^H8Ї guRJ uss2ewۍ1ww8G2FQd2){2^쓸{`fXЙU4¶؟ls 3O4ƺ?uRI*UTRJ*UTE$GR@2#q QiP ,` O{3r? Pw޻/ײpާqmN FBf>> g8g0\>dѨKx\Hc2Gos(Qw< X0>qss (8K@poXfvb:g3!Q&HGK'gP΍ϸ,RW\tpd;`#@Bg?a/\^a р{K!:{LA認> mFNx>nl4~hrv-D5b0 n#4Ld1jr?]z%/y6u=f]쐷_oӅɭ|żzq@[ ;%?P޲t֖c49f{}j+y=$}q6DI<<wl2(^kosL&Aa/1q5&A"v(_X,g8==nWC8D%11aȴ /8:e3~| 6ѢDPzx%k<gY3QaB$4B[]hjfˤכ@3 ͦQ6$˴\__ϟD3Pf, (g?@z]HHg}5ZʳfrY~]@^&IgglWDev[A`>6c/Kn`db̤@y2v 3lSW߈2a³݆6"AVJG&Mx#s?ihMpg[<.&x&3ZǶ%扃qz|>frA(7 DgAssc r.}^VbM: ,֨NS,!3I_y!K@me}~($`5Jrf)6K%TRJ*UTRgtFKyrv"";Nvyk*y e񸴑3OOOc6\ mP0qTx"<$K>L@b\GyCL11tJYFká3P~/ǎ΀7zxxϟ? 8a:FD0 B\jǽm3@sl9ddvIvȐ`>BzXV&W.rYtE{&?2JWU'Sr]|3xJ| d e 8yh11͜3ox20q1'"3x73g{EY_ؑ ¸DY&ُ9ʝ~@h\bfYnM4xA2Q&oxo0l6~_23XH& ڈi`IM$^}mqMMۄQ#a֕Q?7"^3{(Aǚ U|2Dw)+flwE>ä; r:31B7M!c);Y@# `Ν;X,Y e|49'CLbW[K%TRJ*UTRG ~ɥt2Qsm {r]f~Fm%pA/#lFt()\@o˜-*Kx gkqux|9 ?L3f8+`6prrRJ@d|C|~5є}x% NRzrVl6dR&r93鼜NtĴI,ƇHw2s8 [pzɶY.%kٔys^/!1N@;Pɸg&st:v {v/OӸv[dc v IDAT7y Pp\qs 7@3;KFQ\]]EDLdRp@Xc $m6Y9DŽL=H[>FdiYYG+&zXxzB~f[$e̢5rYs2ŠA|"؛}}8C@], A:EihT|x=?ML:oYú?Lʜ`mBǜM3E\^^6^ⓝɌα3L~t⢜""q Dd?(dt:ݻr p ?Cd2)?B8g`=y˾k/*iTJ*UTRJ*U86/ _ |/@ u/x40i'Vp1Tdzf(ڈvq"Xv}!md@`0dR2t4DO`2a&}d0`âp"q sY#C|NmfL/Y&.kc`ph4|٬<sl~۠( P+ ٘ 2e!f)b%꛳ ȶ#s ҇1B~rD2K5R=z̋h=bBs ]Bp8,c7'"M⟈Ydw|KtM&).W=x rr9L7}qqq{{[J~zz]2HfS"@qHj2nVx||,:c ~_W_}U ;&! H~)O`21KL(l6R r5@|adNEDSd},X {K'o~b{7mpI],GfFHtkL bM|ˏMK:0qo«P2o!o4bF/E&pc{ޛ9g2 .&xx(gSd=rx<.AfphͦlprrWWWe-<.{$gQULX\N5@'2e>zkc6}rK1y-٩S4yM!|6I9O f l< :úH, 3dR|}n(L~_KJURJ*UTRJ/"9:;GMG4l, 'o#^J jZ>Ͻ#D1{tZ7Y#Ηx~3;^U fXBx4hZh!jq33R"^cq_> Y?3d];bh"<9g}GƓeDvYNS>q 2v|MLQvQ?n7޽{W8;;+s$;.e9t㹌A/gdt _.(D3Db&4onn榜7yMdGt"eKd=6 gz>7Hz>}*( ßdG`p>od7|{yP}IiEKww|'ΚeÜ5ٹng։ @:u93|7c@ݾ<&1OgAۗydlYLߌ펱???R5d@)HẆNS|h4*ʺil艱y~~nd|ze1L7{,='jP>)FQc(]l`JH#_tٶn[L I>|eR2e(G׀l٬0W@QRI*UTRJ*UTEDL8|6``Ho3bRg,=]0QC ٝ#^Ͽ1pBhPY!L,od0xl7t: ^#tA:d3 FvC,噀qn"[ 1ey`hg>kbm U&9g 6;񼲍>6]*@G:>OF7GVbeql6\.cZ9 "xL|N JSڋ&'6r l_LL&8;;+$v-3HNS򲐻ͦٙms%_6f ܄ 9 ]4̏ gXO`9;Y5777?!z+P0&Pv]mdn;(2 Qʏ rV`0 l,<"DK\r?JW1?Ud@p9+h>3S E`=,Zș8ivv1 믿cZgl?m \2ϚOI%>tDx<ۢ\sˁ7 kvI$mecѓ:t,2h[]RkR&jr9/mŘ9[v!s#g6AG99Ÿ;[ljJW [h3ЫI <-:ʤĺ) "y "K'H z?n[#KiEyd,A&\N?lQ*UTRJ*UT"/>/.J~de9"`jk_y.`I4899I$~?{T 1&ihgGB Pzɠp?E.IfȺv ?ĝXB@cgO&L0߈rh$c i YW rxKlr{8s/`G㱔NqyyY΍rFRwyy߿/:%l "(gZA@̙p?@OtvvpXbB:5s׋f'''r&%9*&XE)72VUL&R։Yrww(_Ÿ\42m!Jߐ''''^2{o&qhR6 l6e>>>Yv`.Qb~?y#A8??^>}*@3p8bQ~|7qrrR$#9}0%԰-F%5g1 B| uqƅlN% ``1)a\uQyxx(Abze BgV: >84N^X㝉.YP&~qUY|o֗(d>''@2񋌿3 XT̘/ˆ7DI<@}s#" 1xB35 =N?SE}ab>ui#u9HkFUTRJ*UTR勉v~| 3 ጡ3/Ś/ξЂ~odGp[áDCm9@3ULtR@ХLt\.79p(ie"t e=x\K/3Hdtp8T3IkcX4jb:gDD9L`P@e<b>^g&`([5 bX(Edv[Δ8;;o ~r,{C0x|9Cʘx\,$T#7\"q* H9dy3_u\\\ 30(tLOOO% a[m~* ҃dac܃rp$v]5g0yR,.2<E,C[0͸RBg?<\KYz 2;r-zbnyv 7߀P%ÈQD@mz}9$ 2"D$`~/ļ횀v?Ϡ/gfOYluș)<>\1A{qB{q})g_y;NVX`2=">dt)]6d҈s0&`X#;ޅs{zz*m+\$=-k3L_J3lهTҨJ*UTRJ*U|D6 6L|$K"7\|N>ۃNOO bP%LY&D8s,$v[FFv ``#nl6x||lJ1Jh`<ۄA,@3(WUz \gqf hblX@%[8y ؓ Xa2A䳧FQsB Rob=QtƹA\OR7?^ E)FuǸkI땳C2<@n8;;k@ (3XBg9AEG-Y$ d :g懇X,bZ9FV E2e%\m!Va\]]52+x}Q &Ic9;a^Y)^Oc>n7...b\z8==ۈx!"gNqiuXh4.YC8b0Ǐ˳ű>3t8;bYǒFd2l>}~鴴[du\.KW>{dD4d2 ;s^)JiD[M ?>$ YK.مn-W'''X,1ݻw\t:^Kl6+MY'E/` X;79;Ȥ= ,&J`+/e!7˚& 09u${6(g#yͅ&xgߗ}{8<6M)RR L&2:??oMo@ 3w (y_y~~˲90 RI*UTRJ*UTE"HwBDC ‘>7 5GtHR??g59 p8c~8Կ5&{#^W `?Y#~@Y9K ]3,á'H. @,F@[c! (pt<|mep &I}z7vc`{ RNb^B`F!Kj99y92' v.82fYK~p1h`{69l?U8x'lɂ\k_2Ϙ?q5X.!U 'FI9ĬhKCĂ 7Yl H._Bgnt:m n+Exݮ1Ch2!%fYDDl6+;D#eߙTv2~&qI7.!YsdM_Rz9LcSʙS!WY"\A3X߽na>+(ۺ3Y!Q'''ncەL]8/`XK^) .Nl봃2L2Ns3N/?rld0T2w "x{y/I dy5ls]d$Rn[2aK|.i?zrVFUTRJ*UTRH>dž m=Mp-$ZlxrYKZJ5Yeˀ It&LLqT3p .tgZ_)tbZMt:o#ø DDϐ5A&%DdD4H D;EnP6T,8Q`ʘ EWκQ1 cE/1@ L)Q}! Eh'0z2gϥk &) x<5y y~"^AqLb&loKyَ=^׶E;n0HqJ֓R d">Gס71^C}86K`:nXhH \ @܀_ABLO@gfbR/ S{<7Re;yʤ59]~zN[! ;g3ϘY]Cy9P ݛ|ጫP2j )#eyCynqo>=ovθbGƄymBˁ$/ h~ғd}͋ѯ&LLlog?F/d֋K(N&2c`"{v"[26g...J ?/4R/^r_9[AͯOKވGTRJ*U9 /ĀmpD=_sID4ʽvRQ Y uM{1ý_ױZ<%H1N#mv"2~ IDAT߹d!QDU& FXVcDDW)؆ha)J$tA9䊉Hk`逪g`'$@o4ύFa@`#1xsGb X2#3i0&"q.m'"`y';ⅴNS rv3]{#2u-/Ya!^_8❌(٬q. e&c3ab?Y|}ha>!%3pLɒǡɦdb{3al*g `ߐ785y_#^(LG_ |>>A8v2VU!)ILFeJNu:R!{<Bad{wY@|֐cb yLF!!sc)=\쓾3_Mk"˥r-k]CC L0Ϋժ^3p t~, GYX{\K)'̾W9pL,?w{=גgkl۟lsJg^z~nR+iT_d(q%L !_B$W/}> JURJ*U7 @I0AKea@k !e lt:L"dP̠)Kx;rt\xfg  tcbć yoj%Le2ѫ \  M^q 퀴#}tÏ>)ɑq/%}^vn/G~/Ql8z~&2eR9h{2 rV:|p>a,m>.l Xnˠ#! @Cp3:H-uh&Ȃ4n )Ff}톴†Lx~/ |<˼r7??Jtksvu)?Uj*q !2dǏd\A07Oڏ8.}3Mz 2 xZx9#(c)~d0\.cv-Yo>syoB//Ktv;kqq}C; Myn9# }#n'.<>ס#^l hl xOY,}NŢ>ժL"`lK(uKT$:x=svDnk zs֗mg2upnxD|I=={au֤C&ݟ,ytdً=q 9>ˊx=5 N`#L6{Y:t;51=(y!(wg_M&/qDM2akQ*F:/{{x]J*UT8 9Zp3yG Di0, }5 6(`"A}GCZQy#}OOO!$a GŀJ6c0䒉B6.R&c xk2 cƘNgB iP`d t> J_u4l6+x,Lp(zf)wFA@13(Hsuv =al 7NFG?, ̛NSJ*19K=Qv b65bZsXg 6H~1Bg<$~3iAgDD/FL<>>|>/n+qww5oRJ@Vv@Nl`idR2 v {~~?>\ϟt:z-Dh4*v< s 2mB&BPzzzZLt3^L3؎ {g%Xg2Ť|} ݮL2]1f?EX)Y+1B۰:įwH&ѯKy|n s6~Ҥxb({,f㤾/\cR'}ksFUx|(?Vd@ߺ~^E#%ɘ?G/w5x#PJ*UTAur>eq.I0Q{IDnrM\^^@x .Ҿp>}nn+zxxh,  00xChID=.@,]G^2ɠ,z3k@E"#B 1.&)gQWr4S\__d2ͦغuf@,sy@=+f~as jUMG_,gN@0PFL`IWG3'|H8O[r >LNs|}3098L9:>sr6 b@}-fB,U/N'VU|oG="![ߏv[@|0zN!p!A!)õ.ucNzssSldG?d2ĔP| 0/ѝZHƚc)j?e ʤD/6oX\rI6N{!&E ql}ϣlȢ;^S#㉎y?on&7/l=b~!gy|g6r/jm6~zd-FG= "`-4R/T(|XIc~y`e3Ǝ(=/y2qfu|w J*UTRpNK+?uXnh} 4sP kt7>l)GA@P 7{^B `l#pl33PvyS?;k&H1~J|d"ߏ l" бA!9<"`$e2dK5cɠ'勰߈(6Μu%3I_9&cx^2 R$9mkHZV1N=dƧn  A 1ui愣l;# :g9 ͙?6Ua:#6v25R##C ?~}O&Ȳ2n2kIPJ oα g\FQ =c=LX|7g@{"~z6}uh`D3'Dh4*tJuwr0=lr 㱐ۙ;Nl6R&CrDsX)sGSw( J# 4yxxh ZdQ: LQ@PXi_My8Hqi2wwwrpFFlfە1Xij*gu:9Z7{n31ㄯel) @=|wwwX + :+(>:@o> TE#DD6L c7K{ 5m0Pm+sKI2(f[GL^|n%wѨE{lW&qg._IM#=yL݈ײlJw> Klت O /ru6yk虱MN{bRξ l4X oۇ}os0% cKВ ę]k6cҷ6aۅb8e%TH2 [dџYћ ;LoXhp-*[$Z*UTRJW-X *@h GDRZ9CC3½ dFD] tilVdRj;"@ʑ9ˀh?"g|8h*@YNK`Q>X1^ ҟ~__"?/gg ćA&!&'''FG9Q~nҙ(ɭLL]>9u5mB40&Zx<jР!9ބuf2ҔrWȼrOOO\.g^vHl"`SΎݠgpL;K5#p(~zq~~^Xcpo樳v vB{iƐl^;ѷ12cZ09K$}`mʁ:ac~ _{g˝)@!j gR"qqVK~4O\ >3F7aM¦˓ɤMvL߽{>nooc\}x]>bϲTҨJ`Bwۛ#?}= $oy(X(d6J*UTRJPފ]| 6`ƻDq?9\h&L ʰ%Ey(ベ|h)6B? $Sbu*. 8@6h`bXsLӢo~{,yA=gQdQ s:6@X9!@9@h4*vH|spr>-I)[CA~QgY'CDk?,q `0x\ʴq/2jb-;ez(s {NĔhY.zoQȪ\ KS\2wd)AB F?zolߤb>nr77lBֺF8v  3JL"B_6ك2L0OEA,tKHCxޙ"ѨA cl&i`1LJb'5s/с1dh<#<z6cL}2knbQ|i*1vdAyca g0o3z.ak-^7I|$d5Z& Fm39ZWW>jx{@tx%<}%ۦy ̠ޛ'u,ƞc;}FX*iT_ ϑDҟs/ ?8eoΟڈ/s`En$/*UTRaL:lh`'ADqL/c'Šp8ӌ/^GEII^/qԠ {ꞓI&rxxw樓< jW&@|'[R"AIQ0K8HrTדԚB2]Ugsb_"+P3jg(v+QS~QZƄi[ZJ @cvt<\._Ϻ%l~uwww=c#ciپe.2w"C38\a+B4Xv [̋Rv 1/S ;Z2]^..):DߔDOd-K  /&I 6 F9dNv8Y$4p~;0=CIИ6J%BN^왌+Ͷzz {fdns$'a=?z=׉|N7 <_i";rY͈oou˽P򘶡SʠKJRFTl@HR'|LN]4;sBB hD9Lf'YrB!2n߾s`qB1!{ VF<'|z+ V.|6>Rh_._u" _q].ar삯~;y @"`3 d,A\Q t:i䶗&^!>fcl`^o%x 63\gn]ϊؒ4r]30=!N'4~ӒY wr'LhgTZ9?3e^O;;;Edz= if>'.iw"ˇzѽQ?҈?Ềd}u] !@,Ɗ9sk3drO %gB[3WƜ銸tla c:@r6TՂP0ͪ IDAT#"-ۙ&sN@|:1/ieart[Y!|ݠ=If}7BJ'=ycNBВH;'IdAhcĿq_Fj4ACCF~C2nw/:{cnߴ'P(hoo/C9hw<=KFek.ŝ݄EڿmD˺F;BM o7R)R Gڥi׵&:[[owdI&dI&looZ r^w^"6xfG_$x/#^%}]\ /s=mmgŅ Bd31|)\nr9FdS ^J!'`uB=a)8h tb03mLw>AO>K@6M۱%?xoyKRdJ[wdb'~"P.|raغPnNbg# `z%hK5Ţ:N|ǁE3Xw9dnWn7tXV#+ -JysSz5RLn4v;ѳܷ໰T_V9]>m\K;z 'E$#25_ɇ-:Š)QQlZ/`,kw%n"$L2$L2y]`D#鋫t,K 0rLw&r @W!)=Ӂ n:ɖ =QyĶX)wLGWGz_gS?=鄠 fa Ȣq``v['g'Rlx9zIMvmq߃ \)U:sN +2BV֩=z2ׅ,N`c؝gG@-*˪^̢čkہg5x .'|Rbl>eΘ\N0_|-ro8>w|N{N^y{OƁv6.gob_uk#o_'?z/lbbwuyGj4Z.q-)zᤏf'ڜD7l}z >3@8g|'7|NckncSZox?3~CTrQ1p%4;y x8y /IYѯ H84$^Yg*ba+}پ_羊8oZp : 7BNnJeQ&dI&dKq7=oAMw9ղvJ_$5>MNV;`=h6~A;ʉS6u]"M . 9~f 8Hv(m:r:,]vu3|6<9JPDوc:as, 2G~\.AfXT0㎎qM<!)'}^Лq|@$#25uduuw57-.uF!(4$/d8Z6i9L2$L2$L^-%ʉ @-I+ĈG: S;)+u z' /="ĉeا}F#TuL#@=椃~K952p,%֑F> &i9RJ%tr9!XU[RIz=@I'vS(/ExVI GCqn{JP C1v3O3- ` }}RK=E@xב=Ț³{t0π|vFdLHm ;56}qqt˞:s ?X}ȔHC~gviswc/FU.H#C X," u_9?:t1 g8yBϬURQDܓTGݕL;t[eMqydcL_+|}ĵRk } x)MAW2) h?ze+:溣] |9dQ&FW}gݽorurk}~?7C/:p\GeI&dI&dIQ `"/,H'y/eȐdك."yr$`sUGJ KeI ]{T-̲wՀ sNTJ(w`q atuDĦeYgK= ͦժ~)vRr óƞn~qU!=ۅ{5'YƄ 2V{g=NyBo"J9hD8@Nj9J|SӃ gAK<<;- ٺKM>2m~b~Nvz%5I_%e$EA'=| ܯG/m҃k>?y.kBx<^}FCv;Ru "2{ǤnwIxw]dzI 盹~fgm3{ 1/z*T* Qhf C)[侞qE3Go~.#25w}=ouumN)=U_Ө _;g>MޖucdI&dI&'|^ٛ9.s^L4£y=KƁ/~90tEΤp&·lPuvv 5$P,IV, }I^_x>`@P4+ÁD:n`gLnȸ;Q@?Q ‡tS-Sp{pp3`0z c'ygM1qdvqż-sGA s$߇y~³R=IIyd2Y։Q^~TB^Ap̦T8.gظzu"|X/trl$(þigg/$x<;YnOyW'$;fd@n\ƛ c J_WhG\8@BNN9\h4i$ή:;;Ӌ/崻sRbkX$*sI /l=Ss9x{0жsrxBD2W|^J|>Ɛ1v<(%nz'80`qߦÉr'B|ѷǟ9?'9[imŖ@g^pH^҃<Ȥrbi}v dl$sJ Fd;C/ ÷w_|eaJJ#.| X1Wj?L2$L2U<*_X[@J9+ãRPA~|g~t]}yUUj5)k4nyrxxnPbˉ1Qv Hy`iDv;t5ͦ sR'j99V0[I+א]$mTߡd@ ŢjZS#ɦ@'sE؟\%Y:NsF 6 aD_#(L%SSRD[7'xǂc<[K G6慏3ƾ 8|2/iʄg8!^gqPWd':$'s|7/@[=Ǒ) 9e q4iZvT*L&jZ:;;糖8vg/x`)/žn!L-b+~1y_IJԬ#= 2s3k.L{>Gp.SVrfd&O&(Yb'u׳!^ 6ئ`L| F?iHv[xU?:`j VFjZij''bL>N yчg_ƚ7(Șx#%`>H8qC&93N5\.#3l>ڋ'R;BrY+Ėϥuz>e,K:Nm_9QtyyHI_]KI+5n6RdqaJR2#25w^Yzͫ~G^E.)|+%XM)ֺSӍ/ӛfI&dI&|PP l83XU|X,"J3ɥU k;t:UV (d>HBAoɉzX2Xs2>8'IQ|@vCBnn,#8 4#s2ϟ?y6{s0ޣ1?Kq!R: $fB]^^F]gؒۏg8D(t{lnG'0`܏C!k) 7!@Z?gäo6;F- &31S\=0\~RR#nK3=s:f YB90E:p fD6Y@NX}崽;`7jX{fڌ'vϲlN/98?tqqAOSUUmooGOF02B?2{` N\(C⬷Μr"-B:dgMΏF#5͕扯kNbNc<,r}44$\<:rUdPz> t"nJ,yGIHw ~OߜFFeI&dI&gI3$wܼ>G;C9LJR+z gp{RB a4TBL IDAT\NoޞTnv#5"_ AY H: I+`{|>W[`<(ʯh{2hMg8V칥ղ0~aVMޓD^o^, L^Ov;Z4?@&ώ=dA**ygH 2ã/Kee`:766vS({}̼[hG]fap_骤6n2!@  iU n}z6)du6x%99Ch2ghwdɜ$᧱#_TX jڊ@rx;b2Rj5tOyElL3])"6|~lہ` Wt&HF\. nd{5@{n'nh;>ſ%IӒnu2"}ƿ0bQn ǿp6D?%A̖e$`0Pb1n2ʯJ9h >0PR߰p2-`e'h?~2>M{˰'ڊ𬞔E(z៼Lx@?y,vI0R|c-Kʳr+t A^˳H@3nnN8 J acnWI)5 n'R)pTDyd#P.%Rl;%3sYFd6^T hc{i0/ȸ@C2>Dӷ-5:ƟD}Y;Ax1f|߱7WK~X,VtNгr?3֮`⻞Bz;y${6;Ocs'`Ȏ^%FC@^/΍`m s9~z&*#mnGqpD(UVRD cvhenECv H˧:0ddMJc|-6|j6j4AB`RIjU|eD0g9Q MlmmEb(O"{%%@~kxc{96?r2%#2D^E#'^"~_G.«t|"͉bX98Meo7ߔI&dI&d}4*T ƆjZ4;l=vCwvv4999sPJzr*J, jZhrYn7PHv ݾ};>? z<#v{\\!9M۳no+?P9t h{vmSbx nJPo42?~c\,uΝVi4i0!I^&"AM"/ta݃@<vm4z߉1!cNyƑg!IZ!)<헿_pHBwK'66^%l6q?gqOJ29 n 7沃sوd,`W>g;(~6ܠ-Ƙj2+!2?ȴ{֐>և~J́~?Rgժ4ٳŢ\.u~~z-XGGGxYӧB%8hO>D;;;TKp8Գg}@J?كG$`qpv13>8p$wl"@ʌ=mB T] `mrpAzFFd1E}W㏵\.8vz7$ݿ?o8jkkKrrggGF# Z~3b9@/qi46@DHml;E4z CgxD6DbN$`g):A lS^#S+GV $r@`=,us:yggwr2_Sœ !20וt[y$frrsBzr]Ƨ$Ĉ^6m8ژ>ۯæ\t^pB Nu%`y68A081m;'#v" 15[C6N\Mxf$>_ݗLDtf` Hlďvtv*d5 ;ID{nޭT*1 "}ĮbiF7{Nq}?}Lwa$Ģ|'}ܞ ,r~~}b#cr 8eFe?/>|v}g7!f^ErA׮#n" ʿۿwQR666׿|>h<P/Cr98^0so;L2$L2ui)H>R Pd2Q @,^)T.#ZzJ=zOhߺuKz]1nuyyfw}WVK$Hb~O>Dn;/XvvvCJ%5pu3-@ Z, )堦.C19l/4l  6p}D^F lPD?O2>;;ӃC}zq_~׋/#[+l6Ǐ۷o4J!DpХssFJ*J_  {o{cw*z-/}c9`rCd< yX gui:Lr]@\.R9)`?Չi`J,|?z- QZx昿;b[';^7d@>8-N@`t蟻rr0lvCR \3pA:mSj̯#` y쥯$רumf-rBYȁpl))˺qvLҠ `RgjUR)9H`~du5I#eȟKU*{Axr}Az=IRQݎiHIv+s"j{e| eonn~#p;N⯰%τNc='̱ 2' YwG[>74$XpJQw!};7%nrMb~ߺ =zdOj6s?88лᆱvzwyGFC\{{{{'Rá...$]E^fI&dI&|3}psǁ^O~?%_%ADOS9??WV`0X90=vDqJe.S~~8Vq ):>>`D  F:n `j4J @"i{Uy b\|IAWDJFpmρ-ϺwקϞ=_|j:#i_zٙ>#UUPr9JMSZ L(&|q23G;w"#l+nGi:sbߩ<p`MӕsgyV sɉ/ø|!d= <-$%ۃ~QޥUi `W{ȿ&<3 L$? }yԏo/g,Quďס׉rƇ>9)u{@7/սkwBgx+UoِLrH|;iNy AJZO}Cw"9vOA&Cx@zdEd'lxB$x<^YO˥zn>t{'ig9axㄻ˴q[ _ÏzpkGZ}YbdLc'$+E`;D蓯G=NtzFTst⍱tL4$Xp7!ҿK}7%W{W]mdMwO+ؖJ%?t:ٙݮnݺO?J$o:2$L2$LK6?ec}*j6Qv#ڝcʣH(t/U 3jX,VʿLS}:88l6驾Kg?JT2=Qrlse0O)Nj{P^*(SeD{y'') ywUR|s-;%z= Wt5،?ǁn6O#j="ljx&ZO?՗_~Z;w/~ߪP(?֖vwwU,D~>}CxBnRR_|%sDsAW2O~yݾ}[z={I P r y3l92$d( ?Ž=cg!aӐw4!E#cD7just>D/~Ɖx7e'K_y"0R ">TusX_v_~tJJ@Oo۬۔gp[F{|ݎ888~c׳ x6syreتu@ll>zQJ]p-mHу4)~u5N8'<8!i|}N}(t" bP(Zj:\.)\.nRh>#sqle'j zB?OT*Q rziN>cf'{7q̢c/[Z׃Wf8l<2;vfY{Lt.1veJ~;px6+sۉ3_WWIiI&rS:UudѺk]N?I^'7%=Bz]V+'~P$~;-K5 mU*ݹsG\.uxVmI&dI&|_3<8co5z...,^8"^ár5sV7ޞvww'?}jUL_}=zztNxb#M&0_|V*YGGGA8 6jĜvѽp_ ;࢟]8Tlm2Zw^DT/~ =~X}~Ν;яxrݹsGT_}.//uzzsr9}n+S> (c<ZhOrqq-mooKZTvAK{lIGU.v62 s~d&$"hNIP엶ODSZ=MAzzρSq'4rr%%RbʮTG 3   !cV=e<y㙂q|>߇ >2'~YRsF ]}4Ny|7J72Jܱ+g8~4R>aNP=3<3c{&;E~6;~чgG9Yئ`J[V)5Ls}az֡lz>Y|^A*eQ&CMIW<7ۮ\}w݋Kq"Ef6 |^HJ%&>c CU*sNT@oF2$L2$LQ%%(OΎө?Ύ$Dv:r95x9 (6N#u2P(j6:99xoX,@|\BO< b,cѣGjQ`X;Dٌ9b1#q\N~_/^yGGGzbjD- $،F#mn^AZb3yfg"0?Xs9`8H~09V Ch%C rXhgg'lZ~ I31C՞؈l!\dĠ߀[R•1 WÏ\;@uN7t">Ɓn|+R%.̀2;ɷX,†[v:tm؈ .Fp"51Wt:{6{ 7Iݍ{ ihcGN0?k:cl8'l}}sByPW,b+T z+D? ]:}蛤(h4$]3,>{H#'K~Ba%= jkk+qdp?,5q#}ގl5)Ir=moo3L2$L2&@4 Y:n P/K ݻ:??x<Çԑ!e[FQjQt:ճg%Zɓ'z뭷;h<kkkKZM|^GGGz뭷T:??WՊrVnU.WmZ-ݺu+" [KB8x:O˚8yP~o1 4{qMwm <ׁDLГVH'{cPVKtxx7|S|.//h4R>׽{tG^rb];::: 6-bZ@^lJZo~ %/mq%cY8x x-bh %Ht67^?# (G~AB% Kj5 39?^>. 泓<#WSN4ҍ) l3/@LT= Iltbgc2{4u#iZ*hRF?{곝;y3c$TxsǚhrN̹x6z|l&mo؏cEn7 n'%z5~&Hp'fu5%xzH#>s?sgaD1L¿ycD^B=@pҊz5Nt&ς'{6 }p_ >e=}gz?x|ry]9 [^ns}mda 182g%HFek,rwa_EU$ҫӿ /$j <=ӧzq,-Iv[b1"RJO&dI&dE"ҵF9 Y. {ァǏNOO_J?֓'O(t||`}-K}F#=z(!X,trrz=mmm?TVV>Sݿ_o 5 T*;w+弈Zx9T* :;;d2ndIu:xBTZMfSzyDwxvQj=\Bq@R}™+  ѺNB;V(h4h4tyyr`% <mp-ϫR)#ɥH|Jq텼QFmww7We5Mj5;}?\.{}ojZrvRV 3S?? j87Nh4\.uqqh4 [ER)=?~_RY-U*Y<a^1_ )H45݀e:$O,$lpw2I'zjZUr94%9\*4WH(Fd:5eNNNbvḘ\.|nǴ"Coܗv27K27 s Sl 'T]*4 4ʹ0vM C?j1 #~rAj'=θU*mrY"1 [ \xe|16Ĝg!dC*4 F#rSvоZNF @`N x$(/~`0h\FN+'A0GV9Od,7J:l⏙+ Xd.bTq Zk/Y^P?#)L&j68?'EENvFdZK\IYmDuXG97zwG H/ID:2/gD?NOOnyomm^hhX{qqiO&dI&dIxQpr{??e岶uvvx"HgϞH|^N'2;֕FzESDr9=yD_u(M;~mZ8 f`CiDrXTٌ{HYUVUV5\]R{Fsa= "JR~m^3ȞD3OPL1HT/7/F@@VBYHt-ƾ} %~UiPDjnlҠv_\2(L^s6oBd/M-|,um& r+dvvvT,[o\_w}WߏRRI~_VKv;eeI&dI&Wᥔ'R(Gz䉺ݮTtxBT*O@z]_~eD zy醔Xn>^/vJO>ս{hj~O>DϞ=SX @%Xv;$v{\.QO>Up8J, YSV%)Jd:  IWSp B.;ܖ9D #@zM^ (\H. QVRh?dQNGn7@?X/^~2Cmnmmi<j]ߍ 5M=\@j5 h;l6#eccCwgr_KvsLBZ_~'; _{mJC| | ,w'1=[[prϬ;N{?kBꇰU?1K W'Ol{6cژV"]Ƒ ->) S A?lˉ'Ĝ|CH@2uޕ_u}vo ltppp^]X,j_d2gl6SV[a=$L2$L2$@B#)|G/g}sUU?nY\,)R##HV+Нkss3{<9g޽ j~[jUv[nW*:??Wd2ٙ?w~ BKT׵Y QVP驺n<4b A qT{J8NY^%]] a凩s ()aD&󛛛Ag/\[,uppKX~e/Г'Os}ך,xBNG8^^^F z @^5ǸQےcOfa~yuw@HE$9 F5 x/cxu @/2\Ȧc|D>-|>1gYnp<? aYG)Y^ #iI&⽎?:hs^uomrS":k|y1A,~bs ?&NNNB>L@nޞWI&dI&d}k%8:88Ѓt}i09+GGGvz7uvvH"8"tR`qXTшmF#}o\.ǏOn1T*2>PB 9'&諓 [J4a^RfS˳k7kxgI 0Wwx/N;јf$3ϚL?coPVWֆt[XkTLd&ϧ+ DVxa㋞ F)JW\Y=.}ag]" {(T2(L^s1|i>ב&}UQ.u}Um_UK:(jϢš|'''*aDJdc&dI&dE$̑j (w~oA!VKnWo>ss=|P.er//ܔ%FT*vz>_x3I2G6#2XHO>`0N`0DF^M,eO>SݺuKP_l65(h[nZם;wl6n絿h6iwwWoV\Vш7@'dHZ)C`%zGlZ^Z-w2gy4=Z{4"JR cLgu~~dOkCnWRFC;;;* QRpLp{issS^OLJEBAϟ?҈6ct4UnjT.osN8MLX @^,?<@ bS ؝2|9Er`.G,ۋRIAA{{) 3q"{,YNrxƀGmAD:rN!蝾{քbLM. Gu{;!>|.C~䉃~_Щg{)G'я~6eJK_g0YH2~3rvNIm `g:Ž`6YX:I<;nE J0_QNJ`2mǃ( |f'vpg1G^3jber ~Ede">x8%|s_/P(D&mjQQ0^!}u}22>>|pDlxib!dQ& " ;tp:r:)UdPJЮ޾ `H$%$%b^E<]3Ν;j+MQt\ݻj4PՊhX`p2$L2$Lo/V8qċ~/>wUWߏBL&Y~"\:zJd4sm:<SVSӉ(l|NeFvww&-ά7,+C0c2s]Ra!z0 ~0 ?o?E0`XaHZvK[RUueVLSos-VKz,@"Iƍ{=g}Ykh4裏tHW5O?ɉZ?4L4Vi8ӧOFvvvt}\ӧOkuuݹsGjZYYQ& k@}0 A?ښ?~ǏGJpfojjH76~tthzrŧpzz^xoF3ݹsGwܑtUV*Pҧ~L&ը{ P(\.B␨ H8T.U(d'@y}ARB.t!Z*?5)H>#A$gS]zVPH|sщ9șJr+ЇN^1pڽi{}7i( IDATm!鄜=yDsޑ`}`r¦^[Ƅ1f\|(' V{"9{jH-)kRl"a>22գЅDHbhM'\GNAh2>뎡OnG+ؐ1[œ4rJ[> b-..V8]]]׮ÁV_&8*ƞg9'J¾.Ҝ#cL?%Qɹ5nFQyZ?L2^GcMeUG]\\mC ~ l::L]3GKDbܖr;]Ħ|WHdOr=9\'xfRl.)iJ*Jo#<\O>mmAL-:N+i_/_ #y=N׻wjuuURIOHe.inJ*J*}myxBǑP@fS:99uyy#ݿ_,Rl6n("_|w}W}HTJ<;;SVf{{{sy%imm-u"NzK}΂h>Ld2VrYvD,--P}zzojww7Ȝfp5:g~l6SRMzxVd2z jxc K//eu=x@ j<GDđ{Da@LҹW=831}<9ͿL&L Ai'=]ˀO'9^^{3Kf'@X?>OM%xaNVh53izD-DH7G917@d :ơT*Emd,'II Xd4-y3o_o>=ځ6UXWJ!蒓AmA_DSSCٔo+lNZPާ"h4}I>HeR%I'$Hf)<?wt}w]&ViggGM)7DE̒ nͽsc|qB'GwL&?N8GD %vyG]rpdxtKG0h$.P)ʒ@&B(8{= ql *2q_=n=mVRZ_|]{ס $}@nI5,K=GWgggq#$}#%zWt>=6"?IN)vk]ߓi6y~'d4J%‚H~~I mޗv$拑/,H 6~qx<|P^~?` Is^myy9~xF?+$ljmm-9lrhV禒J*J*|{uh٬?~>@:<3U*XT*w^-euH\褋h{K崽V%IZ__9^Ozի6!*l.?h2Lx7;[9wbx4D QKKK*˪VZ\\ԫW kth4} cC-)jիWzꕎ" ;;;}û-].5uttu]]]i0D=Ӆmlls[ҽ{T,#K^("h4X,P(h_MKd2QRh4Rrg:jE*Il6h̝gt5[7h aN&9P Is=o;roU2r=ASi;AB28l(v9I8@HKP=20$I/w'%;dzdu@$u ҈DCxBB;9M1"T=ih7lşQRWWWA4m`ܹu֣dw=N3LE͛d 9 x&sv2DAt9\טn5?]G_FHbɖ>{'$cI>77I.9|.{ԛG2c|qQ8k`If+++T*IpO}_@ ^ޗyvˉ#X G^h4i2(JaVVV䦟|mMF2V&3\gܓ0}Ϝ炓fᒒFD|rFf\m6ȟ:Mߥ%5ZRDZŋvݻ\/Bs!iOŵP(Dh ATT*<VO M TRI%TRI&~0Jz9lFc6+ӧկ~bzR^ zִoJŢcﷹ$_L`2~Rh:j8w|W^iooO^OV+jllltqqvKT*sqqR$ @lvj^Gx<`0P ^RjE~2I q1*???p8x<=:F}^+$zz܌: 8P:==gEݿ_APr8}meYmll\&syMmW^ViuuU0ȕeh<kmmMBANGPr:==ԎO`) {tqqV$vvvNN'BrF;d !|'i/lvā$I$B46'В=y6fҒah}>bzuܓKRdpbn2b(]Ğ ﱸ5=UO!.zB[nD+`vݣk6ciV񿓊3dxWc^-,,]wp[\}<RA{0OQ8I+<+IzyBQn#OMw!D<:ã/>UA=ʊVWWu]e2׵r Q 3Ú=){kJٛϦWWWFsD_Y%yAy@ ߏ{!= ]YY _?sdt: jQ;j AQBu? B3Ah44DSPDyH jpY\\HD* OT*_GD.//U׃,E&{=51w :6h(6&(/1ĸ;YC$sh#*Œ/h&bfo5~c" ?Bv:(H_aCID–BDпI0ݣ&w0<>N,2b1uqq@tzh%Hluգ|]"x6Fp=p0]`w=r9/<Ԩ7zd7AcɬCF~C 'Yi+Z]?O!]y>Gwu`H}tQDNN$ ('=,RIr5kf;9sb9ĞS F16w4,'4=r#;̼s[;ul6GɭZ,cq'=%#O'V^R˼#ft֝ߝ5'P~_(k^OJITRNӿs1 ~7sII+Qs\b3|>r?XG?X_o~XȮf92ظf§I4L"V6L,/J*J*'q}{hI ^ݻstttz3y&R AF# ݹs'Tttzz5M5My6?\O>UUVrRRFDLbQL&zuA rG_MS H QT,}QRIt:jnpt:nl6nu~~)~c wRxYIG?ViccC~. RD."(OX̙RZ-@$ag<nBr9 PRj6EںUj93ꢾ}V/^"@~_BA"R\shU(ttrre~Zv[KKK￯w}7NM2'8$|n ss0٣8s9 5LHAA00ӤtQzP±@#;x#<"}F罏0'62>ɚ5Iʳܹx9 8ᠲG@bN0=:`MGz&Ě傄(AȖd$#W]C#H ; du6Kc Nq-:?yot#0]}H1IN Gˡ "z aQ06ңngFR2 'l$QlBޏ!:95N~wO 3 f(A#'LCd'nS! kj\:Cd3p3mÌklGl3n^d{ˌG^rp|")iJ*a`x}!(|pqTnkmm- vPzxPxssSgggjZZ]]C8 Bn6s᫾ª{ R{<9dpJ*J*}{CO^M~޽أMS U*]^^hHRDI7ޫR^ON'Drt:jaaA ~?fx=  Kd_ZZRRʊT*{E_QwPv[ju.*JH?%];InSTVUV/:??WTZՏ~#u]EH5* )1Pf/{j?x[#8#4wr'6"5p|Δ  hs:(#Hl6;ՁLN.oYN, F6fi : ;b ~)I&!$GF$AiΞN9z{;6>C{>4c8{-#~oҷŴI> `^\\h4Ԩw:=??g IDAT1NWls#W|l}c6Ƶ5O@;a;:a-̦Mб%>W\p&`]ڤ']CHO'}Lx/洧=Dw0Y_'=.#] (JbNbksI!&b3'IpIN/Ǝqp pd԰ /v2;G5_'X c-{wDJ=tt%%RI;,!B|%%v/ƕk0xz J(db`txx a'''vj6q8Vl6Sۍ~rr"P ݹs'@zRR'ZMZM'''J!ǤdܹsG?3tvv4o4fSBj5Iv( T**Z[[ӿw."{ Qggcc#vy"³iWOc;=Os|^?qzµG~N`x<"jds'=N=O:,cCgNXG'AgDAKͱ#":$`q肿ɐ>HGrOtsˣ܎{Dܾfwe@:-s'Nzzϭd/;kGJ ʜd5˝'8c]C'| zl}azΤtGQ|{xgim'mNrvv)P?$%RI;.Ϊ} ߿>ӥjIY.//絾>,,,!l6{ΡrwwWTfS+++vAD|(zj<!`vC---Ν;QZ 6, `P@ r[;^J*J*gaiejc}ٙ\//]s AFFO:gggniyyYj*Gdk7a8*˩lF 8;8#ݻwh4zqڊ(x @{J_|Ex?ގ{5Cvo{{ZXNvzz{C}ǑooObQVm:Fڮt4KY[[?\ӧ~W^NtBbZu666t]+4vn( aɚ0tRF#Ƌ/..j6مiD-//0"▗v#}u~VgBFjoqH! rU>==i3脱rAHNd2 g{F /ݐjNTqvrB]"b#m?Eo`NtG3A:i=~ 4V82.Na~ >v2k~8莞6`fAFF pm${m8H~.cJp2V kKJwXcIr߿MLfj6)aT*paV܌W^@:99r9-J&{j5MSj2hccCnWo߾,AnN%=x@Ɯ#s6Ky|!J*J*G!:@ X-//G|3F`h t)CF؟RѾB r\`? x|Hy@xHwtt @ ^:88LFCFCL/^o-@~`@$+ͪnG ˗/p">,*fYmmm ꫯ"]XR{ァz#}*JAq`qq1F3*Y@t'+ͬ7vd} 85dKM2j7uaw} D?c!G'z~nc}2|-Y}>>ג$!uGy$ ! d,c)9Y AsBruydHw8a28eJwXngɟtThțnyDWW9?Ň{^xeYF8poskSqVD.Ӄt~~ޓ,o޼ћ7oT,GI6; 'f29O%TRI%TR Xp8sD׵'SR @iwwWzGr`'֤@av)eǰHK?6yP(STL{{{A@lmA@dNnRo@޼yo~suyyYRIkkkj4ZXXЫW漳~W:<< grrf?~_׿uNF*JBAZMJ%btop8#Dz}M& c< D51VHz;9`DmZn7H`&Cvg2Ź::;;x@BDI'-x{`5s}t2@N8g:iA0$RIskoęi뤛dMt'IWjc 7|!3=qrcpp:8& Q}Nr5NL8a{zg_O_|>xvr~#9LicG;Kj摖r'%-2 t dɔ`NG8?~W61oi'’dk<`o2$XC]},1sߟߓGx5MI7X'IH /s$owǐ>#c{ /_W}ܘCOt=>1R8.)iJ*aIn04J~7XB'I1tIVl6STRp8£G3~ZBArY,kkkۛ;,--$ iۍ F~)ZZZ`0xNOOnI(TvDlllspp) ;&IDpI Inٙr\)+++*˪{nx;uppOO?H1m<{xx^R"( *8C_8h6 Ii'侞憺Y9P=tp WVV-V,]}qHWNΑ')p ]ߝ񳤟'W%vMB5I&' IoGw!ߏ@bd_.OAkNǞba'/<>Fn҉Zƅu g3=u㜼 { ErR֑T*I ժ.//tnUTT(8---ݻё\!ڿЏcf3 ;~gIߔ0J%TRI%TިV^Հ(@jI~eeE=sM&mnnJv[݋rDDL&rH}WVc/K =81~:p xHR I闉6cV+ OrO'y@ЅfZ[[ @ hT*x~)R HjZz>}'jsJ%Jp8f6EZ$\H+u:,k8 Rۦ7f&QRz& N*GpEqjUR1L<ݽ{WV>EƓ5hܓ"...\.G\ac,ʽ# p"7v?G3<:]a<=A:yGy 6B1+iS$$;zz-kIs0̳6[GdWBi<Fp=ȼvpʼn,'IdI8h䜡}^&:øi;96D9d')RbQtRS9$73d̻l6 hmQN&}'x~2k!qn7it;y=Š96]Q' =xGO;"0wEp@3IuVRsgP;uN{?%Fbg Ŝ#sZiVG`S^.)iJ*aqXuɿmZnpi7|`DxRFёuyy;w0rRq % L.NOOU.gAjZrq L&j۱Ї~Ծ񦿒d_BEQ*J*}O)Q+tkoʅ;/sc#D[[[s)|b"?(m|dI-x{ <1 zi' UwRiii)](4TϞ=qB\.ޅAݎ}‚á޼yce2:88Ņ>#U*Z-]]]EVǑ&1z5RМ](|BDOT6K#ܩ `suJ?J^^^Fq^ bhU6{شZȂ+n7U9h$|\鳘+ҵS)*>>;VY+;Ϙ»8Aٱ tw+=tpuzXNr^ ױV!bA7#up6` x<z]nW{{{d...Vi4ZheeEz=UUiqqQ'''r* T*!lhwwWjUkkk絽NNNK=|9ur|鶱H%TRI%TRٙ B 4`o гV Λͦժy={,jV(1 "tHvzz:V99px0(h}}=8 ឵ $@ ]\.<Osg_J%ϟGiT*6OR(ӧ:>>K]myQ;6\rp@2FnHR.#J p-G}bQfS63E L@Q>=^xׯ_kccC+++jZSRÇZj0\S07Kȷxi{nHw7IZ8(Dr 5/P?clwP/ϳG2 >sI0~H7OgAW4쒓DS:@jsX, qꄓ>I]<'_CaG!dz[D `7!8礉Ю7Xԇp# }/@uO}&i)<ƣ)\?i)>!:B[ kX{};Nz.I@R[R3QGVy䑯ن^9Ɯ侞MR;I'hkk5ƱאuN3Nr^)|߅XZZhƓ E^8i8x6gp~wq$핯O4H"Rxd2=dU˾玧L&as("];...j4ž̥sr (l*4(TotouggG_ё٬vvv"ESBZ *J:<<L&u{=DrZ__ kj5j5E{L7N@:0 'niBjZD@bMS=~X?KeY>C?3=yD?+II? "E }<b/ `5N(j*J֊>~/*ռ-|>GP(j^X,4= HB}#l⢶}z=U*?A^]]2 a $!f`Q6 rh:j2]]]۷V:99KUxtt~w*%v:eYmoo?k1cMR˜ej8q>l:"wl`pBbL9o9A H"CoI9E: O4`n,-`gNjym'x»{$ H㩓Ne2*$r{}tqr><;;CBi9G*Ν+X;b`\wxf,Y*)< ; aD өnZ$"[v~~{#OYWG]>t2UMXA;yq]q]桧0Ƒ^N +Ķy)w274J%D$%H8b󴴴'OD76=\)ئil$g Ep,..ơhk}}=6^xy`g=z~6zd2Z[[ O6Vzux"w1*ácc $ #$$_*J*?IN>řLFw￯>L^Օ"q^˗/# " 5]b{r-Tp8wywvvGRϞ= ?>#M&hqqQ=~3}?c]]]~:&>|U Cz=MSemmmi:N!^ skZof2;@= dr* hvofO_pRUT\fh4tjX,ÇZYYѧ~`fO>Dá޽p?gI=z7oגn<<@GptfAT*MSZ-u D`Pp8ȥhLӃxt`=I"f:🌜Y,j-,\jp tu ~ }uzzN~džáNOOͤĢ/..aG3$HkOԮA?y:GrN1Ǽ}]vw1N?`/)H V?X;@%m{ lYo2LDfsGrΡ7(ր :s4pr>F_F% 28k`+XcXa~~O_N+艷 ~?ǣappggŦx>^3XcߗyO[v@J1Z7_4J%ﴸvo/<#*JzM I ^O@F# 6l6sūl믿/L&pqp*Jj6sF<޾}X ٰ\^^Rٳg* sN,*{[?b3?y?J*J*|H6>rï{:0{TUݽ{Wz%`NXh 'ժ...c={LZ__H4,Bt{_=F#xBZM>C_e㞷`}}]<~ߨ~ӟT~_/^P֖@}-..^ZjaaAj5.>wqqQ?ښNNNt/Hy9p?ьH!jI pqqp`ښO~ɟLrY<qeeEFCAA|ᇺ]??NOO^f< #nGHHjbJ%u:MS5Me\;;;oyyYZ__9?3)ętG%Elwq҄tu >*g 10gaHI⁄$ڋ mvНLwy'R ʼn'.WO5xh_p\NAy;I8B2G3O('m=m$#2r]KפN; DCy*dR$߱4O3\t?G j96SN ]}ف{t : =zul6NCzJU)%RI{/I#  kuuU:>>]&~hDJR0Pz]WWWU￯ȁ^*bÉ/=z}W{*U,5Ls-..ɓ'l`;Of4mP*J*]8;,[6a?UsX/_nDhssSv[GGGzADܻwOv[~?4 8l 8r9}e2שvA~`ҒŢj\ RF@⢶'۷pzábk6~lmmMb1ϟt:^H7IrbR.U*f3u:]^^FdRczF޼y)^H7b*ˑn4i6N`l61<̅dL )bDRgggV:??W`0Ɔ2ZT*T#擓ѣ0Q)?~/ 1NN?߁ġFj:99t=ܣ}z$dG.J$Z<'Zdwg¤%ff9yB83z A9Yn\}&C<>OFl{1^:mvHdnL'y&'"OUiMv'tr|~G7Cr>>N.Mƥ~>A5\.k<ϵ IITRs "jkkKkkkjtTVh4vCvBf!fԫW"(ɨi_zweJ%\.S!8 t2huuU+++:>>MޫW4 TDN[>F>mZzeJU Q}IT,*VWW5K-//ljEԆ[qA=/COT gii)<L&z^~Q\1~8Υ;8t0ҁ3 I+7i3L"- g OYXuzz|>rŴ9y\VU,uttQ7De]]]VѣGܜ#IA[8NoA.~v3WpisGC4t5H9_.(No(R2VB!-]} `xG^gOi߼}I߰SNqF>9F?3vPtaޯ{c?n#lEW@* q=]W7@2PL@#9x6cgn)AR#N%#;%$5bc \$Y13s(Mr2^Çy䭓 ߉'}S\F{KN_Nzfw$|~-Ŗ;'{b!a;w 腓;N8\e]K-%`3wiHޑ~E?4JG:Xh"_!.cwg@zJ^]]GwQHIMct('3H8!IO5`.TTBwQ*b)'s|$g}B0/..jmmMF*[~ٙfDPx4FlŢZV}z^,z=6H\NZ-΁m8)oVLF]7, ICR8$ TRI%TRI ܒp4 8245}_|;whFbݮJ$iooO;;;ZYYɉF0p{M`d\.mbL&:::d2ѝ;wt|||k@G'{:5SPNNN"sA^?‚...\.9TgDQ;b4A+:p`H<+++ jtuurjoj4iccC+++qV"u7uժft}uh8*kuuU;;;A8vyy[Cr TNs ^DQ bLa7$4Nl~1t׽& mH#XL5yY6rwd9818IqwtPВZ< r p3'hPh47n9 q'bH/OEߝpbnC6 d6.9A9{~W8~bw N4t>6O !S2}rGRb%3>ܡGy$$B-9'7'< Q{>ϬiEf}$I$9 P(-5ǃa@[kGR47GQ(/nWpNIDl6l6,^I>ƾ!M_D#Gh4J%︰Iro 'oNt"gJ%Ee4H-L;/. ~ CΦhn녁+`aa!~c/T*rp8Tl6ӣGK.(6of|SI%TRI%T_]IptSSΝ;zwVByFJEwni榪j&{Z『g( ml6u~~K&d<Sv:]\\nt_j.ŋzpgϞ6E娧~߽0*tvU,c x~~}\.@p!Jsʊfh43_m_J bH].U(j:A.t78QŭvVfwLGI|5_ $"f[U$k~O9ه#}?{@:K.IA\8a^E*{{{qtt<{ӧONDDpttO>MpjX(:'< 0"hƣ~&:cAXn L i, >ؗ>' ;r/}{z]H[ߌE%+"xG .VU;gt`>'жmk6 2*218Z!F*SdDwO6lE];ҚXvEl#˂>w؁1{߀ϋ"9A@Wm|.U,D,@% u^OyL9v$o[~ M 8 Foqi YDƺndG/";y!D&6_L>{]@_W;`?+HGDXq6Vv"UYDZ4GzM6TH'Uԓ2&c:V gK4Yu~>9l6ԛ8ӏ4}~'|"3=Ӓ b-L@lX OIB:y8 < quu_NƵhħ~Ϟ=Kdgzz. J*J*ĞOz P6X,c/`ϟ?boo/NOO믿Ndؼ{IfڀZ>}UD'ed2o6of= CzZŋfJdEшzN'vvSA@XDgϞeFy% /@a{y^ IiFz]^^FD$|կPl6<9EƬViobPѬڱ6"`;;;qvvĈHi~vo,pѣnqyy2O~RB!WhS28|Gз> a0X,DGg^OAUC:$7P}0_ jRqbpٶDŽ H]_=}mpgV+U`B .'jl Doxf̘oGGt~Um LFDpK͂A'J;v*suL=GG a >ƛvF]U-Y"Gc*'ze*(梻WP zns"юyD*s con?3 '=]'H}CXcl@1e}l6Kx;XpT$;mcݲͱχ}&mZF=CMjLۑp"ij,#Vt9kmۚ4Xr#_= IDAT}D q}\&f@y= Ǐw4.//(fYDbf40З/_`0HZ$yt:X,{^G׋~~?>8<}c)' d|ZYy9*;|sstB2>!>qx5!9bc|&{ed.m\.cXH( 1H;r{GPV"P!!D#1GRF;48˶\}Ld<*:kit&ED xŶݦ$6QrlIJi?ӂI;UIYF[vvЗv vcR9\u>1c2U:g^q)<;|&y)'lWr"*=9aس#lr/z8L<۫^g}g}}Qjwa/^r\'?9BSIzyYdG6@ZEURI%TRI%Al3Pjpk,ʼwvvѣGի8;;O?4(fs" :e Llb2x<.se Q mrvxm v)]3p8Li! S٤u`0Nw`Q^___($qe\F^Oed238Z\oAC2@8ԄuH7ʴZ@5Aʘ|? 7qjFǬ+Y7 9lı {y4HєdMCX?O?v۷)ʪn҈$"9@e5HLInjtfy|˗D@l[6 c>gpvطPn>DZ\.%iKEWR1tb6G}~??uzlR?`3rp+ ׹!7٫s;+tŽy/ds=y^G _4A?:4iu#;S4)HDmoY}6a RE2!F|C8iu;䠨=mL.Ţ5j=pTɽ<5su5dg1nzHd+h>4\#p&dUxa yf.ÒL1|f3Xp`5p#H4AM/-I[.dC zo'e7 A]=R:$:N~:"NDZX,}LϞ=Kǯ7771w]4)&FQtHe9fu<~p& i0;&I48<sD hd0 !/zbQnH|l69Oi>'\r!ƸI0>3Qblh(98=NDRiw䣉60ƣ,\j4vS`郃Nd: F#Db2n~i 888v'z*noo/p=JlRx"?ED$lZųgӧquuRqz?\׹]Ac'9 777qyyj˗1R3<)0&I<{,"ZΝNf&\\ q kgts{V]JC@YtP6zFW9ך#f5or$xtExCtGsby-7&v^;b ^*v)WÏ~Q;W"bk'Uicv"$hwDd}]ˑZ'T~=!ILs <- i˛RePc:& BCSgez}wFD!"*6X.1Lx>V+ 6G%e Ľ h?&Qecs[sh2~m: ҈ut:Mc'a\1>t?RgWCqi+=~iBzJ{pp"fry~n7ut:zi@|h4sr 75e[FTb2("6~/}>u^@{рqׁ:d;6ooZL }0J*J>T4^kz8pH E}ݡf3z^t:Btfo|>Ogݐ֬ xQ>/. <'μ?0; ^>Od^EEJLDg~:nnnTH&#mD6pd2wrcDz.r N ʄ>@IYfwNb G7͘㝌aG˺0(LZ/l}ApƉ=!jfJݼy/62~MЯ_MƘWXj;/5u'͖_@t}wᴟR'iCt4'&m)g " _&^kZ*#u$ u`|cvfg6{!MϣYة62iy}}0ԳmkZrgw1ͻѕfY䔣^/wIǰ2&IoqΘfqxx?NQ4#;N:3n%Z>pP?c66e6Kx{?חYeL_O\,hmo0>' W^?{g.τy%TRI%TRɇ,xzo`&`ׁhZvӺ;[gQbvd16$^6q)xwlRV899V^Ӂxp\Po06M<{,e-`vQg vMݺL uyz(01fi_6:7RAcqK}M;wd_>;}c 2xvS4sJM;ԓ:x`7h$eqt99쏸?τ }J:E)[ N7;eN6E l2q)TF"nskH]3Io{ z))ό5c|7̱Sf9-M?gyZ&}NMNtcсMIzM^ߥt:)MmZyB?>[q$ b8TxeWWWpBKkZ*3ѫ&=LDy-g6xcaqb^nҫ&lHK9'l{sܲ"*=k>769;'ʮ) E&/<ٜza 3cByypI-O\M OeZ%TRI%TRɇ.I#c@5݈yg J%@sNަthMKQ?QE mvooo/nnnRy,&nyuk ".2!.VALi[rxO_X֤16 bcZ5PF9Ŷܤz;ҟl;"jʺA}C^G iTI%x7x.[}?){nnL$/hooo=&/k@ G7e7;4DTRI%TRI%elV zk7sS$w j>GN($n0!B v>(@`mY^f3Z B|a9kܻ5 dPk| A6>$ 4 IDATE S5a])r43Fǜv0...bgg':N"_6`D򸢏XMS'?]E ;Tq;;; @t:pr'O=G$X Ira1Qnb6f]7yǞ?\ Sw $m{B3383a]t3Vi"DD蜣8k[Ls혏o~!ʚ)edl0&#"<'=(s EϹv 2^SdRuM]V+0""cZx>3Y,:,Bd S'''1,\ZF}#kX]|Ey!Sy+=9 ɓ~?...իW͛"Pgw*DjLCHx_@{p&Eі<Rrgg'~Zpm =k4¶:^SZNמ{̺`q8<<<]jZ\.Ӽ9Pv֑-X&GUeA{kZj v&( 2Ѥ]EURL0^X8\ēH214o 6cr=^TzS?c>QyϟowWQ%TRI%TR"xNιxFܧCAf2il6w}QL&8??oYB%"VpZ@v],qxK!fsw~d2))8<$>zg}fqyy8iL&t`7e1ͻmGoMhH]l6ɛd$b{Ofγt#(rL)6M ʠtZG1bXGq=m)7Ϧ!8(>4>`{g&l֘>Ǒ%{{ f" "ڍ8]~ZiG;F<\ym} ;ji?) 9IGD,YzA 1"^CDiCSNIR#"vi^l6x g@nr;>f3v4Bϡ W<@"<6уhsbۍD*KH>nۅv&\G=!lpX,nc>z.̝&poFaZ{$XKEUR{.&16)KNRk+yChFܟDO`D6alPą7ɓX,8>>>(x#@ 6P^'ON@<8tpd<[qxx%Fn7z^wix"F^/aL&VʄPF?F]u t3`z~I+h4b0DDY|1R('9T[ p rJ{ 1"Ҿe9=>Og En(f@Б>p[H+8焠'KN天3ndi)c&v TK@5}:BhtD K R_CG>c,''ZkDm_]OX̴ h0ƀ+e>v*Bsӵl6h|z8¡yˑ>cZƶ G ߐ=s\l61Ӻ"^bH:D#1tih7EDÐ Az!}óH#ӳByVKЗ[19oQ2|^[@@hsb7wX<ΉDGS8'/"{g}YTF=aLL?4ɞ* =y3K\#8C+J*J>D1>S^:57dd>c6{iraXKg}]c9YBHx>z(~,O?i|DZX,"z^a\vwwnG+xB9@!  3ИG%򽽽DdItݘ \.1Lbgg'?~x/˸x1@AH㞶E\'td_ar @UӉJѣGb\Ư8??`P =N A.)ekG9gTp?< W<x$!Y9#:H稆 Xc/g҈2|!7@;&P=!\oOHUcAS$/ަ62~j{fSW )<`N[r~fpj-L\^t_vȳEQo}h4?')=z{1y4\g+&O9mݦyvy^L^Om q0GUjDZ"F"fYpl)iTl{lϩ|Posv*0v٤Ĥ&g3jJ4R`y^MmNvc:Aj= ^m= sqSu!DܶlHJ*y?xa54<}^ܣL$&T{Z2ʤ?w]]TRI%TRI%B|#6:#>AǻטW9~6'''g%f4ŋ/2xUlD:= Snj㣏>nҧ@-sP5vkZmF r!pԅ w*jcɓ'.Ѩ:=VwhGL eG౎ײ,I'7}; aw||"t}u*#2 <|ǘG; HyPk0P#)pLpPLt6 qC l: `5|F9< \:9GxDyH ?d`^Z3^q&=hKDЏ&4ݣQHtj0-[nNq"oB; 6:NKQ nc,-5};b7?q/#'З8T cL: ƞφ^s{{$Mc@brXRN'AVh~Hm]HCH1 6XqQ'^cb}HIF+}0NfYxH/qA*Q)Cxmm0!Ľ6q1(,2"U캝ҕv;9Ђv:D D$uDu;$^0\綧s*ҨJ~@@N7g+{ J*J*ёB$Ї].i+IE{np/S91" +]7GtyGy! 1 ͚N)RT&]oy,"R*0}r݂| @M"4k{L "&1jR0hgʋ~3Fk_x; `מ&)+@}^OgQoXʉ}(DSڞQf32Ϡ D:@z0-f^p5 ygtZߏNS ;N"x6l&Rva53Yq$"^q[  $v']nJf;g &a_h[ҝ_wsd>cS>Gp!\F7!\: iϥ~>d#nmx1qn?!uL5Bi=EbCϿ{DK͙w& v 9ZE鼃M>1\oV5& /3јdrN.eē]I%TRI%TeXȒY&$Xyu}}-y|>o8i\]]ś7o,5(dPgg'^gz}isww7C0H8>Ӕzfyt:`}8/b>'yDy9n0@OHZ7A3}HsV/,?~:a鮯 i|h=rG ʜ0~i<}4ZVsyv"} ~dhVKQ7+%S2րb"N5>9<˞&| ֺ".}Ǖ &J}!L Ҧ,#͹HhgNq)sٰDC62!Ll6K<|ba蹿I&?7/"ͤmLsOD$.W{Ŭ_ y8L9 ԓLJ#iTI%YHc|rs9èyS5>"YrOY{^R`hO >gq TRI%TRI%\ 7V0db>p87oy!jf\ׯn|>toD!AB@{LzՎ>0eq/@=HGۚH"ݯ>cspu]^+:r]fU*/{hm辆ϝ3w4gIn"YVb ͜Fwpq1LBTgDkG0vtcmz'#C`c0aŶ:Mc'} v;"fB~R?iˈ 2GASNcZ''')>6uazg1 m ^vHx#?GЛ$Awww&Bn(_4M;jdN 6 ?2aKԍ0lz;&e|v#5Vyu6ˉ;B?$xNj"sicTJĽSNBzƖj6m>Z*ҨJcJ'eDQN> e/t읃w_~'/F(L^ٛunL&B"*J*J~bI:6xb :gXxeb(F1#|M]2%ֵl֝2﹧́ɒ? 䠎 qxx~?NNNn9Nuىxy&/qB͛7QբvdR8@I,*@ޏGr/_W_}0ׯ }˞oަ<^'v%d2/_&sKD)nQH&&eT^F=V=ǥs܅Pq{wAu6׳bl( b IDATͱSG;N:{w@Պ4XeDL@eēe8_D{y1!S +'mIJ?»r@᡿.y;J*J*܈x7 x<..FJO8MF7{}Z vtw@$#Rs25?d8;;Z\__h4tϟ?zl6$i>Nj/b:&p.>nf7˗/ HMfv:A&Hw2ڍF#z^\]]HiW [ppv KYګ{^d2   8~ųgɓ' tz1N 1h"^YH1ƘË1B>AyIcѓ1ƝP81s hC'TD;|^=SvYyc1a; u@,FD+""m0~du+Pn ; vMH5g <u2e|Qze8)2X`jz VM z8@4ƌuu EezLzlb/8; ZLc/ MPWl6g!vwSZLSO?>?L~)% GVQ%$'o˟W&ڸ9l9Ad}^X= 2/J*J*] Lۣ:^|_ukFo {^P_-ojf-[>6=3"I9 &"^?米swҫW, 6:NicRf 7xI xyH=ŋ899_ X.VaZVmMʸL42@M=F) |ZAC9DDm4@5 sM{{v>РJ*J*CΞH7 z0Kםތ#y+-J9EiYt0ω8hZ)EݎǏjW^EDt:M"~g1 @z,iDܑz=?Q0H9{$ ` oA{l<wb l6)R1X,b6:A =`iV+lz:,!Lء/iH RUuݔ{G$HFQ@rQ[GG͔r,1= =],)u=}}62nooH=#Q Ԛ4qDB?n#_CBFG|3n/ ݎ KVTRI%TRI%|(r~~v;o|gZǬy:l#^ekJ_h93s`HZa:ȣw8==gϞlj9??pZ-rq>gq5QÙEwۘHǩjҭw[消(?l"m\.SQN{{r%)&kZ" l6v#c.e7y{LhDS!R7tD2nHY"Dqqdک!Uɑ Zb=X=~S\Ѧq frLm([7N; s~t:4Δ21O $/@X,)6'~M3) dcԘӶ%|o; A777&A:ۍ9mmڎr0P_t+_1~1i=μml #eR:s-/™@Y~3ios|>d4z]L}붡ׁonnb>YbHJ*ye\E0r(,s"#1 5H@qDdQGDwLg\O+62"ʯXwWCAx'cr{M9N&nxmndONAĵ@~>`^ڀmx\9v }L0Ctg`;a AtBdG>7i yMi18uCAGY*ҨJcašL*xl5!F#y˔o:ࡺ䛜2cSByR_寤J*J* A NBHN9:oAq-9 \~?X8DevbPg ,ׯ_GDZ.5 y݆fN(ϡ_X9Fg䣭׶Sp9 jNM:ec>o6Qԍ~iTI% 6/p#==(;ⷌ4Fߋɍoϴ2©br2be~ϱX|]TRI%TRI%cͫE{i2S2~q"V3yN-bwskbn`B5$uDmbp6qy񘏈pxsv@0hq tqGGGq|| vz].0hQc0ģGLM^tZWW)ҁCnǛ 0f2Pogg'o_KGb,Bv&<%Nڃ 0VBIsNiáڜqjx5Pgؑ! wx3u*'iw7Ic ؀Nߠ<>cl^']k}O3cGd,;\. 򫫫~ { h~2D{ZI}d]gl${k[pipB^WpTHgՈHd$m=.Ch2ZV "#.+}J 'd%I] Ch?]6No6Mb;qP N !=E'CUFt:Xף(˃N~4xͦR$mL c;M$U` G?/"Ry%5Ϟ_AtƑ_DT ׎#+H(=Q65k=r&rL.X(epu>ڊOn+9w.upftwZ\Fac4X<ֈwuœp&E/ԜH>l#RxS F#=7e r[}λʤޕTRI%TRI%X¦k=M9]Og} Y>[6 IDATg9q^{>kbŵps*+4'N=b t(ڀm,X[. v&/N"xfqzzn͛71b2$͛7_i<›3{vanW:\MtbiⶅhAHwd{zN"syf:ܻj{\GL Tw/h=l6֋Qxۜr iTI%/`3AȕY&{lPQs(t~~&LV-BۛW{#'wy_.+\Ew&*J*J*V F܂z&4F#F D<`^ޕ׿pyxzf޼<޼6[Xg 0YT\˟3"L~ b2@{n8<<~n7Floo^9sa8Ƴg2G 㣏>*`v ^6K=Gxb л 8Ń5͒&& rkL@ 8@Ss: 0)dLȚ4lͤsKh{ẍ́I;tcN)g[en1 CK%2Mj`wVe@ mylg=ED 0ɟR&`moU gX/cC#3e3~۔w<~3HzC>:lT8^ԑW1vO'Nq#6MAP^YGv}}]mmm0UG;1k y:J0(#d&q69|>/QSc.B(v{`6vp vD6EDq!a cv0 xٞd{c{uݲ4qǔ$k{jEz݊M&u-7D'LxsY,iTI%/xј7y(2TWm,QYE:/f]VO"6^/ZfN ^y 򢡒J*J*[1Hqʠ׸^_\2Hb e'21U 0O{ϮZo h|Ԡ5?Ri7E{ { n3c2b(D}l6KR+1@t:vjqvvnqFFquusNj]n8Tz~;Y2~xs^LV+@$ql ˑhZ2q}u8*uNep8ղYw71<Ɨu`hX^R>-LC2u>Ygm3oϑ3Sv]*tGH39h[r LbKyI8mp;+M.6f>(4ne `l@9FD!1IZt2ME78: 0>.]܋Xqm{M4eiSIQ&N>Ax̷h`)Um3ȳx}Kdt:-uc|rNv_ߏtZ $(Iynhg (֡k-e'ĞA|OVyYYř(YfXNׂ؇Uľ+n߼nFr9ǻ^qU"*%o2yoe3Oīzw=^- xfCt,/p\VItz_%TRI%TR]AmcI9css&wA "p`׷3pb 578^z:D,,^ Z񸤉jb2xX[[`P.ҝ;wW_^W@dO 0;Gpd`o"UW#0|?`.YoP\h7{h,1dbz6=NKE`F6Ngp{ ;Z@2l6K BG,r2Q5:DQ66Hؑj@6vkXD׋r.I0$;}bBc*`sio; VNx%<ch&ȂlqDƶAp{|agֻ=>Xv,F=?z^֔YEuGŻ<_^eTQ%Ă f/)l9PTld9v; M8(qIruunx=?x𠜑4LJz5ұ5͸(@uh6qppPr$RY_wdhSF&`HvG)Q 8EXܤ| j}Y_1N|A,HѸ~!1Ě/rfD8-zf;vdĶݤ,w^ <zF#8t;fܓ#fyLd6Mg~Y*ҨJ^r Z C>ˆ&qlO,\_L/zyɭJ*J*"$SQl&o jv:Hd\Td 窔d<mowE㚈(a_Kz5ڊF& ܍MhT9 nvZV%>!666b26\$@)@GDneV+n) SERmllD[V>>1 X P`H28N]^|Kw&Z< }OY7PE'mG ^4h_NsF=c~s$g@¾^}ۉT{q bbz4 %rfBb#<~drEt\I*2+ъ.zc{XI&3( "sM,BH28~πv :cXOMv#}n":2~7V97& 2]y}@΀vd m`vyhkW8??/DvtFvwwFD$ l$bss3A9WMІDOhrT~w[Vi = Ҏ~?vvvvlmmEՊ(x;J @^ԏ1\Uye` 3 q$udPVܓgwz>&'cv]v2H:%L9N[0iBܶR>ge>`N6h!c "aIIdQ6]"6p1Rl\d &#+":2e`^_>1幔)3Zq!'yg3.cEvf-lO<8Z8"*w|5oc7>c΄ ױZޱj‚N&{޴ ?[%2巇lcۚϲLJ wJ*J*"X\Ȅ : I۵@At:K ZX4xK}`=Qy%/_c [PH^4,aan7= X )f@>Gۍ8==gϞtkv{)b)@eO@]ZHNq l6["#j8_[[;wDݎFݎbQ. 6sn!{Ȅd{4 ^)F;qbP"癜Bّ #` jq/G0Y :!Ǟ0!sW;t~~m{\J9zԟs5&3= t8ϸ1 }uq{>SPkBt=;v%LKEURK,lڼB'}Kf&; 1t:X,b:FV+4,7/jLxm8|^hwP\UM^< E,{rzmKȋTRI%TRI%/K^;X3:b Xp}}{H&,l]n 8 `eϻ v0pekey]ͻ2 a :tzm' R. XqrrR@Vz=^lŨZgR@||[[[:AHoÝzXhG{SH3YX^ODz'Rz-^кR'qyLjpv%VD TsG p4cZ ` k;i`㈲C.?M=O6O9mal-oLәZ \[,l(ޟ6BHжZ,$0ͧ~9MĂgbQ&}uaL3-7}i9Cven3=P|kHg=Ql]>7Q{|Ÿ49^1"28܌nD,X,nk "Ù=̾''#o& ,'5~rptURI%TRI%|,,8&kb6&{yI'^yם3H`)*)G=Ϛ!{G^\79D!jzdm4Xt:]Hf1c2."Qͩm쏈% 4vb7GsP_{/H6h)R JaQC-@2o>1>!W]ܿq SݤeGhw~\^>QEVkOY5/@mȋ~^n2eb}iNoiGqFcdߙi61ͼ,&&}=m j H e؇1W&( vđ[c[!L'جs-lI{ƽ}`G9#pmm#-&gԁ<Ϛ! =namǹTK`m~D8"z sSv2䵣)h4Jz2 t:6y^ iTI%/^(K|ɳIcc >O}Y4я~7$ik,bXD׋VwލtZo6f^;I%ׯyCoF)~8>>f{>,պ٤p|?AdNs Ϲ}Lr~ um`$>4GQtZݖ6M3(u58FDžHL&ro:ЕndLcPHVMTD^\\,bh`3EC& ۛ!RNi# L-gnK(%$EeH!l|>^Wl%LRF}Nͳ>29 8 IDATr1 ,͵&]v&@G,;ږT}FQCNKѤN>V0]_)vnGՊpd_MŘ`Niw6trglH3\OOO˼@/|v!ʌqO$ unN0'`xg hKh˄JD,9:ǎ1?=&Z]II fl 5MqDgwRڀTƎ#"*l8&R& /`r*;3kkkυP>}}0?~\EǏkcK&xxVRI%TRI%|%;DN:@2c Q3 ^42!K\o`aY`Re娇L$1X³g9u^n G͝vu_7Y>u\k`ʹEYSq:W_Y#m ( =0`k2ҴMoڈ}VOT|f|hM/(AF$0alҘwX_ vqyァh` V;1?# 6zdDN;Ws%556CM&ҎM1 u1g0'PoA}40vݲ-v@ϗ1}}M&]y0pVllw1YA=(#_FPϡ`}]^"bIx{sM9ms+m7LGvhQ"\OL9>Ϙm,2adBr:cbDߩ0SFN8ZdϵJYܛʟQ>ו\QFTRKՊ;wp8,bqsPٌ @'i;eR{7Vix뭷ӧqΝַ>\:D~Ţl܌gϞh4Zڨ-*J*JAi^Ppq DL>>r+umPt}qn nW<<5+ue=*vyy/Cޣ/b~ ' g?׼J5[__/Qʇt #常([٧pAym`Ԡz ZWrR H{}ՋFyM@̀I>#!& 8A?ƻ۶,&{=6=6- ^.bv3Ύ9`gNe04_7A4j;!m xӾAM<<뢉)aƩbqq,ic")cn#EYgRdck=zq<`rg 9GgЎ:J0ϫֽhTI:??xv8Y#$bvt: lv]t_a{Eosc q#q?q^/\X"j< &ezs)a{#l6z48tԤѾ=1t }͉1v5y2;XEzLo&Vg<yHJ*yEӉ'?I1K,b6Eۍ`n|_FDWۋd|I<}4vvv"|/9={ b}}=ipj//(0ty>]m>0!e/:yRfq||qrrݹ00Ȼl:4@2QKbxkg0iݻw܉z&`X"yIcb0{ɑ9(O -gNsƲ@t_` gs^L {GP.]L^nll԰Ct۩%ɺI2Lcf$تuSSj6syo.//}aNcПSLg,̇~#W#n_eNf8 GG3= y~cNd@-%R6"LRf*QFTRvܽ{7E8<<~_B!oʄ|rrn7={WWW?Owމl|%_%r7,{_{N+FF[E,/??+i(_v!¤D}޽8yJ*J*"0Y9rŀxP$ZdA&x*Bt@ (tp0e`O PNӥ5O`UC^qN}XNGg194c?/v)` PL}r:M*p4=?Jy8] MZ_lP#73F@zܳ/q6R-tʠRd3>Cb4} "xv*H'~6aqŞħE 2f 3yo2{jej3`qQX"  !NFF+K`YAfqvs>qO'dP{! ;2sF&p>Es4zjw-!cֹLڏN?c0h4*DDlmm-*2@0h4`PfG1U>gdIb}㻬 {]DH)Ñ~?$Y)ex-~Ήd\zOicpݲGC\m=d„1mbA9[Iָ1# <]}|侥|=ϭ1Ovg\ZFTKՊ{p8xIqloo&ݍ'O{0/G^__wDD?z+vvvӧ 駱[6}W_}lorAp͸sN@aʋ{6?<f2k(| 1җ~aloo//p8 q~~|I+[o--"(J*J*+0gryk/] e#ez7ط*b sNx6׳ލz2u {29@eπ !鲳_җqݥ/t5duN'Cp۞kϗ X3?GX v>:XgFٙг>r56:`\:Aѯo{ftGCĒ @`{lN"#9b ֮KN~E216]Ez&vvv3{Z@I&¨?_5Fs ߩW(Ƹ8U%eFxܸO߇~9҈z^9RQG8좻#xu&t^F< QT8fYNBm>zt:hjۥ-spĥ锈Yi0n;#7> XM̏'ۓbz8σlw}FQ/Gꬭ- +loB6]>vxAg.,?ˑS~FЖvV}.j?;1y6fnCԟ5r湅7sTQ%b*o7ʀ?;;[ +___/QF4A4xAƇ~ɓxA?bZjtkkkqttTAYIg#X`srۋFyl !2)onnd2)0aگZ4899d8fY!>h1N{^|MF᷹=eQ}WGTRI%TRMu j%bu,2d9fxć0I70u9>CAV+;2Ü4d/jL:fgz-!:)Ⱦ_ vvvބ:'M %^>'hOd}c`@q:x<(egOB }lЗ`N&w' ۳}ue4>3XM0Qe=XTl3|~~x7ϕ]jZt:vqFUl6 n^/vvv>qNtBTSL)/;aL&2„s3h&ЫKW}5n ;KDk=`:||e0| NFs>??/Yyn&hVIey>sxo[&Mr/QSXL&eGܒД hȳNSv=<6w0},sǤۖcby%UQ%?&G/xkkvWWWnLn7~{/y|籵Uh4l?я ӧn9}i|ge!v6|^bkk+.//ckk+aM=*0ze:FݎLFxZpn0rQlooG*LV+9Po2~f1wM3J*J*/8;` xtQ  \{r A21Zsr\g{)uB xG.5dԀ EIsͳLQfYRݍ{ݻw.y:bd=e Җv& 9ͤR9 g@1̀mŽ>t_DRN}&4X:N)ϴͤjoUnSJ5ao|&'YHDzCjBdjgpuԘA\+^v5iܮٛyߙ|7#t~yl9϶.7畡['iKG{Y`D`d{nGF&-t:-4 !ԋ{eüQva;me^eRM M{EURK,Qo,~W~1ZxLu%&Ǐ>>(quu}YaDO?4n8;;+6;;;`PӲߏ>(C݋NSIq~~~?qR j4ocinC7TRI%TRI%_\b]q 6y^c=9`0YA;:E,{Ӛ<>m޻G>/H5  %<$ jf txpNL1O?2bB= s "&({.`}.D3І8@ ɻяUgxi7p|}}^P ̈́2{2sisp \vuD0ILt>6YSxF>6I,u?r$=\x.2)$v:p8\"=~IH6"T iyj k:sרֿUq;m36TQ%#-w'?Ilook:F-mm-almmEۍ{|;߉~;&Iٳg~ vW^!3Ncss3FQwD[[[G}z+j }w|NCO yl0ȇޛp/Q3&A(;,۞?3HBbc:ߔ`u 6 $0{gR򚴫j1LʾSDø9 (ac;ypuuU"u47) %ҭԋ DaR>.dAwGS\OaC3a<Fp 1`O~qap2P'8XNý.l5HH oiKB!C777 l6K(Ʀ<56stڶmλ\͘;Iē -m2Fit=xS0񺺺VUnYCٹ˶ghEDIO8z9j 3LԍF`%wD5ʩ g3ganq\嬲x\p>ۉr1hƖd,"s'Ob4ZfWD1Wb{#tG.zmHx#sy.6iys ΀Ah{]4 @f@!o0<#4n6!cpdۀvJLNCkheBݠmۍ^WXK4̈́ptuh>Md6Ic8c d{]7Mu8_i{M!s8SDt[" ?-y]h,q2t"|^:%e0p8,xl6+$M|2tDN~?jZIT^qzzWWWcPA&Sp^/|>%=h6j? +ٮp#>lwَ'_| }^c]x!#HV=W{\نzmS^;9b|n'th5ctl6c:jjb{{;FQz8::sN[Q/n"vI޽{h4*g~i\__oo h4r-(dvB}B4xZO>$cww7NOOcsss)v?__7x,~ TQ%TRI%TE0>lh@ :^r ߛ[BğfՀ9MS2( STI6J l++D/km;ERQV< ™!^|^ʹQRŬ!px>Vq?ALʡ}(bq`M^!?} qhƞ>f2^{ܘt 3aσs45NS;21cŸC ߴAj?`mԙzL=pyALig^ߤclLA촫=!6b>bn۶BMӒzbe9]{u'W{9u%Ǡ3fN4zX, ;y@#y7Y_@iшt l6+(Ny C`0i;9a[j}7]#Ǥ|l 62`8KGo>D>RaCǸfc.n%;s=:j$5,@[Vr6zg;,('k/QԩHFDaI}H_>{I"-+F!(!z(mD:lϰOxowݲ8??ol6ѣGg% fķ8::}lnnAh۱pųF1Lݻj$NOOItsɤ,܎ ; ʄW׾xq%o(J*J*y>5][MP.9_=_6ƀxp[/bQHxCZ9U`uAʤH!-2g7<{,Ft:L% evh6"bLt:%*PonGRKǝ;wΝ;t amy$^&= ںhh4*{`P3Jk1gOr_C,{3. ptL&}LjgƐ$ϋ%@Gzp;=.w0]E {|26qଷ;ԃ{r1ĵQzL! ",uȿQ;l=vs^u&&v텝O=Y!a=08ߎ}(a&?c-"s(!go @D\8;;+kJΉ,o4%\sŘSHHfG_Bh7n'l=VNs jA_5`+\:e爾wnlK5Gk&=m#Gf\kF~ >>}٩LVѶNEinyĺG`x}mcb^ gfYFT ?~F<~8fYA<}hTB89x!vF#޽[66X42 xa?N1 wމ> տW~?|͘L&Q?xqwW+؈~ON'΢KZ:#,y2N8B:c}}=z^O~ۿ^J%TRI%TRY vxTx33Pt oVlq`v֬l! vbkky/UףjoC0X/yZGۍv%S3 J\f'6B2pk,j_g`;hs ! e}X D˃^0ohW"2GG")b]m>v 8dz=`2hK@wmiuY1=Lv{Q~p>\\d-sj<~̩ (;'D `~&e5qkm`6괝NSƜU!}B;??h'''1 tvv%5NoڋH ru&LGuzh8bLحhkO_d3QONkƳf\thnW؎qh?]uݱ.;Ѻ=]y=fR5:,,ik"[8bۋ]GɌ/G?3G9d#v.i֌L~"iTI%/hz#"ɓ'{{khtM$a"NNN GEDx>8=='Ǐz=NNNuݎhۥ\?u㿵U)jLӲdwy(F믿^uxg ;6D8URٌߏh 8pl vԄCNDD}˙;t@5<;01LZo^?X<8zClIcs^[1fz6lVpCR.|t۳V5f) 5m繜Q~l$GQ~c'}M?;r΄,m)D-kRRFT ;ğɟć~fo͹qrr뱷 q|ߎ??7|3}w܉?.//ɓ'n >}Ѩ\܌ݸMY9䑜=~!#g%TRI%TRIb 3|^h^BFh4nҽgc# ,vvVu֪Q@zK@`gvggge]:jE.{) `F+3Rfy HN'vvvݻK :0`w֧oo1Ȫ- ȠIX0C(tO('^|D9 yq Pp X>+bNN <*HjHН)d@u #gp-iLd]+`2'^_8s9 ĺj͠\GIa)ьkh/mqϘhO&FMp.AaRaQo\ 4]Ѭ$#Pn iG#zhccv0&58rrVb㖶) 59mcXX1fߤ#pYǚyINР]o?_ڔjȆv]6Iy.&kc1ni37&NZ]륝9‚鴌!-DufC@B1.|UtgX1A>_kBvB%2DF"*%~0Y|>voV^7:dlȓ@-o_ooy?~w~'ū^/;o %Ç(>}Z/= 1|7TRI%TRI%_aSQcG 8": ڻx^/vU!ƞwR?>rV lK@7bzvz^o)`҈v  3 X#3ac"w X&D?uyΞ^wܤI¢ 2x 1cvg̞Iu Yp9 -j ݆+sSD6yIJ&,&}M1dDm/ >,4  A"3m p}e<qe4C*lU W &vl[gY.e6Ӕ>0xn}x·map/~ʌlD L (= 3Ω&)L*ZvˑB1tmV+~,/y㖶vDe+&[R$ckUD&xd,R2ېئʏ>zr}F9r{$1NMl{,|0L>femBY&3& x>)Ou?eTw76goZc4Av4 HK֑O c5@ 4XX|GNWWW1__Ǔ'Ob2,M̞(t:13ȃFDދ8<wt:<ڀ\g'zX@f`/^gϴ;1ڃ:n{h}ez xd11)f=;;}q;vYe_o";N|D*Fێ^۹ l'k=;]66D73qMcGٮA%6 IDATqp[LR"*ҨJ^jY[[+pX$1Lbww7~W~%Ϳ7qlM뷇 :"}ݸg1bXZ_b8_?hZ0.//coo/vww_/^dWWWկ~5=zT q~~^/z^pF8>>ڊ]_r³B(ap 048<gµZ%vvj( 8&Z+ޠɓz%@fw*khpp޿[[[q/OxHu@@ b`/`2b3Uq䢭 ~9튣bP2.. i@$*ϲؓubXre>5pk0\^~ާ$t,C tQGߌcۏ&F2Y \*4q9[&=]c|D6?3K\4N]96q+=ZE?rI#/ov =8"u9LTҖ h?54&DQQo$tenr9;6b<h4*vl6EV[rpz1%w$d2)$$Q.gD_D,א3A˘.wD"b(2Ih:L#;2>[E')Q ǀB?nާLAq219E@?[EYiW^s6*Rg t<{bss3qY _^^toLa}7 crfX,?/m?~_qDh4Vf3>+iB aBB ew*UwQzN>lv#*98q~qIsrr0&I\\\&P&2v+m7K;Ib> +g_F/liR16k/s&+͝ Be2$")""aw*˕-ǼMJ1nȌ @|ޏIP{&RWSdL<>~{ݏ&r=CDzعN=uK9I'ƘӮ r[2cky`rs1{_EJ S&>Ym8 %թJ{ny&2Ͽ\LZSfSTfM"CL&y51 N6 GrD+kJ\08mG${;$̙7ÆuQ_^LSLHx=tg& =lm^߼.xuԤWaNoH %s;,>G^!k$dR-2xg?6 Zq2d2jBݤ!1=s.G5dsذe(2qw)40aI6Lʆ33͊QvV(~x8,y.Yp@;iwz0qɟIfxQٟYE'?ۿ??/":noo//?qg?+cKTz嬢fJx'''н:NOOիWqvvVx^{YwFSxdARK-RK-+@vC8Ax>rz40f؉gs^W? `Y FѩjTK\7b2D׋dRW Pnp8dŽ2D~\__~w>xCcKĞrGX\Zrɀ o @e CVtJAyg{k({Vȟ6ȹcA~~ImtToWngx2b@@ ˆr&r!fMY{eژmtfS0mkSs=ٟZmG_ƹ)s2i);>GcsO'IBVp:&_sRY+ e XV\.jnۘNeV,78YA -tK?3_Ip8tZ C[9]Zd@aFF\__l6{mݥ,SOͦI~~|fkvzO:yu,5st3)%M&G׀&ϳ}FD%qD|_blu</) eo( fЋ} ~Eg \샐a٬e0٤m6E=!{8t::L,t<_x^ڮ3bCnMXL~_ٞ;GGGx3\Ȟ0㇌P'D.29o]w ?mFNfON;FA>3=M]y6Q/ې/%ўLw$Br5|1Xnd2))(kRye¼sANFǑqc!HT0b<aق?&) b;O_V/]}z,lq̡x#T+Gࡳq5m "k/=`r&ێL6N[zh{ 9Fn:!\oX2ىݎ?&BW` @n^ΔLd-O;ulU. t ~,"u>s hƠV6cAY׍q\ LDD#@~_j[wYy*cDwGDcZ69@=Idowׇy3svB|9#=>q~~?NSFonnF_K-RK-.pqzzZ;FlnkltE]w2q PF&s6!l\cs4696EGPo{VG4fCi{I#E,=AQ~ű3,G!/_&dqI,`ӏ1Dhdz0WyDd,'8dD0k"*9b]羶̙TC;Nl=YtIJR[]rbn%I|AZoٳ/?y$9na·cs::M {#P8t}YL&uqqw"!?ik|,,:1N ELx⣏>?8v_U\]]){xQ-RK-Rˁ(rlf|3A6ʤM3mtH} (_<@h&1fi,FF<Q|Qm;Q'.7@tڐ;2Rx񢀉Ds6u邸sz!{;=Ƙ#xy r-@5{BK63`H A)-I ,m0lo&LvHht }'~zOh@l/!"2~8eN .;e9҉g_(X]dr>50ok9ey&l? B[9L`Yzon w]9kZ 3u lqev.rAa&GYƳYC[]7d G}Y!i?x8dmdS'yτD;n8(; ,vj?kf|1vQw'ql~"C&&##L~soNKɜdl7ʟ/t>p_x{~xݳMw!?mrkEXjB5 9a^1H5L ?ȰpvFz) -gJR[\X0IpwwWYo|m9$~_F)1ٳg+e󆑏ݯ*...>OƓ'O"*^|Y ϟǏ~*EmjRfqvv/?A!u6fZb>z.%b {! <~C)gZjZj]/#sFތ8ap<"v+ŀ| Gpxh78xQ"` &S7O6v?F R/8 'wkT/@ϰl9'}4눞UxhZ`Y_YPOM֥NibϱND&D|=2r}ygdyN܀ԏ4};Oo ?E7ƄL(gBO<Ji/z Rb{pL&{0OGR<͎ 9 4o׸UgZEԡme49_u/ur ܬv a^1n8X^"kun^Si[/Œ'crtĶqm6Nyg#~wԵm_&+iTK-oqiX,ի~77 ÔܧO~dƫȭDy >99G1N[V|1cZ_V+>_"~_V+(t(yЛǗ_~/㫯y E( QRX^]]CѣG[['FBr5sx<.Ɨ7xSK-RK-tq04vz2q9 `r)&M&TveS&daytm0!- ;9 0I ؃qp=VIl;BuM IDAT\cxZ5(" Q@}|Ɗ!{@c <^Y7v &W ,+su=3y PE:GLf!N# 3OIž{4D-!&3M} >Ą *{2J ~ˎ ^dn+rvsnDj _&cˈyk:B8:LCںrYAQF]^^6~#5!moN9-cvЀ<&:Ȅ*uwpȅ ~1}@6CLl3LlGn/Ĵ`e 7Nyc2>۩xxqx"˸z?EՊ'Or}/NNN?Ay-(Y{:VwFn|A|~(nnnqnK;,^h*iTK-RK-k)D æ1::NoܜJ#GL`dnO07@YuqV h4 Wg Pc~Ķ,cu<34+޾u7Hduqd e>[Vo \/{4Dnkጛ 6{]Ns~?L0A>cb@d#3x7cX4x&"죝{c~l&91L?[G >0 ;ϭgp?^\wNQb7xh?Z1n2QkV& z4~?cߗ\/Ӻ8Dm66@^8,7p&=٬KLO<At;2 &Me҈8<4= Vv C&cEGVU9v|p37=?h_ߎF,y=,xg[q{þpͩ<}m21;Ч;ƆeBXۡm_gB]N4?S'?)v3BBa' $(d+90yNwO?jb^t:v]BQ_F׋?0͏>(={V; noTE`/5+WNeGD /^z/_FӉO?4 "">xEZNŘZ#锳<&RK-RK-b!ݎSym:p.ӹ琓6& D0`;!k#mn4tZlLrDD1HА#i 2g!dgMk!a!\c\Hm }.`%';F xEB2aA) ?#=2u(<6Jnoo>iLb\( :{tj1J&sWIv]-)d8Vs9je鹓NQ nz5{S7=2Qg2Dg#^ Yݟ s{=;;ó7rlDOe9yY񸬉gggE7@艼~xXIʘyc\YSIcXS'e-8r$܆b`0g,a; rY@M/@oz^L/k `<}8Ot22L=؎ٮ͓I&k&7HhcIr!^q:FETҨZ¤vwXl,a85 7N^/>8aX`hsx6e`PwfmL&Sc4Boooϱ#6J֝8 ||WWW%,.~?z^9s. ^rjRv:F{= JZn[@Ft#N %xk WѬLZ[y:&^Lp-0ڟ .q'gad҈yzY>vxVQx't:- |>RNjO4曘L&l IchD"ay,<(nBY4bߗ ,@8;Wu`;/~ȭ;ꙟCQ-bCdrûovިXIi vBɤl"<0=zӟ46Mfc2/ŀ; ޻FWrX3?$~e%)jZjZؓ~6{2" vi>M`戃G<3𽁓i 2-M:1ˠy6E&nn;6|D4hxLyuQ }Rl|AP$3 T-LQW¾64h]SN_CWsD#(9Xs[C5(c/{L3{Sd#څ@|nӽ(3g}]Ǣ`2ibt{G41ގKo0BrZa@/}b {C?M& 72ep mhzZ 2z#0=N )@zC4b~7G.{1Ч+˸AH=&-=ϩ3.D*N"tuY1b~ Do Y'~y })`ϕ1ɻ벷.ժAlr=)ԆaL&2ƎDrDF5L: W=chl:܄8Ѥ#G 蕝p2q*u{"X O0~(ԋ,6 LH/EK%ϧm*XP~8>ȣ.DPӞۀ2kD`)ߠ"DiD${rBVUyz.4hCv)&*>dрdžq1YYap8l}zzZX,4q9@:ԝxnC׀1nD񑕇B@B92'ia<9:뻉!:>Oc\bWWW-8bxM1vFw[-/(53qncX\.8:" 2&d'8rֳCf39nj5'5у,0n|9Bgq@;^[1ƻ5pf777c4~_eJ%j-.,^,6PXkpw,>ك5BMSbP/ ¡7M\^^&6ɤ,gggvlX0"(/\<\0Nay'[!eED&i<jjxoccZjZj]+Ds|V3{:M;#v؝a2df{稁\?e2J\nu6~Ld'({ g&Ym8[ z=DmWt1 k I#5m~dz1 Fj YN;kSNMs4!v:z= yeF!Ƃ7(d XUB<ʱ MJ(m%0 n*6M{}`ԯt/(6{e!,Nlx!h mP1@1XQK-RK-.<M1iai `~N?pNE6T1cp e0٠ γui3Lp 3k.{MANc awSOÀDŽ ԛ^sBιDL\v\^9R 4~;M@>mO&l8P09`ftt=; 9:Ųgq\&bDcr ALѯvW;IJ~%^Od:DY.M4DC';&0?,8 BZO!:y<;ĤNZc}xC2y3QSECMP?uت[u:.3]^qpߏچeA:RIZjy cG43uj㎗cH¨g!I"d8#qp~i[$o2݇٬/\(g$+w{X!ɡ,$Vaϳw^ɣZjZj]+ؗ&lI&&"}4\Mj`:"" Atca9 d0 :r _k(| `7ǑQey<lؼ {)\GA)0׉k ^E?h?8hDz}D /|+ra7}zdYdPQĽz{@[lP2 oyܱy< [Mʺ%29# N}3:č=g⇽̝y|ΐn_Od]9SǎCBd@?cr} u'c<^,06#L~y}cw r ]9bݘ]ֱև1~vnHncӥFrQyb őE> % ofeބ{#V:>}[1~bL&F9R(VUr:a~;ʼnZjZjyЈC[ l e"!o)_,{Yc0v.u.c@ñ6g;qL"e&C4:x\ S/b:H~|M7_=^?BquuU4d8rf0xlD z`0l"ę LuB6D/η~_x^FDpBkyHJ7\...wcfu <6swQ-ŅH"~z%?L6B8 ~qh(Cn"?- jNe}iBa<d$d q{{[ugs( d5WK-RK-RK;Qܛ|gy~ԋ8> ]2Px6w<0B(s@#M gG, k=~N6 d0y1ls=@ ;aF{AÛ!hDA%vypyWxͳr1NxX{䑁b`/~}eꓣLR2. ;R}mD4j Oyb?<\ hzOehL!ˑyY@MHBkp}Ho~~~?nnn"n󲟇hv:NŋXV1Lb= D^˼q1F&hv]"8WyⳀHӆ8ZK;2N!x3F|>`m\pѨDvAt lnWu8m5H(cCxW-цRqD"G.1>&\lGzd񰎃4BV-+Yq!3TM8gkXx<.rxxB0X#GDi3b<uaM7W|j=a9o#ZjZjwdAin7d3l嘇6p{Pś|:Vw0hB 4d)?rp j'_gϠqul`PjޠmqO RO`vsvm2W ʼ (31 fO{Q#vLȂL i3b+;ȋQ>ߖqi K&j=5K}mD#XvƖT~/,@<[.i g"hA"8Fw|777`U2^y464GPߐIH.R&cM1^ ~#L\ Gz\gR Y[,X,˗űs4n& -?g 9nbhT̶2`}ⳕL#cz^w]I13x a0iHI] sd rlon2y49A,4^/L,x<}6bX4Apcx0?l1s{F1#"c|+imfR|z(ZvacmԘ.s#6,JR[\0xv EzcjK&F+^YYbxhKͦŘO>'O|>/ YZF~МuBk>b\m: ڣ!{yd ZjZjyW}], Cd[D{"oy=ٽ ?Ǧ;-l1w\ 2ț \+;خY}hp 2׃L}vʤWN>gZ@~`{k=v=φO˚g0uZvg˘2Hh'<|}srO&GuϝtY)o˵~A(>m4HI5Lv:2"2 xO;!=.a<r pRhK&ez2l6+}iL1N;M,o=-bQH&2Ds:KC(cpDemqt2)d1=&o<12b 먣 t2_+@ZQG$<0Pߙ고h'rJ;>}-el$Ϧ^`.9fD%j.(9/,xQ r #*RDY12b()x>3ѣL&<{IIkj#cn[ / eJŤ7%6fK-RK-RK-f7{46y2т jH]vF4@ |-F& (SK?c25;k9􈈒n ;@XP3xAnX, `5͊ =KIi.$˅K @Ȟf4;p A=_6\z^µ|}!Erlr{`=~άg& yx?go|}eqDE 2~.4wrr_l2FכMoG't3z*cԑqv4u&}gYv3F Z1/Wi2X,||֏^/q/Wv:>-&/&2 t^_Qgyl6quu6&I g`]#f(>4Hd}ؘl&y[Zcvy\0^>GL*mryG&!(vB_M*{ >LjgRl(2NpLx28Hjz,ӲMYR?s;Pdz;9RIZjyC(x!_`0(߱0b,Te N'^zUz:"Ee"@I?y$?~\2{MCE{zzؐ̏-d x!6MFƢ LaCb BaZjZjw`3LHlt|ww%bb:fM1oP!3#Lbϣd^=,? &=C>'4{t#+8nbs$B)bo_\?ʳ7c@R}id ,!&n2 _e -rMqv]D4Ζ>&1 㱵^˱HґC1j%=2ߖ%ҏn琝#ϱ> XH[{ 9s1p =;;kDxNǐպwAq(}^0?p?rD4j4l1>v1 )=oM&H<6\.j  5@q }F'٬=Kr,s>z<$c~d i"g3/Gv*gWv`0|Ev#G{=mǸI|`wҠED.L25~<κLcLon^#;9-"޳lJ>;4y}<}tI;B& .:F>{L:z7cZE׋hԈrQ-ŅINDS1@%tEmcߗPxX0\Q,Q n=xqcX.%LbQ1;qX#[9Em,x܈ |27vm,4>M~o jb udm=AmxTҨZ~J6"]%$?,,xFa"3Dʈv]0>b0eqql/$+MG`HޖH%/oކacsB`{ Jڈx'b(^K-RK-.z>o'8@r&! yʼnxI p93XgrS/! Ƥ?q2X{ Lv&Lnt:Bhz=@L22lj,#}c-%& b\O;x >e1.Tnф́CN^ô&ql6f%QIZjy7h6 PNfoCEC8J߇yx€dFq0a3Ej8?L ^^h M 1b}6P| jZjZb^Ne{y:xvh4*ݛ Fb/>ۙ>p{:~[q\LQ?>w&\g{QL:xG11g 6")B#@M,3F@mӛ8tC <naAxgcQ01y3?XܚPw2 1{3˵y8#a??)8Ѕ>FFK%o_ݎbC@ d^Q~_ٱ>1JsѪ<$^~>fYC8`ny\6Bz<  C=~8z֓?@ t:-zgd6L1bpGdwrd'`͂A4_)\v 2d.4NqnLx,\yLه'eb6~B۰u3eZ! IDATC'{!dZjZj]*1h0I"(;?yg؅l~Zfu"2ɰ^c>[3 lR>߶e=f@ҠA}љTh1*)3a7u?^r{U˚r4MB>X#/"2Xe`~ٯwd<:"qlx~Yp9 Z㜞is c|Zvt3כ9O D9~>`0(;9b`zQ"ȼIn ä׃Axnq2␾ y\e<t2K3Nq4捣'q 8xA\2c)D 5vz,XG/R1 6˥= ⢌-谋,lG 肞e !h&X\EKm&qrEnZϠ/s>g$ ?8XSU$5=RkR.@L, vDe}bb@30Pρ0 H tf<u!D&7equ$ϴ'sEL"{5mo˩ v<sݑc83iڇΓ<~.Cj G4;N9X;ƾzq~|~ j)߃ED!F`Myc603(˜L𙯱1'C%@nҬ1\dˏg?%GuK&p:5 mAnW";@:Ϧy'm\{"_Ȣ1cFqj=D>\^^ƣGq+u#}&cA^&>#$4#hZMv;wԁ9jzޑ'8 #q?c9&HLN+b=( MmD4rY9 J%h@e1c653C|+/C|N'i}h4,&luD }9ĞЀYgD&Tuq3ad`<nusL%L~D4{siX|>/M&2r8uǏN&vpu0Q9OD]+uG~;"# jg:YB^3u}b>l6~gggq~~gggN<;U!uf|<,ocwGaeҎ}B8dBTAF0/rcG1)|Y9c E*κƺr<ΏDƱ; ',~$*r?8(`}b[Ȏe6ߖ`P@r4e\mt3idBҺCCd`[9t:q~~V RIZjykJ?CD{NCltz;QVkZ5Dv;&Ic@xxK0""` _S{^I|Qͦcʹˬ>uвP-RK-R˻Z 52Nub6ьQ6l 4o?^NbԀ z8 {zg ϲG<á6=S[&zLPd۟,Xϐ &\ef^ĥĒA 'zey\? یK&D2 tcs5>rݸ2ٖ1=$5 IS2sZ>ֲ9yGk{bcK" 8`0(5zOg1轳eD vXMHFYy# z(/BEVBV װ<%r,x-&#nqf-@ & Wm]5@Y䤤Q9l p9|~NÖ7#d\` -xO(ށ Z{ssğ 9X,jyqve[V9Ę  B< ;Aq,+IP;^;҅۞n%1:m-c&Ցizrf)g9"dN /τHCۆa28sQ-ťnp8ٓzz777EzQHR6WlPp+(b@9 $AckdP n c:Qoh+}BJ=c|ǡ$VUΆ" ZjZjy |)?3c 6ט1pqyy,2+|fgiv5i&GNe jsٞN>2ɽ<;֚q}vsU̘1佐I,6o b&r= >k&srl5yv݀iy'ZV& {{[ml6hޙn!Wޖb9|t*NSq {-^ߑNdsdg־߄&!t\Ly~Z>?rO&LPHM!kxt\4 "G!y]A,2ᰜ!q&.Mgl _QO?X?!& ^Y7[>[ךPA">ʤ ΢%E>S(c>/vnZܱ^WDd~@/qtdS&&۩sD@wSG&U:ޠMښ$\D>9F3xi >Oϱ5vRmvhyɢO .C.iI%B6𝣘*є{y9XI@34c5tJkD1c=pIJu>2QsδlZTҨZ~mV(Ӳ`p`c8{"$TgClιfQ,^Y:N,t]V%G|dcIb8mo`la蜬(H^y4+鈃g-RK-R˻V +i"4϶^^8g(4idg(GǃנF9bg~^| F. 4=]#'>{=#G-@13&' 0{a`cILV8rD1҈Z :`ʄ+u<]_LҐ4z 1)}>7j5蘁bXv]sDܧ {PWoׅqq1`L0.=9`c9.>&SY^TE1dEꉞDNLYfbYI~,ڔgxm`#;ԕZ?|ćezWn}|beyH<}8!SCVfENfY#0;u yD֘= 2&˔e2Ǽ`]bPLqABc\F'{.vV5ޕO^vFDCQbho;ՎcBQDaəM.SI,OsYc|󌔫Ϧ'zq\RIZjy )stȯɢ4 P4# WUZ,E^m(`ժZ^"xhA@Q=6I7 6'F ! SƛQ drUg#RZjZj],mbc#đsw ُr 4`C>޶y ,8€oL&ӖAz x]oYq3`Ph;{ت1-maHC Rd*{T )|m6IR6PA(SMX솬W( |,s̼3g,[֩ ].$֓>+ qn&ia(=i,wbwdq I+z"VuJݶP^sٓiZhZwwLQS~bDJxMt6LY%%cڵeE1EN}()P!8jF,uL'uli\ Oڬ TΆ4[~}B>poQVaXA`کbD^ Pڃ0?V"Vi_}Ʈʆ+*:4ՑV a|LOOGggg9֭[T/P:w6OHSW޵?ɺTUUMDU5P/aECJWUC;uy|:;;KE'hI;`}>;j͏ޓC6]t+)$^Jiڇ!L*@<٣&uѢ&\k^{˺ubƍ܉Ѭa_1 tpzXcjjt9iӦ}v̔nQCwhA"]}1 *q(BCgggD8ZWX8Wϩ(d1iw"l2U1ȓHGzo.)CYI_'YD4 ƍ^GDH"=ڈ]ZR><_ ȳ_niy8ʏ.ee\⻜:RTr~~*Bc<[H=1Z6.5|R̀iq/kc<mw]ժ/G s^eލUr'j!wttgjz4A3ȭ-mUU\諫5 εf͚TUUa~CVkڊuݺu%;\:nwIaylY pa qp`>`iw TUUdzU:ajuz}u433Sn4q*<8Seu7=c1t/tnV+/UuV1\}<1cDtvmD;_T@af#b]I ͛7cvvI3G|? HUdے0V.Й}cF,YG3UY뤩ۓpۯݸq*2]_L^]mK#@RDz~G';0!F|*J4ydVa>"˫H:F !-ul ~R|:l 8w*QEM1& @jGUqx`f8ysijrwYv@^LRj:J+IBH%ΥF["AMTWVߐOVqa`p>ʙ݀$g>1vѶNBXfM<UC<""zmܸ\oWWW!4V%T&fUXW*1'{(e=33d@зs O?]ʐ5{gggYy$^Q>U' +ȦuauLBikZׯ/}ҮaQ &9:ߨ F^ !]e"]Qwb,lN'_LOOXT2GO>b<L3?x'm}qFܼy ( ίX>c1IAu[UUTDϳ; TTQALgBΖq|ýg΄fLgh'yf ?ӉF:m5V#$50}U Q\oy`eWqwD4bƜw (,wf뻆EX`93"vm< !hS*.e[:kV8/!.^-5X=VfkS2KV>'oMW3}Ft ѵoP!G9 T ESh=75 B5y'"}bSE_5XHqWɑQ85Tভ/mGQjΩ<&>@'.ɏ5t]nݺ]V,eʊ#bIVc<8XZ5?">R36^ok33MxNuʊt:sjZN'a?͇Gkq1^~}?MzR'le6],5v]tke!QSZ3iV͛Mc]{.;Ka*yڔU^A6@GvDeA B:>Y{f3yF ?8җ%2[7==]+:C^`KgǠ']o\OgggLMMA&<t>]zA8`L47!07Dc1Ƙ' t &)j5STH`v0"Uzn>QBEln:^6q]^l踈 &eDv=,9V:WN]͏}֝\lED8šV"~-iͫF<֬]6zLMM699Y -0V,UDڝrUF 5'5MФaj.͋ƃ:&ж}KQـPsB54D϶nZ?OkCLK5/ ң8l_{jI+):;;۴ED';;aP1:@xܖu\uGk2^>cBzƛ^)_ՉuPOM#cSjZ9s&⋲Q%L5+dYD'٤phZͳxpVE"ntlK鍶ՠ\b(ItFZ̼CԸ_1cT@SA6"\QM֮]ׯ/{#4jA¦:SJ y7GMb~\lFyfy+ VQUEEJG[Wۨt:"g?y;ӦMa" | }&Q<ܺut!_Ew>P`+ёȭF"Q5}>Ϋw=fh2*DЙi5>:A3ǍƄlWit/uǷzggXHu28]ᚍڀ}^Z[/?:I9&ƳNr'.>b9/olGlGg}aDN'FYkIy!C -wVUURbZQC=Kͯ5(cD1Lyj^ո_fMֹtʛmBmM8:.q#hGyhi̪ɝL:xF'Lj1N5&kP`Bu421V[ Rχ c1O"Y RFG:nݺ155sssE@P3nh5;\sU*կ1C"lyaU"g3Jum)wE8nYέ"T!_乕A@]]]]Ez@;$ _bQꭕЅ^I_3X5V qL}g= 䛘RTF U1?1QUU,kg!d\M}7k!2CCTT|VH ln1z!mN'jI@qj<纊CEi_@kq%sMWgrqLoDSVY>$}֘eTjͦeWMϥW+PXK1jPy5d66rBj oYHzjz@VMfaND&P|ӤOj2QKq-_fK}Y5O*Bg# Wd@냾TrIO<)Cڬvi5h{5 lȯBlyR=4t5娫"trDmbTMj+kGr Oձ  mD 9/V![W rLOOdž bÆ 133~[f-5 ^ D1UGLUJ YUq(*Y=B|GgQ@&<==]utƍ"7n{jj9y֙Fz'Yl(IK6gK5^С,^* KF/%>P񑴳`תD,@Sa_RgIgў|!ʎtUQW,5 nX@z۔\3ϴZ|c{RˉtuɊb֕jtG4QA`)}1 -O>-VuB9QYs445cK=,HѼNTPSvc'l&UU-HB|6TtF{Zj Po {ڞ1":xSk jh]yVWڐ9IqIٌ%N8T󪪪JL 6eD3Up/} B`,""F(Οg0ď?e֍7VLNN!/da"^[[[ܺu]ՠb*" 铖ԥ~6h8q3U"dm0itV<3ǁvygI^U+hkm:𙚋\1Нgkg?h*5 0#Vh>-C5V+TW7#-K0331;;["7z.WC7_wĝU -UmFH |~M]mϙVR{AM[5ZIE,FRͫ^c]MV r[!?!e}}yŭ[Jđz-ֶc5auQC[Al q?V}yS '[3ƞM]#c3&5Vjn#mZ~jb떷1Zw+W+c7O$ 'cޯ~bjIP:yOyfᾤch#:;;lݼqҖȃn}bc1cς Z.ttF G?/Hf04y(F\@PA["/qiӦ[WWWDDƆ Z T$jub83X1Gy2Kk׮-fRV+eˀ#t9K*J 0}_ _sss%H#j2Bi<ąֹd7<:]2M_UG(*eCqOR\QӴU _x"Eggcs=;l FbtNj ۷o2U W1Z$Z9β1dRUq^o6-|voݶ~G~9~kڙc1h_Mo` m4&y,he*iӺq&@6yy$9}5ɈmM2AB;?kʦ+zلiu6{qfVU'hz{|sPhոxf"irML[x5vhsi=[W]"o8(Q0{R{МQA\ UHlV[GGG9O^/3M7mTfb#`st64F$͛PmmmeiDDڝIxOB /jֲG8lt0 @}q[ek ZFT,W1D5NQaX/bLGԑOϯ׭bbٌQSk -r\Eoyu>u PoaJE|6}bqJUZGMMKUQ|<es)q:$.kk?0i/VeMc*^Z9Ё|R]M#bJc&"ʪʨUH9jh߭#l%뱭VqU,Gv1c1cY42c1c1c161c1c1cM#ccDшjQbqYV݁[,u]bll,b֭%[@:8|=z4bM=z4&&&V}m}}}122hJ/]_kOFQ^`Syi?79?&?ݾ,u7::Z߾}eq'F ,*۷ӧ:WE\"_G7c1c1M#c#D8p N</rGUU166/R:t ̙3ogΜbxxx>|8Z8t^i4/sϭڌڹsg۱iӦ8qD?ŋc۶m1::ڵСCK/_7x?_'{8yd stvgΜ'N˗lͱo߾X \z)&VO]>lFR IDATGш>(yh4o߾8qDTUqС%xO?۷QZ}U`:t(v%_=q*^2-bʕ+144oV\r%{61c1c]Rc#j||_uU׫z^ED5<44T>A[W(ԩSE.eRLp&1c1cJ#c#Mш .c˖--sΕU ccc+Ĺs碷wz{{chh\-[_jU~]~=_oooe9x`ÇW<#G""b݋>{7bӦMMɖp|W*oNZT;vK.z{{_\7ߌxزeK|:ÇŘ8|p:u*{eWc1c1Ƙ{Ǧ1&"bMV:yd߿?m۶l===?N޽{W<:ٵkײVDĦMoYF׮]zkU퍪X={6=t߿|M6-َ;y]gNM-[e!1Aǎz~᪎7c1c1wM#c#X\vm/s`8{]Q̆O?4W4V{DD_wv׮][ժ*</^~UǮ/^z~W1/^\c1c1f? c G.-VڞKhħ~|"3g׮]qvV+[\/][j~D|'gŷ~۴a^>eӸvڊ+V˕+W?-c1c1<^id10&&&bqؽ{w4lj'0|rug5[Rm۶g}/r 7hn zgͣ1c1c̓M#c#3<ފ~gϞ?ǎ[t-[޽{\7o/7ԙ}ŋ/_~e>|xіp۶mh&vww߷6m1c1c-lcYmkU8Gr~qҥ-[܏70FGGR:{z~gΜi޽{-`|OΗ1c1c42<Ҝ8q".\JtҊzi%]/#GĥKV~/{緿i֌_zoh4b͋>88_}}'/+Wj;‰WOׯGX1Lؿ|G111b1c1c̃Ŧ1ñ8xh:t(zi9ah+ry[z{{chh(:*xWNn[nD ą =-[bhh(.\O^؉8zh+100cǎE:thUӧVUN+wK.-{cccgϞvڲim۶-N:oƒ6+׿5n{Y&v[c1c1cȃs8|D4W_}uU58{l>}Ɉ:|ɓ'WǾ}bppI<xW_]6o{x7Cyܹ3\Ԋƹswߍ,2|FFFѣm۶ܹsL̙3ꫯƾ}b``)6Hn:uj{|^{}EhرcGޱcJٷjGFCCC+e˖/b֭Gm2&&&bdd$cΝq8w\1c1cY-1<& U"^W﯆ǏWQΝzzz544TUUU ϖJS?_/˗|._t,]v5S/*Y/:f׮]^xuԩEyZW*\fGiO޻I맒f?ZZ?~|swN6>>^?~|Q9ڵ:rHI'r7<<\Wz)܎4+_jڶ1c1cwjUUUwk4c̣`{1<<{}1gdd$Ǐw}agc1c1Ƭ3xgv1³>`1c1cVM#c/m۶=c"!c1c1Ƭcƙ3gbhhagoǑ#Glc1c1<&x1fbb"F۷/8xΒ1O<ϟ={Ď;>x1c1c1+1 [nk׮-{OOO;w.re̓@={v߻ѣqر+c1c1sԪv&1c1c1cc1c1c1ƘHaUZIENDB`ufo-kit-tofu-ed0e5bd/docs/source/figs/reco-geometry.png000066400000000000000000004616521521054151500233010ustar00rootroot00000000000000PNG  IHDR . 2iCCPdefault_rgb.iccHgPY<@BPC*%Z(ҫ@PEl+4EE\"kE t,ʺqQAYp?{ossp e{bRɎ(tջ{i常r)teJOYLgWX\2XyKο,]~ )sT8بlOrTzV $G&D~SGfDnr&AltL:5204_gK!FgE_zs zt@WOm|:3z @(U t08|A $`(E`8@-hM<.L@ށA2@F 7 Bh( ʀrPT UAuP t݄84 }aXև0v}p4 ^O6< "@]p$BV)GVC!Bd h(&JerFTVT1 uՁECDh2Z@Ёht]nDѓw aa0Θ Lf3sӆL`X VkaӱJI%vG)p`\.Wk] p xq:o—;IA"X| q B+aH$͉^XvbqD%iRi/82! L ے&US{1O,BlXXؐ+ NP6Pr(3;Yq8WJ)Hq"HJ IKIJGJJIKa8y"Ֆ͒="{MvV.g)Ǘ+;-Hז,L_~NAQI!ER¬"CV1NLMZ)VL $L`V0{"eyeg :JJU*[5JLGU殖֢HVQ?ާ>حѩ1͒fX9֘&YF3U^FuX6m]}G1Չ93 |UҪU$]nnCM/OS~~>&   .y݆i񍪍&v\mu:ƑGLMv|253Θ՘lOv19|y-Եl^Zä́UUКij}ZhlfSoV6¶vʼn伲3صs-[{'BCSGhGfhgWΣ<lqu%V>svu.֪MZ26,b&*4r**4j:*@LMLyl,7*us\m|GD\bh$jR|Robrv`NJA0"`H*hL֧uӗ? ͌]֙ՙdKd'eo޴gTcOQ{rswol m ڳMu[NO [A^i۝;OrR V (m? Yrˆ[EEE[?Xި%%Ga%oDDiNe̲²7Yn\^{p(㐰­Rr_bULp]u[|͞iU-x4:zccǞ77Q'z̚KZ!'lsWnk]8q/v=s}ٚvZ{aԱC) _}ABEKr.]N<{%DƞWzuW8}nX8[[Mowf[@;]wv8d3tyoy02*|`a׏2-<>+|"ߵ~o /ۏ?yx??'󟓟O)M5MMqb݋ɗ)/f 櫳/ M^̛oy=}na>|ZZ.V|R?B, bKGD pHYs\F\FCAtEXtSoftwareGPL Ghostscript 9.50]E IDATx=o#Y猦'8ةS2Z(b9Z̝ ؑFdFhL[>V7U*z%?i7R1w>}~44unxHe>}%&~<!&8%fv-$O_ϟ 5 .P37I`oۂ׊KT.Y\E&n9uc<2EzMyU> #Bp pTXG6v@8Ah 8 rmvDp 7(-c.6q!dM@E.hPߛF-i.:)(Wt_HZIZRe G6q:H'b d}ms(Ĺ L0~Uz-%- {u֊*K,%-ܝhBKs.~BhǗacG.Jcfㅻ7s!IL1kEa.쑢CX^ݹUfAv/v]?&nUx<`hITZ*d Vߚt*H) \n̞$}S4Sh)dfB5(jK8*W:F(o~蜮q $=k{M[ٵIўCK470f'ӗaOp|BKI._Lҥq]{nkZpGv_s{c 3#s,Bҷ|i/뺞2!L2݇{@e>}>k{xyB^m֊M_2CVar>-tGU!IFpiE;o/Vp}}ECt6˜kqP'5@ah>ٻJ`}PTƾ=Ch%Ua1YX^ٞ5swc9췍e])_Kc@%@]Vz^`ZVh)(;t-ikp)!5ڵM\8R~c06j^CeʤyQv1BSX0/QԈ 0XGm -IѶJO]ߞumҶ5%_Zh;K^- }efKO"Z..^P@ U&.o"?M_Ѥzf8'TIk48{|l[IbvY灡M}IS~۽UroI9mo%\SFgwW"~&6f9 v)Rm~]VZa蚴W*ʫJre\rgwW-aYݵ6TV`h$m:P4ayc5-\6UF%T<W3ְ9_~˓^y4P{lu.q$NhUv(yM8Q /TKj44Ι_H?=J3\z{|Y]1SƸgo/UlA^{x SU;Vv5I>^HJ ^Ļ޳7HWZi#lܷuof[p<}кs﬩ErmAnOJ/8v`g5= U\Rũ.ǂ6q"Ֆ*kiHo/E=_nߟfn?THIHn*mO!3̸1PY'u*{^HE3TzuѶǗ˷Ǘeg%1l6u~=u2c y%@ Q,MY*+v6BNp ]h8o%w\nD?z^Rg˦\VvIڎHc($=+/`ٞ,%Dr ۳-|ھrU۶;z]\2L~((Б|^xhK L GZgu+/RR\VINJ2`ڢhHbS^mvvv8يsfmokx愭.;"Rٓ+L}FT[V&.)4 {!tvn%)K ,_sAo/ړ wP*m|o}lm֜u~8ܐxl[` hߛh3{PzuI{VUkn}*Te$+ܟ]$ݧZQk*e{>Ii)* z_ǗC*o/볻>^_&j(g}1҃ R4Gh G`*ĕr*~=I +Bd}IFaEZ2pi˸hs/bmL(R[.@O2^I{[;(Rmա&q+j,g5V*9^R WhהF! 1}U\!dnߞUk~۷R@\tA@M*fUb9ʖ"fWv5BK82+".k<6U:a2 -ֳVU_fgwWSešRBQȪX4n*]'=:+V5@%@MpمL ([@Fą6Gq 7)pSm+l /6BQ%CLuh]e{?@( /U eZbf=E p~|_J,i҃Г0 J U{{|UgqKoa{wU~9%jd{ 㨸h=w_Yݕ,&~w9N?WƒƟ UB5VW<+Ec]* [=-gwWnw!A524;JzZ $ ;X}ijݞm=;8KX(}Rgf *M$M3{RsMuT2WnZVMhVƸ&5qiUZ}+s+%YՒnCp--㪨|اRZ^=I]CgwW#}!۳|TsBf[&^9Tbz!y֊ZcYOD-/*3op<<.?祃!,wRR5QBm:w_DbQ>3˚PM)=`xB=?s?5*(*xx{|PArEɁ\WOk_۬ Ub*M UK^{^Ǟl_Q,Z}h{.~\%ϩՆ>v!Z%@BھPX:x'fOQZ%3+2YKP[Ei׀Zez;جH+zW<k4=C+"s}v4n %i,i@e- *ek!V'*+I2wVH -6I8pvw5RT9Hq(3MVIe.:ց-rZ uw*۬k0۳׵ mtm|{|]U|ϲ~M 쉿[w?AmAPE8NgwWq1tׯ*+I:+uFh*mܢa08 8PWPS*>us !/i "0gel#9_R[\8yaoM!væE\e8t/i&i|@p h BQxӗRQiF ݛ;f(p[CA|2I;!KEIMC@ߚP- <#c3(i@.K8M\jJ|@-:Ph %"m%Jhs $7pQ U` ?:inT\Cp e1-~|Sq .!ߚ&30GGNl`fsI$7=Dp ffϊZ F @\X'33KvFp -?#@p 2Kڞ wH&~4t6ٖi? ![IlZeCL>}^8'?43O%( ŸWwم=ᙒ6q**|<-Ap gfI$͌V[2$Wk!@\ك@NOQx IMdBymV> '*3KkNM[۱s [&[~oz2޷5[ImhHm4V]V?p*z; m%NOC奸:f&nvw_HZ|XQf*ir۬"E\K8wRRݓ-Ϟ%&xWjzӗH8JC3{Я]s3[H .8Rfv_b}I?"޻ժ˰'-K MZk .@Ҋ L1eM  -&~ IDATkz}}*RR\)h-ix؃of67Qk9+6qGK /% Ce^R_Q8) ̬ m>T2w&Qq r'!i0g3{!ST @8#Gp #Y M$3P42fquRiBO_1 m:L^-)-Jk &֢nS2tT2O+EAՖF8Ap .TWʫ_I{[pi}]pfvIXTEK8@%+II;?mh CM}[oBK m>LA8#@p t ܿ1ԉ6q'䷦8!8Kh"Ֆ881Шm?idfOf8{+RmPP,ojgf}35=ƨch`fzN칩] WI٫=ٶ6a8R .<6q@f6t8(VqU@6+-֊ځ-%_ MPZzJk)VqV"*efIJyz*2 ǥHp6q@3Vvhi뚇@!M!3) ,FOwI3&Cha_1B͹W-:෦Kr+(GEE6W3{. -%Z@i$Aū۽[S&Ӗݗ *,-]N襥 [:\x. WI-:Vq fv-\BKR$infٳ,9f%M$rC8 !ZBK.K=peS KƸuNcfE׾~PFl_Z}Ӳ@.كtε@D| !&EA?F|xPUnyȸ^&Kϊ:3;:cfsE -:(,TyRz"֒>NYvO!"J8D5&~I=I떙] rwK,𻱙0'I>w̮ݝ֕PK`A$l8RuӔpz4WyרR>='wUoY&ܝ pPߚh73CK+I7>T2]HZ̬gf $=OµUۯY=Er{a;|Sy>׊ŷvt%PmV@Y-EaIar̮ͬHK?31WЧ5^١ی_{P҉TMŶw6}VZQ``مyqm!JQ$ϒƊ!nD5&>l: RZ䄖$i~ U{2m[hwk%ߛ]<1m`TW+Iߡ I_Q:DpUJY( QW$7jBKiUn~ݗfv(#E*p=VNipT\_Qxiu z^Ku]~ݛAP~t :HiD6 ݽH+>{ B%ƭ "!&dSBHMEʼIQhp&T+Z{nUǖR5g0Sb9Mqli˹V~bin,?wY*5Ufy քy¤] œzp))lVgkɾO_<5ЊqÕa r:IkEUf5<<˾i-˟( q /KlK'~<_嵾ʹŗ|\B((nif6ז*nEp)C՘柾 Z*jm5#ĄSZZ+:ׂ{E}BPK[ENm T -Sst #@1ՊK* =I;-2\Ū*Q3{Ur*ƴvPm[]qixQY*blC{*CK!sz(\ګ -m,SkN#KJk~ϿTq:7  M;ƀ2Gf3'Ey%IMN}cwYҭpСCBb?+ӗarF{E!W3ֲ1X*-vvybvHhInr2)Px97]fܶ~^KLtYI Nۊݧ~% %M߶" ʐlZ}mI~z*Q*w,T 4/Uzv*+%\87MLǂ-afnA+ul30&'ga XiBhWel ZJ-yO<^OJ\t94u2ËyϝHsnLV pK4,{b4 5[ȸw Pd;VcZ*(:_EEp:zmiۓCKDK U&&pJ .U־.J.G4:7}_& :p K4F*M4kIcwтE>+RGIhThK5كÉrIKEqIPQ!T}M:U C1 @.(禹/"SMnmNMSdf>;ݩR%;8i8X!(6 }2ikIPi)>nvofkEYKot@SטZJ`Qx VK;SJϿZz7 :7}.shkᥨ Kh-7ZZJmB*.! uueW?:_+អȓƄ R-vf7=k, /(TIpiK'ZRPo?n4/$~w]~,'K̮%=hkǶ ._3IPZчqP)@՘̒ ZMVim> -!͟K>u﷭Q?k C ]J k\ @i禱 X+VɸBT!ŁgqI|Z}] ef4~jJ@p Y̞/ڷBgwwrG`VS$V:%C5g1!4kZQeگst6nEk֩f$gL;zPqI>Pd{ݕlP!K%}STekECiwϚiQTzBm=E&n*V%Եk1ku[ZƅvY!>NPdxKﮥ~}:ݫG8l}!m>{?;vߺ_.u}jLG[כ$yVvh`Gy`V5(/Ymn;Rp\?:g,w'ԍ{ m$jإKiK]}(/Eء2RVϛCKf67fi][yA--㞔7}LNVZ8.S߽]SAp :"LNzzC¤7EU[V4xIƂj:PPi1}VTif1=״NFh)-tuB;zX[&K'j[JYc-BX:k-B Vz۶`5ШsSt}nz8 !`>@"im*j w BB5IC՘ݵ\5-g28SK--[n9,de\fe@]fދL$ WÆ4tqnҫq/*.xUp  T8J-Q"czhP蟅B{ƭli-/.1P*3{Xn+BKR;B۰gf Z: 3(],{qv#Ͽ7=s4֥6ng凓.M.='JT\Ch}'Yג&BKRġWcZ,tZ5mChI8n_][h[91^w+E2IU'm~~87u3"T.9BKT}6 !]'g.1\}YҥBnH#jC%t{Ȳp* -hwBب,yA.˷J$*;<o:ժ UP:7VZܴp2LBb%Tc"p +EI])TxDhiS0.^e'.F^B7+5AVx8!44A=uR/\K݋LNUYBCm+mNp ۧӉB;CwǷ}BKO)w-Z2}3> ^6jYV;YYoowh՘X.MOKY3έa﮵Vq@@p B铋 p!t N3)PВ=mQZAby%IÊlf̊TW{IP˼o?^ AsSJTbY-fwu187M)o5yȹ^$Kk_kI)uZ?ilR ;l3Я s<ٽeBB'3Ta_+3}k E.T^v>=ZE*lWJT͖D8pMKt(S<R؃H|֊*iMUZWT̤V}՘<宵 -M Bw[&"5 "y",׊yvP1e;@s離{ wRCB @W14)LFl*`}S4*7kjL23)jaTTIp1-%'J-m7\mU`> /vi?tCۗZzßDp);78)|չ"-]']-!Efg~lf}3{U״- IDATE0ɷ]?xKH[HxE9y9%HtS J5LX5iMjaf=3{V4QWt=Eߨ\ޡ\+@̾ٓ]}O+l N>лc"Xϣj*}JxZ( ˨TL±~P??[yE } z;J."_3J\7MRHc}B!pbb=EaB02|_F'@Ũ/a]VcxeMk?T׌}?+|@>}>}>2d0U6-f%mfs3?.nZ­w}LEߢTQ`j|P*Vs^QTHxP y5kOTp~oz$ZDF!-4֯VG{df o MnMdf˼oyKvgqgg8&nWo Afy 5^wr-C\ޜ:^-{ߴRA ?~&,Jw>}ŏYꜙן]ۖ뙙g֐t -iYlQv۸}qx6ЯBIkEuu\)=JбiWIQ;t{:ݱ??Xq+zϛf!qP~8tqd؉g18\g!~6'SSԞ*-0U!@٭nBL[?S4e~2w}c>Dd:G&kRٶE8SSڲg~'} m,mo>+6܋Ly2Vo?蹷Sy}n*@יY֒03K~hޠO_ySBL{ k ]r3dd#dwq}Ì ܺ{nhBY}*B,kcw)o$Z@Wh)MrMvuݻX|-B=\7M^ >86*XB4mrQb[{"AġOɪKk|L;XY-ʹ\bUM TPɷ_yRs.YiWz{q86$ 4j8=l7= 8Yj#;VLM xbv?߾QI4 a@;j*+~Pqs\ڳMTܩYNlnj:a{۾PaUғ1SZY'Wa ՘.UC8?Ei`}3!bfNz*(v+~cfʜ!^z4Cp KIMN^s ̱*:7l7 7ι9y&$wbs /MLmvvs3RnY5RDm+L \>N|h0'tq%3VzhiҔ3pҵhLJly2Zݗ gAp KIka؀ol :q*ǔ 1@=ƠĤ8"CIoa5.nPj1&JorH_JMe8B5ySB>yrCKG֘ʷmJu)Z& JJ1>ߧ ?a{@g| ֱ<-yCǮ8 .\H+ʾUJy>ϓ%U5'psU{KuzBr--3 !OhiNaGJ'o&_K Whua`[3i ιMt'uSl+^?Y:/%9ڸO.(Y$6263Wu{"bcݚKͬjqQ+̞:91 ,44kdUZqs3*>%;p*"l%?DPm.\8Υv)z1͌ҤI8iϲI״(E߷2s!:瞋>.jFDžrIx߀n+U'+U<.`[ъK'㜻 aR"ikTcO6* .TyR SSl@k՘NRns.U-z兖$=Փ:TnNv6wU y'J7z49pJ%eZ[wp^=Smq[D.wkenي&'KsmNɄܣ=f(m?jk:OP"\k@uBKJ(BTcTx*~rВsw_MƯGjS *G6گTx߀*gJ fpt:\KXRls3HHT[g[-dC^қO$8&>6Z#sέs'l- 'Gf̞6v͖#D04sn?fqM<%FJG. 8i3=Wlj ~NpT;4@[Ob+/2Ķb&sK܃sJ>b?rkkO!4XvLC,fC8Sٶ"Z lIuu\$zM*\K|N$)CVYfVn̙ٛ|H}6N;Vιvr}s7%sU5Q/~% UOYҕsnZߖ9&@EIf^uFC㧖f=S_~5\hPV@%pIb+fVBfvmf{a\ZE*+lLƂ*a1"Ȋ f6cZyJdM/g:1C5]lpi-Bsp&t ~x'Iof΅hwfK'n㴖_UJd488>Kbj@ D+ >B^޺(VUKw_cZXlkd{y\ ϫ>>kIK.^;_Is?ŮB a:FBtE8Rk6?P b;q*6ֶtq8/fڟٸdwڞ̵3}mfP'+6aι͵45iɊ#)߸V/ι-_eV]}Q)94? U`/owNlnjδ~ߛ٬w+0uQ¤ B$=p<՟=+>&n^;2s(߮o+ykI יsIsPZ>kWlw_!yݱZ6َ׳#43EvKz ?9OWzSo#'*lsldh~ {c^dߟ-\gc\[]6Ni*_:tsc21ܹXhιh#%ipc-``cٛ'UE?Eqsw;?}>gx[oT76T˜:R~VFN}~b朻9;_޷ؾ/{ 1ɚ;~gpcFEw.8OJe~n*ߎq%鮉wYe>E\z^A ¹v甶'1V[%MN?L+umH%=,rEJM-r5d}RIқ-B^䏥y GY $6 GަuX"VB =mxÆ[♱ך?В䷳A`].Yl\s#.N[960PdOo<*=t *K3VLS|0c(?r !WWNht $xr0cf .sKޛʆ4J qʿ{?:~ZVfαW }̐c6LF+Jl}w/)t`ZsU%[a|Vw׮ ߑI6#֎bL2UK#Sj⾮ UQ~ !vW_өKu>6K=[}<*w6 l.%M6NM+~h_ν%M>s/̖[Wux<'owo )M;mc~snefk}͓wsÓ̵9.}H~}k1eVIU f %w}n%䦜t~g;f8fv7V+}\ DuXt}- Dm˕g*f:c5$\ܱ*QY ;IӒ{~k ZV'v߹~I-JwݎLZq,}ŷPu3O*Ƥ=XFNۺZ߷JolPҤd(5\xQ/Ǝuȝuw\\vJ"Mk)?iJD?tHFrTI1[v'Cᕚ?N!m?`h<-'8o=sɛC 7T\1dGip2%9Bx$vtp hls&i8ok=Kz8B ,v{SC?tir4:ͥ߷J[54[<+;OmpV%|0E9v΍Tp[TB,5?97;aJVe'ao'ޣWyH^;L k$CJ҆ܩ:>3F*٥&lk]-e Y^_U{F8EFh`X9tMsI$ȟXZl#@ 3KIWG A!I -M؎ZTuXN~ ipw 2.ǔ[A/IZ3anC0h4]8N]W_b*-ӜF .^4\ɇ NN'3No=M~zϷvH}N~&!(r0 ql“Xa"lw|l:lϝ`V~Crd]9ιUn&ҖNko{,긬{>˖gPh)y0\'͜swι+kmϭ7B@;KRhˇZyV-e/\JFZ{v[ԷsmpjҭXX)6BP\8[Ňp8Z0)gzy4bbTG9M望n*_Π<Pù-= Ynű!|-ivae52]?>~/#ӳWC*Ff Fw5Īnz|dZcX#UӽBݖ*QRnK}_hg3N+KZn T Ck }ޣ:VqcysEhb+v*r&'Zz[97u8LҍOTٛ=Y"4EӲBB!F-}'jwv*]&ūܿWkh45Z\^Y˜ #vq=S+}- CtC!N 80!L[R\I٭Zq͜s'|퉹d93{1P K }B2cM5D_WR W)GXBc F$,O>xB_1c*sFp 9`Oe˔M_|QZma#IsN-M_jL/v}X5^g +tWQ]J{E?{*.{1!i3_ǾT:)1>soYT[\T[--[tEɇLǥsQmW1b*nC̱Z85#|vlu+~꣼WC~sWKz>öEϷ|T{I];@ZK M_tpf6VzhikDiVc2cMeǪI?~j\+LIB_m:#6Zs;B%Gք8+/ЯM&`*1?ԽВT{5&I2s- %;o.M34a3nUny4utKI{5"P%0y3v2>Z'QqZ9Z&e-}{vΙIS.9?/̞:|NPFlbc Ρa+4dz%6Nw:)op h-Kbf60W'fv5`Wջ.ROVtyU|?{jhisn{p΍$]IΝ"Tcz^ ]bU'VbK=kswT摥,@.|xiqT1V VZQҡ{*- />H`K(,#t+:zιUt&iWcjD59FǮ_.-t #+_9kd62L#ScRcAl|<֒6NWG;BHіg^{2'囀쇟9FeM0B;IwuXRB6q(I* U撯(L8_)@MG+号[JZ/.8kX8G}[æ%+n1"GeL寇cLM퓷]%%B[*E&\&k<ϊR4_ɇνMy [X@gu/MڗFM@mz48=ZBDJ^B,ݏjcеeB6q($bgA8ιHҕ|մ9($T]/m5)*Qv:y]t \0O!%\j{N+ŷBǔzem_%]m6E? kJ>1 Ep .l!?Zvv*? ٯt=*=9H1ɟ DZ@`AárϤ,=T}[PHRmz~VvpD'۸3Bo sΝ{ *֒sq9HϽ;h1,[J9JWe +U BHdxhe)a̖hs>v{pאMg.&ϕ~rwRm|u02[az,Z@HY,T>ZBIY퇘:&bkIw%ВCPi)ir\9ZU 8L+,3u o5Ӆ$O`-+34"?r[\IR4Y4|8#K@f6-^T LҕsWZea萌9Whllf W(⿑_O,p4 Uhv||`Rhig,SV]_N*-m?$X4ܱgJn9taF pO*_aI"؁ M#4GѼ*hǁ2[U=Jz`0q*,{.Z˷c u3aN2BKT C'-=_Z3{7e_O1@M.ٽ|۝t㜛G9뜣>~/J-C>~uDZtM4^UlQX\30-T T97oˀObAmTYZ̞І&fv-X*;qo9蚞i3=\gCr . vUmU\~˜]8P= ?=;禧ޞ1| *e3k.N陒[_.٭UXZ 833D9RmYZh6[ok|>&@WZ`I *N`BAgIWpByUK@ m2 B7jXBK˪48mt %mᨨsS dyp2BK&'ޜ0{V&%b-97 Ah V]Z&'Xa.yU[e-Хf6ԁB[GUk w圛շeqzJkIw+K\{@@m䟞w[+, Y_~p϶UT \`sޫדJ/f6/yNnI~gVof=ymι͵JsG%4';I%Bh5OkZ*,3ßl|ᏟBHSjLP `;62_U<-xܛ<̹Y|jZ)6N\!e +@s>1'[uu]FҶ[3XUg]$@tz@QS@ W 'oXUpn-YmwBLt0$Z97jSe)Xj1~뗽tqk.B\G[R*q&VbEXZhi~?pLVl(̺^j? ߳dṣ5?>?8QZJ܇\rzEx u 2%4?&T$qވgДv>MZDtVh)#TEZAoYKz'@>\KbZ827ɷIy 7Kz8^.)O܅OfNj?EIk֗MwgU8~c.ij=i?^=EǼ>JmtjiVJh8[+Zʬh|sְfץȶ/I[8tW-C1bZ-6bUR'„nĝs.-.LL"K|¤Mc%2g={ I~r|sI#):-LfCEfN>p KҸ &b*3>{n%]"tJS}]ɷ![3pzq3L ?*}6p1.9Me\94rx"2!`TMoaM NqN}%6Ykw?ō Kc7} j]o{] }ٓneuBLO}%1fv뤻SoOtAY7Œѡ 2;.%p PR손V!&9w$KڍTyZm ꓪ{ll!3ҋ|e.D*=Oʱ"TbZUfA?~덤o?ҦC7sڜK}mϪݣYyɩ>Fp Blllfy.c Jq똠=Ơ`ۻKT(@tu cH \RZ16q h'qj'}٣2O\ZXJ9z[HJ_@^,%i|p. sb<Պb?-6WGkO'k "9]!60R(t"3 3ٛkIr^vY2&0sn;vgU.]zb}V KQk@-!]_ҋft7,G*н#$ҏFۢcfꙦ*^Yt㴪k[~Y肥Wo\lpI~`$NJzrw f?W^k+Z\l*:$,txRJIϒP w١>'*VV_W/Q[%s{5B\kIc=ȹX^@RυJJOʾcB#Lcn6u1煯T\BY2.sk+.u/g.{V-2hc>Ml&j&vńMf-ޙJ @ -%A7n]߳'AA?L_lҺϫIkiCKI2 ;ԾQñqz虮?,4vFZέjꢃKι=1qbe)CȪmyiƒf}^psl~lfjۻx,vCq>5x#, xT3pm:mhi J˒U+V=|\$;Vs*c%QWhZ-l7KUʫ-ߏ,eM1n/~Oj?~6v\;=mR0SvVU&hKIm0ѽR8*+y7aN+ [oUrV۱>s57)f) XNG]˟;Y*9R*ϟ>jpɟs^B%B l9wVUR^W*%Nl.KgSsOO*x ܚٴdեǔ7@+0qNbKVHJk1uݙpʷffvohu$kp r᥋e jhVls-=0o+Sf#@cV/퓄I+⹤̓#tW% Alg[z.91ys'?virm:j.?6) p'&%O[Su̵?Hs YPT}If6)a2$v>B0:V1̊ǬtSū8U}xp 1*gBK'CAcrqSvҏ K,zBE'U4,_ym!<m{gxv.I#SV4 ]Kzo9uS{&Kﭲrnw{6P|՛>56އ82%=,\EwrBsoP?{jg(O_21Жi)q >ʇr{^ɡm@c6Y^t N$:OWVs~e'(U} lahsP],J=ȹ+Sc2;e /Y}+쮥[&b֟!st#pdZogڶ;*vHz wMއmcIz(%ܳW NTd]Qm }Ysw_# %mZfqG %]3oBIf6ؚ6NdrrHl"f(iafiaҲMmD:f*J% ubcw:K97s7ǪNs?)Yu-4'UH]Uh:.hʶւ|P>@T v-U ߖ />W*%Z IDAT OVq[fʾqVIN}:Z$ e.Hk~uf g+t}HOCmʋ<NTv=z㘓\sΝ{jq?rzۗ=fί;BUU mzSq'{ݪI&ymI*yS?N K 2@IbA4Z|ߧ^wٽWNG eҤ &flB;5|g&\+6):YCZi+D>I{܁Puj oEy,lGt\,hya7߷BFUotwʞæ oVz@ _&UCq$=S+i?ϰYҚVq{[;RKtW ɍ}?I撖Mcɫm]ˇ4Z;yUre>V?wV7T{9[<-i/ר OWɷ|+;W*..xQ=P32޹3-b Z-\kߣONso@s+3onkM,S."uz.!uqxg"ՖJ‘dfIiaBޓ ,+-1X6JfUHZfr5BZ(L~TT>]x/ $iB5~תo\d&o3Ǫ6S׹gV&4$ZvϡYiʾKiZʡ=W*$ɰ/JZIp .AZI&뚴*OP7T3 zWU[X&5]r=|'yk]p)sA\ua6qT.< 9gA{s=jM/~yi*Eh"??g7E_W7?Y`N,#tw9PeU%^ىgIWιi}[e@JcF,#8/S}+{sE!FpjͪY N&}Br 8ge_˯.+gjwAvz U: 2V>e!|rp N|N$ 6o׉ 2KTpUiF)Z|kP=j"[;}xdrGx.yZrbs}~FsnifK9We(eu pz Fj: 2%ac>M~U!b[mS*Bh)O6,FG*@늿8zK_|5ݸ+JK{Oހ#*5Yƿ8v͝sι7I#*@b2 |kNZZKPhՀ20??ņ:RGچNu|t㜛Z*ImdL PHQRN7>3Uˡ%ekm%f:<70ayNT[j*5Vx0>˚H\bb̞旒p̟tkIޯMn_ U]o6s)$^#VwBK݃|u]SIӮ'9@ BkWeߣ叛sIN vfp:6NPu1+IG1kmp) .I]'b8uN_&Y]BH+zvi7p:lLUʬ.& C&4}2-fZҢH{RV<˹" pp_1)vNW:ZjXN}:W;>Nj?v-RKwd*~m4YNTi5lNR,5M̮sy&>-I8^mC72 tڅ u~L#ӽ|[Y-%klimso\|RY+Z՗Ljf^̮.oiCKKIBKUM\]>P.A9'Tcd+>SK챫Oy_PPY*)ZF\^]XjJYl'gf^ PHnf 3{2ܗͬ/G)_ϳ|h&nu_RGZ\@܆jDEł.q͔O a .qqȼI4{מʝJp)@PVp)g94U]Ֆ?Iz3fbftkIwrNkvY6q;HٓⓙSBK8w*8edzTf,U^IަL+Ivub?AM3 zTZLN~]R Oj ։Re]؎Vٵo[x?'pV:e^QbܸWe)i Ge]WEBKιSoOY4Jz{{]5T.#SdzQkFZ8͛%0Rs_NRt3WF}/\+hљ@cu"O+urαb Z,Lr?:97wMs&i$A~1[.dI˽ꣽc#Z˝ʒDk8ıa^顥SoOf60+x88kIYFAłCk:];ݏLY!Vқ.8X8=(~nfiBx+Y{ϖ i}[ҫ|ŭa3n_4E_1h<ۡ~9snff+^%{7M\4T8}ǹPF8ߍ: Y{'ey&?h3U|uvALB>Ѷh| }J<3{7tpZL3}%ݎLs>kKj1sĶ^>Е|fy?uxV -ljLM+L]s?~>qZy39V:\ f:<<05Ojeh!anUO 9}MX ojn? QMZ )Bh)OKI-ˣi88;w-k.lGlr7bDr_i? ci=%);m)M-z W2NfiQ:hC@uU/`{A5@t .@넶p۫ 97s=l製1OƬ/X~CKS97"m6*{_LkWǾD喂Z顥IY̞txm-6 5/?uZg7H҈so9f_ر|)̬ V%Nι$y{5s⸃&fMO4[Q5WYb-&n.4_@j%+=̭%M -5_/dK*484\h634ֱ:\ f.@rVDH oķ #>mTؘDTw[ܴà#&D .I~d(w^-*~xkZh)kVɿ6BKu6N˞)AZ_!%ʳp.^ .9f`Sp2 AwE΄}ћU%v S|F.I mⲪ5kŠo_gv/Zjcf*NûPAͦ*\WY"|k١E Z{[:ӹ7NFY_X=@KZhL(34dFo,SFrnDh &Na:R_ҋ^>vՓCK+2Zhiꜻ"`|?,j@h2{*k,߳-ārp`Ql WIYaVR~DF7IW$ץW*e\lcKՇ:̞Z2W*;=t8*um&re|BpgU(;62_\5%]RW/僝˫_=Gg]k}9W3K;Aλֲ,+R{)_)DBM+*_\Kz^~EӊG.:౔10=5ZZ2K飨%tk8),Skb^|!SnM=vgLJQkr8@OpN *I˯wRu`%"̤=*x;|$"/Ed%"SM յj " 2GO"25Z\KEST'DDDD=w CK*r FUvSCvrpyX夃KfBnE$#"2-rwZz1i!n+DFyP9fƷWA+LiD'Ĵ|psȎo:+@dMhiO>؎ ̔RJ)%"""STch޸K[ ;w Ww ;Uw1rN:d "J)P@AfZ۴[(*j ` 1=xGCm>.W2aSE8MoںD%k }- 1uSNh鲩D"22W8Zj ɡbB /"Ib8;N!hq%JCD-/R#"$|t.MwKZ(h["W}I"곌 CQDdQ%)%LŔRLۄdW>(ƍAUa>*ҸXDp!1t` Ӊ@3` IDAT(&'ܸ *-lkxR!ق,ϏDDDi); >$g^sQ\Cۢ OYGDTR폡Ei -En%w" 1DDEn)!uZi=czca>[?^6Ĵ{m'ܸ_c*4)>~>˔RR]4G5N9JJDDD{yNaqhhZtZT("W$cs1QpK]\5Q]vx<cELIq#Z>ttS$؆hJy Zks}8f P pZZ2<v3簈C1\S8fZKf*oDtZ2cY2Nv+|Y|<M T}}TI,&L D:@.lLX  ҫ&um )l[VgBKWJ-Ddհ&սW*v.@IKf V$""ꁓo'"svP)N])99ڵRjvm'.|Lg8"v(ʔЋ+4Sei M(҂1 1\L˸1tx,M\=` X¿7Z ڶЭ.$""N(w=xªKDDDF&Nf(ñU-9l* +.uT{yCKON)d2A/&W">f7O>ysUQ*RW.=}6ҿ Bdۦmx:7QcS/5^]:+.՗9;IdUPJɑ/<+jSi-!̊KDD+XmqZJ_ܦJ!t[lT!B6\zGz|M3(,fHޥ6> %X sEgkBW=%ȹ` ={쵠wZ""{аiο=ёՖL붴Ŏdd$"fpU:h|ȮcmCK'YJNcs82f:P,YBW'5Jft{<0_} EvS:uDž<;ut):ww]k7hXCDz'\2 ye8* .)Gビ";DĶݲA&^nM5AfVI -كCM/cvq])Љ:N?Q|LX)U|_1܆d'=ڔ!JCDF`hIR)u\öSwY BD)Z)ӞO)žDD5rEcxgZIx&EL|LϯS7x#7hͿ 2 3Jh6 lhGDD]p.BW]-,q#Jmh[ARct"z⒈ Rm"DdZ0DDDDDԸUC稷E`&Ye) ?Lv0k$v])j5Aˎ> ֙l2DB(8:oSu)mNE|1WlC|!>c{xRⶅb!#u܌6 1U7%+cö?Zc#"$"s${<2gXKDݖ7~Q0Lـ-C/% Ah:|wH|JL>tئ)[ۆ4Pߔ&3Vwh%t%:4+,qUznlxmQ%/A>_c_c_)~bw"v94> rՖ Dל%""j;5ٝ:Lwx`Lp Gn-.+[(T?+?J}:+|k""} ._DDNʝi-Wb\8O k *l' .`L,BŶ]R7q̅}QqYJM'Mk&qCYM +NQ 8p-D5` b&HL14{L<$z}`."~OjC !*\QaBKk辈Iotj&ض`_\'""2F`| FKo {M]GCl/|ßϧ #}\>4Cա+vYxW8@P#%N8ٝt[JT`&ZQurUmLκZ'RV2^_)͜KKV@5܂UKł)&@ 2 mXx6MbU]#L3$8CޔtShi `twHחCn R|t`ylpumZq9+=|B!pX(>n^k9D23cjWKp)e"2HD%w9o_KN>C聃CIKM <$Lp`pߤ C/r7LhxcФ2TD5}HtWg+"Z[H.}ZDR[,Ksut` ` [ S紨C!10+>܊"sl-ؓz&:j ,U&ti=f>6Ĥ7 iup\2-Fc#X`VJ=nI"&#%EE"F@| 8Ɂ~F&[DO%Z4߉Z\v9X{9rk$x~ۖv3] t2o:{f:2w]ŔeX YQi[*\ lPFPrݦ)*W?ouW@Ǡם1&nȢ Y w^Gb-evxy""j 7;OEeNMV# {%lK QMq)<t@)s3rQn}1ZcwIm @h;\Vabrj TQYk쏯V4p Xϙ,X$ܾkYn0>?+vU6ߩAU®;,t%DPj!#3<7SJb-vʦ!>q{ R*9m0t +cT…Uf L"H .5-Ł$I1 qM ɇn Džsr\I\tAH}dUtǔk2^ph)~FDD ;vV0bm7nc E.|Ր;tc|z^*yn7C\#CyXY!{ ͥ-uIjpq7+yR'":5KK&"2UGTS0U:3%[Z55AQQ1"yWzCK}HUknH\DBqHfNJDVϵ\+K_lI^8V"""EJ]jHiGq,>ŒXeBKq:eq+Pn݂(w yrD{KfovRDD'J)T]Z /Růjst@$Crh%sN^3 LD=Ҧ կˇ;*5spXp."@ sPJL0lY94n5#ss{䝓yu^lb97 P]iop"Q!oдeYx+SêCMjKyV"2+Z5/G"2,M.IK"2EvUCW|).Mhvr6 `UD"I!7}e+G!.S.Sh PΎo_wz]Z~pd5'fdjD.>7ߌq*!Dj7q|z+(|!{s:n 8J<;}ҷee &"Fr)UJ .mGDvP5AH˙;clLM|Ozdk+.bڎ{+C<vK r ^! eyhb'.T7jt_56޼eQ`q6ՠ{lF6_?f6g2UF V`[/;:P2.nEm|2Ad㤟G":EĩZ}hK/nW 1>:ǣ?BG7|27q`t(B]D-sbefQf:%Q =̟7 Αm#|rlN](\*j\/s)Y /qUȵB7P\Jb߼{)D_3DDtR3 Ql.Co$u˒8Mթ衈sZUbM)!TOh)n 8j%3.{}4Ȕ9"_ _!/"!:ńˎB KKw v[˾n\?7TJʇ.[p]3qs&I\%h2NInA/N1Hӏ|5\|<5KT;\]&yzaMDD@)0^Y RJ]uVX#ybc ;exyJ).jѩqJ Hա%:k=ꌄrCl+쵗mbpn.ѱ ѡz2-t|{?ݪ96Sq):9Dd)"z 26"zRkE\ OCIOZSk|ts*ju`Sd\٧xp 0y}`N^ /=GDBtf}38U\$J\Rk|M 0e.MDDt5./;iXQWDX&JK|ى٪9t%*e}V"A&q% aunK1똶5{^yZV$[yXk`}i)[ϕovpCu`|iqL׵UFpX)"(le3yf؟gh\&"S}l55ȵ5rK"2A9;+josV.CWZ%D韯Ա\vzƶp‹Re,0L[csVGDPjtZk.7{]~9*>8qz}-2NJ4=|㳽ca4tӧk!Gk;ԌB%Scz1DDDm LUx}i6)䴟 HkODt0VsDdbaO> j޿b[|px+R͝])W8Bs:}<^BWaBU`b,Dh\(MF*B.1|"ޥCyt9q<6||{!ttL5%V˥V(\`x 6QU&6 jSV!MDͨEBR?6f -|pE"!6XkSl{9;aL8yg[Cֺ"SbHz\$^ 9O]kC5DUp)me5^PXerN!8~m!ScpisCgᥢUYA)W/"ƉR%LuMyl"")Oze%nybQJ]"D/f}wC_ݠšgD'"+jP 34!"(}\H9vN0+WJIMȉ[ABQ>vBs`tBϑ¶B'@$Q8 IDATf37Qͼy7 Q_Tq) L.ݣMAU9ȭ5'H+8&}v3%FhmAѩkjw|R{9dK(yn*9̜t ;Ad xu)V\(iuCE.OE#ch`wGuUt MW(VoܤAD=ލ@O"6tVl "_a 65KtJI񢰈)TJ>%D&WpQGy1AdJ]45V)`Ƶ+:%XD8="T}\ V9Dg;ŰII13$]w\poLjDT\zq=v zBd;0DDDQ"v>uBDDR)puDD%+p :͌ sc?V.mGU"Q_>-sJD&s=0Q'΅ĭ$.<[.^&<] ]wsծ%z: zVިy"<'ҘmX $6s2Z؊.vM+:FH2d&TB[%"Ja>f&+Xr +rvJ-܄D^+>-k3]G[QP&=u+]c!<sFpӶ,sDCENƲED7E6v{cybRkȘ|]H0Q* .Y&ؒ` . 6 KNϕ2b< E ""ԓط" jgX Qo1øCDLE0*E z ˹txU!qnrIvCE_GHYl  ˺~*ՙue8"?Y0||rc)DZpP[ Nw~=g;JA^t烫c,HIl""j'" Nrs_'D.z9G/v/hWLDl:""J 4ch'׵x\rU]SI%ZdgZ[4;2~|J&X-2f [Q4\b1IIEKL {>Ks}86\2DDĵ[s򋈊2=M%h)ov|6cͩ{NŶ_fJOݿc/, /m|-߾8yp5mbzDDt,%@#Qjeu(~ Cul"3W fsq_;QSJ";d^a׬󟙈䈌K""Dc1>͹ăR7^함7OͅJnNDD"7 *uUa# E^m ίQ0D0 #MDԸlSՇDd7h۸H)EQJ}afsܞ*ADDiOf-∨j&4$00Dm׉C$oهROky,-P[k,zKk{ sDDu`8jD.MlEGDm0I;iI? |z"p>NҶ+ l:.Yqބkt@&I-RD(2)+gQ0D`S)84(d^?]DKVf~3)"#awL*4*<(tx{DD.B\(!""""km\jRKh DDuMDC0+!_i]gi9UX0bOn"bV[ JxDD,ZIU\jt˸.>w7'BDDDDWds@NJDF .5&Ӣg)OಊϕRjЃhΉٽzJВ+Q7[ٻN/\RNk?-]58g@urq;DDDDT{F s-.v#\C)W^a=~Xyapl"d7_^[ڳ9M/F|?=N*'V[" wb:< &Ẕ :]($[B}o Yl |~##"""1a^yay3িyrJu@o&z\J٪s}UI9ʷ×b5C3vc?1FYZCdbJ++5s}  m¶DDDDTd e^clﯛ .iW^!3:xuU#ιS!G\!\"=y&`gg&"m+Þ v msza%kÝvDi|Mk?Gkgf:Z%~Kia&cĴp\*ʸ.T9c;YӏVd9r6ؽNl~ZAo(3 JJIuw@dbc)D%,cpap*`ڠ-~DTI~M/d8K`ni 0EIJ HZU&s\qNdw|(t{ """й` b*KZfQY}p޸k&[ / B$RmTAPdcő3 .Q#zlDDDt^Thq` <#Jɏß/e_<:1W1{"8<_h.K\ """An.fwS׍%}競-^s܄@֝.c=9RǂMq?3DDDMt'O[;dq:+ßM+.ğ'cS+:z^ޝ*2.AW(CdXq /!0< \`p:e_qgH"2nOe_|-Q .w[}F0x.jvX/ߟo ? R6h_*񾫺>\jC)N wc@j"s?fC]q?K#A l" EGEKݜYUS dB>v_]RCCe>BEnrz{ܵYr yP+< vwáD'}k2,Ac狅9݆戈ګ{&$MO]Kn,ʲ2mPe'`kPِ .%`8 &::LpQx<]V{nGsnc7!DQŷ޿!'`GQ8e/UYj" 'ȫ-_mi!C؀z ܔ 䅄C<`:.RyC }fo'-8dCע(_Ǽ<}Z.ED]EƩLrvv# m}}[90 J?]l J`p 65"gKDПL9RB -i F}?EǑۆq_ʯoo/w.T:y[v>T[:&e2ូۛʇcz  -EE_K^{Wa/"Stt-ENeJpq#/!R 55(Ղ%" 0D=WtcZWZBh)~NmgnloB9m0ŸJh=^_nqu|*}m%%Lt%b}I/$a\ }ygI|_Y?X?+ <0UׇmEángO`ڭWW˗յ -j4}}=` j1DD!`|*}\q3#Z0DD?uUJWU|Nz/B[Z]6qBGm(7*ڲC#Y-G<rPlf^,+өf}z]> ?w<$}YIk簌=66QQ$|vZk?y9 bh+"~*2㴪vY"W)s`pl"""zЅpnX6$RZkU5~ s]a"+ -?BFd7JUHj+Zu[xXЁM$tZ<,3ϟQ<:2>}ҏ%゚cp튜{a׎J- Tv 0-C.DTKDD&j}㸘E}Esa%6T( Ly.'Oڊa"P, *$"E)mvZX-\U Ê`rC*ԡl'+)Ӛ/1ɤ\˿2|?ԱxKD_EEWC^&lIUZ(C)pȀh`9tkz7;Q1DDD%W8wo{|/9Mji-xhȿP- Qio9UI*9Qy'-'He+) kU6@ PϟI3V*j4J~Н.\=oxnzSAɏ'^#||A EC<^r)kc+zL ɜr0DDDu%d /Bl/`S;( xءD띅oL=Fջ$QZly\DŽOyt'ga-tʝI_bHGH9Ikt$^խ6)JD-lPhVjS/JA!$.[5!$< ںEDBwCd(I*im8mts^y kLe +8]*Lu5=8ayF Ul"":c|]5y-\劵ċ߷\V򐼨r `"j&.o<6qs:X65/dn5&`:4I}VE%A """w96/=g{7cݬ„e0A/xD'ԂKiz9@D! 6C_x6|ΈMc_XK zN[;lOE.KLHPtynS~%Ѕ'#7p1_%\oG[ti,jb7]_L !"".K ng vNwAAd ]9ŵm IDATihD: .ݝLDD-`S/&V"+6Ni .,oB CwE(-s{SrZ#U-8ֿpe&RP-:yMv0./ˇ]h<&ꊕLn>*~e1p-EW3VԖ]H:~p&\Rh ??<VE%WՖP|݈T 9y2Մvql蹼yM`FIj+gL'S .e2/դ@ g:lRJm*i+-֬ϟㄏENuxEoG~3wR&*F5QM\{2ye۱l*%UlkCd&v I qK>s4!)#"v)rB@?>Cy1eC~*L l+$Uw9V^WhVkMpţg#ޫ>\AţgCs~&""!Sa$uTM"2hϴX}ZgՊDY]KX'r\j)V!j޿P(r.T|.rXXeXpXTp:qls< %6DŽF#z}}.nuh;C""v(2ᆫxؽ?>]9%i%gEu̍ٵ9CLDF^)囖rWkkU^/l9&ωjxťw?{/={ǤOw.E=exluY{LgQ絰bS+4A௽biJgW-"ut+]('{&;Za?"&jկSUf>p[gޢy0зKDD&ۖ\YBwf:p^sFiJys=<6rKKV`"^%c}IK.N_}䀈ms\,ԛA6nE˗z|R H yS!H?U@Wm}Obhb۶rk!oq? FnV[ eţgef,N:hM˷B^FA7@7T$ O whEgA&1DaSy֥yR!DjߓD8IZy4_qDDDT2.2; *6Gq=LB^ũ .y dpi<^ҫ.UPĎ(ˏ%_acT0c)2)K2"Cxo}cN6GP3H_+ !\Ɇ!BeMќBĤ(ck%<3DN]3&salA3 mPY"} Q7i64CDDDD!S` a%+qGI@s'gK=^'Q)P@K K0R-*Ra EDc,W~>Sp*! /x ov!1-2.-눈HQjMZlH* ڇ Ԫ" CEBy)esj -aHvW &";]fFŸf1hW@ I?NAg"""klV~*.&6~? !rROulS uO2I6Ŗ>ėDCSKy$}/m˜61(ЏHcz O%ثBQ Z@aH [=5*BnHgK1}[-tQa",AYw%I0DD]HG_BX|ӦhGz]l1^ DXdx//#crB[s~R`E7Hy<_),؅>⋭^%*, D!YcG"V)3R:d6` =h'-t`S Օ*s{SQ\JB$ *TZKy@ 9[Ʉj&` ZtskD5ic񧠍s /M8ղu I@tӰKqD!Џ[Ab"[臔'∈L/B5 #qV["X3$j'orەpI' `S .# 6QWVBg lDD@%}}VYC6u=?5؆=&.8P}Q/HoMIy ڸ$Jե{6e}=y |pi<^Xg`zyK"""rթ}[EnWa2*;}tK[]{)9ID-">b<,1${*i[`QK-@]p)S"F ``{-t&"""O$J;=)'Yye+J"Jl#Aoyxgv $IGtX3lɻ0H*R-X-E*TvTx}⡪[򎵵F@!uQ5I j*l0OM҃3'SOIMDMrھϽQ1mѶX H1FDDUr/b3^M7Q}< )K~_AzN,@5"4R/-Ea)W!V7"vA \PQ)"vA֊c{9("}C诺]bӭ-u6Xs+l 3[T ܂K*3sHO[ !.bD& / |M2!!f%'THHGU/. !a%]RR "" ڸcE=gڱ>=G}^h(bjB߅zN2= kU(*.d`/;q;c%KZ*DDR+(( ? @ !RL(8@Hb&zR ovqDD*9495p56=N *瘏~ڌV͌UP{*S qO|*4R-EgQUְSU!I ꛴s6#q~XVh`Ipv9T-֨cВ!-zr`u QkmP .Wp ;RdLKDwեT7^h"1t"GJKKזcŹ_R٭JS sa< ̊@cZA;(UuR6hVm]Z/'Xnc[jM NTC0`~A?$""s?b  TߪK)y16c ,O_/ǖx{K+zmjI~r+[r21ɕ2TʤzxØ?ŰR&`sJ 6"+'""CPchk~gLՖex9=pBMPYq[#%]o1X3ڬ C:V q{ݪv+=r濣%!-u1" )nR @|;Q'XM]!`5CM8!RDDZ`8fm2WMDDrOS+4 wc8V2!Kܿ2iIY3ؐTvbͭO#?*?@We5"B$* BLB &%ē` a',P.򉈨T1"]ǡ1=ODD6)RDa8"58A2CT .Ȋ` Eq }fs?QWDBq%MΛ3B<ԍl:x \ DιlF -QA`e|"5tR{&eɥ|ڤ|VTGx}" R2%6z~HyjB"Ŏ=7%.$6Z Hy LV }OrH"h1$jLKBElF=(`r6`e [t`]:KM濃g I5ًHd%e B,lGDDD~ %h%y{a%儈m)/L$ -yF }! ! IDAT&clɢJ"qYDSU*+G(Є}eB` & {>d۪" =63!rRd>VrCGQ@-ye1jr#c>G1~&pW+ P\)wXkn (\F3C зECE%C,DTu)#tu^5'vr=ZH( 㸆VR* &롯'۵Q-RВ&`A6yՌ"&O`bҼ "}~@ɺ)}=w{)$"[3N(p &?:m.X`h)pfP}۩q~u9GTC[Kf#Sz6#Jbe\@?$6,>Ky3 ~<~ˏB~!}/X AZUV2*چFMDuC; 2u.DDԒUzZ-ۄIlWELAǙY=ND!`?A GZEv;fm5 !9 FDDDQK49T%Hor1Dx>KT(&ZDc%,٥| ۭj_#8T??v/{۲ǃ%܏my?__ V+jHIv[1k}PmiDDT'g9.Z~n&*ė+h[ :bvパ/lFDJd XOl>Oɇ #bn`4#{7oCL\'xe'|P=YR(f;5dXòNOJBv3WTޜi!@L(Mzr1`+|Zl@MpDap)X8&IR.@ŀ HA|&"""" (b뛼 #d6E-jEoݛ ;pi% Ty>K5Td"mmJ청|![ !R sa ;9(*6%ۺy\"ًKK@;rjEg܇I]`.p""""8UAMݤ%Y #hٕLKKyM2\Fݏx/"&=!0q9Nx%Vn+*jրj >$QJZ B1́L""%8TgV{Z^D|WSъ8>4MXW\cNg/3q ~kp/P\KDDDDDrcpBe q߁MB`KK81!HlbK,VkS|*ÎAYI_S\(d`ɮͷV ":D ΑRSljm(hT3,!&:ÙA eBXVB$yMDDDDD7Q"yqcJ>*oX2*,RȬ .-Ed@!W&0:s|,kpwOw%>| "2TO2^*>S pk6:!1$bU>-2w"""yB֫5`'Vz[u]fDDDDDQ$7yly0lF=Qaq p| Z%["Q;^#`@~Iߕ_Mv'N&}"!Gy> 0:QϸtjێQ凛+ۙLME 竀P.;s""""%m~l"" |pB]piQ ,\ZOBd'∈j#vnFu {ߑ+:U}}O⋭SI.EW0"Pyh2Ce4Ƈ[K WjGK1PTڛi kR1̃"""$KlT" %]&J"U5@<" a| .-E%lvRa w%~[u;p?>~ǃU?4ےEH*KS;@YbK-?C [N3OaC& 6]nv)Qno\"k7_.|~yBS7??DD&Xf>P1hDDDDD_Ɂ(b~'$籃cK"1hԘDcU*]HJK~#qʿ<(ĺ0W\ڄw%@}!8[EjPl;>Uȯ.f֪NabveFow?j{U+>:\ck%[TX@S.RVjx_Rd>-8diؒElɻNm\DГsb~*~Լre>Q*2- UCWLд Q9rJ] ^(fy`FDD>~KA;;3?\ )Kb%vqDDdsW.`UW_Dr^%]X{>D(BqA${={p=?wpY€/ n'5Sh2dXpi)2V6%Џ}O@ !-yǷ؀ti''\oDھhtbVI~|2HPY)^.V+` ~+{]F(cL9"""RϤu?6$p??D}<[/$pCByrd@ @ @2Jʊt$K> rQ̡%N"Ua mz5(!dB<be(t!fbE -f|h<UVmjqflTER͔_Qbv'DDKj# ܤ'8)yxN.*FQLXX L!+)  T 1!جh%g/$PPcbBbR@ O"1G0 @OUМ&7ǧ&-MõQJvpm?$,gbvۀBK)#VjW 'Df^#".v٪87\m^^Tj^0o0({\j nKދC+K7xÊm`r8Fdy;`=[%wG聥)h%hm .-Ec^g 9(Aa/scW%?sޒE/~Gr)pJ& 'xDw8ǛVo7cIk寳6j<( íhyxz|{MĈw!RЪOz(̺37YnTlPЮNm"Qm AqJk8>Zߘ^}+|j܋8w6<ˋoͷ@TAK񾟿c0Ż~'tR[Kzh"{-Y+m TWP(U.ˏCԿ[(f-%"v% L6"Qs .jByӿ9)%kUO?u] A8IwH< .<ȣHwP\GFUa uex%X'?CǫB!iɁ(뛼( \ؓ$r;c;)Rd> -H<HylCu=]mFU Y'b*Iy:V-lpET&kB9!^"3Z*@{OuO~Pk}iޗYGTX`u5[.sCD~^'b Z):>*QXIYy4+fGLY Z 箿K7UoRic F5lrtL9+ׂ,Gq^DEvc/-ݟm?ώbʅʶt.ݸ* R1T[Lj;;h |K'Ǿ+Z\[͠?mD%6%XRӖ\"ɑā"F1rodpczu%˭ҍ6X@{V"=Ɨ,ی&JQB}trӲFϻ ,=$#0+ˏ~gڋDY""BDi$|!2L^Оg|ۣ[RwB첹KNY~xzﶲ]#Q!LV+@*$Ǵ[Q┃&!]DD-6wBU(jKϟEMA\6~{qw~f&m|JkȾW{ǧqR+ݎP1}m~l3AMmjYp BKe ڒ\F+*[U-YRΩuY~gG'ez1j4$Q ILIxfHys(|*fs(f`dEOK+{Q/S}48`QT~ة"8QoW⪷R bx5EJ?zdf75k 㡐\Z|[D)%ŭ/J*9pRmiZU"TU(U<Ćwh12Kymr# NNWRJC2=ZX}ǨUc}YwXZG@]ԿjbDDA$ `[QB7 T.1JЎwKy)%/!%{T~X2> jK0*GFqt߁8tsW.~6 !ckPuB+C`Hܓ(ȣ)'ySh@7>544R/Il J*QM돭 [jK'*d_lM^̏a T@@Q+60FpHȪ].a;Ҫ`B`9PȪQyQk VwI<=0k}ge^ᨶ ^1zkI0*;!5%jkDDMN;D|7 VIvVVsW\nBrުٷ\h^xLiz=WIiÝ%SKF4ۋ[ .Qw_ 2f̷*v'DXIJA*e^P;$,UCRCd2-+NjBA_rRu\avjЩ|7 OU4+D ʎ Z\!P8BJ)C9Hc8pʫ6ot;jKc^c"W"Vu'?P\ ]]'b{qbẗu箿^ y&v_M:^\*Wq; gt~yQ < (&XuĠ"lѬSxf)2? kB}lK߼"|l#Uz&%,BKau_a[a1li6n!,=Pb = v t$BL0QlԜg#?s9ADԠ+bp[Z5$wѽܽ~O?/-f=?w;l_08$.ݸֲuԾ*oʟ0D3cz疼B8RՎ@ lɢ~%Ԯs z2ÖH mՐDꯌS5]0O۪:QG `:x7&BO[!-q1X*vX g{g RZ)!mHY%4ߞZI{.I")Wm ?1n*658:c*{/GF\_'{ϟu -wg-}Sc]qͱONRtIv Pz D*w,Z +$Q%gkw)"96&]h⪪7q5v!*L7m@^cQ镏Zup8 wԝhFР]>֑" gйc{UjFP z-4oinC~~e @Rq){WG@n?%WT)'喙MJ6+۫Ͻ7m!K7!{yW$VA>u 0ljѽlAsbcz^-jA;$/ ,d,0)7!aOa<^ڒ7l"" ON)h'az sE1Y1h'vF$F :s'JꪔcHzPԚQ<(=b1䧶CBSl6~ix0tIDԘ܍m[_Q IDAT'jG_M6֪nϾjp.+=]& _E*U8d5qxWVJ{cp~z} .-Em!h$O#Rd>=Т)[CK0k=v)ߤʝDED!P.JF]Ke|.:Q\S2*G|Zj^Y%OPf'ǩ -x+ߛCLA~l]N~Fy6K."(m*q G" >"^Z|^qw_Z|x5L°UrsW.nO۵maM6ZP( tj2Vh5knFN,}G,^9 Z7! =ٝՂs{,\:+4 .)mi)v0+C `DDԘ6#[a 2S+b=|ez8* D6dyށj'^5 -JhD-'xt'!?!SB1)%[yz+M""j_T\  }dˋUoD"GFqYvz{[QwZz} 9A@ F{3yXDn z*depi)2o$ʌ<)" ВN\ !-9 +)lWH?~(u6U'k($ܯzPz'ݒ|PjS O5m{>B~|߈YH`:f>6qf3BdoyL+DD p d< Xxq5[ۋHwBMv{0~Ͽql>2C;FTqi0 &}Q-/ >ϓ_KPP{,?RWQo$` !ٲ7{eIDD]]u$9(jgO'\jʪ -6š[m֕ǰENtr:  ͪJkx Hwcը^Z|ͱġ=Wm˭=ڼ6e7%`yV&%ЊҐ6q>nDŽ1SsGEchvǰRJ>oDDtt[uj5A>2܍ksƵ o+pY.\c4" $5(b]ӿ8\AV)"g~́86DA&.aMED"""es}sW@*2pk.Rgm 첽j6' VjOY$x>m+ H%!TiDZE"FuCof5ןw ot -/T&jP89yPxxQdX)ͿR#٥2Y& I,4A"*%Tr{-Q*f8ɴVR9J юpg_XojJoAQ/~x?_zp#";Ֆ|n@޾bx~b{ %k&:#"R_h]_q Ghf&?wl?Kw{:V ?&}1 S Er^oݬO*Ϲn{S1)o .-ENKhՑRrf]uE<T2cB,c^Sy\?r;&{OxNaȠn6q.sPvƐQ=E`z<޲x~)2? `R$Hy\e"O+Lߊh;ռU`%""jbvY@<=lԭ'8<;Z23m$F J'l܂jCW+*jRr^~ȾmXN?vos߶UQL8>?O L\羽f$MVܪGK^g"Vs0 `ʿuJ_-d0 ޯ@fHhDDD-6dF(k8H8iGubvYI[=VO> đzhՖ&vCX N?l 8} 6ϟu}q_^Z|go0X ^}E\yȾ{U٩ۅi_qq/Zt㧎mɻm^%%+ CX2LD]:D(Wƪ .wWά̬VuDDD%%)eNֿNJ)}SP?S~֏|1S+0JQe(<EOK׌/R%!6`:p==KM:+˜9;\K<'apmBKA<"""ޥX@$nW)fw;ؾbz1h hj (tn8gpIJ"uW#=^Xq { 6P(jVq_}EFn\åא‰ܽO U|m?5+Z9z>1aߔ>2ŪۍP룯x.?t|~zJwal+s~DWr {%jʩ)DJ .Qܨ) "PvU(xTTI@$Db9[8T ēױ_^t3c%Bl3PYmIJnogL""jZIN F:V85TG_+㲌 A~r}O Ϙ8ApXicMkɪsotxѦi~i?,Wo5|Zf_n:T[ pkD7( DCu1:JDD kipi1z5m6a0ZQ,nT6ӗ4}-{vt`Wm0vt`CUr5ersLa9,n=Hjޖ #b<+(eۯ-ˤ*A ;J\{S夓nnADDDF]ۍOV[2 f_5۾ ?-c@-~,?·Q(.U4 g86ytf c @j1z5hŤr -Y2{>' 顷3hlR(.ۆ=aH:mTNM o94_IazVU2,[(\!,/qdT[@goM܎1\ދ|U#r'D 8 DD X-Tbx~v箿܍+-JwuV]ǵEܛ/>2ax#bx}P\oDk8Uʹt#^qVhq>CUi;:A@vǯ(t -%\\^^Z^=5%N + ,8e Z ~Kzh",Z^ndTLY|E@b{ 8EDDDj. d bvy H)Vנj\}׆nPaKZPB 0 3PpOA[u%G~BE<+@J>DDJ[g++ @ ]?JkUU]"{/޽vnf'F9J32߅?|!}3[7+;:^}Jeʅ%`˗:Y'?йܕ h5o&nk .P}>"*DʂKz62WRsLgDL7ފTR6:myf1z5_Ghm`bx*vtWMh3,dp&BUa lD=]0m x9 ?Հ+mjS&.mQ&50d4!.VΌN),}u^"h?en u;J1rt߁Jn?w $0q-4f0Wq (m!w}%ա%'ݼO#Nokť7 s1&""/*+.9 X5[Mm\ۀc@%|0]TYc`6ѫFp' жgs =mefLXfF}vr1z57yK=dHb@kHvtDDDa#e BqH:g PJYBGxh_@@mϛmpLJYpac,ˠ{1|#+jh3ՖfDD M9Q\D[Cu_^y9_{]KaAӻҍkms.t{>kSLo[if5rNJ)K  |W<=< m]X"Bx?d{Z'""Q|ȾR^qgŪZ>Ѫ6I媨dZhkS~T\X p4s[Q\{޿$W -F7Wga@@UJ~/e@ \*@VZJ29LkTA.R%L۰oQ cЮAHj5+KMCe≈AM{I0DT=0^gk7ꜰ]A] I7}~Qk%Q2fOc!NtSf-@ eV+Vr&"RZuP\ť׺)vT,2/픿u}MNl@ Y=uDG֥E.HovbkGkOe_Hj^[u\3˗_PYqIC,yh=vN\=!)8eH.FBZuaKf`[^Y9mKӰK`r)QC 2*V""FH%ԨSB8AKT?)"Sp&> zWpJvZ.rN xCgT ;bvsJK+JJqDD-Tu)sKM0}5"߅BqG'p]q ϿT*;k0{OpRh;~j{k&7#T>tsW?-isd+Z>)Vd<(F7>3y[՝$kfP00l^h=Spi6kz|XW9Z)Бp FԨ ך*HWE6%L#"" Dԓ9)hAMuE= -K1|]8fRŭZʐXv*OKWBK~$0ҫbn\ 2?O~e=t5WhV߻5skRr^O监ܽ@㯁vo+m[~;oUFOj+ߜ|?>8}׍V?n|IԢ*6XV=̓%knlx"B8Ӳb[fyOD>s:mp1$1=$qrHbϐ6)0 WScrNAc5AJ9RjO}`'=ϬwE!xzxF_+kL#IDD%(\T1tYO߮q8}cfkǑ8}xJod@><nLvkPN?x r;V[Jw!hSh} sGr9|c1*wV_AפU'1}o0j${U1bjePP K*ŒפDυVT,ogp¤֡+M 5jjگjUQyW8_7ݒgHH7w}=Hx Dnݨ =!?ufLy1WIh6Ԧ@L\>R?Fiq,1 G} 햝3Ѽ{N \ @Bgfu!'#P%Z`X1ͻ6=8[-s;yǫꮮz]MT{]?ޏ~?ݿ_lu\ޟ_.F7~p~u>wͿ!W?]┍?vٱydϮ>D}<EKpˆ=u߳m{Yga{u"¥ׯ)ݖ~`i?z T{ ps+cZaݚu3{C,~֘pٛ5_-Yp~Vtcqc2kppu}樛8CDDqٛ2}z$$12'`7I'ۯ( !1Q@%Bb2JdT7v&} cEӒFC] M1%.ZԼ?0s+ :noN6BH$.~qO`n6}q?#K+nvU~ ?~ooa:|{~ 7~p_/G~o͛mW,Wl?+pQ*Qρq4\ to?ztGFSy $׿/ zAukeҭ ~v/`0զ;q `͝7go&Qd!d,K8Ҵgנ+R=t%B05%UKaϮءh)!B)xS#*Xw~OPgDK)BCyPNQia\oέ,HΤBH8_纄۶PzQ|ʧ/t>_Ο\.QJo~?..~+\SXt w쟏@Ly_͏e9"K{ҥqk(nK&8i_ Q=lDuk,];-uc2*/]fh*):?go.jM"*#P$S?'2 -6*PcBة[YCEGGtE'EL8徳5Fkɋ"!wd?l)T]TȻ9BH43i=(cɏN/j=/c?Ӯ߯~sg>wueOq}zߍ]Sɧo>7/w:>+Gw@ Ў^[O~;}?7q u}z {ۻ_ۻ_ O|#wG})gh;?җI-bwnW`ݚ#Kus]zk=/ƅKP>{5XBpT\r&Hʮ8Ic2/7楙e !sHـ P2LMvleue;'ul,RʴO$XC|wVC?wo>𨳏s=}__ {Ay퉦*?/cb 0}z(yBHڈt:7qn},Y$PO>5F~3:x.HsxB&r}zr=o~OtJ#R-g&s,8}|^{%Cú5G3%u+^Er战(\ d}2 WuN[La&W6:M5/''B2TuHBlW!D͉# OĔP`L\jZB 0_-632ЗT7zuBo÷Cw'?!kMa?G8a{8q[? ם[Y]잂#Fvs 78d9!iТչ\j9B2;L-PJUghRYL_ -=}q2/!@ :OWozӑ0[Ч+(P/>0x]ӝ&`WNK36ᒟ gp:goa"{t # g(`dcJW`:l( I8R ;O!$j;&yФRB8$Bѯ/r*{,)Gm(Ty莡Zdo<;Ko'( 785Y_ d;-s+ kBHv)B N\ץ>-|ޑ+.\Z8tﴂӅnIKmgPw?7\vΠuVPĵnvnDa%ʬ;S:Dhᒗ g[Pqr5k&jpbI'ʗ>{ӺppvA7@;&qm%Bƈ# j I yLBHB(mdmu]`C"9>i(A=TF!C{hRN;rݮI'<߾Csiy[tУ kN'MQYĘkneA:l[˵B!D(ޏ!HYu~ϸL;Hb$/C7~ғJTFwm%h .O&Av|P`K;1.%A2votc#%'8_88;`7נ^nkN~q {xOTslVXD`Q1QE8Qz,]88;J7W}W/B=]7 ˄NBtvIץX%K&O4 NKqڢDӹuש6yO#sI5оD7$'6{.NydlLbnea'd]oBjk }b*2!5O5.2HP͠Pk4EqKr?rO +w}]k;P˧/CPg4,~# .EQ;aU8uxIϨ#X.^88ۼ>{jtUts!14)0ZsSc gy5+05ﺴr"!dHھ 0h8"R D7ynNq=jM 蔩 dǐ8Bʂ9wjo KI4H4NKœ9ԸE j\LE&·5T ) O?Nы/OE6_*w67|6|ú %A^^s[ZJx|aiG$?A름Kgo6l1\s9LɅٛ!]2b"Gx /ئ_cc4.]w"n#/^Y!-/!$^P"Rw5k^B(CIw0dAM,s#!d"rBQ 5n$1*Bc;} 77b}@KFRo.v[Y*y9T1`?s+nU9~UQr$$vA!x'i,;(q*6灆V>x٠ PS* u{JPtz=nt9iKq>] }0@ެ <4nrs} %GhwhGz}f}o".91k\>=G~jmTD[_>{0.2V%Z LK/Ձ<}oz1*)LBHZ G‚,cԈ"B;%ci^'wMku󼺂'[;tY\[]/Fڡs+ KNi4̭,x߯o첍L&s -gܢ_L;G{- Zč+J4w ;^{Et*a9 ^6pIWϗ lׂ<@׾5Zܞ; s¥+ ! s{s ߰͢ZUe't5+RA1U->|qwҹ6ad!$~鈤FfO%lu!&kæAHiC*#a#$*n_,8RDqE8㋆\DbCu3"!;F鍡 $CP-x:Wu<"^wi88mj [gJ݇FUn(#si]9ETut?3ZGveR3‚-8 LI{0!7zGS#"[ɶCՠu{JPuV@QG_ N_ct|֝~Lqiq Ķ; dqGr;@֑8Ev\GMg+9kago֡wM*@ENwK jUMnK.I|o} tCݹ?MtlW'ed{U$tkV`O)!d~3(eYhjm#!DgpEKjKy6 ׸nacwv nT8quo+ʪSMs9V@ψhqrm6Q!tPQ4?EQFĜ!`0SGv*&-J]4 襍o{nE qv5N9I(뜴@{#pIWחزN/ҋ/GOvW/ 0Xg)f]z„>^ l.anK.K^:J}FRF< 4TGHvX{+H-p)쾜G& !$~c&[CՋQY X!Q PJd!+j.u_˩ۇ_M(";2i@yrRsT52^*ތurK;3+,ɓDצ*y9d5,/}mE\_|PtbH%pk[wm-&3 [QNX; IDATxL޵:.y';E%:<)숗hP:ƞr_+a.9H*GsqW֪i>MH󲽊3W؄Aqqp&.Z =,W!&EQB 8/mY(w/ +vo wawsَs^e#\0M8 3V["eˉ~Ml .ա.5 / P<]?/2JŹqq4~=uo6ĩyW0&%->pJl6TCwMwh.F#׳7!5}&ɅkN}"Z}PYDxO.JF/!hs\\!v? jWB Dj"a: EKӍTh:_й1u7MSOvs71 VQjq#hsheQLŲnXM;{Ϣ nC=-OmMNrc߾B>}QL+A LEM穎6.U24.\rqD@ (!Qt:E4w&ݷ9r;lIgGT>{3׻R-sCԁg͠8B o}z*s ٛ(; ʆ9A1ʞ9krm@qOV+ ! '}m5)mQȕBfuY*CGNx5c%nr q~78 {M޺ @SNdnoFƍ 5iS6Stc uoV P@pQamc82]q*n:~,kA%~&ed~.;poj >|޹aA׀;p;`ݟƜMS穗^ыNkocl%/ؙԾMݔa鈼8yO76}Oz1yOr仃; ЕƱ~2HYugB4!e\1B% qN\_ ^aO!DCJp^9~&k׃WT:jh9|$!8>q,ØA^>S\/74zEL6`Vq9m~BauiZeuJȰp)NwP4{FM?3)4'7߾VL[h|cXKBHpڝ=v!$KV4UtK:`H!_n~Q`4׎w~ܶV-ߩ7G%%nj9Eq['t v FM8XtD~WEX=оH*9nb T2>*)\"B&G4GWgUɞmA Z9x2 &~2S29X-:aB1 Ei@(FBc};D' i S;gI2zDj48*9x]2P#{ONbdHيj\f*V`G{\>t/\9=4򻿇wsqI2!Hu&&g7(&v|P}-(\ҹ\]~&~gWu:n(\"B&Gj`vZ7X8}cxxv @Bչy45!n XV"FmU b'v$ӶŜ? S\܇#n:m /lQI"pQBه9j*7RZگa:MKXf=ˠpixZ0^ P"AEE_~A/Y8r3}k} SuE֝ھZ`l|0N./w:EKKBp\ )z:MK*~lUlxJ$S[Y\#ʾcLg6Μ9࿳cDl6h4nZ$ T&e[Fj *1k&%[Bqz:!" wۏC`uT Nv1tklQ45PP0m!ʂ+7vCK_ъwjϝ B7oDgǍ7Cv9\%Ylj8[Y lp+Ne8⨹_oG)o\0$vݖ$-\&`m,M\\O\|Kz˩r][oxJ`_&K[E7_^|o/'L 9zG)RD!d=udr!&Dh­b&!b ׈^([YlAꃊj k}߂۫V=E/~::"677Q-eYX__GR:iP%O,Q/q+j& ׸zS;L-ǥTu[3MJtG_صlRJD9%E-ư*>ILàs̱ǻ/=1&6oTѦ:O9o'* xmg1^ϭt K^x3wm3ɘ[SyvzB/_&*Pǫߜh|I !̲',Ș qB={^b~!#3q/A LR$^TE0۶QTP`h4p[ғ, HN2jfqݺukFå~'Y§u8i!+vйqĸѶsM]NMW7)gLqdukG,k|s>,ϫd 0l?ժ?: o&'LCLs$DpZ\{mj7E p|V?O¥o$ŧ/ WokA@0{ϭn|ʔvL֭WA8=D]޵÷ù_N(\"B2˼D NsOwDZŠ(l!eBEOB eG I4HyRTu,*˶m,..'n+), H^ױY$l6hto/d я{hBɝuN7y'{Eӹ}vs""{2 L!ZP?߾ )#V:# u oO rwC3!$GEqfJE, 3E>P~2(9Lr \NZ}{wZq/',2 S''$uJjFK 겗h鹕Dd?(KBT0/@ =Wc'$c&{RHY"ua3MLȓz=W.aY{e mh6jX2Ws֭Gel@W,Q,j:`ix)2Vg~b QInI6yOqEvjr^nN]$AH ]1<~)#H F"VQkcui7f*PP"(f>[ZG~уMѦA&vgBQ J9஭NKE霑|+f=l(vtqqkw&nuxW:γܖ\?W&qZ(RD!թ1&\(h9,BHzkI6!(^")ŨےmX[Cuj˪X,bssS+`)JX]]EZ չXTc:A2:Y.򭯯̙3][[[PA@.K%h<Ÿ^&wwsqԄ HIIH*]is!Dɉ\l[Y|и.9~א?" !{dmisͮ^O*QWsVBSGQ˃9|E!aqwZj/o7$m/%_ NUeAEP:1թ.(\"B(. XPj$謸9x0d# !.!Z dqleтaRZE_]]F'r)}&ea}}|JpLhV\.t\&rـdP(\.wjJE#& %ʍ-I4Q"w1S5:8yW@K&gҞccMf5c43qO8pV1he\mՀ:*y>}!Cݗ08{_5~׽岔F!]v, o%ה@'ɜba71^'-ߌȸ^Q1gOT~,W";!wO%A2,NKǙdP.ύpB!63 FKc~qT^ #oBbg:&lB A,;b$eTAÆ"ĵZN>J`@P!6n\6wI.j/5\֝c2^݃MY#)m gf*:t0;P%!ׇPpwYOi81Tqp:^W,1_gRaqwm׾5MQ6> .eeu9nhV{pB!pbLOvX!$Lׄkxy)ՃcdƻELK:%˲.PǞBa9PW8=Y.4\.9 %XJm ں4YDᝠwq,"nj9G$ٮ'$VN(c)>o.DEVQKac&gam'uiT,=(pOlH#\vGA>-J *.Kcut;~,w^691ɩ<YGh#^',2nWRL^8\NIyZ=KKBI:uXaO չm9m:",BH8Q&:m9@I1^SԿT*҉Tݛ%QM^mC0dl@zW*u (4%d\x\z\7"'@{8c;O{ }>Vt=k/\C2 u0࿇N^ui7,ܗCÑ#!0sqѦf5P:_/ⶇ.}+'i<}^6}ǻ]Ժ4a>YˤufrrpB!I'v f=%W,2D蟾W)mq - xL m !,jl6R jpar3Q|mFp]jA,ei6D󜐘R3-C+xpM:߫ RNB("rD!jPJ(!hec7;GEi+{qڹ0.e{ =pI5%INDh\'//;{Mc]EݹE*{2pmpyvn˵vʔ^:\OX1 qV}pȳs@;L, ~bwAE;G (jlQ4%|9X,NTܓ+_\lhlU!eWoM 8סV)+܆+n..,Dk9IcDZث8HɤQGRB1Aݾz=.x3|@@>x튐Ay}vf{~k +AWׯuA3xNM,.nliDҴ8B%Gy1hi;Ua躡nP-.S+isVcplؙ:]=aT )N!׍KM['lB5tLQö I&ݖMgu ;rl@&-K3Sm?z-_w>~sbÐ3kb6ml_+Y %K/}¥8 xIK+Z3aZrj{q8.Rn綊6շ ۹j#a}ƔT :ޛ!4̞a76'\QM2E? !e *~b5!(^"Yl`mJd2*e+и=Y.:וK>}HɾtX]BQ/q+jFݜ⤧`8NM 9QDRBKmܢR)\M'8z-w>s˿O{;88-\TdU~dKwZQcnw$@W$Mk]ׯ/?7Al ĴXP9^ε]\tBN[{rO`gO`}O`uOL55hLWnBDDѹ-Y5p)LbB;4dl@:˧;OmFs|#?z$mD9Qm”FJٔR65))bKP*:. IDAT IXu~v}!B !vu$( pI17cQ7:!GᅤR{?S1:;@ :'oU{pi;#&*\Vϱ s[6Pn6<'kj`};ȫεN!i'l@DkvnPϔu@L샐q"2XÉ8D!щF-DDUI#e]su\b/(8^B)e759G4%nZDGTӳ5o %nz7I숛Q0BH[LӢMe 0Sı!8| yz!&~!U2J/}7]^-Led=pq6"-m\:jqG1q}=-UOD9 ֟*T!۹2;3+0YkdHsۋ6XZL!!|r'=4ɐP(JmhEECn\LD7q,q_xh#nj#jrn?M? )Fg~G2-p),N}kGvcbΓ/TѦz{hY_rm\Fcҧ~Id[ynVq8.jq[ 鍜t\.j9B\ܩ!;wIڭK Hyማ7ՠ&BgA7mBn9&7:i~i\'e# "Ѳ'uiC9ڴ{cKPr&?Sx/]+\*sh/}^GSy%~-%^" O?;"!q[5QZB6YM bvn<!dyB("^ 'DI"R6!Djp".9.O$7n|H~Z3awY e̔P(c6~=#o$|>B %H#*W!(^!6-FgL)K M?gl3+_RYx(6T6E6LjXGx$^ZgDw|b,C,9>'-`=lӓ~i3#ν^g:>A3'>;CZmۿ;ԶWϿSg9vFq/|1{Ψp AǕ Chrs;'a}K˭ܖ!lBGl,f;⥸ܑv "K$ 4L tioŋ}IPg3!hRF7<:kN1=ɝ'kGӥx,4?)" 1@R mڍu 1meiyMPXuo2ʶNzh:n@-x匿->~Y`B@^P5kk3BʾuD?aO~ܕcL*!xӍ&ǩmĆ8H@Ӊ'L?t<$s]vcpyvn˵wҊgb<'MR@pXCD[~;U 2<{@؞UTns^REHHYԎKTK$PDHPF0$(7. !dHPyq <ŅnmGyQIyGqBܔJqHjAEF>a `_\ʩ;%ś"9wOjFo@%:emca;eZ[K0M'Vs@7)8ݸzZ@3p̈Plׄ,irm@InC 3EB!gl"Tǯ,S@y2]PD//KiPe[[nTlLL!18j!Wub';+qnN7mqyǼzz!t ^82hyưmPxw˹wЎSEcH8`ɴ_>!ƈpi;Gk !(#6tCrZIZP Q ݖU z)lC= _+By:8/:܏D{N']*6IIf&, ~HD3!LvDs:7n& ujrnNi 'DcSKXY,v]J`ɅN82L AH,.9oaq;t-ɉ2mīJMT}..RSsn\z;%2$Nl[\ 8A $g'u[V1p66\6 +V-b$2O/q+jFݜ$7Йho9 :#,r*QEpo87zq%uiq \;)$/KF¥e{;U^ ۪ͥVWvnp5lq#YOKCn֟Z 5:jK˃^ y.BH4d(sf/Yx$_ZR4}dl@}W'} $D& 5y+US۩jh9|$DiP=vJf>v6.KzNHYs¥^b>AtpS5b;qXkȸ^٩ЗtlD`ӇէNm=g',6\?۹2&[:\n8T^ ۹-T)BW8NO&(S [PϦ39/IGn9q\}JHL, ~X!~܉t<+jr'I'<2w/,N'nj915%HDǍ+ s(wK3ōiyrXv|mtKCrϣp>p\2,BY]ъ( ӂ '欈Oa;8#P cKZ}..7.ZlƬnN$Fe;U~`q}Er"z%X4/BM%m!@c+NjVZ X#l[ߍ%e_>2pBB'g4O:5c6Eq9~7-x@o5Ӈ?arCR,v]XKPL ᒮ4RDxRS*oJs;U:mP7Kv)dI`} pxƓץzK\@f lC7R4 =՝vnknrL6 aqIWVݖ =IX &dt<-D !$KyyiT`L/ )nu8d VZ汕[VW,w~~#0KΠ_LkOuԩF8|BRʆSR9?ΏR g\+P*E00wa\" B)xS#*Xw~JB@6 p[:t9ljgQ<>;xZDK@4%U!.ᒑɥe{pTiWyk;D {L գ*l֡W(7ur")tlGX#.RGT1$].B؅K{{{=gI /!T!Z,na++5*,,0H'~'e]>S*J,^$$9GԼ%tMuޙu)#n 5#nwM;qmK$EG$Us+V RD  B3::tX:\"MX[Y~mqXmFIH}K%Gd3XbRp)|B̞)`6ٶ,3ukO@ Tw40R8K‰k|bTH%mg4v$Bz+,~; GJYw&{p&{&{љpBHjRqS#nyW7IۂӺ>c41 06`.*gDw\,~b`y}Qvnk!T&ݿ۹- q!dyƞ"sBy A.S螋{d'$R 6I-)َ#~^P;;?T , Fur&/r}|aٳgHj*:=Lk !y'p` at -i3)l{~m^Ys0TRdA &^#F.aŽ* =ZGJEJh8̾s[kPv/.'~"lYCP[p¥L|DꜩC?hB!`^3݉s3"N=WQAҁ/ڍqT԰k!b⼴op)JպOVf9P%LR,oL2e[0__!B)eq+jʣ)x^e$rƷrLU=ۃp fJ 5wZ>2"&rspympyqpY@E8lL#F'C,>BÝZ3c|*C/k!'yiڻ@ K$=Ek{o؄:!NO:ђTBI ='pkO`sO`}O'BH b/Jg4Ig>7 V.aY[q^uG֕el>V>۶6Θ=B!,#l9zHŔЯQFl;pFx5vRx1;.҃8'Y:\ٝlK-$He ["ous[E#Zځ*# ݶ9IK!$i螽4k]^JNGw !v vSʱG_n"E'Jm&ɍFCcYJs#+_n1{B!D߼.Md%pץEn`;nPB&J6TCmV.2C| էN=[98a|t\6oDV$2KKAVuAzBKE WZN'Q'FVF ;L.: "i6twa@X\\ -_abqB!M)fQoےˡFa`Y.C#N;ET\+:YX:\ش,.7s[uFE o}Wm5fo\:\s[541^i?;_եqR ZB!qWj!y" )m)v DRfݾt{LKXV9f˲,emL+Y__)^jX[[ƀyv)L2Hy\6 LʱB!B4D<9:Gd'~HYR!$Rѽd**us,C 6,!LwMsθH[}F2uyxڄ~MEGB!1^0&Z( sщ)mHyYB:˒n-aZ'xkkk]mZ T*Z \z1K|:VP0D!B!s[:d0.q`~*]#:!dfC8T|[Fyl֡D8jp|>7*1s[mo_cp9 T՟s.M]!/a&;%{{;{{b H)+ʶUqBp>-&P7:qJnkgg'T@b6*Μ9!CR u͑L(_ nKBb_) !B!d!Qݖ\tB9j"L) Ain[\7#Nm\.۹~rFH[}:F!7#m?[x&`62!aOj*|&zT;i#G™d-:?$%^ILSb=G~Q.N˶mcKe֭[*(rـt/Ldssg\BH!U!D9B!BH87Nis$s[r9( >CZIƊ)@ 'Oxp$\v3n[ &SR'm3חm'K7CaTԧ@w)߀"\rH|9Ǣ;m86ځ՘!D2/aKKT%K*0n kmID*gߙd}S8F XkP^C ؄dEx1&ych_'\[Ò米A$a/_XLOl(Ag*B!6 }پc Ys @*P٫,",M s3 f3 IDATYwBFp݊: 0t݆n4iOm\s[X9?G?xipyY<\ !L=yڼ4q=;Uqwum׊RLʽի` a۹cjbDB^AK?"DQ7+JRD4R,Q,jh4˲۟5F\6`Y7lYBr}B!BH 0 ֹ㣁ދ'ZlL`BDp0sQ5]dPʰN27k_vn+pNIJtcu;$Bj{TZc$K˺Ƚqy'BKxbuQ򾇿wM%F&8Վ{EQbS dsDK~bꋍc+eL &ΥT*!ϣ|^7.\rFӹ eQ>@/\*˰ΥdC"W6:"XwB!L' ȩYx), ]$(YsDtMw/1үq-d?0NDž4!9Dh3aDX۹;%AYPvb܉g#x7غmi=d;~bqy(w~p>ԡ)1dKf9ub "VkX7*B!f^k">@h[bt4(q ;qE Ů,Z`LK\FZr]jZ(^쌡el.jp R\b:)h@Mԥ|xB!8!Pj;RSߑ_%D'9דͶ>1֔.z,(K#8!7Ƶ}>Ύu;V6exHn)z^(T` UbȁBQQ+Q!Ό`|xR5Ѹ@#̓qZi'"ڐ4lF)Cܛ΍&<5c{-k-~I9g~c-YXYaCy烷N^>~Qh;u'qPƼ?_kȸ`d\fޢݟq>x -:PnZu{t-iMt[Z~uy*q!kl~þ~+;HGaPRKfGUoH:){h)$٘S_*\j6JTMu5컾_Ils3.Ta-_Hr= 縘XZ y\|}aWK(wLkӅKRjۑaezs"(Ah!r+h6ܟ+X%eq/~,铱铱jl:Ƭ8ד;ySZT(1qyk.LUpvvuv^६z޽Qx}W~q=t h(pA+3{mfK]5 tK :8\- H:Q $f =p#iV${v9:~R7`lּYJMAU~"[9@* t7\j;1ݖŸK*<%N叉_;)yھJf.ȊPCKE5/2o,WF#{Z&_Y2Fш h9Fιs#WMf]١u`]NC/$}#9;L-#^Jۂ0TVGbIRv@dRξq5^>~ ~8򁑞 CC$蠳T4I~E./ۺ?WƑ {i֪pls}}}'?9E3?YfIa%HwǶyhKz]fEEOZ fL\h^WKcVKWWiOߞ*mynK8&ιs9gu-|/%}bffvL n{ oƊS^טyfͷ;uͷ}@oDLzg}W.U6TJƜK'O񋦤3Ԥ*v'EGŝ# (;?x×oϗ_\ahN>tetPHfvQ·`bm᢮[oRqNSWΝx{kIZTp:g|U}p0c]\\d:;;S...hT'7R-tppp/l6իe@>0g[y< a0L ˜]w.Oܳ\tiBNމGқ K_w/ |5TM=0OFI뇥Vt\3Vy_H-$I/։JQ0^>~q`)`'3< :m{w˂)".3 2"bjE#NYƦph%iI2;qhYJ:Q;-Zw?ϳ><ix. D~z6E5 nWsj^{vyyåo+̬S+C .[^*Y/WTyI8;i)Yoѷ:ۍma|H53]HW߹v\i x\_D RLޙl4Cw{ԅp+.ĒƘ8)A\Wc^M@q/A=QKҕ]hIj@~FX؜iW"U H&Ƀ0.9 BEδad,~'@\ې2 NױΗN~8Ë ,@PU_]28 P=qLV)f $/z~0noKYWu߅놤s]ɬ0OZ{nJ0nMI5[;o4o~_!_drh4r]!#I[Z-LOYkapA0k`FЙjf/ziܰ7&&ٛ 7]aUp,~R9[AHo?x{_tj)c" ºaD~arhqk jŅE%I:]ئ.tb=tm/)цNy `S/X[AHjccS[Xs(H Ź[q?n!f'szʟVYEA~'e/Mͩ=W?~~}*ّ+5tJ: BLa'E\N(ƑBkjJL?;ra'w?ROIfM}sѿ_>cpQMQ.ۏn4?44O7k & 귒׎;EIp郷_>~Ut]Zω~խzk=,,:P4Sf1w7 /Ɋ秹m/J<)uKb=yt\CҥʡRLhS$ iw_]~8$Ҳ'm![S7(Qfk m kY7IjZ׮2a$y*',O>jET77ܰ5},=~.Ek/ώq' ?۩) 6E$~!ܨp~F+o]I WVV$B];X0d(7_*zm͜nK#m0B:.;uv[h> @W,F$0?:nMv_:s,n r_̪|h)P1qH`qo-q/om!?Et[/άr{OJrVs߸6ƶﺙR^SwK_';muu)嚎ik~q")?Kk`& H)MbT}w7E ԧlwlfD l$+֐t!CK+=,N$Hc␆!%xs҂׹L;$Raw.ݢ/G(9_7t]/ެm;U ι|MIB~r.TaI*ɲ>xa=ݽض_>~qn/b{ncO!wy.bs;옕ypil6SleC;K#Dȹ:zZ[ku: -uvT1qym  $ i7M j%9#p t?$Ij+]ҒdLv0b{v ]SVFnv ?48I:ŀPN Q,/Sݡ`=E4sI. tjj)h}EC3k'Y&IAmSLIwFyRCKRImǘsÝ{ٕj {S\DT2wbHRf`ϼMEvw~ s@K?\> ` MRd ]oK\ 2yp)+=I_DM>x!C7"\Έ[׵G!?+3k#^UFˎN@V]3q`I_Z_KrSA~F'GîMR>$1t{ݜv>rG%}h$T* T.;w4,+v[8n+cpLs3 Ǹҁifn1lpёٹq]AmydBK M>*cB'Yn`fv(Jꗠ3-ю$j'ouI¯i:>_Z|iv ]+9nKk Av ^3¿iTE&(\K'gW{ cۆZ2zMc96Zd֗ヤg]Y_Ȏ,THmR](㸎J/%1G y;(0.'YK $,*>HoX$T}|^%?TvVH$zmS(j ?gnBpӽNMn,Bpi{d+tAhNXHZUecjYI:'%t :-+fGaA vҊMǤL54s(g,مsjAZay5l4]mݜ t.\HXN"3 F-8@~,< N=ңis; QBٯ۰$u$- yv4>I#‹}1qEev,Y- CI97/v.CyKr!~ ߱K([l/QICK]KXї tlU޵F>l4]* ;5h)KIMϷݜ\L+ %o{Nv9,|--":A7̙lHG(bs} G%*?fUI?:{zι|7x>j$+;bh]DxoH:]%FIhVה/85:1yA(:䣫X u.yKXGPSSl7,-|n NRsdsΖ}qRWq^[}m)Տ:U+z)y_"+e 6'OpSSSsQEG #'Ft>:.Xxd|sn`~Dڼ~?!ʿ.i^p)bs];}iQh}&PNY^ZbfnK4t]P>Ĵj:ne5{@$9%]UXG3vl _±tѯeeGO|O ǏC:;uƵ\Hʮ*;̺΃I: OǦ 4 a4˞+K- -$Mf`Bm/mCI?&+tQVl$b/1e* - F%S)z7$ #t ]x=Iv j跁MƕW9綽 Nιy]:P =NA%%lߥFC}`#Ob%xkJzFF^Ư8|I]0).,a5!!X{(_(Ns2ubq(2xe'rY^SdA`T>$Rg2>юYjmch1F%'w(9\la-)5%D-rw\Wf}P]|^Zj_UH4%]A]h4*R';UDCqS{zA)7oLjZU}I H[1Tvnkju %!M)Gqc߳cSVv 牤I,.'E?.Xce>~Y^ IDAT8\q :UMC CKW13 PS(bDI:ykPLA|]Z!, Iö"`Zr==Nu%.PPy,C_JP'z v ]o+K`KIrk,h3"s} a7j:OnF#k^=}SUCKQ5d6/\҅ ű ~1W1<PHd8Rn+r3U^Hwu'`$,h(|. , Y* Ѕcc:Ӓ$vj vs*Byt\B Ox[x; pε3ҾnW5ԿQZW[߅i0lB鏼ʹ^eJ/h?n!< @qLH*/KLa);.Yn CM:l7,-,FM#I#\' ιo@م|s@n'7'$҃"tL?[^/G E'&͏LIM`, A0Ĕt:xb0 q'%QGEp]4~#:q=vyf4>&貴za2w_5 ~,Вt7JN.QCIC.|*qUBK ^!a0Ĕf!xg^S[fJ7̬k #{7Smw }8F_v{j˳°SӺR!`v[{]Ffv, g$&$`jHt8~{:֍UP5 D#柿=[,ېp%P#FV3kPΏK: 0ĴiwP[VP~Q[>N &{t_ڕs˸3%5W`!Nt~vj MSa\0ON^,a~c3Gf#bcfuLҧjәS yvOV 9 o?P7.D&tD/$߇d2zM򝮣q.]2חCzMi;7qX\P{|%_ \J*RsNnvj$%t,' D.sIۢ3sI?*I-ӏ?E5ϪO֓ϯ 2|)Vb60]vŢ׊uu^82Ix} -NEQceW/IKIݛ)!4 b]m,=vR>zρ.]=SC|%q*t.o,9eX -uKX׎$tb-ki#ivF sm{Tpd"tz(|m44EtZz7s_ԳT.Mպ .SWkI_#݅vXXN~+m5sM~]isg6 / ,>@왚 *=v%ÖdK=K!{f|D=ksܞRŞ_ mţmo(ε~` 9;ZZ(n sax5Lߩ!iO]}NF-`|Jv{5ncԬԾB 5#ӣڒz[؜ xy$y+k|\N.Y+:,.-B3TF_H>vgq+Zʠ tL\¯~s_yko毦v%?!OKhW}?/gJ|$>zҍcGi_bӔou3ՠ^Dύ\bL\{=>4vfebwMړO'`S 1+7+j70Ԍ|^۟7_~K_o_O6,o˯H$,Zݫ pìK#FvFZ~VQqIR Wױ;^˪+q< .Dߗ% ]琀9綽 3;t.mΫ9_NhiX;QoaO./}^I~'~~GWtN&-APe熑Lwcxƈ8 _{K.:ag4o2v*ˈSiⵣL21R|x`g:O) `'X2CE5+=,N$|hihi?/Ooiӱ>U[Su_z>**Ḣ46kq.界s[#$I}9wm\Hat*j+$,.Ԕz査7ӂ?WV#9wUAp N 4ut8HLhV|7C݅e ) 14t_h~Kbn8h)猈î3ًB k{E݃CI- =^S#zMt1fr{|RXtɶ7rr+,؀h6[w%u~a&n\AjV`)~o& 07:(\IڐR~]H:"]f>U[δRkz̮[HR.%#5ؐp\*٪fF$a/~kKKT'o*^l3UƩzew \'Ѷ7Ytѽ'R-ms}9w"I:߷Kzg:ўzNMyfCK´^k(y^GA|H,$qk`n߰ }{_ZXG 5>!!@dfmoCL0Yvh3{b*¨iϩuϫ/꙾SstIv2_pyx܅ ^#93k٥JU.5%gUr0 ,O^}tgKgt[,ʩJ^J- \zmf. >΃C3R3ۉshRyAhi^Ghi|LHzBOR7ZL+=SMiC:0F"@CiTg7S4`-ՓvW>_;o9Λ5<(gɏĊ lQE1J?CIgA-R@1eѽQSzjFzh auܢqY5#|h1~џ{Kqa%z&u=,Op) k"@ )QRfv*BKsbʏ)K?WNհ}f0sIWZ/ԗt@h ^ 㮅Fߞ?{BK@9E^q,`a5q lD%.|i((AZȥ^!(G%NJ=DO5  - .M_~}:=^j.Kgna?2YB^7Y[ujXi (ے[ڤdKdXi"#~f %u ȹ39\ҁ3QKlIp)cS{l:Zs6tYzBKI -Naxs0hЮ%)IpnK.\펃1Y1k$BKv7RHΙ|g(N [2}v,|56jl:ƽ|F̬efD㬰Rꂱoa:i%%da^k~L\⠉a lQq*,Jjq_hAhJIGvYCw*;Vn5zQ~ ˜}-G z?}PtYZ780SCw Ea(/.՟`]=TƦcĮ3Cu#IG`]Ѭtv%&2 Gv-ҐYkˋ4D1qEs%IlT ̡s\޾ ZB| (vY`t(1 >6hlla+}&KtѡpVu;-%PD]= RluiNbt4vOҨuL.Hqi ̰]R%=_g3[t1Kh [j߻2uOZ˾ \Z8M̴NKl8($%jb@J.Hb s% H_? yŚs&g}9W0쳏$X8pl:_I{FZ JVП'.K+ Jݛi e6se_$fv1Cpiٽ.mIkǸ4\{"K\ ](Y'EPft9w@]> d>M$ .Zif0XnG Eax+ JiFo$#{oԒk-ژ K:m$: k1w[t)3;DzOhi;sז|p⽏:}` 1{!G[>H+E( }#X5/|h4SYԔ?LW$|}3IWYU7YK%R ȘBKs4T+;ܲbaT+0 WEww[mfpiѶlb `O% ,8]$4]wPk&=S%[.X;lX|` -umUV`l[Ʀ'1& s3;tGcgbԔx..uzMv]rn$M51/IpV(_h`{nq\,6KzCKsBK`f-Y-mٜ1q>pV+e^ƦH{ݘo ;3L{=kgz~^>h1ʼ1yKRI˟'Yɢݑ\B Dぢ<Q,X BKWzN$Xm]"3d%ɲ4CyͱIPRo߱Jr]ݍE%3LlmΤwgI%?>R1P@ >L(YksvIuTyϠnkL6;I+v&Z^҆춴Wc]h 5aLU*dtd:Wn@k{+%JX0'=~?fBKR9:r$8Zhɬ--!^\͠g#,@p mi;Μs\- tt\E9O%D g+Dv6l{R*_-3kiIhYƘ8s Ui>_Άfvc0@^ӹ5uoJv왆Z~35]G2;חD˻6e֒s,nCnKI:QwAT_:(kx q ]t\՜/MDhq^-$bf^kH~d虋y>~zזu.jl~T,@zM^ºҗp6tC]4P-[@p @m8Zd:B.aL+3]JT'H4w;璎iMˢ_nK͠՗%84͞45;nKI^l JF]ڊ\7<;H}zιcL&͐ 'Xw[K۾Śj- qN|uBRMdS|0Pܡ눔4H%`KhҒ/~\J^م_UxfvšI8&n"!=ue uVAe\ҕ -%lZز׻scZ!(zMzM :,H|8I`M jVt[ Kf"Ql U0_<bL`_ےREE6۾KnCʧORA7)PPNJ?vILPFAץȱxukkjLW:_M"m *K 9w\mˇ4Ha&d t1KCIG9oØ8-.Cc .Kq#8ˇ:ɇns~7թѱPPk7SFd6D2OKfm90պQm (sm{` vEjk9ir ՜/ %uJr31qcmW؞<ۧs)fW8:l df-Pjy&ιRw;P$JJ ʹ^j ^>4HuYs~jo#SAF޷͗sDh+'%yRN64T"{Z#"&(z1r+>t(&AlfIstfOݚ}:KJ'v{Q=D;2ō@JCnJg,cLNW.K[ttujs$is-?'z N_w9tfKzMiq_lL#ߑ) S.AR'QR}i)~|t\2aB%C/j}C*cSS48B;=OvRfJĂΖ9YWasom' -["#r9+ S#Fu 9]R%鶴kE0$EKwc0ٳ]hQ]+c;$niw. rň"T|=Ňn7tFnkw[ݖ! HA$E;2d+tF`L*d?]m>mo'Vӕ?%TMdW|02]/I}P79wΝ9Kz&#_PKr> AhҮu$fv)?NcR9R, =ֱllkw4nLf3BK$ԔY:@EIe< E !hW;\HPRZ\gBͼIêt*eJx59 >$xJ͔Jsnt];=ܢ}/L2&(:.ιAБ3LgBCfvŅ|ǎWf]iB\**eRkvYRe]:nKMSQt/Jv+mordV[,1$8 K@p8ӢFZJR`Cf&Jp:{ }#PNm,H:5p@ƦwhԲqt^FszM].Us?TwF txQu3}p\$tllԔԼNiqK\0s%--; 2I#P}|5}NS?]їnK@I9ltFT_~4%`lJ.bҩ7ʏF^w]"}ݽtddwmnaKC~ҳmnWsn;̎_KmI+ 轏:-[*}a:sx5͠ ̬!GMKqt]FN)Fz;kzTmmrdyr΄޽n:Qfg:u=vt!\KE9?mGLC'p snbf'.|]̢wmI R@w7S쐞" u i84;,d1qH[m-Ep dIhZ^0d~G&L%#&ι6 ёn~1<֜ \/ix3eF.*Ѻ!1/IBK#s@y]`TlGd)OMsDLChQqsF ,Ʀ~JcƦE]>5p3]!3g䖤ɵɖ7i=f犿p/I#H]1gb2g ݌S]U gKBKIËA#,`ƵD;2dF.aW貄>Y.`S%)i3s.0vz T@Fw4LFLW>vaT(&ԉv ikȏ;: ;yts3;pQ,llz-3T$O c&gku)={K=!3] 97YO]2ku )KGh *>Ԗ9m{`W.88:ι#3I%ɟncP35 )-{|S>$u,QJ'cyXCfsN9$E趼[D_!=Rs\:"KI^ߗ-yt\+/3v-۟ юLYX! +I23svSORoljn\MSRk?Nc[z0lI:,-s(1..EIΡ{aKț% pdگ90ܶ*-Zwzet SI XQG;3fW9Nr76J ;+u!h Ĩ>kk+/ȹN[*3;WRgz뵓9è8^%Ir΍s=܉sgcoIP2f$ȅ)g^[[_9&T'*&RZA$?$:/ش5ꠓ#C m(dj Ă@" + ցG,-i=%omkmoyYYs(›EF&6G2đp #ᖅ ׋uOhi+~U.[9x䔑\u XW aJҀv$wby#IcTl=h,פ jig*ólg7iQuI4ݪs^p4ORy F-S曍Qf-.Ħ(#g=(qؕv]2\-]]0Ӳ /N CI}w?[`RHPّEkmP=LwpTWۜ2T8WXڝkm\Q /Zh*no)'}^_ءS9o%؂RsǓÂDžLwSSu4q<2bҌ<ג4pw@uT(FRKǴ-85+:oS\eM1?J%-\9W_nϬˍ꯾.W- & .-@p .%#4 BbmEV&$4idb)}`9mlfD`K 78E7Zz!T}-W0^Ѻ"}yJ?</.+ ˾I -e- -wJ[PRR SKRzf֎٧p~1>3TP2#ӕh.fFVlZ.2|{7BK~ )[@p ,$9t\O#nb @[z Y7Y8BEn4ےz#L?Fv{{PF,QJ ̱l:Hu%އ#zX8V?+Im؏EV_N#bT _Zbʊ\k*efx9mp*򾯎{!,iYSvس?2]+jS*Jkx52-j\XգyЫa;FJْ#a,17~u_/~rPNA<;ng\!j5umsvrSSGiO`G,q(_SҔ@dd-y#Sꅅ1; Ű7I˯׽ SՏqpM?pM7~+Kcy#w^CثWW~4s -ƒn -.se-~Ma˃fhSQH|-*~{j:#V`hIQp)6;g;?;J $Ǹ8Glkpdk[ `M1(p7QX"BIdfC}mdne}Zx4adKLc}HVi >g5iP:N}-cB#S"38WW05f_s,o<$Ȭ.w^&!eƼfFp VCK=e׶sûX>x>vd(HQ8hLvIw~z@ǘ% hY8̨ձ4*Vt~΄2;؛fvZZ'8T8 #kG8W, ָ ۻgsMo\GWs܇2hl&_QSrM(r0)0ۢ&-R&X/F@Z(k,}m<-L5\*Z,2`?ъ IDATn)|s|jj~lx#ږCp 0;eZ:1T(#>w/KE֖tcfcHg=7H )u8.ZJuZ:Vu Uyy{RCt~3Fwg꼽=H2+Ѝ-Ԥ\+K@ -=d@=jql &mKݟ{"n%~x<0]iYMa,-Ke7 {12owCXIXXב kK,¦GelL9?XKz{׷\ fvš%#ȴXpʘe3d|IݨXU)oqT-K}7K^8BjcIͪ3+!4:8 C=*$XMfwryCKT&^g~Т{KUXD}XcV1a337c[N[kT53ٳ -%ݻ{HUWS #U |E~P7ptT??SOROo %5i2+D[i#q¿q^M mKYzY-%CK= r*,5qPvm:kRފL}meⵌi вū&cfʸՉ&H$6I<:>et^%oPR}@>Ky+ #xRۖ2Nkp~<} P 9S-G^R~(f&L}=bTo F̞ .Y(G|;b;~_!la̰Ŵ̌LW*EC\gksW,KR_6J{<}aLomH,Q?hҕZBSKғf.>?Gގս/YKf:kBKWE<2Fs$|RLwgp}r_R] BKR?02H)m$=){BXaU!Z){4W`jWڕhfC}mdp-ߖ%8ҳ"߫O?X>vtvȱ9?ӍB%`ܻ2+Ja'e\xWmыK{`9Kʮ W}I>yBh 윻?P]7wa64g!*wqTUYny@9TTSxQS7 zBa 3[4q[w] 2{',o QLK{@2K R[Wiq\4.@I\PZq۬Bch.ݽSs۔dpQ7-2 @rv-oSv>*,jiB~ŠҝB'ϑ7;!cg/U(#Knnhc67k6k7+"̲j7^(6JXݛM 2mp@iR}ůQRC!DϮ)gBh265u>Jm[!%`f e12 )u6nzѦr0/L Ӆ_}DKf6E=m&7ފэBwY%Rfru4ӡ+@3MQ7x}A6])F֮tp{_i(45*F|_x)#@힓C{NKlA c% ^E_EU~%w˜w̬Z+X%i`yy*4y].>ٽGeQJpiݠQϔn\QqɶBK =pj}]wwkw7}`Ii4$HZI/l or*H&؃E5 GLw#L\n400zcR̎ :?KQ`5Au$C6\\z{dִu>eN=,KlmD <̎ -_{?A]5O3{"ȴƥ4H0.qȟKu8G&/9J:HL|ͺ͍k SN5|֭Hywlr~vU^rqbiPf7W^^5lOr[ޭ(,nJW'*kI H5QM6.iS 4]n{{WL}}< Ũ8 dZL xeQfYm)Qrat}Jݦ_;7*g;j^]'$fW Zv%o$$I-3Bi LNrA EmYK25m,(:ovRYv]*k*+NlSʻ>ɬ-]6Zjoi#h¾Q3F(% f%/?,zY뙙Yfvr@yMY]L7#+M٬g~h]w}#RQPϷ)5ȸÐ\*=~T INvʺPKZ^Z-IIҟf|BAi`݈*-72cdzq^\ښ=9Lwg{S'۳:N':ke¼Euے׺tu<~roO񚄖ۂ\ GCr4%es"1T^S=s#SenyVqne5kZ:?ӝBJ[cJ+e F9W !]RBCr/dۻg9gyd֗0_Jp~}5˜dZž'~3{2Vl:*{®_\qկ1.me}-iJGPxOu/ 8&)߳غ#W7*:uoZBO.Kk6 t. -vFvvc7Žжg3LUIғrNr fpl|Tz#S{dY(󂆘3U ,MdNJlٯG^i_µ #z5<Woof/rBKB NFi1,TW73UL7x( b@)კ]32RZb(m>K42IT@igs~ ty%=2&'V嚒WYK-Mخ}ʦi]'@p @Y^eV:d{%?*,0%u~1whTjwuy as_ ZZ<JF$WJGϠw!Ep hcͼX#aNūkxajMTy]bv2Wv۬ גؗ1qsDZKm#oʺ~E>%?EIo4"ĴWAIi:дM);%oQ˂𒖥YwfwZDT]_*`Q0zX OhCEऌlJf1hXge62 SSrqڛ܇qd\Ѭȸ^/&s^u"v qP7ٟ2P+癄nBE{,\GG?'ϤzKj{yu /LC-ߔpKc k:X1&ϠgEp aKaǒnvx㯼6+3MS8y, 6ʴNvIRrCwwBKjmҐQhW;h}PABܗ] h#Ӆ~ S 砼 W9vLS?oAp @pqnBKIe2-1gK'v4<X@ 2]b⯛q)ZdzZI&5g IPRT#aqۻgzThN ,MYvSu pn֍l(6cc$ Kpp R&ӆ }}w'cXFiĴYO0$$} 2=,N^xn|PhCXG 5}`G1XXvYXxzdj7!,9ƚjdm)rˬӖY_l؅5Xiv;? }"f5SIN}42iPqL-"Xtlدx"6 lfLsZ-K`kS]cnM?f Z,qżcnMt 50 &A%ޛ/<=}S)՚ohS{`c/,BW،q VbQ 1q&<K~oĆ#(|-%gT?c}6HRF;Um;w; &<12Z4-4>'>/B-Ȑo1.gLƞۻnwy q @YN!]Sw9 RZ*Ժ7Np -Kܴ5 #mmJiuv/KM{hJ?o6Չ?s^2kےrMnW_S~l%o%jS|o躖@KKzHxtMR[ K9wtazM\p(%[+-Nn7,^ 2ѲJ?n`g=fx5@)=Jce‚KTi sgm^"hu1_c SK!9 '$ %e0&cpP2Lְ S_e:pTփK#SER/q4$PZ[mOfi=!$5uu^E TܽcicRvaCipx3\t5ʹ`(^(LPmj} },[_'5u:RK-2Zf=02(Z؍[uV]ߪ.ShyWeY(`Ա5楔&|օԓ+`6Dp UWT7QsWzrM!5. Ɲsaj+LiȸbF%1qg5KaANIb(iH Gq Lr/5x TưY{D\}|A {XfzZ>jNfٜvRB-TbҺ# #aK|bg g[L-(<-JV`Xlѹcb(N$|*oQN -K}%q$N507qK86:Hlvu)]ے)8p{s+bѸ@8#)ifdSbpx iPuQ1{U06IҜRzFFҲE4.No4jZ8/1&ihqTs%Iҏ]s+qo*}RM4/e[WJ8n2qDo]ei. Wu=V]$.pCQR6$ѡnalܼ{Ygr~F (gXqFp U׸W]UWjK~t,8c^jxW˾rogmVuDPRsÏeig.Nc`^UqtW#hM BxVJnW!$)_ۻpDF %5^]MZv1&vkQȡȦIG&z#S{djL.L ͅ}az0=@ZaRʘ򏁜]߹Ku$]v}{\zQxT{դ`lS}\UWً ӕo5 5 c̚ R#NM_kB'!8Yc?ʾl.UCZ01t/ܳK%rv/4Pv\)]땽%U[7T.LׂF$GK);I2Kb܇OE FNc(9R~(]𹺄_5b|X<)E_r*[%TזٓNiXkPf׀R֍l\RnPCGkNy#RTHaK#S{dz[YR+OQR !~[5t#̎?$G.Y:w<Ѻ~t8el IDAT9˂E:)TZz~L#kZ;fveREROf[=YJPF4fm^u 2KSE!ns8ԍߏwЀ NH(BФpm7 N9ed2{y"}T.Oa녗$%2;q)CVKbvر_}vZ^T5vǕ®}wg@p X_4Ku<ԲKW ٥?"nquRU lkMaY hW!CuHh dPhZ%H19=L 0Лqן}3]P"ص.Lׯ\N@aa%i'HXGCr"#cpEv_=إWMF,($0 #W~KE /=iIk]Kf;Z'k-.qrbc܃>cwDF'$6.b3R̬E3o e/Ѷqˮ˒G٥opTkI1292Ft7^=M1W]̞x\:A=v,,=?#Cp 81~Wbf5%.hK0"L BLpECI9kfR)׃}uc"2&W[ k5#\ڭ@X},[IM-xvv8ҎèWvT0=k{Vlb!ff4Z®ćb ؙc_`]f 3VhwBu_u]W]gRʵ+mr+I /q)>}MJlS)ūͶ?K Pg *jd\[i,Vc-sE*--߄lƍ%^T]1l [}3}͚: ar}伉+r\kC7 v䖵,bdܑ!(wO83t%CEyLfGλ/j2^=imgaSt32FaAV\)t5TwwǏ@3-Knm1x\N[ގى%K >uc P2D&# *89AٵՒw miG#Ȓ7a{Wǭ#l l(^d8%72<2=L 3wZxa/%7.5,`.X$ul‚EꈸK22.78p#ӕBw_kLEZC45Iw asSy?ᥔѭr~3=}1K8aͬ))Iωv1t{jR}FǯJ-*1T ,jV:̑S(5 IiN.t~nޏ KHEn`ejio448"UװV]1Դ-E'[MI1]c34XF%Fp @Y-ZdIjob 6,/IF=2 F̶=ږ$]cͯ_\ Bp R!vOwD^kR^@e;-;ti)1292FDS~*d:bn'\p]ֈ`_}f֑;VXdT[[Np8џ%` vSng -OҗWM;.(EA*V/#>5}Fx)nΚH+8 '-9_0m181vg=k]wPh\I!I?wtHXz0q"oX`Cv,ncKn/s/7jY O-)Nk,S燲^KůբvI_E_o @XTSlV]-L[빏6ԑ{zNJٗ;<hARW (e`uUSlmWaha1q@Lj%٥+~ꬍ2kߝUBOYaBl F.LW‚U_#ޑtu/׍OKHjQu'L3;?+J -K KٕFw{. $BlHDw=q #V d5IlzS}Y_ެ%d*pa02ARkχw%}tp%BKbPYp2j^]]Ev}RJǦq-3;#5YQ]RKۥ|cȲθ S[xZMTy]e$/neVT߰mҧ/dLR5~V.2VhYb v%_YKًU'33Zu??~#,,`JK17eG]F`SyRցp¦몬s.ѿu?6ǕuiI%%̬Poh U(d˻Kccb*ed\o%4d1so>J5Ooju!qcIƫoVQq$I(5Hue۳:Z^C>qcf7.أ ==Tl ҇-P:zyNa㱄k+`Lҏ^HjX¾\0R*wYSR/= @9ٝgKif ʕYj^;7O*`ik> S%)p(T'eWHۅx^& @fVA4S 7N4=5Ő,tYK!Hugf5>}rC7Vq @оB#6wu>\WɃKDh e˾~qO8֭YY1xI_C>wn+Po?} -3{`?SY%ռEzm gvы.xPBP!,63b@ye}yN6C\ RFj3r~ K -G7O7J ޭ/C K GdS@Vҽ,ۥ$Xx]~('pTo雐.cs4_}p.'$WzVڮѥ !ŭ(13m H9o$=WY,KjR ☷E׾\(skIcݖZi Ojm5ڥ .uViẰP$5^Q$fCwb҃%}{u8~bfЋlcŦo-~7}K]2Z%l9E4T1 N5e\_8<Րe☻e;=_4tyE8mvV6w't"b(𚺕|u=Z7mnilYzVz(’Ep 8>t~~c/tvW6Yd{|gW!ˍZHh/j(lI t%]oZ9{I"Gp+6-M?w#>?FW}^W9su~¡ SOR[ioH؎u{˰okmi,[ K^qơ]~~{ֱB/oˏq>[:ƒVKTVUTShSEI_NamcM]cIW߸(-KPfv݀mB˂KfVWAj{7r$%:nV_R18v#3~fۓ2jYZJ-5-p4jkXAh (w$=fV;62istP:jwl'>qW!+p8%VıpYHm3-sfv즢[w_uo--*}lG$%ƒn_],\t+iM"gfJ+}+Q -Iy#ﻒ;8$t} ŀҳB`i@'O}g[;YE!MjJ[>$gE"cIMK8u4.r‚ܴhcBkSZN`s FѲD1o7sh'ʮXҭφ7t} ^IbT3kKj%>o&Z4Ԉ!.pB.,#|Ep 씙=HyXҷ7ٝć7qK`YGݺ{wWdz3K%>;<\;gfOVxݛ;>YM+ /LI7 w]!"_2J -ݽC%s~oxezZ%>ɒ` B1b 䀀o. Uc?H 5Z`d IDAToߙ-umZMrމ-P:|)` .ӄר]Zo69O?Ӿn86ifn\KFZ/3 ozyJ)?w8t:I`J~JNzf>Zgj$?:~ڄ-+ϟWڕ;OwU>%9J)3Af֋uMG/L'%R\'/kk xY$Oj7|mmzmdhFh zVkZy9LеuXK3J)LCK~6Qտ>0ͤdSthYjce%E8An#MKPiai<ϻoԴ,V;I)gڴ$|tgI|+ٟܾ@lYzfsE)G?=tõO+д,umR5tM܉%xKIJ)ӽ>zyQv6镜u"pW+ XZ7=FR\we"ɁleR6-WŏnO1khRsi i$GImw';9N~a}lh)z'ب.3&5罒ôf/\w`)Ivw+񓛺L>5%n?t'9I_JyvSkU_ܤW2 %>$'%9Zwwwv2x!I?g,Ip X~dY[ ]$9[fw\ -l.$K|$--!Wɳ9H66^m fȤvZM-do3>x_u .hQx+Z cK|YK\Ѽƥ$ }do#Zg`KJ|iX6ͤfa뙛ry`mzvZ+9L3~dMq X^I?Y$w\N~%9~zCʆƁk[Ӳ4CKI7Wijw |KZJI3 *:kBM_UmX\HS_reN3tw75l%`e:,+ftz\ViYf W\׶.ݧ \-K*!ywo;>+%вbRvc}KRmzc3mYz$_4vovwҿZS|]KLjƽ,߶t19_LcC6: l@nz w8T_N;s19Z .o҄.^mYV+Ѳ2B` x$L[N: I7=G㨎r!Xۦ>ImhRF!t%;%}06x涽c:(M@ݝ'Ow  $י>./MrTGOl)%^Wr0I&5>ݝ%1sq -Qi$V8:{To|t.m^I?$wf[-ts&eYZ:ZxXW#ٜCz%{*8mz%ǙWNE\SNKteڧW,=5NrډM~fq:,mz> K[Y3 ,%tR@e|^G|dR3֥&Tz]-K瓚#%E{ ;\EK;6v$Ǚ>KN33Nrqެ 3u jk\4tӴN&5^Ip] .`q{j2ȏdacy}`\`)MP"旛IՌiO&FGݝ?;NiA]_CeIvYGþ%69 E5"'o .= n(뜃צCgmHNJ|fuфvd]34.lL[d 5--e~_Is'9ު rik?>L$W;43<^6|648GEǓO[c$uֽRk]yXݝ'|~esPO5y|mu '9gR[ =Y|^2NrQG r:Z.(ք;&yǵ P~bM =sP˷̵,S潕Ae_I~{`Gf/ =8aa[SҶ\5镜J~mz>־v9W |_py58*O}|}kx mU]*H.6=|6LZ&u^0Y3N:fDwú'|\nǭ;mQhEOBK/3 &{r~kfW% U(/\Ok{JZU蕜ex<'9?|!%${ ߙ/rɳtezYWuU^wU8I- lt''L[fW>a^^owRGCngoy{ `z%{$?[ӶE^85eBOm Gmkc+% -e*ߓ]|0Ú( gھ4/\LpivEkM{¶2_/i > .t ZxYwqʳQuسAaڵ{9iKj A[>l9V\wѲċ5I&'uPㅻ? .ݾpRCš}QLjz%Wi<'9Xb4 wZ9;('9uf.I&IYy4.rN8-K,mXsiR{~C._ -|`^lKI͸Wrד,&Ú$?|y??%G͊E[n&cozuӸKjJ!%-KDJ:J{;R?yk 43s5j?5W6VK^IW9V4ɤd"Š kÚ$-_'լ{InV2\mZ2^C\`-qGdL`} %eXsi8σ{imi<-2o >~ҷM+9L~}'kXsyPrW7|?(I5p%y[-tXmje~ 5b u rǿmqmO%V8YҾ 7 kn}$wim)nzxv(y}&<\:k>~5n %Vd,aXs /46mKѴ*55Ξv2x˳zExj?mM/6Fp ܥ}S5aiC6m먎r{gs4ǽj[|\7a.3'N{Es~.+u/W:hR;3I:oT|Ojn55g45W%5}uV,CGm90/]$lBVme-t[G_eDE!N01Vn)v~\$dmAmKK 'io,ϴ.qwt厚뷅Β5sp5]ιS7Yp%V/;=iY9s^٤v^J^/|j#C}pu2j =is&<5/,*=utv^t*NlVUӲz^Y/a knK{rX߾뫪&9NmmWuqˋLui{yZ +o /J{%<_aT?&&w15_M宎ri{R*[CK33';Ң0qk/ѴG5w%yֺoYԜ?2?48>2 kN6=gSW+ƙns-3<{l;j$uZs~qs2-Ns/5YpͤhgZcYc*kBBBKkZy݊qے\`],^~dV=uiJV6=VapW^..PXKLJI`Rs+ɒk2mYԌZ7=TLJͲ+z%{.;e \`mz%UJΒXp%Dp WrqZ>6=tt6%Yc{S#,IENDB`ufo-kit-tofu-ed0e5bd/docs/source/index.rst000066400000000000000000000007201521054151500207040ustar00rootroot00000000000000.. Tofu documentation master file, created by sphinx-quickstart on Fri Aug 14 17:29:07 2020. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to Tofu's documentation! ================================ .. toctree:: :maxdepth: 2 usage api Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` References ========== .. bibliography:: ufo-kit-tofu-ed0e5bd/docs/source/refs.bib000066400000000000000000000101051521054151500204560ustar00rootroot00000000000000@article{Farago:tv5034, author = "Farag{\'{o}}, Tom{\'{a}}{\v{s}} and Gasilov, Sergey and Emslie, Iain and Zuber, Marcus and Helfen, Lukas and Vogelgesang, Matthias and Baumbach, Tilo", title = "{{\it Tofu}: a fast, versatile and user-friendly image processing toolkit for computed tomography}", journal = "Journal of Synchrotron Radiation", year = "2022", volume = "29", number = "3", pages = "916--927", month = "May", doi = {10.1107/S160057752200282X}, url = {https://doi.org/10.1107/S160057752200282X}, abstract = {{\it Tofu} is a toolkit for processing large amounts of images and for tomographic reconstruction. Complex image processing tasks are organized as workflows of individual processing steps. The toolkit is able to reconstruct parallel and cone beam as well as tomographic and laminographic geometries. Many pre- and post-processing algorithms needed for high-quality 3D reconstruction are available, {\it e.g.} phase retrieval, ring removal and de-noising. {\it Tofu} is optimized for stand-alone GPU workstations on which it achieves reconstruction speed comparable with costly CPU clusters. It automatically utilizes all GPUs in the system and generates 3D reconstruction code with minimal number of instructions given the input geometry (parallel/cone beam, tomography/laminography), hence yielding optimal run-time performance. In order to improve accessibility for researchers with no previous knowledge of programming, {\it tofu} contains graphical user interfaces for both optimization of 3D reconstruction parameters and batch processing of data with pre-configured workflows for typical computed tomography reconstruction. The toolkit is open source and extensive documentation is available for both end-users and developers. Thanks to the mentioned features, {\it tofu} is suitable for both expert users with specialized image processing needs ({\it e.g.} when dealing with data from custom-built computed tomography scanners) and for application-specific end-users who just need to reconstruct their data on off-the-shelf hardware.}, keywords = {tomography, laminography, parallel beam, cone beam, 3D reconstruction, phase retrieval, artifact removal, GPU computing, user interface, batch processing, visual programming}, } @article{MOREL2012342, title = {Fourier implementation of Poisson image editing}, journal = {Pattern Recognition Letters}, volume = {33}, number = {3}, pages = {342-348}, year = {2012}, issn = {0167-8655}, doi = {https://doi.org/10.1016/j.patrec.2011.10.010}, url = {https://www.sciencedirect.com/science/article/pii/S0167865511003564}, author = {J.-M. Morel and A.B. Petro and C. Sbert}, keywords = {Image editing, Fourier transform, Poisson equation, Local contrast enhancement}, abstract = {Poisson editing, introduced in 2003, is becoming a technique with major applications in many different domains of image processing and computer graphics. This letter presents an exact and fast Fourier implementation of the Poisson editing equation proposed in (Pérez et al., 2003). The proposed algorithm can handle well all Poisson editing methods that are currently implemented with finite differences and multigrid methods. But it also authorizes fast complex editing strategies where the edited region is obtained by an algorithm instead of a manual selection. The selected region can therefore have a complex topology without additional computational cost. In this letter the proposed method is applied to a classic local contrast enhancement principle introduced in (Caselles et al., 1999). The manual selection of the dark regions is replaced by a lower threshold and the method becomes fast, efficient, level-line preserving, and interactive. The proposed method can be tried on line on any uploaded image at http://www.ipol.im/pub/demo/lmps_selective_contrast_adjustment/.} } @article{moisan2011periodic, title={Periodic plus smooth image decomposition}, author={Moisan, Lionel}, journal={Journal of Mathematical Imaging and Vision}, volume={39}, pages={161--179}, year={2011}, publisher={Springer} } ufo-kit-tofu-ed0e5bd/docs/source/usage.rst000066400000000000000000000001621521054151500207010ustar00rootroot00000000000000Usage ===== .. toctree:: :maxdepth: 2 usage/io usage/preprocessing usage/genreco usage/flow ufo-kit-tofu-ed0e5bd/docs/source/usage/000077500000000000000000000000001521054151500201505ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/docs/source/usage/flow.rst000066400000000000000000000270231521054151500216550ustar00rootroot00000000000000Flow: Visual Graph Creation =========================== You can use command ``tofu flow`` to start a graphical user interface in which UFO tasks are represented as nodes which you can connect together. Once you have created your flow you can execute it. Nodes ----- An operation on data is represented by a node in a flow. A node has inputs and outputs, which have data types. An input or output of a node is represented by a `port`, which is a circle on the left of the node in case of an input and on the right in case of output. Every port has a data type which is represented by color. There are two data types: - `UFO`: you can connect all UFO nodes together - `Array`: a numpy array which comes out from UFO's ``memory_out`` node and may be used to visualize the processing result by ``image_viewer`` A node may have properties (almost all UFO nodes do, e.g. ``path`` in the ``read`` node) which are listed and can be set inside the node. If you hover the mouse over a property field, a tooltip will be shown describing that property. When you right click on a node which holds properties, a context menu pops up and let's you choose which properties you want to be visible and which not. Some nodes, like ``general_backproject`` have many properties, many of which may be considered `expert` options which are not needed most of the times. By hiding these properties, you can avoid clutter. There is a pre-defined set of properties, which are shown by default. When you create a node in the scene, this setting is applied and you can check which properties are hidden by default by clicking on a node right after its creation. In case a node doesn't have properties, right click either doesn't take effect or pops up a context menu relevant for that node. E.g. ``image_viewer``'s context menu allows you to configure the viewer's behavior. .. double click A node might implement an action on a double click, e.g. ``read`` node opens a dialog allowing you to choose the data ``path``, ``image_viewer`` pops up an external image window which can be enlarged and put on another display for convenience. Current nodes which implement double clicks are: - ``Composite``: opens a new window with a scene displaying internal composite nodes - ``read``: opens a dialog which allows you to choose the input ``path`` - ``write``: opens a dialog which allows you to choose the output ``filename`` - ``image_viewer``: opens the image in a new window .. auto fill ``read`` node currently supports an `auto fill` option, which may be invoked via the main menu bar. The node sets its ``number`` property to the number of detected images found in the specified ``path``. UFO Nodes ~~~~~~~~~ An UFO node represents an `UFO task` and holds properties which are the Properties of this `UFO task`. Please check the `UFO Filters Reference `_ for the complete list of UFO tasks and their properties. When you create an UFO node, its properties are the default properties of the encapsulated UFO task. Composite Nodes ~~~~~~~~~~~~~~~ In order to reduce clutter, you can combine several nodes in a composite node (main menu's `Nodes->Create Composite`) and you can also nest composites, i.e. have a composite node and create another composite node with the first one inside. Internal nodes are listed as groups in the composite node in the scene and similarly to property nodes, you can show and hide different internal nodes from the listing. The input and output ports of a composite node are the ports of its internal nodes which are not connected at the time of composite node's creation. A double click on a composite node opens its internal nodes in a separate window, where you can edit their properties but you can't add new nodes or change connections. You can open this window also by pressing `Nodes->Edit Composite` in the main menu. In order to store a composite node for later usage, you can export it into a file via the main menu's `Nodes->Export Composite`. You can import composite node definitions by `Nodes->Import Composites`, which are then available in the flow scene's context menu in the `Composite` category. There are several pre-defined composite nodes available via the scene's context menu (category `Composite`), they are: - ``CFlatFieldCorrect`` encapsulates readers and averagers and the ``flat_field_correct`` node itself - ``CPhaseRetrieve`` encapsulates padding, fourier tranformation and the phase retrieval itself General Backproject ~~~~~~~~~~~~~~~~~~~ This is a versatile back projection node which can reconstruct tomographic, laminographic, parallel and cone beam data. It has one parameter which is not part of the UFO task, ``slice-memory-coeff``. This parameter sets the fraction (0 - 1) of a graphic card's memory which will be used to store the reconstructed volume. If you are working with graphic cards which have other processes running on them and these processes use a lot of memory, then you might need to reduce this parameter. Phase Retrieval ~~~~~~~~~~~~~~~ ``retrieve_phase`` node may have varying number of inputs in order to support multi-distance phase retrieval. You specify the number of inputs in a dialog when you create the node. If you specify more than one input, the retrieval method will be the multidistance contrast transfer function and the ``method`` field will be fixed to `ctf_multidistance`. In this case, fields ``distance-x`` and ``distance-y`` will be disabled. If you specify one input, you may choose different methods via the ``method`` field. In this case, you can either specify one value in the ``distance`` field, or specify separate distances for `x` and `y` directions via ``distance-x`` and ``distance-y`` fields (they take precedence over ``distance`` field in case they are both non-zero). Image Viewer ~~~~~~~~~~~~ ``image_viewer`` lets you display the results of your flow. It is composed of the image itself and three text boxes with sliders, which allow you to specify the image index shown, the black point and white point. In case only one image is input the first slider is hidden. Right click on the node opens a context menu which allows you to reset the black and white points (`Reset`), set them automatically (`Auto Levels`) and specify whether they should be automatically adjusted when new images are on input or left unchanged (`Auto Levels on New Image`). Double click opens the image in a new window by using the PyQtGraph_ library. In case a separate window is open, image index, black and white point settings can be set eigher in the flow node or in the window and they are reflected in both the node and the window. Flows ----- On right click in the flow scene a context menu will pop up and you will be able to add nodes. Then you can connect them by dragging a node's output port into another node's input port if those ports have the same data type, which are distinguished by port colors. By connecting node ports you create your flow which you may later execute. Every node in the scene must have a unique `caption`, so when you create a ``read`` node, the caption will be ``Read``, when you create another ``read`` node, the caption will be ``Read 2`` and so on. This is important for setting property links explained below. The roots of the flow in the scene must be UFO nodes and leaves may have `UFO` or `Array` type. It is not possible to go from `UFO` to `Array` and back to `UFO`, i.e. the `UFO` portion of the flow in the scene must be one contiguous component of the flow. There may be only one flow in the scene and it must be completely connected (there can't be disconnected ports, e.g. ``flat_field_correct``'s ``darks`` port). You can delete the current flow by pressing `Flow->New`, you can save a flow into a flow file (.flow) by `Flow->Save` and open such files by `Flow->Open`. Property Links -------------- A property of a node might depend on another node's property, e.g. the number of dimensions of an ``ifft`` node depends on the number of dimensions of the predecessing ``fft`` task. In order to reduce the number of properties you need to set, you can `link` properties together, i.e. when you set one node's property, all the linked nodes' properties will be updated (e.g. when you change the number of dimensions of an ``ifft`` node, the number of dimensions of the linked ``fft`` node will be updated as well. You can create property links in the `Property Links` window (open via main menu bar's `View` field). At the top of the window, there is a tree view of the nodes in the scene. Its items are the nodes in the flow scene, and in case there are composites, they are listed recursively. The last level of the view are the properties of the nodes in the flow. You can drag these properties into the list in the second half of the window to start creating links. If you drag a property to a new row or a row doesn't exist yet, it is automatically added. If you drag a property into an existing row (over an existing cell), it is appended to this row and a link is created. Links are allowed only for properties with compatible data types, e.g. you cannot link ``read``'s ``path`` (a string) to ``fft``'s ``dimensions`` (a number). Also keep in mind that nodes which are able to process batches have their fields which are responsible for receiving different batches (e.g. ``number`` of the ``memory_out`` node) have string data type (so that you can type `{region}` inside) Execution --------- Execution of the flow starts with executing the UFO part of the flow, and if there is a ``memory_out`` and subsequent nodes, they get the result of the UFO processing as the batches are finished (or just one batch if no batch-capable nodes are in the flow). You start it by invoking main menu bar's `Flow->run` action. You can abort the execution but invoking `Flow->abort`. Batch Processing ~~~~~~~~~~~~~~~~ Some nodes require a lot of GPU memory and they can't process all the input data at once (e.g. ``general_backproject``). Based on your system, they can split the work on their own and tell the execution mechanism to run multiple batches. If your system has multiple GPUs, ``tofu flow`` may create several batches and each of these batches may be executed on one or more cards in your system. Currently, only *one* batch processing task is allowed in the flow and only ``general_backproject`` supports batch processing. In case your flow contains a node which is able to produce batches, then your consumer nodes must be able to process batches and they must be notified of the fact that they will get more batches on input. Currently, ``write`` and ``memory_out`` support batches and this is how you set them up for it: - ``write``: ``filename`` must contain `{region}` somewhere in it, e.g. `slices-{region}.tif` - ``memory_out``: ``number`` field must be set to `{region}` The `{region}` template is then replaced by the current batch identifier provided by the producer node which is capable of batch processing, e.g. `slices-0.tif`, `slices-100.tif` and so on. If there is no node capable of producing batches, this is how you set them up for normal, non-batch processing: - ``write``: ``filename`` field set to normal file name, e.g. `slices.tif` - ``memory_out``: ``number`` field set to the number of input images Python Console -------------- Main menu's `View->Python Console` opens up a Python interpreter console with attribute ``scene`` set to the flow scene, which allows you to interact with the nodes programatically, see `qtpynodeeditor docs `_ more details on flow scene functionality. .. _PyQtGraph: http://www.pyqtgraph.org/ .. _qtpynodeeditor: ufo-kit-tofu-ed0e5bd/docs/source/usage/genreco.rst000066400000000000000000000110721521054151500223250ustar00rootroot00000000000000General 3D Reconstruction ========================= You can use command ``tofu reco`` to reconstruct paralell/cone beam tomography/laminography data. The algorithm is filtered back projection for parallel beam data and `Feldkamp `_ approach for cone beam data. It always reconstructs 2D slices in the plane parallel to the beam direction. The third dimensions may be the vertical slice position (the default) but can also be one of the geometrical parameters in order to find their best values for the final reconstruction (see ``tofu reco --help`` and check the ``--z-parameter`` entry for possible values). Angular input values are in degrees. Geometry of the 3D reconstruction is depicted in the following scheme :cite:`Farago:tv5034`: .. image:: ../figs/reco-geometry.png :width: 800 :align: center :alt: 3D reconstruction geometry The dimensions are: - **x = lateral dimension**; - **y = beam propagation dimension**; - **z = vertical dimension**; .. note:: Pixel counting starts with ``0`` and integer numbers are **boundaries** between pixels. That means that integer pixel specification, e.g. ``--center-position-z 1.0`` means: "use position at the boundary between row 0 and row 1", thus the algorithm will interpolate between the two rows, i.e. blur the result! For using solely one row for one CT slice you need to specify ``--center-position-z 1.5``, which means: "Take the second row of the projection and do not consider the one before or after", which is what we need in case of parallel CT (``tofu reco`` automatically adds the ``+ 0.5`` in case of paralell beam CT and integer ``--center-position-z``, it also informs you about this on the output). Examples -------- To reconstruct slices -100, 100 with the step size 0.5 around the center which is defined as 1008.5 from 1500 projections acquired over 180 degrees stored in ``projs.tif``, with rotation axis in pixel 951 one would do:: tofu reco --projections projs.tif --number 1500 --center-position-x 951 --overall-angle 180 --center-position-z 1008.5 --region=-100,100,0.5 --output slices.tif To scan the roll angle around -2, 2 degrees with step 0.1 degree, one can use the following command:: tofu reco --projections projs.tif --number 1500 --overall-angle 180 --center-position-x 951 --center-position-z 1008.5 --z-parameter detector-angle-y --region=-2,2,0.1 --output detector-angle-y-scan.tif --disable-projection-crop To scan the rotation axis region from pixel 940 to pixel 960 with step 0.5 pixels, (the ``center-position-x`` parameter), one can use:: tofu reco --projections projs.tif --number 1500 --overall-angle 180 --center-position-z 1008.5 --z-parameter center-position-x --region=940,960,0.5 --output center-position-x-scan.tif Order of transformations ------------------------ In case you need to know the precise order of transformations, the OpenCL backprojection code re-written to Python is:: # Rotate the axis detector_normal = np.array((0, -1, 0), dtype=float) detector_normal = rotate_z(detector.z_angle, detector_normal) detector_normal = rotate_y(detector.y_angle, detector_normal) detector_normal = rotate_x(detector.x_angle, detector_normal) # Compute d from ax + by + cz + d = 0 detector_offset = -np.dot(detector.position, detector_normal) if np.isinf(source_position[1]): # Parallel beam voxels = points else: # Apply magnification voxels = -points * source_position[1] / (detector.position[1] - source_position[1]) # Rotate the volume voxels = rotate_z(volume_rotation.z_angle, voxels) voxels = rotate_y(volume_rotation.y_angle, voxels) voxels = rotate_x(volume_rotation.x_angle, voxels) # Rotate around the axis voxels = rotate_z(tomo_angle, voxels) # Rotate the volume voxels = rotate_z(axis.z_angle, voxels) voxels = rotate_y(axis.y_angle, voxels) voxels = rotate_x(axis.x_angle, voxels) # Get the projected pixel projected = project(voxels, source_position, detector_normal, detector_offset) if np.any(detector_normal != np.array([0., -1, 0])): # Detector is not perpendicular projected -= np.array([detector.position]).T # Reverse rotation => reverse order of transformation matrices and negative angles projected = rotate_x(-detector.x_angle, projected) projected = rotate_y(-detector.y_angle, projected) projected = rotate_z(-detector.z_angle, projected) x = projected[0, :] + axis.position[0] - 0.5 y = projected[2, :] + axis.position[2] - 0.5 ufo-kit-tofu-ed0e5bd/docs/source/usage/io.rst000066400000000000000000000051351521054151500213150ustar00rootroot00000000000000Input/Output ============ Image Reading ------------- Tofu uses UFO's `Reader `_ which can handle multiple file types, including single- and multi-page tif files. If you specify a file, only that file will be read, if you specify a directory, all files in the directory will be read. If you specify a pattern, all files matching that pattern will be read. The following arguments for reading are available for all of the ``tofu`` commands unless stated otherwise: - ``--y``: Vertical coordinate from where to start reading the input image (default: ``0``); - ``--height``: Number of rows which will be read (default: ``None``, meaning: all); - ``--bitdepth``: Bit depth of raw files (bits per pixel, default: ``32``); - ``--y-step``: Read every "step" row from the input (default: ``1``); - ``--start``: Offset to the first read file (default: ``0``); - ``--number``: Number of files to read (default: ``None``, meaning: all); - ``--step``: Read every "step" file (default: ``1``). Image Writing ------------- Tofu writes tif files by UFO's `Writer `_ and they can be either single- or multi-page, which is controlled by ``--output-bytes-per-file`` and ``--outuput`` arguments. If you set ``--output-bytes-per-file`` to ``0`` or any number smaller than the size of two images in bytes, the output will be singe-page. If you specify a larger value, there will be multiple images in one tif file. On the top of that, if the file size is larger than 4 GB the tif file will be in the bigtiff format (this may make it harder to open but ImageJ can handle it). In the case you specify a file name to be a single file, like ``output.tif``, you need to make sure that ``--output-bytes-per-file`` is large enough to facilitate all images which are about to be written. Alternatively, you can specify the output as a *format string* e.g. ``output-%04d.tif``, which will create files ``output-0000.tif``, ``output-0001.tif`` and so on. A new file will be created every time the amount of bytes written in the current file would exceed the value specified by ``--output-bytes-per-file``. .. note:: You may use ``k``, ``m``, ``g``, ``t`` suffixes with ``--output-bytes-per-file`` to indicate respectively kibibytes (:math:`2^{10}` bytes), mebibytes (:math:`2^{20}` bytes) gibibytes (:math:`2^{30}` bytes) and tebibytes (:math:`2^{40}` bytes). When you want to make sure all fits into one file, just use e.g. "1t", which stands for "one tebibyte" and equals 1.099.511.627.776 bytes. ufo-kit-tofu-ed0e5bd/docs/source/usage/preprocessing.rst000066400000000000000000000377401521054151500236000ustar00rootroot00000000000000Pre-processing ============== For pre-processing, like flat correction and phase retrieval, there are the commands ``tofu flatcorrect``, ``tofu preprocess``, ``tofu find-large-spots`` and ``tofu sinos``. Please refer to ``tofu command --help`` for a list of complete parameters, many of the commands below share a lot of them, e.g. ``--darks`` are available in almost all of the commands. .. _flatcorrect: Flatcorrect ----------- You can use command ``tofu flatcorrect`` to flat correct projections. For doing this, you need a set of dark fields (images), flat fields and projections. It is good to have many dark and flat fields to reduce noise. The command computes an "average" dark and flat field based on the reduction mode below and then computes the corrected projections. The most important arguments are: - ``--projections``: Location with projections (default: ``None``); - ``--darks``: Location with darks (default: ``None``); - ``--flats``: Location with flats (default: ``None``); - ``--reduction-mode``: controls the computation of dark field and flat field, one of: ``Average`` or ``Median``; - ``--fix-nan-and-inf``: suppress NaN (not a number) and infinite numbers which may be caused by zero division ; - ``--absorptivity``: compute :math:`log (I_0 / I)`, where :math:`I_0` is the flat field and :math:`I` is the projection. If you do not specifiy this option, the result is simply :math:`I / I_0`; - ``--dark-scale``: multiplies the dark field with this value; - ``--flat-scale``: multiplies the flat field with this value (e.g. you need to reduce exposure time by half in order not to saturate, then you would set this to 2); - ``--flats2``: this argument is useful when you have two sets of flat fields, one before the projection acquisition and one after. The flat field for current projection in the sequence is linearly interpolated from the averate first and second flat field. Preprocess ---------- ``tofu preprocess`` is a command capable of: - flat correction; - applying cone beam weight; - phase retrieval; - projection filtering for back projection. You may use the arguments in :ref:`flatcorrect` to set up the flat correction part of the command, ``--transpose-input`` transposes the images. Cone Beam Weight ~~~~~~~~~~~~~~~~ *Cone beam weight* coordinates are defined as follows: - **x = lateral dimension**; - **y = beam propagation dimension**; - **z = vertical dimension**; and the following arguments apply: - ``--source-position-y``: Y source position (along beam direction) in multiples of nominal detector pixel size; - ``--detector-position-y``: Y detector position (along beam direction) in multiples of nominal detector pixel size; - ``--center-position-x``: X rotation axis position on a projection in pixels; - ``--center-position-z``: Z rotation axis position on a projection in pixels; - ``--axis-angle-x``: Rotation axis rotation around the x axis (laminographic angle, 0 = tomography) in degrees. Phase retrieval ~~~~~~~~~~~~~~~ Phase retrieval converts the interference pattern from propagation-based phase contrast to the actual phase or even projected thickness, depending on the algorithm used and arguments. It is turned on if ``--energy`` and ``--propagation-distance`` arguments are specified. The algorithms are: - ``tie``: `Transport of Intensity Equation `_; - ``ctf``: `Contrast Transfer Function `_; - ``qp``: `Quasiparticle Approach `_; - ``qp2``: modified Quasiparticle approach where frequencies are not suppressed by zero but a small constant with the sign of the CTF sign. The arguments are: .. The following list generated with the following and hand-improved: .. from tofu.config import SECTIONS .. sec = SECTIONS['retrieve-phase'] .. for k, params in sec.items(): .. choice_str = '' .. default_str = '' .. if 'choices' in params: .. choice_str = f', one of {params["choices"]}' .. if 'default' in params: .. default_str = f' (default: ``{params["default"]}``)' .. print(f'- ``--{k}``: {params["help"]}{choice_str}{default_str};') - ``--retrieval-method``: Phase retrieval method, one of [``tie``, ``ctf``, ``qp``, ``qp2``] (default: ``tie``); - ``--energy``: X-ray energy [keV] (default: ``None``, meaning: turn phase retrieval off); - ``--propagation-distance``: Sample <-> detector distance (if one value, then use the same for x and y direction, otherwise first specifies x and second y direction) [m] (default: ``None``, meaning: turn phase retrieval off); - ``--pixel-size``: Pixel size [m] (default: ``1e-06``); - ``--regularization-rate``: Regularization rate (the value of :math:`log_{10} (\delta / \beta)`, where :math:`\delta` is the real part of the complex refractive index and :math:`\beta` the imaginary; typical values between [2, 3]) (default: ``2``); - ``--delta``: Real part of the complex refractive index of the material. If specified, phase retrieval returns projected thickness, if not, it returns phase (default: ``None``); - ``--retrieval-padded-width``: Padded width used for phase retrieval (default: ``0``, meaning: automatic); - ``--retrieval-padded-height``: Padded height used for phase retrieval (default: ``0``, meaning: automatic); - ``--retrieval-padding-mode``: Padded values assignment, one of [``none``, ``clamp``, ``clamp_to_edge``, ``repeat``, ``mirrored_repeat``] (default: ``clamp_to_edge``) (refer to ** section in the `OpenCL `_ documentation for more information); - ``--thresholding-rate``: Thresholding rate (typical values between [0.01, 0.1]) (default: ``0.01``); - ``--frequency-cutoff``: Phase retrieval frequency cutoff [rad] (default: ``1e+30``); Projection Filtering for Back Projection ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Projection filtering is done prior to the back projection step. In case you need to perform many 3D reconstructions by filtered back projection on the same data set, you might want to apply the filter before the back projections. The arguments are: - ``--projection-filter``: Projection filter, one of [``none``, ``ramp``, ``ramp-fromreal``, ``butterworth``, ``faris-byer``, ``bh3``, ``hamming``] (default: ``ramp-fromreal``); - ``--projection-filter-cutoff``: Relative cutoff frequency (default: ``0.5``); - ``--projection-padding-mode``: Padded values assignment, one of [``none``, ``clamp``, ``clamp_to_edge``, ``repeat``, ``mirrored_repeat``] (default: ``clamp_to_edge``) (refer to ** section in the `OpenCL `_ documentation for more information); Generating Sinograms -------------------- ``tofu sinos`` is needed when you want to remove rings from your reconstructed slices (which manifest as stripes in sinograms). After this, you can use ``tofu tomo`` for the 3D reconstruction. Arguments: - ``--pass-size``: Number of sinograms to process per pass, which is useful if your PC has little memory (CPU RAM, sinogram generation does not take place on GPUs) (default: ``0``, meaning: automatic); - ``--y``: Vertical coordinate from where to start reading the input image: controls detector row of the first sinogram (default: ``0``); - ``--height``: Number of rows which will be read: controls number of sinograms (default: ``None``, meaning: all). You may also perform flat correction in one step by using the flat correction arguments. For a full list, see ``tofu sinos --help``. .. _inpainting: Inpainting ---------- Inpainting is useful when we need to (a) seamlessly blend a part of one image into another, or (b) if we want to interpolate some corrupted region in an image. In the context of 3D reconstruction, inpainting can be used in :ref:`broad_ring_filtering`. The algorithm is based on the Fourier transform method in :cite:`MOREL2012342`. Seamless Cloning ~~~~~~~~~~~~~~~~ One may seamlessly blend two images like this: .. code-block:: bash tofu inpaint --guidance-image guidance.tif --projections boat.tif --mask-image mask.tif --preserve-mean --output inpainted.tif Which will produce the following output: .. image:: ../figs/inpainting.png :width: 800 :align: center :alt: Inpainting example Interpolation of a corrupted region requires the same command, just without the specification of the guidance image: .. code-block:: bash tofu inpaint --projections boat.tif --mask-image mask.tif --preserve-mean --output inpainted.tif # For comparison: horizontal-interpolate ufo-launch [read path=boat.tif, read path=mask.tif] ! horizontal-interpolate ! write filename=horiz-interpolate.tif Side-by-side comparison of the result of ``horizontal-interpolate`` (left) and inpainting (right) generated with the above commands: .. image:: ../figs/hi-inp-comparison.jpg :width: 800 :align: center :alt: Horizontal interpolation vs. inpainting Harmonization of Image Borders (for the Removal of the Power Spectrum Cross) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Discrete Fourier transform assumes that images are `N`-periodic, i.e. after pixel `N-1`, pixel `0` comes again and so on. The transitions between image borders thus often contain harsh discontinuities which are reflected in the Fourier transform and manifest as a cross in the power spectrum aligned with the coordinate axes and crossing the `(0, 0)` frequency. We can remedy this by blending the opposing borders. In the image below (boat cropped to ``371 x 371`` pixels), on the left is the original image after swapping quadrants to emphasize the border discontinuities. In the middle is the harmonized image without padding of the Fourier transform and on the right with padding from the original ``371 x 371`` to ``512 x 512`` pixels. Commands used for their generation are shown below (with the additional swapping of quadrants). .. image:: ../figs/harmonization-images.jpg :width: 800 :align: center :alt: Image before and after border harmonization .. code-block:: bash tofu inpaint --projections input.tif --preserve-mean --harmonize-borders --output middle-harmonized.tif tofu inpaint --projections input.tif --inpaint-padded-width 512 --inpaint-padded-height 512 --inpaint-padding-mode mirrored_repeat --preserve-mean --harmonize-borders --output right-harmonized.tif Below are the power spectra of the respective images above and below the commands to generate them. Note the cross in the left image and how the positive and negative frequencies are starting to get mixed in the right one, which is caused by ``mirrored_repeat`` padding mode. .. image:: ../figs/harmonization-ips.jpg :width: 800 :align: center :alt: Power spectra of an image before and after border harmonization .. code-block:: bash ufo-launch read path=input.tif ! fft dimensions=2 ! power-spectrum ! calculate expression="'log(v)'" ! swap-quadrants ! write filename=left-power-spectrum.tif ufo-launch read path=middle-harmonized.tif ! fft dimensions=2 ! power-spectrum ! calculate expression="'log(v)'" ! swap-quadrants ! write filename=middle-power-spectrum.tif ufo-launch read path=right-harmonized.tif ! fft dimensions=2 ! power-spectrum ! calculate expression="'log(v)'" ! swap-quadrants ! write filename=right-power-spectrum.tif .. _broad_ring_filtering: Removing Broad Rings from Tomographic Slices -------------------------------------------- ``tofu find-large-spots`` finds large scintillator spots of extreme intensity (zero or maximum) on projections. These spots cannot be flat corrected and cause *broad ring artifacts* in the reconstructed tomographic slices (see :ref:`narrow_ring_filtering` for filtering narrow rings). By finding and suppressing these spots prior to 3D reconstruction, one can obtain much cleaner slices. The algorithm creates a mask which you can then use to filter the erroneous regions. It works on flat fields, first, it removes the low frequency components of a flat field, then thresholds it and from the found extreme intensities "grows" until another threshold is hit. The arguments are: - ``--images``: Location with input images (default: ``None``); - ``--gauss-sigma``: Gaussian sigma for removing low frequencies (filter will be 1 - gauss window) (default: ``0.0``, meaning: turn low-frequency removal off); - ``--blurred-output``: Path where to store the blurred input (default: ``None``); - ``--spot-threshold``: Pixels with grey value larger than this are considered as spots (default: ``0.0``); - ``--spot-threshold-mode``: Pixels must be either "below", "above" the spot threshold, or their "absolute" value can be compared, one of ['below', 'above', 'absolute'] (default: ``absolute``); - ``--grow-threshold``: Spot growing threshold, if 0 it will be set to FWTM times noise standard deviation (default: ``0.0``, meaning: automatic); - ``--find-large-spots-padding-mode``: Padded values assignment for the filtered input image, one of [``none``, ``clamp``, ``clamp_to_edge``, ``repeat``, ``mirrored_repeat``] (default: ``repeat``) (refer to ** section in the `OpenCL `_ documentation for more information). *Broad ring filtering example:* .. code-block:: bash # Flat correction tofu flatcorrect --projections projections/ --darks darks/ --flats flats/ --fix-nan-and-inf --output fc.tif --output-bytes-per-file 1t # Finding the spots tofu find-large-spots --output-bytes-per-file 0 --number 1 --output mask.tif --spot-threshold 500.0 --find-large-spots-padding-mode repeat --spot-threshold-mode absolute --gauss-sigma 10.0 --images flats/ # Filtering the spots by horizontal linear interpolation ufo-launch -q [read path=fc.tif, read path=mask.tif] ! horizontal-interpolate ! write filename=interpolated.tif bytes-per-file=1000000000000 tiff-bigtiff=True # Or by inpainting tofu inpaint --projections fc.tif --mask-image mask.tif --preserve-mean --output interpolated.tif .. _narrow_ring_filtering: Removing Narrow Rings from Tomographic Slices --------------------------------------------- This is achieved by removing stripes in sinograms which translate to half-rings after 3D reconstruction. This algorithm is most suitable for *narrow ring artifacts* as opposed to the :ref:`broad_ring_filtering`. In the following example we assume sinogram width ``2016`` pixels and height ``3000`` pixels. In order to suppress convolution artifacts, we pad the sinogram by a factor of 2 in every dimension and then need to manually compute the next power of two required by UFO's ``fft`` filter. Moreover, we place the original sinogram in the middle of the padded one and use the ``mirrored_repeat`` approach to fill the padding pixels with data instead of just using zeros. The computation is ``2 x 2016 = 4032`` and the next power of two is ``4096``, ``2 x 3000 = 6000`` with the next power of two ``8192``. We want to place the sinogram in the middle, so starting ``x`` is ``(4096 - 2016) / 2 = 1040`` and starting ``y`` is ``(8192 - 3000) / 2 = 2596``. Put together on the command line, we arrive at the *narrow ring filtering example:* .. code-block:: bash # Create sinograms tofu sinos --number 3000 --projections interpolated.tif --output sinos.tif --output-bytes-per-file 1t # Filter narrow stripes in sinograms in frequency space ufo-launch -q read path=sinos.tif ! pad width=4096 height=8192 x=1040 y=2596 addressing-mode=mirrored_repeat ! fft dimensions=2 ! filter-stripes horizontal-sigma=100.0 vertical-sigma=2.0 ! ifft dimensions=2 ! crop width=2016 height=3000 x=1040 y=2596 ! write filename=filtered-sino.tif bytes-per-file=1000000000000 tiff-bigtiff=True .. note:: We do our best to keep the argument names consistent with the parameters from UFO Framework but sometimes that would lead to ambiguities and the arguments in tofu are prefixed with the filter name, like ``--output-bytes-per-file``. Also, high-level features like using ``k``, ``m``, ``g``, ``t`` with ``--output-bytes-per-file`` is a feature of tofu and is not included in the low-level ``ufo-launch`` command. ufo-kit-tofu-ed0e5bd/pyproject.toml000066400000000000000000000023131521054151500175270ustar00rootroot00000000000000[build-system] requires = ["setuptools>=68,<77", "wheel"] build-backend = "setuptools.build_meta" [project] name = "ufo-tofu" dynamic = ["version", "readme"] description = "A fast, versatile and user-friendly image processing toolkit for computed tomography" requires-python = ">=3" license = { text = "LGPL" } authors = [ { name = "Matthias Vogelgesang", email = "matthias.vogelgesang@kit.edu" }, ] dependencies = [ "PyGObject", "imageio", "numpy", "tifffile", "scikit-image", ] [project.urls] Homepage = "http://github.com/ufo-kit/tofu" [project.optional-dependencies] interactive = ["IPython"] gui = ["PyQt5", "pyqtgraph"] flow = ["PyQt5", "networkx", "pyqtconsole", "pyxdg", "qtpynodeeditor", "pyqtgraph"] test = ["pytest", "pytest-qt"] ez = ["PyQt5", "PyYAML", "pyqtgraph", "matplotlib"] edf = ["fabio"] [tool.setuptools] packages = { find = {} } script-files = ["bin/tofu"] license-files = ["LICENSE"] [tool.setuptools.dynamic] version = { attr = "tofu.__version__" } readme = { file = "README.md", content-type = "text/markdown" } [tool.setuptools.package-data] tofu = ["gui.ui"] "tofu.flow" = ["composites/*.cm", "config.json"] [tool.setuptools.exclude-package-data] "*" = ["README.rst"] ufo-kit-tofu-ed0e5bd/setup.py000066400000000000000000000024571521054151500163360ustar00rootroot00000000000000from pathlib import Path from setuptools import setup, find_packages def read_version(): namespace = {} exec(Path('tofu/__init__.py').read_text(), namespace) return namespace['__version__'] setup( name='ufo-tofu', python_requires='>=3', version=read_version(), author='Matthias Vogelgesang', author_email='matthias.vogelgesang@kit.edu', url='http://github.com/ufo-kit/tofu', license='LGPL', packages=find_packages(), package_data={'tofu': ['gui.ui'], 'tofu.flow': ['composites/*.cm', 'config.json']}, scripts=['bin/tofu'], exclude_package_data={'': ['README.rst']}, install_requires= [ 'PyGObject', 'imageio', 'numpy', 'tifffile', 'scikit-image', ], extras_require={ 'interactive': ['IPython'], 'gui': ['PyQt5', 'pyqtgraph'], 'flow': ['PyQt5', 'networkx', 'pyqtconsole', 'pyxdg', 'qtpynodeeditor', 'pyqtgraph'], 'test': ['pytest', 'pytest-qt'], 'ez': ['PyQt5', 'PyYAML', 'pyqtgraph', 'matplotlib'], 'edf': ['fabio'], }, description="A fast, versatile and user-friendly image "\ "processing toolkit for computed tomography", long_description=open('README.md').read(), long_description_content_type='text/markdown', ) ufo-kit-tofu-ed0e5bd/tofu/000077500000000000000000000000001521054151500155715ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/__init__.py000066400000000000000000000000271521054151500177010ustar00rootroot00000000000000__version__ = '0.15.0' ufo-kit-tofu-ed0e5bd/tofu/config.py000066400000000000000000001072231521054151500174150ustar00rootroot00000000000000import argparse import sys import logging import configparser as configparser from collections import OrderedDict from tofu.util import convert_filesize, restrict_value, tupleize, range_list LOG = logging.getLogger(__name__) NAME = "reco.conf" SECTIONS = OrderedDict() SECTIONS['general'] = { 'config': { 'default': NAME, 'type': str, 'help': "File name of configuration", 'metavar': 'FILE'}, 'verbose': { 'default': False, 'ezdefault': False, 'help': 'Verbose output', 'action': 'store_true'}, 'output': { 'default': 'result-%05i.tif', 'type': str, 'help': "Path to location or format-specified file path " "for storing reconstructed slices", 'metavar': 'PATH'}, 'output-bitdepth': { 'default': 32, 'ezdefault': 8, 'type': restrict_value((0, None), dtype=int), 'help': "Bit depth of output, either 8, 16 or 32", 'metavar': 'BITDEPTH'}, 'output-minimum': { 'default': None, 'ezdefault': 0.0, 'type': float, 'help': "Minimum value that maps to zero (turns on --output-rescale)", 'metavar': 'MIN'}, 'output-maximum': { 'default': None, 'ezdefault': 0.0, 'type': float, 'help': "Maximum input value that maps to largest output value (turns on --output-rescale)", 'metavar': 'MAX'}, 'output-rescale': { 'default': False, 'action': 'store_true', 'help': "If true rescale grey values either automatically or according to set " "--output-minimum and --output-maximum"}, 'output-bytes-per-file': { 'default': '128g', 'type': convert_filesize, 'help': "Maximum bytes per file (0=single-image output, otherwise multi-image output)\ , 'k', 'm', 'g', 't' suffixes can be used", 'metavar': 'BYTESPERFILE'}, 'output-append': { 'default': False, 'action': 'store_true', 'help': 'Append images instead of overwriting existing files'}, 'log': { 'default': None, 'type': str, 'help': "File name of optional log", 'metavar': 'FILE'}, 'width': { 'default': None, 'type': restrict_value((0, None), dtype=int), 'unit': "pixel", 'help': "Input width"}} SECTIONS['reading'] = { 'y': { 'default': 0, 'ezdefault': 100, 'type': restrict_value((0, None), dtype=int), 'unit': "pixel", 'help': 'Vertical coordinate from where to start reading the input image'}, 'height': { 'default': None, 'ezdefault': 200, 'type': restrict_value((0, None), dtype=int), 'unit': "pixel", 'help': "Number of rows which will be read"}, 'bitdepth': { 'default': 32, 'type': restrict_value((0, None), dtype=int), 'help': "Bit depth of raw files"}, 'y-step': { 'default': 1, 'ezdefault': 20, 'type': restrict_value((0, None), dtype=int), 'unit': "pixel", 'help': "Read every \"step\" row from the input"}, 'start': { 'default': 0, 'type': restrict_value((0, None), dtype=int), 'help': 'Offset to the first read file'}, 'number': { 'default': None, 'type': restrict_value((0, None), dtype=int), 'help': 'Number of files to read'}, 'step': { 'default': 1, 'ezdefault': 1, 'type': restrict_value((0, None), dtype=int), 'help': 'Read every \"step\" file'}, 'resize': { 'default': None, 'type': restrict_value((0, None), dtype=int), 'help': 'Bin pixels before processing'}, 'retries': { 'default': 0, 'type': restrict_value((0, None), dtype=int), 'metavar': 'NUMBER', 'help': 'How many times to wait for new files'}, 'retry-timeout': { 'default': 0, 'type': restrict_value((0, None), dtype=int), 'metavar': 'TIME', 'help': 'How long to wait for new files per trial'}} SECTIONS['flat-correction'] = { 'projections': { 'default': None, 'type': str, 'help': "Location with projections", 'metavar': 'PATH'}, 'darks': { 'default': None, 'type': str, 'help': "Location with darks", 'metavar': 'PATH'}, 'dark-scale': { 'default': 1, 'type': float, 'help': "Scaling dark"}, 'reduction-mode': { 'default': "Average", 'type': str, 'help': "Flat-field correction options: Average (darks) or median (flats)"}, 'fix-nan-and-inf': { 'default': False, 'help': "Fix nan and inf", 'action': 'store_true'}, 'flats': { 'default': None, 'type': str, 'help': "Location with flats", 'metavar': 'PATH'}, 'flats2': { 'default': None, 'type': str, 'help': "Location with flats 2 for interpolation correction", 'metavar': 'PATH'}, 'flat-scale': { 'default': 1, 'type': float, 'help': "Scaling flat"}, 'absorptivity': { 'default': False, 'action': 'store_true', 'help': 'Do absorption correction'}} SECTIONS['retrieve-phase'] = { 'retrieval-method': { 'choices': ['tie', 'ctf', 'qp', 'qp2'], 'default': 'tie', 'help': "Phase retrieval method"}, 'energy': { 'default': None, 'ezdefault': 20, 'type': float, 'unit': "keV", 'help': "X-ray energy"}, 'propagation-distance': { 'default': None, 'ezdefault': "0.1", 'type': tupleize(), 'unit': "m", 'help': ("Sample <-> detector distance (if one value, then use the same for x and y " "direction, otherwise first specifies x and second y direction) [m]")}, 'pixel-size': { 'default': 1e-6, 'ezdefault': 3.6e-6, 'type': float, 'unit': "m", 'help': "Pixel size"}, 'regularization-rate': { 'default': 2, 'ezdefault': 2.3, 'type': float, 'help': "Regularization rate (typical values between [2, 3])"}, 'delta': { 'default': None, 'type': float, 'help': "Real part of the complex refractive index of the material. " "If specified, phase retrieval returns projected thickness, " "if not, it returns phase"}, 'tie-approximate-logarithm': { 'default': False, 'help': ("Approximate the logarithm of the tie method by the first order Taylor series " "expansion [ln(x) ~ ln(a) + (x - a) / a at a, a specified with " "--tie-approximate-point]. This way we may do the filtering for FBP already " "by the phase retrieval and save one forward and one backward 1D FFT needed " "if the filtering occurse separately. This is mostly useful for online reconstruction " "when one reconstruct only a few slices."), 'action': 'store_true'}, 'tie-approximate-point': { 'default': 0.75, 'type': float, 'help': ("Taylor series point of expansion used by --tie-approximate-logarithm. " "The error of the approximation will be smallest around this point, " "so you can tune this for the desired grey level of interest " "(given by the sample based on e^(-mju * projected_thickness)).")}, 'retrieval-padded-width': { 'default': 0, 'unit': "pixel", 'type': restrict_value((0, None), dtype=int), 'help': "Padded width used for phase retrieval"}, 'retrieval-padded-height': { 'default': 0, 'unit': "pixel", 'type': restrict_value((0, None), dtype=int), 'help': "Padded height used for phase retrieval"}, 'retrieval-padding-mode': { 'choices': ['none', 'clamp', 'clamp_to_edge', 'repeat', 'mirrored_repeat'], 'default': 'clamp_to_edge', 'help': "Padded values assignment"}, 'thresholding-rate': { 'default': 0.01, 'type': float, 'help': "Thresholding rate (typical values between [0.01, 0.1])"}, 'frequency-cutoff': { 'default': 1e30, 'type': float, 'help': "Phase retrieval frequency cutoff [rad]"}} SECTIONS['sinos'] = { 'pass-size': { 'type': restrict_value((0, None), dtype=int), 'default': 0, 'help': 'Number of sinograms to process per pass'}} SECTIONS['reconstruction'] = { 'sinograms': { 'default': None, 'type': str, 'help': "Location with sinograms", 'metavar': 'PATH'}, 'angle': { 'default': None, 'type': float, 'unit': "rad", 'help': "Angle step between projections"}, 'enable-tracing': { 'default': False, 'help': "Enable tracing and store result in .PID.json", 'action': 'store_true'}, 'remotes': { 'default': None, 'type': str, 'help': "Addresses to remote ufo-nodes", 'nargs': '+'}, 'projection-filter': { 'default': 'ramp-fromreal', 'type': str, 'help': "Projection filter", 'choices': ['none', 'ramp', 'ramp-fromreal', 'butterworth', 'faris-byer', 'bh3', 'hamming']}, 'projection-filter-cutoff': { 'default': 0.5, 'type': float, 'help': "Relative cutoff frequency"}, 'projection-padding-mode': { 'choices': ['none', 'clamp', 'clamp_to_edge', 'repeat', 'mirrored_repeat'], 'default': 'clamp_to_edge', 'help': "Padded values assignment"}} SECTIONS['tomographic-reconstruction'] = { 'axis': { 'default': None, 'type': float, 'unit': "pixel", 'help': "Axis position"}, 'dry-run': { 'default': False, 'help': "Reconstruct without writing data", 'action': 'store_true'}, 'offset': { 'default': 0.0, 'type': float, 'unit': "rad", 'help': "Angle offset of first projection"}, 'method': { 'default': 'fbp', 'type': str, 'help': "Reconstruction method", 'choices': ['fbp', 'dfi', 'sart', 'sirt', 'sbtv', 'asdpocs']}} SECTIONS['laminographic-reconstruction'] = { 'angle': { 'default': None, 'type': float, 'unit': "deg", 'help': "Angle step between projections"}, 'dry-run': { 'default': False, 'help': "Reconstruct without writing data", 'action': 'store_true'}, 'axis': { 'default': None, 'required': True, 'unit': "pixel", 'type': tupleize(num_items=2), 'help': "Axis position"}, 'x-region': { 'default': "0,-1,1", 'unit': "pixel", 'type': tupleize(num_items=3, conv=int), 'help': "X region as from,to,step"}, 'y-region': { 'default': "0,-1,1", 'unit': "pixel", 'type': tupleize(num_items=3, conv=int), 'help': "Y region as from,to,step"}, 'z': { 'default': 0, 'unit': "pixel", 'type': int, 'help': "Z coordinate of the reconstructed slice"}, 'z-parameter': { 'default': 'z', 'type': str, 'choices': ['z', 'x-center', 'lamino-angle', 'roll-angle'], 'help': "Parameter to vary along the reconstructed z-axis"}, 'region': { 'default': "0,-1,1", 'type': tupleize(num_items=3), 'help': "Z-axis parameter region as from,to,step"}, 'overall-angle': { 'default': None, 'unit': "deg", 'type': float, 'help': "The total angle over which projections were taken"}, 'lamino-angle': { 'default': None, 'unit': "deg", 'required': True, 'type': float, 'help': "The laminographic angle"}, 'roll-angle': { 'default': 0.0, 'unit': "deg", 'type': float, 'help': "Sample angular misalignment to the side (roll), positive angles mean\ clockwise misalignment"}, 'slices-per-device': { 'default': None, 'type': restrict_value((0, None), dtype=int), 'help': "Number of slices computed by one computing device"}, 'only-bp': { 'default': False, 'action': 'store_true', 'help': "Do only backprojection with no other processing steps"}, 'lamino-padding-mode': { 'choices': ['none', 'clamp', 'clamp_to_edge', 'repeat', 'mirrored_repeat'], 'default': 'clamp', 'help': "Padded values assignment for the filtered projection"}} SECTIONS['fbp'] = { 'crop-width': { 'default': None, 'unit': "pixel", 'type': restrict_value((0, None), dtype=int), 'help': "Width of final slice"}, 'projection-crop-after': { 'choices': ['filter', 'backprojection'], 'default': 'backprojection', 'help': "Whether to crop projections after filtering (can cause truncation " "artifacts) or after backprojection"}} SECTIONS['dfi'] = { 'oversampling': { 'default': None, 'type': restrict_value((0, None), dtype=int), 'help': "Oversample factor"}} SECTIONS['ir'] = { 'num-iterations': { 'default': 10, 'type': restrict_value((0, None), dtype=int), 'help': "Maximum number of iterations"}} SECTIONS['sart'] = { 'relaxation-factor': { 'default': 0.25, 'type': float, 'help': "Relaxation factor"}} SECTIONS['sbtv'] = { 'lambda': { 'default': 0.1, 'type': float, 'help': "Lambda"}, 'mu': { 'default': 0.5, 'type': float, 'help': "mu"}} SECTIONS['gui'] = { 'enable-cropping': { 'default': False, 'help': "Enable cropping width", 'action': 'store_true'}, 'show-2d': { 'default': False, 'help': "Show 2D slices with pyqtgraph", 'action': 'store_true'}, 'show-3d': { 'default': False, 'help': "Show 3D slices with pyqtgraph", 'action': 'store_true'}, 'last-dir': { 'default': '.', 'type': str, 'help': "Path of the last used directory", 'metavar': 'PATH'}, 'deg0': { 'default': '.', 'type': str, 'help': "Location with 0 deg projection", 'metavar': 'PATH'}, 'deg180': { 'default': '.', 'type': str, 'help': "Location with 180 deg projection", 'metavar': 'PATH'}, 'ffc-correction': { 'default': False, 'help': "Enable darks or flats correction", 'action': 'store_true'}, 'num-flats': { 'default': 0, 'type': int, 'help': "Number of flats for ffc correction."}} SECTIONS['estimate'] = { 'estimate-method': { 'type': str, 'default': 'correlation', 'help': 'Rotation axis estimation algorithm', 'choices': ['reconstruction', 'correlation']}} SECTIONS['perf'] = { 'num-runs': { 'default': 3, 'type': restrict_value((0, None), dtype=int), 'help': "Number of runs"}, 'width-range': { 'default': '1024', 'type': range_list, 'unit': "pixel", 'help': "Width or range of widths of generated projections"}, 'height-range': { 'default': '1024', 'type': range_list, 'unit': "pixel", 'help': "Height or range of heights of generated projections"}, 'num-projection-range': { 'default': '512', 'type': range_list, 'help': "Number or range of number of projections"}} SECTIONS['preprocess'] = { 'transpose-input': { 'default': False, 'action': 'store_true', 'help': "Transpose projections before they are backprojected (after phase retrieval)"}, 'projection-filter': { 'default': 'ramp-fromreal', 'type': str, 'help': "Projection filter", 'choices': ['none', 'ramp', 'ramp-fromreal', 'butterworth', 'faris-byer', 'bh3', 'hamming']}, 'projection-filter-cutoff': { 'default': 0.5, 'type': float, 'help': "Relative cutoff frequency"}, 'projection-filter-scale': { 'default': 1., 'type': float, 'help': "Multiplicative factor of the projection filter"}, 'projection-padding-mode': { 'choices': ['none', 'clamp', 'clamp_to_edge', 'repeat', 'mirrored_repeat'], 'default': 'clamp_to_edge', 'help': "Padded values assignment"}, 'projection-crop-after': { 'choices': ['filter', 'backprojection'], 'default': 'backprojection', 'help': "Whether to crop projections after filtering (can cause truncation " "artifacts) or after backprojection"}} SECTIONS['cone-beam-weight'] = { 'source-position-y': { 'default': "-Inf", 'type': tupleize(dtype=list), 'unit': "pixel", 'help': "Y source position (along beam direction) in global coordinates " "(multiple of detector pixel size)"}, 'detector-position-y': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "pixel", 'help': "Y detector position (along beam direction) in global coordinates " "(multiple of detector pixel size)"}, 'center-position-x': { 'default': None, 'type': tupleize(), 'unit': "pixel", 'help': "X rotation axis position on a projection"}, 'center-position-z': { 'default': None, 'ezdefault': "0", 'type': tupleize(), 'unit': "pixel", 'help': "Z rotation axis position on a projection"}, 'axis-angle-x': { 'default': "0", 'ezdefault': "30", 'type': tupleize(dtype=list), 'unit': "deg", 'help': "Rotation axis rotation around the x axis" "(laminographic angle, 0 = tomography)"}} SECTIONS['general-reconstruction'] = { 'enable-tracing': { 'default': False, 'help': "Enable tracing and store result in .PID.json", 'action': 'store_true'}, 'disable-cone-beam-weight': { 'default': False, 'action': 'store_true', 'help': "Disable cone beam weighting"}, 'slice-memory-coeff': { 'default': 0.8, 'ezdefault': 0.7, 'type': restrict_value((0.01, 0.95)), 'help': "Portion of the GPU memory used for slices (from 0.01 to 0.9) [fraction]. " "The total amount of consumed memory will be larger depending on the " "complexity of the graph. In case of OpenCL memory allocation errors, " "try reducing this value."}, 'num-gpu-threads': { 'default': 1, 'ezdefault': None, 'type': restrict_value((1, None), dtype=int), 'help': "Number of parallel reconstruction threads on one GPU"}, 'disable-projection-crop': { 'default': False, 'action': 'store_true', 'help': "Disable automatic cropping of projections computed from volume region"}, 'dry-run': { 'default': False, 'help': "Reconstruct without reading or writing data", 'action': 'store_true'}, 'data-splitting-policy': { 'default': 'one', 'ezdefault': 'one', 'type': str, 'help': "'one': one GPU should process as many slices as possible, " "'many': slices should be spread across as many GPUs as possible", 'choices': ['one', 'many']}, 'projection-margin': { 'default': 0, 'type': restrict_value((0, None), dtype=int), 'unit': "pixel", 'help': "By optimization of the read projection region, the read region will be " "[y - margin, y + height + margin]"}, 'slices-per-device': { 'default': None, 'ezdefault': None, 'type': restrict_value((0, None), dtype=int), 'help': "Number of slices computed by one computing device"}, 'gpus': { 'default': None, 'nargs': '+', 'type': int, 'help': "GPUs with these indices will be used (0-based)"}, 'burst': { 'default': None, 'type': restrict_value((0, None), dtype=int), 'help': "Number of projections processed per kernel invocation"}, 'x-region': { 'default': "0,-1,1", 'type': tupleize(num_items=3), 'unit': "pixel", 'help': "x region as from,to,step"}, 'y-region': { 'default': "0,-1,1", 'type': tupleize(num_items=3), 'unit': "pixel", 'help': "y region as from,to,step"}, 'z': { 'default': 0, 'type': int, 'unit': "pixel", 'help': "z coordinate of the reconstructed slice"}, 'z-parameter': { 'default': 'z', 'type': str, 'choices': ['axis-angle-x', 'axis-angle-y', 'axis-angle-z', 'volume-angle-x', 'volume-angle-y', 'volume-angle-z', 'detector-angle-x', 'detector-angle-y', 'detector-angle-z', 'detector-position-x', 'detector-position-y', 'detector-position-z', 'source-position-x', 'source-position-y', 'source-position-z', 'center-position-x', 'center-position-z', 'z'], 'help': "Parameter to vary along the reconstructed z-axis"}, 'region': { 'default': "0,1,1", 'type': tupleize(num_items=3), 'help': "z axis parameter region as from,to,step"}, 'source-position-x': { 'default': "0", 'unit': "pixel", 'type': tupleize(dtype=list), 'help': "X source position (horizontal) in global coordinates"}, 'source-position-z': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "pixel", 'help': "Z source position (vertical) in global coordinates"}, 'detector-position-x': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "pixel", 'help': "X detector position (horizontal) in global coordinates"}, 'detector-position-z': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "pixel", 'help': "Z detector position (vertical) in global coordinates"}, 'detector-angle-x': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "deg", 'help': "Detector rotation around the x axis (horizontal)"}, 'detector-angle-y': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "deg", 'help': "Detector rotation around the y axis (along beam direction)"}, 'detector-angle-z': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "deg", 'help': "Detector rotation around the z axis (vertical)"}, 'axis-angle-y': { 'default': "0", 'ezdefault': "0", 'type': tupleize(dtype=list), 'unit': "deg", 'help': "Rotation axis rotation around the y axis (along beam direction)"}, 'axis-angle-z': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "deg", 'help': "Rotation axis rotation around the z axis (vertical)"}, 'volume-angle-x': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "deg", 'help': "Volume rotation around the x axis (horizontal)"}, 'volume-angle-y': { 'default': "0", 'type': tupleize(dtype=list), 'unit': "deg", 'help': "Volume rotation around the y axis (along beam direction)"}, 'volume-angle-z': { 'default': "0", 'ezdefault': "0.0", 'type': tupleize(dtype=list), 'unit': "deg", 'help': "Volume rotation around the z axis (vertical)"}, 'compute-type': { 'default': 'float', 'type': str, 'help': "Data type for performing kernel math operations", 'choices': ['half', 'float', 'double']}, 'result-type': { 'default': 'float', 'type': str, 'help': "Data type for storing the intermediate gray value for a voxel " "from various rotation angles", 'choices': ['half', 'float', 'double']}, 'store-type': { 'default': 'float', 'type': str, 'help': "Data type of the output volume", 'choices': ['half', 'float', 'double', 'uchar', 'ushort', 'uint']}, 'overall-angle': { 'default': None, 'ezdefault': 360, 'type': float, 'unit': "deg", 'help': "The total angle over which projections were taken"}, 'genreco-padding-mode': { 'choices': ['none', 'clamp', 'clamp_to_edge', 'repeat', 'mirrored_repeat'], 'default': 'clamp', 'help': "Padded values assignment for the filtered projection"}, 'slice-gray-map': { 'default': "0,0", 'type': tupleize(num_items=2, conv=float), 'help': "Minimum and maximum gray value mapping if store-type is integer-based"} } SECTIONS['find-large-spots'] = { 'method': { 'ezdefault': 'grow', 'default': 'grow', 'type': str, 'help': "Data type of the output volume", 'choices': ['grow', 'median']}, # median arguments 'median-width': { 'ezdefault': 50, 'default': 10, 'type': int, 'help': "Width of the median filter (operates only horizontally)"}, 'median-direction': { 'ezdefault': 'horizontal', 'default': 'horizontal', 'type': str, 'choices': ['both', 'horizontal', 'vertical'], 'help': "Median filtering direction (in case of 'both', it will be a disk with radius " "'median-width' / 2"}, 'dilation-disk-radius': { 'ezdefault': 2, 'default': 2, 'type': int, 'help': "Dilation disk radius used for enlarging the found mask"}, 'max-spot-size': { 'default': 1000, 'type': int, 'help': "Spots with less number of pixels than this value are discarded " "(only available with --method median and this threshold " "is applied before dilation)"}, # grow arguments 'images': { 'default': None, 'type': str, 'help': "Location with input images", 'metavar': 'PATH'}, 'transpose-input': { 'default': False, 'action': 'store_true', 'help': "Transpose image when *vertical_sigma* is True, i.e. filter horizontal stripes " "instead of vertical"}, 'gauss-sigma': { 'default': 0.0, 'ezdefault': 5.0, 'type': float, 'help': "Gaussian sigma for removing low frequencies (filter will be 1 - gauss window)"}, 'vertical-sigma': { 'default': False, 'action': 'store_true', 'help': "*gauss-sigma* will be used for removing low frequencies in a horizontal stripe " "(vertical Gaussian profile applied around frequency ky=0 for all kx in a 1 - " "gauss window fashion)"}, 'blurred-output': { 'default': None, 'type': str, 'help': "Path where to store the blurred input"}, 'averaging-mode': { 'ezdefault': 'median', 'default': 'first', 'type': str, 'help': "How to average input images (first = take a single image, " "only available with --method median)", 'choices': ['first', 'mean', 'median']}, 'spot-threshold': { 'default': 0.0, 'ezdefault': 2000.0, 'type': float, 'help': "Pixels with grey value larger than this (after blurring in case of 'grow' method " "and after median subtraction in case of 'median' method) are considered as spots. " "In case of 'median' method and if 'spot-threshold' is not set, it is " "automatically set to 99-th percentile of the histogram."}, 'spot-threshold-mode': { 'ezdefault': 'absolute', 'default': 'absolute', 'type': str, 'help': "Pixels must be either \"below\", \"above\" the spot threshold, or \ their \"absolute\" value can be compared", 'choices': ['below', 'above', 'absolute']}, 'grow-threshold': { 'ezdefault': 0.0, 'default': 0.0, 'type': float, 'help': "Spot growing threshold (if 0 it will be set to FWTM times noise standard " "deviation). All pixels connected to the ones previously found by " "'spot-threshold' which are above 'grow-threshold' are marked as spots." }, 'find-large-spots-padding-mode': { 'choices': ['none', 'clamp', 'clamp_to_edge', 'repeat', 'mirrored_repeat'], 'default': 'repeat', 'help': "Padded values assignment for the filtered input image"}, } SECTIONS['inpaint'] = { 'projections': { 'default': None, 'type': str, 'help': "Location with projections", 'metavar': 'PATH'}, 'guidance-image': { 'default': None, 'type': str, 'help': "Guidance image, structure which will be inpainted into input images"}, 'mask-image': { 'default': None, 'type': str, 'help': "Mask image, pixels with ones will use the guidance image, pixels with zeros \ the original image"}, 'inpaint-padded-width': { 'default': 0, 'type': restrict_value((0, None), dtype=int), 'help': "Padded width used for inpainting"}, 'inpaint-padded-height': { 'default': 0, 'type': restrict_value((0, None), dtype=int), 'help': "Padded height used for inpainting"}, 'inpaint-padding-mode': { 'choices': ['none', 'clamp', 'clamp_to_edge', 'repeat', 'mirrored_repeat'], 'default': 'clamp_to_edge', 'help': "Padded values assignment for inpainting"}, 'preserve-mean': { 'default': False, 'action': 'store_true', 'help': "Mean value of the inpainted result will be the same as the one of the input"}, 'harmonize-borders': { 'default': False, 'action': 'store_true', 'help': "Harmonize transitions between image borders useful for the removal of the " "cross in the power spectrum"}, } SECTIONS['ez'] = { 'ezvars': { 'default': None, 'type': str, 'help': "Path to ez parameters.yaml file. Executes ez pipeline without gui.", 'metavar': 'PATH'}, } TOMO_PARAMS = ('flat-correction', 'reconstruction', 'tomographic-reconstruction', 'fbp', 'dfi', 'ir', 'sart', 'sbtv') PREPROC_PARAMS = ('preprocess', 'cone-beam-weight', 'flat-correction', 'retrieve-phase') LAMINO_PARAMS = PREPROC_PARAMS + ('laminographic-reconstruction',) GEN_RECO_PARAMS = PREPROC_PARAMS + ('general-reconstruction',) NICE_NAMES = ('General', 'Input', 'Flat field correction', 'Phase retrieval', 'Sinogram generation', 'General reconstruction', 'Tomographic reconstruction', 'Laminographic reconstruction', 'Filtered backprojection', 'Direct Fourier Inversion', 'Iterative reconstruction', 'SART', 'SBTV', 'GUI settings', 'Estimation', 'Performance', 'Preprocess', 'Cone beam weight', 'General reconstruction', 'Find large spots', 'Inpaint') # Add unit info to help strings for section in SECTIONS: for k, v in SECTIONS[section].items(): if 'unit' in v and 'help' in v: v['help'] += ' [{}]'.format(v['unit']) def get_config_name(): """Get the command line --config option.""" name = '' for i, arg in enumerate(sys.argv): if arg.startswith('--config'): if arg == '--config': return sys.argv[i + 1] else: name = sys.argv[i].split('--config')[1] if name[0] == '=': name = name[1:] return name return name def parse_known_args(parser, subparser=False): """ Parse arguments from file and then override by the ones specified on the command line. Use *parser* for parsing and is *subparser* is True take into account that there is a value on the command line specifying the subparser. """ if len(sys.argv) > 1: subparser_value = [sys.argv[1]] if subparser else [] config_values = config_to_list(config_name=get_config_name()) values = subparser_value + config_values + sys.argv[1:] args = None if config_values: args = parser.parse_known_args(args=subparser_value + config_values)[0] parser.parse_args(args=sys.argv[1:], namespace=args) else: values = "" return parser.parse_known_args(values)[0] def config_to_list(config_name=''): """ Read arguments from config file and convert them to a list of keys and values as sys.argv does when they are specified on the command line. *config_name* is the file name of the config file. """ result = [] config = configparser.ConfigParser() if not config.read([config_name]): return [] for section in SECTIONS: for name, opts in ((n, o) for n, o in list(SECTIONS[section].items()) if config.has_option(section, n)): value = config.get(section, name) if value != '' and value != 'None': action = opts.get('action', None) if action == 'store_true' and value == 'True': # Only the key is on the command line for this action result.append('--{}'.format(name)) if not action == 'store_true': if opts.get('nargs', None) == '+': result.append('--{}'.format(name)) result.extend((v.strip() for v in value.split(','))) else: result.append('--{}={}'.format(name, value)) return result def without_keys(d, keys): return {k: v for k, v in d.items() if k not in keys} class Params(object): def __init__(self, sections=()): self.sections = sections + ('general', 'reading') def add_parser_args(self, parser): for section in self.sections: for name in sorted(SECTIONS[section]): opts = without_keys(SECTIONS[section][name], {'ezdefault'}) opts.pop('unit', None) parser.add_argument('--{}'.format(name), **opts) def add_arguments(self, parser): self.add_parser_args(parser) return parser def get_defaults(self): parser = argparse.ArgumentParser() self.add_arguments(parser) return parser.parse_args('') def write(config_file, args=None, sections=None): """ Write *config_file* with values from *args* if they are specified, otherwise use the defaults. If *sections* are specified, write values from *args* only to those sections, use the defaults on the remaining ones. """ config = configparser.ConfigParser() for section in SECTIONS: config.add_section(section) for name, opts in list(SECTIONS[section].items()): if args and sections and section in sections and hasattr(args, name.replace('-', '_')): value = getattr(args, name.replace('-', '_')) if isinstance(value, list): value = ', '.join(value) else: value = opts['default'] if opts['default'] != None else '' prefix = '# ' if value == '' else '' if name != 'config': config.set(section, prefix + name, value) with open(config_file, 'wb') as f: config.write(f) def log_values(args): """Log all values set in the args namespace. Arguments are grouped according to their section and logged alphabetically using the DEBUG log level thus --verbose is required. """ args = args.__dict__ for section, name in zip(SECTIONS, NICE_NAMES): entries = sorted((k for k in list(args.keys()) if k.replace('_', '-') in SECTIONS[section])) if entries: LOG.debug(name) for entry in entries: value = args[entry] if args[entry] is not None else "-" LOG.debug(" {:<16} {}".format(entry, value)) ufo-kit-tofu-ed0e5bd/tofu/ez/000077500000000000000000000000001521054151500162075ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/000077500000000000000000000000001521054151500166335ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Advanced/000077500000000000000000000000001521054151500203405ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Advanced/Batch360.py000066400000000000000000000227471521054151500222000ustar00rootroot00000000000000import logging from PyQt5.QtWidgets import (QGridLayout, QLabel, QGroupBox, QLineEdit, QRadioButton, QPushButton, QFileDialog, QMessageBox,QCheckBox) from PyQt5.QtCore import pyqtSignal from tofu.ez.params import EZVARS_aux from tofu.ez.util import add_value_to_dict_entry, import_values import os from shutil import rmtree class Batch360Group(QGroupBox): """ Advanced Tofu Reco settings """ imported_good_list_signal = pyqtSignal(dict) def __init__(self): super().__init__() self.setTitle("AUTO STITCHING AND BATCH PROCESSING OF HALF ACQ. MODE DATA") self.setStyleSheet("QGroupBox {color: black;}") self.setEnabled(False) self.info_label = QLabel(f"Input directory structure MUST have four depth layers:\n" f"Input -> Scans --> CT subdirectories --> flats/darks/tomo. \n" f"You have to configure 360_overlap_search utility in top left " f"to enable the automatic stitching. \n" "Then switch to Main tab set reco parameters and press Reconstruct as usual.\n") self.dummy_text = QLabel() self.list_is_good = False self.outloopscans = None self.stitch_and_reco_rButton = QRadioButton() self.stitch_and_reco_rButton.setText("Stitch and reconstruct. \n" "You must provide structured file with overlap for each data set." ) self.stitch_and_reco_rButton.clicked.connect(self.set_rButton) self.find_olap_stitch_and_reco_rButton = QRadioButton() self.find_olap_stitch_and_reco_rButton.setText("Estimate overlap, stitch, and reconstruct") self.find_olap_stitch_and_reco_rButton.clicked.connect(self.set_rButton) # Import file with overlaps self.open_olap_file_button = QPushButton() self.open_olap_file_button.setText("Import file with overlaps with the structure\n" "as produced by 360-find-overlaps tool") self.open_olap_file_button.pressed.connect(self.import_olap_file_button_pressed) self.open_olap_file_button.setEnabled(True) h = 'Example of the file structure (as created by 360-find-overlap tool):' # h += '/data/TestBatch/foo2': # outer loop scan # h += ' {'z02': 40, 'z03': 41},' # several inner loop scans self.open_olap_file_button.setToolTip(h) # Select directory for intermediate 360 data self.halfacq_dir_select = QPushButton() self.halfacq_dir_select.setText("Select working directory") self.halfacq_dir_select.setToolTip( "Slices to search overlap and horizontally stitched 360 projections will be saved there.\n" ) self.halfacq_dir_select.pressed.connect(self.select_halfacq_dir) self.halfacq_dir_entry = QLineEdit() self.halfacq_dir_entry.setToolTip( "Slices to search overlap and horizontally stitched 360 projections will be saved there.\n" ) self.halfacq_dir_entry.editingFinished.connect(self.set_halfacq_dir) self.doVertStitching_checkbox = QCheckBox("Invoke vertical stitching when " "reconstruction is over. \n You must set" "params correctly in the Stitch Tab.") self.doVertStitching_checkbox.clicked.connect(self.set_doVertStitching) self.set_layout() def set_layout(self): layout = QGridLayout() layout.addWidget(self.info_label, 0, 0, 1, 2) layout.addWidget(self.stitch_and_reco_rButton, 2, 0) layout.addWidget(self.open_olap_file_button, 2, 1) layout.addWidget(self.find_olap_stitch_and_reco_rButton, 1, 0) layout.addWidget(self.halfacq_dir_select, 3, 0) layout.addWidget(self.halfacq_dir_entry, 3, 1) #layout.addWidget(self.doVertStitching_checkbox, 4, 0, 1, 2) self.setLayout(layout) def load_values(self): self.set_rButton_from_params() self.halfacq_dir_entry.setText(str(EZVARS_aux['half-acq']['workdir']['value'])) #I have to mention everything in load_values so that they will be initialized properly self.dummy_text.setText(str(EZVARS_aux['half-acq']['list_dirs']['value'])) self.dummy_text.setText(str(EZVARS_aux['half-acq']['list_olaps']['value'])) def enable_by_trigger_from_main_tab(self, enabled): if enabled: self.setEnabled(True) self.find_olap_stitch_and_reco_rButton.setChecked(True) self.set_rButton() else: self.setEnabled(False) def set_rButton_from_params(self): # TODO: it seems it is not being set correctly. Plus: check that dir empty here? if EZVARS_aux['half-acq']['task_type']['value']: self.stitch_and_reco_rButton.setChecked(False) self.find_olap_stitch_and_reco_rButton.setChecked(True) self.open_olap_file_button.setEnabled(False) else: self.stitch_and_reco_rButton.setChecked(True) self.find_olap_stitch_and_reco_rButton.setChecked(False) self.open_olap_file_button.setEnabled(True) return def set_rButton(self): if self.stitch_and_reco_rButton.isChecked(): add_value_to_dict_entry(EZVARS_aux['half-acq']['task_type'], 0) self.open_olap_file_button.setEnabled(True) elif self.find_olap_stitch_and_reco_rButton.isChecked(): add_value_to_dict_entry(EZVARS_aux['half-acq']['task_type'], 1) self.open_olap_file_button.setEnabled(False) def select_halfacq_dir(self): dir_explore = QFileDialog(self) tmp_dir = dir_explore.getExistingDirectory(directory=self.halfacq_dir_entry.text()) if tmp_dir: self.halfacq_dir_entry.setText(tmp_dir) self.set_halfacq_dir() else: QMessageBox.information(self, "Select valid directory") return #if os.path.exists(self.EZVARS_aux['find360olap']['tmp-dir']) and \ if len(os.listdir(tmp_dir)) > 0: qm = QMessageBox() rep = qm.question(self, 'WARNING', "Directory exists and not empty. \n " "Clean it or select a new directory. \n" "Is it SAFE to delete directory now?", qm.Yes | qm.No) if rep == qm.Yes: try: rmtree(tmp_dir) except: QMessageBox.information(self, "Problem", "Cannot delete existing directory") return else: return def set_halfacq_dir(self): dict_entry = EZVARS_aux['half-acq']['workdir'] text = self.halfacq_dir_entry.text().strip() add_value_to_dict_entry(dict_entry, text) # stitched_data_dir_name = os.path.join(EZVARS_aux['half-acq']['workdir']['value'], # 'stitched-data') # if os.path.exists(stitched_data_dir_name) and \ # len(os.listdir(stitched_data_dir_name)) > 0: # QMessageBox.information(self, "Problem", "Directory for stitched data already exists \n" # "and not empty. Clean it and try again.") def set_doVertStitching(self): add_value_to_dict_entry(EZVARS_aux['half-acq']['dovertsti'], self.doVertStitching_checkbox.isChecked()) def import_olap_file_button_pressed(self): """ Loads external settings from .yaml file specified by user Signal is sent to enable updating of displayed GUI values """ options = QFileDialog.Options() filePath, _ = QFileDialog.getOpenFileName( self, "QFileDialog.getOpenFileName()", "", "YAML Files (*.yaml);; All Files (*)", options=options, ) if filePath: # structure holding path to CT sets and respective overlaps # must be imported try: self.olap_lists = import_values(filePath, ['ezvars_aux']) except: QMessageBox.information(self, "Cannot import list of overlaps") # self.list_is_good = self.check_olap_lists() # if self.list_is_good: # self.imported_good_list_signal.emit(self.olap_lists) # self.set_olap_list_in_EZVARS_aux() # print(EZVARS_aux) # # def check_olap_lists(self): # try: # self.outloopscans = set([os.path.dirname(w) for w in self.olap_lists.keys()]) # except: # QMessageBox.information(self, "Cannot extract directory names from the supplied file") # return False # try: # for o in self.olap_lists.values(): int(o) # except: # QMessageBox.information(self, "One of overlaps is not a number in the supplied file") # return False # # EZVARS_aux['half-acq']['list_dirs']['value'] = '' # return True # # def set_olap_list_in_EZVARS_aux(self): # for outscan in self.outloopscans: # EZVARS_aux['axes-list'].update({str(outscan) : {} }) # for j in self.olap_lists.keys(): # tmp = os.path.dirname(j) # if outscan == tmp: # EZVARS_aux['axes-list'][outscan][j[len(tmp)+1:]] = self.olap_lists[j] # return ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Advanced/__init__.py000066400000000000000000000000001521054151500224370ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Advanced/advanced.py000066400000000000000000000112641521054151500224630ustar00rootroot00000000000000import logging from PyQt5.QtWidgets import QGridLayout, QLabel, QGroupBox, QLineEdit from tofu.ez.params import EZVARS from tofu.config import SECTIONS from tofu.ez.util import add_value_to_dict_entry, get_double_validator, reverse_tupleize LOG = logging.getLogger(__name__) class AdvancedGroup(QGroupBox): """ Advanced Tofu Reco settings """ def __init__(self): super().__init__() self.setTitle("Generalized CT reconstruction settings") self.setStyleSheet("QGroupBox {color: green;}") # LAMINO self.lamino_group = QGroupBox("Extended Settings of Reconstruction Algorithms") self.lamino_group.clicked.connect(self.set_lamino_group) self.lamino_angle_label = QLabel("Laminographic angle") self.lamino_angle_entry = QLineEdit() self.lamino_angle_entry.setValidator(get_double_validator()) self.lamino_angle_entry.editingFinished.connect(self.set_lamino_angle) self.overall_rotation_label = QLabel("Overall rotation range about CT Z-axis") self.overall_rotation_entry = QLineEdit() self.overall_rotation_entry.setValidator(get_double_validator()) self.overall_rotation_entry.editingFinished.connect(self.set_overall_rotation) self.center_position_z_label = QLabel("Center Position Z") self.center_position_z_entry = QLineEdit() self.center_position_z_entry.setValidator(get_double_validator()) self.center_position_z_entry.editingFinished.connect(self.set_center_position_z) self.axis_rotation_y_label = QLabel("Sample rotation about the beam Y-axis") self.axis_rotation_y_entry = QLineEdit() self.axis_rotation_y_entry.editingFinished.connect(self.set_rotation_about_beam) self.set_layout() def set_layout(self): layout = QGridLayout() self.lamino_group.setCheckable(True) self.lamino_group.setChecked(False) lamino_layout = QGridLayout() lamino_layout.addWidget(self.lamino_angle_label, 0, 0) lamino_layout.addWidget(self.lamino_angle_entry, 0, 1) lamino_layout.addWidget(self.overall_rotation_label, 1, 0) lamino_layout.addWidget(self.overall_rotation_entry, 1, 1) lamino_layout.addWidget(self.center_position_z_label, 2, 0) lamino_layout.addWidget(self.center_position_z_entry, 2, 1) lamino_layout.addWidget(self.axis_rotation_y_label, 3, 0) lamino_layout.addWidget(self.axis_rotation_y_entry, 3, 1) self.lamino_group.setLayout(lamino_layout) layout.addWidget(self.lamino_group) self.setLayout(layout) def load_values(self): self.lamino_group.setChecked(EZVARS['advanced']['more-reco-params']['value']) self.lamino_angle_entry.setText(str(reverse_tupleize()(SECTIONS['cone-beam-weight']['axis-angle-x']['value']))) self.overall_rotation_entry.setText(str(SECTIONS['general-reconstruction']['overall-angle']['value'])) self.center_position_z_entry.setText(str(reverse_tupleize()(SECTIONS['cone-beam-weight']['center-position-z']['value']))) self.axis_rotation_y_entry.setText(str(reverse_tupleize()(SECTIONS['general-reconstruction']['axis-angle-y']['value']))) def set_lamino_group(self): LOG.debug("Lamino: " + str(self.lamino_group.isChecked())) dict_entry = EZVARS['advanced']['more-reco-params'] add_value_to_dict_entry(dict_entry, self.lamino_group.isChecked()) def set_lamino_angle(self): LOG.debug(self.lamino_angle_entry.text()) dict_entry = SECTIONS['cone-beam-weight']['axis-angle-x'] add_value_to_dict_entry(dict_entry, str(self.lamino_angle_entry.text())) self.lamino_angle_entry.setText(str(reverse_tupleize()(dict_entry['value']))) def set_overall_rotation(self): LOG.debug(self.overall_rotation_entry.text()) dict_entry = SECTIONS['general-reconstruction']['overall-angle'] add_value_to_dict_entry(dict_entry, str(self.overall_rotation_entry.text())) self.overall_rotation_entry.setText(str(dict_entry['value'])) def set_center_position_z(self): LOG.debug(self.center_position_z_entry.text()) dict_entry = SECTIONS['cone-beam-weight']['center-position-z'] add_value_to_dict_entry(dict_entry, str(self.center_position_z_entry.text())) self.center_position_z_entry.setText(str(reverse_tupleize()(dict_entry['value']))) def set_rotation_about_beam(self): LOG.debug(self.axis_rotation_y_entry.text()) dict_entry = SECTIONS['general-reconstruction']['axis-angle-y'] add_value_to_dict_entry(dict_entry, str(self.axis_rotation_y_entry.text())) self.axis_rotation_y_entry.setText(str(reverse_tupleize()(dict_entry['value']))) ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Advanced/ffc.py000066400000000000000000000175041521054151500214570ustar00rootroot00000000000000import logging from PyQt5.QtWidgets import ( QGridLayout, QLabel, QGroupBox, QLineEdit, QCheckBox, QRadioButton, QHBoxLayout, ) from tofu.ez.params import EZVARS from tofu.ez.util import add_value_to_dict_entry, get_int_validator, get_double_validator LOG = logging.getLogger(__name__) class FFCGroup(QGroupBox): """ Flat Field Correction Settings """ def __init__(self): super().__init__() self.setTitle("Flat Field Correction") self.setStyleSheet("QGroupBox {color: indigo;}") self.method_label = QLabel("Method:") self.average_rButton = QRadioButton("Average") self.average_rButton.clicked.connect(self.set_method) self.ssim_rButton = QRadioButton("SSIM") self.ssim_rButton.clicked.connect(self.set_method) self.eigen_rButton = QRadioButton("Eigen") self.eigen_rButton.clicked.connect(self.set_method) self.enable_sinFFC_checkbox = QCheckBox( "Use Smart Intensity Normalization Flat Field Correction" ) self.enable_sinFFC_checkbox.stateChanged.connect(self.set_sinFFC) self.eigen_pco_repetitions_label = QLabel("Eigen PCO Repetitions") self.eigen_pco_repetitions_entry = QLineEdit() self.eigen_pco_repetitions_entry.setValidator(get_int_validator()) self.eigen_pco_repetitions_entry.editingFinished.connect(self.set_pcoReps) self.eigen_pco_downsample_label = QLabel("Eigen PCO Downsample") self.eigen_pco_downsample_entry = QLineEdit() self.eigen_pco_downsample_entry.setValidator(get_int_validator()) self.eigen_pco_downsample_entry.editingFinished.connect(self.set_pcoDowns) self.downsample_label = QLabel("Downsample") self.downsample_entry = QLineEdit() self.downsample_entry.setValidator(get_int_validator()) self.downsample_entry.editingFinished.connect(self.set_downsample) self.flat_scale_label = QLabel("Scale flats by a factor of") self.flat_scale_entry = QLineEdit() self.flat_scale_entry.setValidator(get_double_validator()) self.flat_scale_entry.editingFinished.connect(self.set_flat_scale) self.reduction_label = QLabel("Reduction Mode:") self.median_reduction_rButton = QRadioButton("Median") self.median_reduction_rButton.clicked.connect(self.set_reduction_mode) self.average_reduction_rButton = QRadioButton("Average") self.average_reduction_rButton.clicked.connect(self.set_reduction_mode) self.set_layout() def set_layout(self): layout = QGridLayout() layout.addWidget(self.flat_scale_label, 0, 0) layout.addWidget(self.flat_scale_entry, 0, 1) reduction_layout = QHBoxLayout() reduction_layout.addWidget(self.reduction_label) reduction_layout.addWidget(self.average_reduction_rButton) reduction_layout.addWidget(self.median_reduction_rButton) layout.addLayout(reduction_layout, 1, 0, 1, 2) rbutton_layout = QHBoxLayout() rbutton_layout.addWidget(self.method_label) rbutton_layout.addWidget(self.eigen_rButton) rbutton_layout.addWidget(self.average_rButton) rbutton_layout.addWidget(self.ssim_rButton) layout.addWidget(self.enable_sinFFC_checkbox, 2, 0) layout.addItem(rbutton_layout, 3, 0, 1, 2) layout.addWidget(self.eigen_pco_repetitions_label, 4, 0) layout.addWidget(self.eigen_pco_repetitions_entry, 4, 1) layout.addWidget(self.eigen_pco_downsample_label, 5, 0) layout.addWidget(self.eigen_pco_downsample_entry, 5, 1) layout.addWidget(self.downsample_label, 6, 0) layout.addWidget(self.downsample_entry, 6, 1) self.setLayout(layout) def load_values(self): self.enable_sinFFC_checkbox.setChecked(EZVARS['flat-correction']['smart-ffc']['value']) self.set_method_from_params() self.set_reduction_mode_from_params() self.eigen_pco_repetitions_entry.setText(str(EZVARS['flat-correction']['eigen-pco-reps']['value'])) self.eigen_pco_downsample_entry.setText(str(EZVARS['flat-correction']['eigen-pco-downsample']['value'])) self.downsample_entry.setText(str(EZVARS['flat-correction']['downsample']['value'])) self.flat_scale_entry.setText(str(EZVARS['flat-correction']['flat-scale']['value'])) def set_flat_scale(self): LOG.debug(self.flat_scale_entry.text()) dict_entry = EZVARS['flat-correction']['flat-scale'] add_value_to_dict_entry(dict_entry, str(self.flat_scale_entry.text())) self.flat_scale_entry.setText(str(dict_entry['value'])) def set_sinFFC(self): LOG.debug("sinFFC: " + str(self.enable_sinFFC_checkbox.isChecked())) dict_entry = EZVARS['flat-correction']['smart-ffc'] add_value_to_dict_entry(dict_entry, self.enable_sinFFC_checkbox.isChecked()) def set_pcoReps(self): LOG.debug("PCO Reps: " + str(self.eigen_pco_repetitions_entry.text())) dict_entry = EZVARS['flat-correction']['eigen-pco-reps'] add_value_to_dict_entry(dict_entry, str(self.eigen_pco_repetitions_entry.text())) self.eigen_pco_repetitions_entry.setText(str(dict_entry['value'])) def set_pcoDowns(self): LOG.debug("PCO Downsample: " + str(self.eigen_pco_downsample_entry.text())) dict_entry = EZVARS['flat-correction']['eigen-pco-downsample'] add_value_to_dict_entry(dict_entry, str(self.eigen_pco_downsample_entry.text())) self.eigen_pco_downsample_entry.setText(str(dict_entry['value'])) def set_downsample(self): LOG.debug("Downsample: " + str(self.downsample_entry.text())) dict_entry = EZVARS['flat-correction']['downsample'] add_value_to_dict_entry(dict_entry, str(self.downsample_entry.text())) self.downsample_entry.setText(str(dict_entry['value'])) def set_method(self): if self.eigen_rButton.isChecked(): LOG.debug("Method: Eigen") EZVARS['flat-correction']['smart-ffc-method']['value'] = "eigen" elif self.average_rButton.isChecked(): LOG.debug("Method: Average") EZVARS['flat-correction']['smart-ffc-method']['value'] = "average" elif self.ssim_rButton.isChecked(): LOG.debug("Method: SSIM") EZVARS['flat-correction']['smart-ffc-method']['value'] = "ssim" def set_method_from_params(self): if EZVARS['flat-correction']['smart-ffc-method']['value'] == "eigen": self.eigen_rButton.setChecked(True) self.average_rButton.setChecked(False) self.ssim_rButton.setChecked(False) elif EZVARS['flat-correction']['smart-ffc-method']['value'] == "average": self.eigen_rButton.setChecked(False) self.average_rButton.setChecked(True) self.ssim_rButton.setChecked(False) elif EZVARS['flat-correction']['smart-ffc-method']['value'] == "ssim": self.eigen_rButton.setChecked(False) self.average_rButton.setChecked(False) self.ssim_rButton.setChecked(True) def set_reduction_mode(self): if self.median_reduction_rButton.isChecked(): LOG.debug("Reduction Mode: Median") EZVARS['flat-correction']['reduction-mode']['value'] = "median" elif self.average_reduction_rButton.isChecked(): LOG.debug("Reduction Mode: Average") EZVARS['flat-correction']['reduction-mode']['value'] = "average" def set_reduction_mode_from_params(self): if EZVARS['flat-correction']['reduction-mode']['value'] == "median": self.median_reduction_rButton.setChecked(True) self.average_reduction_rButton.setChecked(False) elif EZVARS['flat-correction']['reduction-mode']['value'] == "average": self.median_reduction_rButton.setChecked(False) self.average_reduction_rButton.setChecked(True) ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Advanced/find_large_spots.py000066400000000000000000000145761521054151500242510ustar00rootroot00000000000000import logging from PyQt5.QtWidgets import ( QGridLayout, QLabel, QGroupBox, QLineEdit, QComboBox, QHBoxLayout, ) from tofu.ez.params import EZVARS from tofu.config import SECTIONS from tofu.ez.util import add_value_to_dict_entry, get_int_validator, get_double_validator LOG = logging.getLogger(__name__) class FindSpotsGroup(QGroupBox): """ Flat Field Correction Settings """ def __init__(self): super().__init__() self.setTitle("Extended settings to find large bad spots in images") self.setStyleSheet("QGroupBox {color: brown;}") self.setEnabled(False) self.median_width_label = QLabel("Median width") self.median_width_entry = QLineEdit() self.median_width_entry.setValidator(get_int_validator()) self.median_width_entry.editingFinished.connect(self.set_median_width) self.median_width_entry.setToolTip(SECTIONS['find-large-spots']['median-width']['help']) self.median_width_label.setToolTip(SECTIONS['find-large-spots']['median-width']['help']) self.dil_disk_rad_label = QLabel("Dilation disk radius") self.dil_disk_rad_entry = QLineEdit() self.dil_disk_rad_entry.setValidator(get_int_validator()) self.dil_disk_rad_entry.editingFinished.connect(self.set_dil_disk_rad) self.dil_disk_rad_entry.setToolTip(SECTIONS['find-large-spots']['dilation-disk-radius']['help']) self.dil_disk_rad_label.setToolTip(SECTIONS['find-large-spots']['dilation-disk-radius']['help']) self.grow_thr_label = QLabel("Grow threshold") self.grow_thr_entry = QLineEdit() self.grow_thr_entry.setValidator(get_double_validator()) self.grow_thr_entry.editingFinished.connect(self.set_grow_thr) self.grow_thr_entry.setToolTip(SECTIONS['find-large-spots']['grow-threshold']['help']) self.grow_thr_label.setToolTip(SECTIONS['find-large-spots']['grow-threshold']['help']) self.spot_thr_sign_label = QLabel("Spot threshold mode") self.spot_thr_sign_entry = QComboBox() self.spot_thr_sign_entry.addItems(["absolute", "above", "below"]) self.spot_thr_sign_entry.setCurrentText("absolute") self.spot_thr_sign_entry.currentIndexChanged.connect(self.set_spot_thr_sign) self.spot_thr_sign_entry.setToolTip(SECTIONS['find-large-spots']['spot-threshold-mode']['help']) self.spot_thr_sign_label.setToolTip(SECTIONS['find-large-spots']['spot-threshold-mode']['help']) self.median_direction_label = QLabel("Median direction") self.median_direction_entry = QComboBox() self.median_direction_entry.addItems(['both', 'horizontal', 'vertical']) self.median_direction_entry.setCurrentText("horizontal") self.median_direction_entry.currentIndexChanged.connect(self.set_median_direction) self.median_direction_entry.setToolTip(SECTIONS['find-large-spots']['median-direction']['help']) self.median_direction_label.setToolTip(SECTIONS['find-large-spots']['median-direction']['help']) self.set_layout() def set_layout(self): layout = QGridLayout() # layout.addWidget(self.median_width_label, 1, 0) # layout.addWidget(self.median_width_entry, 1, 1, 1, 3) # # layout.addWidget(self.grow_thr_label, 2, 0) # layout.addWidget(self.grow_thr_entry, 2, 1, 1, 3) # # layout.addWidget(self.dil_disk_rad_label, 3, 0) # layout.addWidget(self.dil_disk_rad_entry, 3, 1, 1, 3) # # layout.addWidget(self.spot_thr_sign_label, 4, 0) # layout.addWidget(self.spot_thr_sign_entry, 4, 1) # # layout.addWidget(self.median_direction_label, 4, 2) # layout.addWidget(self.median_direction_entry, 4, 3) layout.addWidget(self.median_width_label, 1, 0) layout.addWidget(self.median_width_entry, 1, 1) layout.addWidget(self.grow_thr_label, 2, 0) layout.addWidget(self.grow_thr_entry, 2, 1) layout.addWidget(self.dil_disk_rad_label, 3, 0) layout.addWidget(self.dil_disk_rad_entry, 3, 1) layout.addWidget(self.spot_thr_sign_label, 4, 0) layout.addWidget(self.spot_thr_sign_entry, 4, 1) layout.addWidget(self.median_direction_label, 5, 0) layout.addWidget(self.median_direction_entry, 5, 1) self.setLayout(layout) def load_values(self): if EZVARS['filters']['rm_spots_use_median']['value']: self.setEnabled(True) self.median_width_entry.setText(str(SECTIONS['find-large-spots']['median-width']['value'])) self.dil_disk_rad_entry.setText(str(SECTIONS['find-large-spots']['dilation-disk-radius']['value'])) self.grow_thr_entry.setText(str(SECTIONS['find-large-spots']['grow-threshold']['value'])) self.spot_thr_sign_entry.setCurrentText(str(SECTIONS['find-large-spots']['spot-threshold-mode']['value'])) self.median_direction_entry.setCurrentText(str(SECTIONS['find-large-spots']['median-direction']['value'])) def enable_by_trigger_from_main_tab(self): dict_entry = SECTIONS['find-large-spots']['method'] if not self.isEnabled(): self.setEnabled(True) add_value_to_dict_entry(EZVARS['filters']['rm_spots_use_median'], True) add_value_to_dict_entry(dict_entry, 'median') else: self.setEnabled(False) add_value_to_dict_entry(EZVARS['filters']['rm_spots_use_median'], False) add_value_to_dict_entry(dict_entry, 'grow') def set_median_width(self): dict_entry = SECTIONS['find-large-spots']['median-width'] add_value_to_dict_entry(dict_entry, self.median_width_entry.text()) def set_dil_disk_rad(self): dict_entry = SECTIONS['find-large-spots']['dilation-disk-radius'] add_value_to_dict_entry(dict_entry, self.dil_disk_rad_entry.text()) def set_grow_thr(self): dict_entry = SECTIONS['find-large-spots']['grow-threshold'] add_value_to_dict_entry(dict_entry, self.grow_thr_entry.text()) def set_spot_thr_sign(self): dict_entry = SECTIONS['find-large-spots']['spot-threshold-mode'] self.spot_thr_sign_entry.currentText() add_value_to_dict_entry(dict_entry, self.spot_thr_sign_entry.currentText()) def set_median_direction(self): dict_entry = SECTIONS['find-large-spots']['median-direction'] add_value_to_dict_entry(dict_entry, self.median_direction_entry.currentText()) ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Advanced/nlmdn.py000066400000000000000000000343721521054151500220330ustar00rootroot00000000000000import logging import os from shutil import rmtree from PyQt5.QtWidgets import ( QGridLayout, QLabel, QGroupBox, QLineEdit, QCheckBox, QPushButton, QFileDialog, QMessageBox, ) from PyQt5.QtCore import Qt from tofu.ez.ufo_cmd_gen import fmt_nlmdn_ufo_cmd from tofu.ez.params import EZVARS from tofu.ez.util import add_value_to_dict_entry, get_int_validator, get_double_validator LOG = logging.getLogger(__name__) class NLMDNGroup(QGroupBox): """ Non-local means de-noising settings """ def __init__(self): super().__init__() self.setTitle("Non-local-means Denoising") self.setStyleSheet("QGroupBox {color: royalblue;}") self.apply_to_reco_checkbox = QCheckBox("Automatically apply NLMDN to reconstructed slices") self.apply_to_reco_checkbox.stateChanged.connect(self.set_apply_to_reco) self.input_dir_button = QPushButton("Select input directory") self.input_dir_button.clicked.connect(self.set_indir_button) self.select_img_button = QPushButton("Select one image") self.select_img_button.clicked.connect(self.select_image) self.input_dir_entry = QLineEdit() self.input_dir_entry.editingFinished.connect(self.set_indir_entry) self.output_dir_button = QPushButton("Select output directory or filename pattern") self.output_dir_button.clicked.connect(self.set_outdir_button) self.save_bigtif_checkbox = QCheckBox("Save in bigtif container") self.save_bigtif_checkbox.clicked.connect(self.set_save_bigtif) self.output_dir_entry = QLineEdit() self.output_dir_entry.editingFinished.connect(self.set_outdir_entry) self.similarity_radius_label = QLabel("Radius for similarity search") self.similarity_radius_entry = QLineEdit() self.similarity_radius_entry.setValidator(get_int_validator()) self.similarity_radius_entry.editingFinished.connect(self.set_rad_sim_entry) self.patch_radius_label = QLabel("Radius of patches") self.patch_radius_entry = QLineEdit() self.patch_radius_entry.setValidator(get_int_validator()) self.patch_radius_entry.editingFinished.connect(self.set_rad_patch_entry) self.smoothing_label = QLabel("Smoothing control parameter") self.smoothing_entry = QLineEdit() self.smoothing_entry.setValidator(get_double_validator()) self.smoothing_entry.editingFinished.connect(self.set_smoothing_entry) self.noise_std_label = QLabel("Noise standard deviation") self.noise_std_entry = QLineEdit() self.noise_std_entry.setValidator(get_double_validator()) self.noise_std_entry.editingFinished.connect(self.set_noise_entry) self.window_label = QLabel("Window (optional)") self.window_entry = QLineEdit() self.window_entry.setValidator(get_double_validator()) self.window_entry.editingFinished.connect(self.set_window_entry) self.fast_checkbox = QCheckBox("Fast") self.fast_checkbox.clicked.connect(self.set_fast_checkbox) self.sigma_checkbox = QCheckBox("Estimate sigma") self.sigma_checkbox.clicked.connect(self.set_sigma_checkbox) self.help_button = QPushButton("Help") self.help_button.clicked.connect(self.help_button_pressed) self.delete_button = QPushButton("Delete reco dir") self.delete_button.clicked.connect(self.delete_button_pressed) self.dry_button = QPushButton("Dry run") self.dry_button.clicked.connect(self.dry_button_pressed) self.apply_button = QPushButton("Apply filter") self.apply_button.clicked.connect(self.apply_button_pressed) # self.apply_button.setStyleSheet("color:royalblue; font-weight: bold;") self.set_layout() def set_layout(self): layout = QGridLayout() layout.addWidget(self.apply_to_reco_checkbox, 0, 0, 1, 1) layout.addWidget(self.input_dir_button, 1, 0, 1, 2) layout.addWidget(self.select_img_button, 1, 2, 1, 2) layout.addWidget(self.input_dir_entry, 2, 0, 1, 4) layout.addWidget(self.output_dir_button, 3, 0, 1, 2) layout.addWidget(self.save_bigtif_checkbox, 3, 2, 1, 2, Qt.AlignCenter) layout.addWidget(self.output_dir_entry, 4, 0, 1, 4) layout.addWidget(self.similarity_radius_label, 5, 0, 1, 2) layout.addWidget(self.similarity_radius_entry, 5, 2, 1, 2) layout.addWidget(self.patch_radius_label, 6, 0, 1, 2) layout.addWidget(self.patch_radius_entry, 6, 2, 1, 2) layout.addWidget(self.smoothing_label, 7, 0, 1, 2) layout.addWidget(self.smoothing_entry, 7, 2, 1, 2) layout.addWidget(self.noise_std_label, 8, 0, 1, 2) layout.addWidget(self.noise_std_entry, 8, 2, 1, 2) layout.addWidget(self.window_label, 9, 0, 1, 2) layout.addWidget(self.window_entry, 9, 2, 1, 2) layout.addWidget(self.fast_checkbox, 10, 0, 1, 2, Qt.AlignCenter) layout.addWidget(self.sigma_checkbox, 10, 2, 1, 2, Qt.AlignCenter) layout.addWidget(self.help_button, 11, 0, 1, 1) layout.addWidget(self.delete_button, 11, 1) layout.addWidget(self.dry_button, 11, 2) layout.addWidget(self.apply_button, 11, 3) self.setLayout(layout) def load_values(self): self.apply_to_reco_checkbox.setChecked(bool(EZVARS['nlmdn']['do-after-reco']['value'])) self.input_dir_entry.setText(str(EZVARS['nlmdn']['input-dir']['value'])) self.output_dir_entry.setText(str(EZVARS['nlmdn']['output_pattern']['value'])) self.save_bigtif_checkbox.setChecked(bool(EZVARS['nlmdn']['bigtiff_output']['value'])) self.similarity_radius_entry.setText(str(EZVARS['nlmdn']['search-radius']['value'])) self.patch_radius_entry.setText(str(EZVARS['nlmdn']['patch-radius']['value'])) self.smoothing_entry.setText(str(EZVARS['nlmdn']['h']['value'])) self.noise_std_entry.setText(str(EZVARS['nlmdn']['sigma']['value'])) self.window_entry.setText(str(EZVARS['nlmdn']['window']['value'])) self.fast_checkbox.setChecked(bool(EZVARS['nlmdn']['fast']['value'])) self.sigma_checkbox.setChecked(bool(EZVARS['nlmdn']['estimate-sigma']['value'])) def set_apply_to_reco(self): LOG.debug( "Apply NLMDN to reconstructed slices checkbox: " + str(self.apply_to_reco_checkbox.isChecked()) ) dict_entry = EZVARS['nlmdn']['do-after-reco'] add_value_to_dict_entry(dict_entry, self.apply_to_reco_checkbox.isChecked()) if self.apply_to_reco_checkbox.isChecked(): self.input_dir_button.setDisabled(True) self.select_img_button.setDisabled(True) self.input_dir_entry.setDisabled(True) self.dry_button.setDisabled(True) self.apply_button.setDisabled(True) self.output_dir_button.setDisabled(True) self.output_dir_entry.setDisabled(True) elif not self.apply_to_reco_checkbox.isChecked(): self.input_dir_button.setDisabled(False) self.select_img_button.setDisabled(False) self.input_dir_entry.setDisabled(False) self.dry_button.setDisabled(False) self.apply_button.setDisabled(False) self.output_dir_button.setDisabled(False) self.output_dir_entry.setDisabled(False) def set_indir_button(self): """ Saves directory specified by user in file-dialog for input tomographic data """ LOG.debug("Select input directory pressed") dir_explore = QFileDialog(self) directory = dir_explore.getExistingDirectory() if directory: self.input_dir_entry.setText(str(directory)) self.set_indir_entry() self.output_dir_entry.setText(str(os.path.join(directory+'-nlmdn', 'im-%05i.tif'))) self.set_outdir_entry() dict_entry = EZVARS['nlmdn']['input-is-1file'] add_value_to_dict_entry(dict_entry, False) def set_indir_entry(self): LOG.debug("Indir entry: " + str(self.input_dir_entry.text())) dict_entry = EZVARS['nlmdn']['input-dir'] dir = self.input_dir_entry.text().strip() add_value_to_dict_entry(dict_entry, str(dir)) self.input_dir_entry.setText(str(dict_entry['value'])) def select_image(self): LOG.debug("Select one image button pressed") options = QFileDialog.Options() file_path, _ = QFileDialog.getOpenFileName( self, "Open .tif Image File", "", "Tiff Files (*.tif *.tiff)", options=options ) if file_path: img_name, img_ext = os.path.splitext(file_path) tmp = img_name + "-nlmfilt" + img_ext self.input_dir_entry.setText(str(file_path)) self.set_indir_entry() self.output_dir_entry.setText(str(tmp)) self.set_outdir_entry() dict_entry = EZVARS['nlmdn']['input-is-1file'] add_value_to_dict_entry(dict_entry, True) def set_outdir_button(self): LOG.debug("Select output directory pressed") dir_explore = QFileDialog(self) directory = dir_explore.getExistingDirectory() if directory: self.output_dir_entry.setText(str(os.path.join(directory,'im-nlmdn-%05i.tif'))) self.set_outdir_entry() def set_save_bigtif(self): LOG.debug("Save bigtif checkbox: " + str(self.save_bigtif_checkbox.isChecked())) dict_entry = EZVARS['nlmdn']['bigtiff_output'] add_value_to_dict_entry(dict_entry, self.save_bigtif_checkbox.isChecked()) def set_outdir_entry(self): LOG.debug("Outdir entry: " + str(self.output_dir_entry.text())) dict_entry = EZVARS['nlmdn']['output_pattern'] dir = self.output_dir_entry.text().strip() add_value_to_dict_entry(dict_entry, str(dir)) self.output_dir_entry.setText(str(dict_entry['value'])) def set_rad_sim_entry(self): LOG.debug("Radius for similarity: " + str(self.similarity_radius_entry.text())) dict_entry = EZVARS['nlmdn']['search-radius'] add_value_to_dict_entry(dict_entry, str(self.similarity_radius_entry.text())) self.similarity_radius_entry.setText(str(dict_entry['value'])) def set_rad_patch_entry(self): LOG.debug("Radius of patches: " + str(self.patch_radius_entry.text())) dict_entry = EZVARS['nlmdn']['patch-radius'] add_value_to_dict_entry(dict_entry, str(self.patch_radius_entry.text())) self.patch_radius_entry.setText(str(dict_entry['value'])) def set_smoothing_entry(self): LOG.debug("Smoothing control: " + str(self.smoothing_entry.text())) dict_entry = EZVARS['nlmdn']['h'] add_value_to_dict_entry(dict_entry, str(self.smoothing_entry.text())) self.smoothing_entry.setText(str(dict_entry['value'])) def set_noise_entry(self): LOG.debug("Noise std: " + str(self.noise_std_entry.text())) dict_entry = EZVARS['nlmdn']['sigma'] add_value_to_dict_entry(dict_entry, str(self.noise_std_entry.text())) self.noise_std_entry.setText(str(dict_entry['value'])) def set_window_entry(self): LOG.debug("Window: " + str(self.window_entry.text())) dict_entry = EZVARS['nlmdn']['window'] add_value_to_dict_entry(dict_entry, str(self.window_entry.text())) self.window_entry.setText(str(dict_entry['value'])) def set_fast_checkbox(self): LOG.debug("Fast: " + str(self.fast_checkbox.isChecked())) dict_entry = EZVARS['nlmdn']['fast'] add_value_to_dict_entry(dict_entry, self.fast_checkbox.isChecked()) def set_sigma_checkbox(self): LOG.debug("Estimate sigma: " + str(self.sigma_checkbox.isChecked())) dict_entry = EZVARS['nlmdn']['estimate-sigma'] add_value_to_dict_entry(dict_entry, self.sigma_checkbox.isChecked()) def help_button_pressed(self): LOG.debug("Help Button Pressed") h = "" h += 'Note4: set to "flats" if "flats2" exist but you need to ignore them; \n' h += "SerG, BMIT CLS, Dec. 2020." QMessageBox.information(self, "Help", h) def delete_button_pressed(self): LOG.debug("Delete Reco Button Pressed") """ Deletes the directory that contains reconstructed data """ LOG.debug("DELETE") msg = "Delete directory with reconstructed data?" dialog = QMessageBox.warning(self, "Warning: data can be lost", msg, QMessageBox.Yes | QMessageBox.No) if dialog == QMessageBox.Yes: if os.path.exists(str(EZVARS['nlmdn']['output_pattern']['value'])): LOG.debug("YES") if EZVARS['nlmdn']['output_pattern']['value'] == EZVARS['nlmdn']['input-dir']['value']: LOG.debug("Cannot delete: output directory is the same as input") else: rmtree(EZVARS['nlmdn']['output_pattern']['value']) LOG.debug("Directory with denoised images was removed") else: LOG.debug("Directory does not exist") else: LOG.debug("NO") def dry_button_pressed(self): LOG.debug("Dry Run Button Pressed") dict_entry = EZVARS['nlmdn']['dryrun'] add_value_to_dict_entry(dict_entry, True) self.apply_button_pressed() add_value_to_dict_entry(dict_entry, False) def apply_button_pressed(self): LOG.debug("Apply Filter Button Pressed") if os.path.exists(EZVARS['nlmdn']['output_pattern']['value']) and not \ EZVARS['nlmdn']['dryrun']['value']: title_text = "Warning: files can be overwritten" text1 = "Output directory exists. Files can be overwritten. Proceed?" dialog = QMessageBox.warning(self, title_text, text1, QMessageBox.Yes | QMessageBox.No) if dialog == QMessageBox.Yes: cmd = fmt_nlmdn_ufo_cmd(EZVARS['nlmdn']['input-dir']['value'], EZVARS['nlmdn']['output_pattern']['value']) else: cmd = fmt_nlmdn_ufo_cmd(EZVARS['nlmdn']['input-dir']['value'], EZVARS['nlmdn']['output_pattern']['value']) if EZVARS['nlmdn']['dryrun']['value']: print(cmd) else: os.system(cmd) QMessageBox.information(self, "Finished", "Finished") ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Advanced/optimization.py000066400000000000000000000101261521054151500234400ustar00rootroot00000000000000import logging from PyQt5.QtWidgets import QGridLayout, QLabel, QGroupBox, QLineEdit, QCheckBox, QComboBox from tofu.ez.params import EZVARS from tofu.config import SECTIONS from tofu.ez.util import add_value_to_dict_entry, get_double_validator LOG = logging.getLogger(__name__) class OptimizationGroup(QGroupBox): """ Optimization settings """ def __init__(self): super().__init__() self.setTitle("Optimization Settings") self.setStyleSheet("QGroupBox {color: orange;}") self.verbose_switch = QCheckBox("Enable verbose console output") self.verbose_switch.stateChanged.connect(self.set_verbose_switch) self.slice_memory_label = QLabel("Slice memory coefficient") self.slice_memory_entry = QLineEdit() self.slice_memory_entry.setValidator(get_double_validator()) tmpstr="Fraction of VRAM which will be used to store images \n" \ "Reserve ~2 GB of VRAM for computation \n" \ "Decrease the coefficient if you have very large data and start getting errors" self.slice_memory_entry.setToolTip(tmpstr) self.slice_memory_label.setToolTip(tmpstr) self.slice_memory_entry.editingFinished.connect(self.set_slice) self.data_spllitting_policy_label = QLabel("Data Splitting Policy") self.data_spllitting_policy_combobox = QComboBox() self.data_spllitting_policy_label.setToolTip(SECTIONS['general-reconstruction']['data-splitting-policy']['help']) self.data_spllitting_policy_combobox.setToolTip(SECTIONS['general-reconstruction']['data-splitting-policy']['help']) self.data_spllitting_policy_combobox.addItems(["one","many"]) self.data_spllitting_policy_combobox.currentIndexChanged.connect(self.set_data_splitting_policy) self.set_layout() def set_layout(self): layout = QGridLayout() layout.addWidget(self.verbose_switch, 0, 0) gpu_group = QGroupBox("GPU optimization") gpu_group.setCheckable(True) gpu_group.setChecked(bool(EZVARS['advanced']['enable-optimization']['value'])) gpu_group.clicked.connect(self.set_enable_optimization) gpu_layout = QGridLayout() gpu_layout.addWidget(self.slice_memory_label, 0, 0) gpu_layout.addWidget(self.slice_memory_entry, 0, 1) gpu_layout.addWidget(self.data_spllitting_policy_label, 1, 0) gpu_layout.addWidget(self.data_spllitting_policy_combobox, 1, 1) gpu_group.setLayout(gpu_layout) layout.addWidget(gpu_group, 1, 0) self.setLayout(layout) def load_values(self): self.verbose_switch.setChecked(bool(SECTIONS['general']['verbose']['value'])) self.slice_memory_entry.setText(str(SECTIONS['general-reconstruction']['slice-memory-coeff']['value'])) idx = self.data_spllitting_policy_combobox.findText(SECTIONS['general-reconstruction']['data-splitting-policy']['value']) if idx >= 0: self.data_spllitting_policy_combobox.setCurrentIndex(idx) def set_verbose_switch(self): LOG.debug("Verbose: " + str(self.verbose_switch.isChecked())) dict_entry = SECTIONS['general']['verbose'] add_value_to_dict_entry(dict_entry, self.verbose_switch.isChecked()) def set_enable_optimization(self): checkbox = self.sender() LOG.debug("GPU Optimization: " + str(checkbox.isChecked())) dict_entry = EZVARS['advanced']['enable-optimization'] add_value_to_dict_entry(dict_entry, checkbox.isChecked()) def set_slice(self): LOG.debug(self.slice_memory_entry.text()) dict_entry = SECTIONS['general-reconstruction']['slice-memory-coeff'] add_value_to_dict_entry(dict_entry, str(self.slice_memory_entry.text())) self.slice_memory_entry.setText(str(dict_entry['value'])) def set_data_splitting_policy(self): LOG.debug(self.data_spllitting_policy_combobox.currentText()) dict_entry = SECTIONS['general-reconstruction']['data-splitting-policy'] add_value_to_dict_entry(dict_entry, str(self.data_spllitting_policy_combobox.currentText())) ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Main/000077500000000000000000000000001521054151500175175ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Main/__init__.py000066400000000000000000000000001521054151500216160ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Main/centre_of_rotation.py000066400000000000000000000246361521054151500237670ustar00rootroot00000000000000import logging from PyQt5.QtWidgets import (QGridLayout, QLabel, QRadioButton, QGroupBox, QLineEdit, QCheckBox, QMessageBox) from PyQt5.QtCore import pyqtSignal from tofu.ez.params import EZVARS from tofu.ez.util import (add_value_to_dict_entry, get_int_validator, get_tuple_validator, get_double_validator, check_that_num_failed) LOG = logging.getLogger(__name__) class CentreOfRotationGroup(QGroupBox): """ Centre of Rotation settings """ enable_360Batch_Group_in_Advanced = pyqtSignal(bool) def __init__(self): super().__init__() self.setTitle("Centre of Rotation") self.setStyleSheet("QGroupBox {color: green;}") self.auto_correlate_rButton = QRadioButton() self.auto_correlate_rButton.setText("Auto: Correlate first/last projections") self.auto_correlate_rButton.clicked.connect(self.set_rButton) self.auto_minimize_rButton = QRadioButton() self.auto_minimize_rButton.setText("Auto: Minimize STD of a slice") self.auto_minimize_rButton.setToolTip( "Reconstructed patches are saved \nin your-temporary-data-folder\\axis-search" ) self.auto_minimize_rButton.clicked.connect(self.set_rButton) self.auto_minimize_apply_pr = QCheckBox() self.auto_minimize_apply_pr.setText("Apply PR while searching") self.auto_minimize_apply_pr.stateChanged.connect(self.set_minimize_apply_pr) self.define_axis_rButton = QRadioButton() self.define_axis_rButton.setText("Define rotation axis manually") self.define_axis_rButton.clicked.connect(self.set_rButton) self.search_rotation_label = QLabel() self.search_rotation_label.setText("Search rotation axis in [start, stop, step] interval") self.search_rotation_entry = QLineEdit() self.search_rotation_entry.setValidator(get_tuple_validator()) self.search_rotation_entry.editingFinished.connect(self.set_search_rotation) self.search_in_slice_label = QLabel() self.search_in_slice_label.setText("Search in slice from row number") self.search_in_slice_entry = QLineEdit() self.search_in_slice_entry.setValidator(get_int_validator()) self.search_in_slice_entry.editingFinished.connect(self.set_search_slice) self.size_of_recon_label = QLabel() self.size_of_recon_label.setText("Size of reconstructed patch [pixel]") self.size_of_recon_entry = QLineEdit() self.size_of_recon_entry.setValidator(get_int_validator()) self.size_of_recon_entry.editingFinished.connect(self.set_size_of_reco) self.axis_col_label = QLabel() self.axis_col_label.setText("Axis is in column No [pixel]. \n ") ttt = "Axis is in column No [pixel, can be fractional number]. \n " \ "This can be one number or a string of comma separated numbers \n" \ "one per each data set in the input directory" self.axis_col_label.setToolTip(ttt) self.axis_col_entry = QLineEdit() #self.axis_col_entry.setValidator(get_double_validator()) self.axis_col_entry.editingFinished.connect(self.set_axis_col) self.axis_col_entry.setToolTip(ttt) self.inc_axis_label = QLabel() self.inc_axis_label.setText("Increment axis every reconstruction") self.inc_axis_entry = QLineEdit() self.inc_axis_entry.setValidator(get_double_validator()) self.inc_axis_entry.editingFinished.connect(self.set_axis_inc) ttt = "This value will be added to the user-defined axis for \n" \ "to get axis for each consecutive data set in the input directory" self.inc_axis_entry.setToolTip(ttt) self.inc_axis_label.setToolTip(ttt) self.image_midpoint_rButton = QRadioButton() self.image_midpoint_rButton.setText("Use image midpoint (for half-acquisition)") self.image_midpoint_rButton.clicked.connect(self.set_rButton) self.unstitched360_rButton = QRadioButton() self.unstitched360_rButton.setText("Input is unstitched half acq. mode data (Use Stitching Tab to setup params)") self.unstitched360_rButton.setToolTip("Works only for input directories with 3 depth levels \n" "Outer loops -> Inner loop CT sets -> flats/darks/tomo") self.unstitched360_rButton.clicked.connect(self.set_rButton) # TODO Used for proper spacing - should be a better way self.blank_label = QLabel(" ") self.blank_label2 = QLabel(" ") self.set_layout() def set_layout(self): layout = QGridLayout() layout.addWidget(self.auto_correlate_rButton, 0, 0) layout.addWidget(self.blank_label, 0, 1) layout.addWidget(self.blank_label2, 0, 2) layout.addWidget(self.auto_minimize_rButton, 1, 0) layout.addWidget(self.auto_minimize_apply_pr, 1, 1) layout.addWidget(self.search_rotation_label, 2, 0) layout.addWidget(self.search_rotation_entry, 2, 1, 1, 2) layout.addWidget(self.search_in_slice_label, 3, 0) layout.addWidget(self.search_in_slice_entry, 3, 1, 1, 2) layout.addWidget(self.size_of_recon_label, 4, 0) layout.addWidget(self.size_of_recon_entry, 4, 1, 1, 2) layout.addWidget(self.define_axis_rButton, 5, 0) layout.addWidget(self.axis_col_label, 6, 0) layout.addWidget(self.axis_col_entry, 6, 1, 1, 2) layout.addWidget(self.inc_axis_label, 7, 0) layout.addWidget(self.inc_axis_entry, 7, 1, 1, 2) layout.addWidget(self.image_midpoint_rButton, 8, 0) layout.addWidget(self.unstitched360_rButton, 8, 1) self.setLayout(layout) def load_values(self): self.set_rButton_from_params() self.search_rotation_entry.setText(str(EZVARS['COR']['search-interval']['value'])) self.search_in_slice_entry.setText(str(EZVARS['COR']['search-row']['value'])) self.size_of_recon_entry.setText(str(EZVARS['COR']['patch-size']['value'])) self.axis_col_entry.setText(str(EZVARS['COR']['user-defined-ax']['value'])) self.inc_axis_entry.setText(str(EZVARS['COR']['user-defined-dax']['value'])) def set_rButton(self): dict_entry = EZVARS['COR']['search-method'] enable_360Batch_group = False if self.auto_correlate_rButton.isChecked(): LOG.debug("Auto Correlate") add_value_to_dict_entry(dict_entry, 1) elif self.auto_minimize_rButton.isChecked(): LOG.debug("Auto Minimize") add_value_to_dict_entry(dict_entry, 2) elif self.define_axis_rButton.isChecked(): LOG.debug("Define axis") add_value_to_dict_entry(dict_entry, 3) elif self.image_midpoint_rButton.isChecked(): LOG.debug("Use image midpoint") add_value_to_dict_entry(dict_entry, 4) elif self.unstitched360_rButton.isChecked(): LOG.debug("Use Stitching Tab - Batch360") add_value_to_dict_entry(dict_entry, 5) enable_360Batch_group = True self.enable_360Batch_Group_in_Advanced.emit(enable_360Batch_group) def set_rButton_from_params(self): if EZVARS['COR']['search-method']['value'] == 1: self.auto_correlate_rButton.setChecked(True) self.auto_minimize_rButton.setChecked(False) self.define_axis_rButton.setChecked(False) self.image_midpoint_rButton.setChecked(False) elif EZVARS['COR']['search-method']['value'] == 2: self.auto_correlate_rButton.setChecked(False) self.auto_minimize_rButton.setChecked(True) self.define_axis_rButton.setChecked(False) self.image_midpoint_rButton.setChecked(False) elif EZVARS['COR']['search-method']['value'] == 3: self.auto_correlate_rButton.setChecked(False) self.auto_minimize_rButton.setChecked(False) self.define_axis_rButton.setChecked(True) self.image_midpoint_rButton.setChecked(False) elif EZVARS['COR']['search-method']['value'] == 4: self.auto_correlate_rButton.setChecked(False) self.auto_minimize_rButton.setChecked(False) self.define_axis_rButton.setChecked(False) self.image_midpoint_rButton.setChecked(True) elif EZVARS['COR']['search-method']['value'] == 5: self.unstitched360_rButton.setChecked(True) self.enable_360Batch_Group_in_Advanced.emit(True) #TODO: also disable INput/Output in 360-SEARCH #and completely disable 360-MULTI-STITCH def set_search_rotation(self): LOG.debug(self.search_rotation_entry.text()) dict_entry = EZVARS['COR']['search-interval'] add_value_to_dict_entry(dict_entry, str(self.search_rotation_entry.text())) self.search_rotation_entry.setText(str(dict_entry['value'])) def set_search_slice(self): LOG.debug(self.search_in_slice_entry.text()) dict_entry = EZVARS['COR']['search-row'] add_value_to_dict_entry(dict_entry, str(self.search_in_slice_entry.text())) self.search_in_slice_entry.setText(str(dict_entry['value'])) def set_size_of_reco(self): LOG.debug(self.size_of_recon_entry.text()) dict_entry = EZVARS['COR']['patch-size'] add_value_to_dict_entry(dict_entry, str(self.size_of_recon_entry.text())) self.size_of_recon_entry.setText(str(dict_entry['value'])) def set_minimize_apply_pr(self): LOG.debug("PR while min std ax search: " + str(self.auto_minimize_apply_pr.isChecked())) dict_entry = EZVARS['COR']['min-std-apply-pr'] add_value_to_dict_entry(dict_entry, self.auto_minimize_apply_pr.isChecked()) def set_axis_col(self): if check_that_num_failed(self.axis_col_entry.text()): qm = QMessageBox() qm.warning(self, 'Input error', 'One of values in the Set axis entry is not number') LOG.debug(self.axis_col_entry.text()) dict_entry = EZVARS['COR']['user-defined-ax'] add_value_to_dict_entry(dict_entry, str(self.axis_col_entry.text())) self.axis_col_entry.setText(str(dict_entry['value'])) def set_axis_inc(self): LOG.debug(self.inc_axis_entry.text()) dict_entry = EZVARS['COR']['user-defined-dax'] add_value_to_dict_entry(dict_entry, str(self.inc_axis_entry.text())) self.inc_axis_entry.setText(str(dict_entry['value'])) ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Main/config.py000066400000000000000000000677501521054151500213550ustar00rootroot00000000000000import os import logging from functools import partial from shutil import rmtree from PyQt5.QtWidgets import ( QMessageBox, QFileDialog, QCheckBox, QPushButton, QGridLayout, QLabel, QGroupBox, QLineEdit, ) from PyQt5.QtCore import QCoreApplication, QTimer, pyqtSignal, Qt from tofu.ez.GUI.verify_delete import verify_safe2delete from tofu.ez.main import execute_reconstruction, clean_tmp_dirs from tofu.ez.util import import_values, export_values, get_fdt_names from tofu.ez.GUI.message_dialog import warning_message from tofu.ez.params import EZVARS, EZVARS_aux from tofu.ez.util import add_value_to_dict_entry LOG = logging.getLogger(__name__) class ConfigGroup(QGroupBox): """ Setup and configuration settings """ # Used to send signal to ezufo_launcher when settings are imported signal_update_vals_from_params = pyqtSignal() # Used to send signal when reconstruction is done signal_reco_done = pyqtSignal() def __init__(self): super().__init__() self.setTitle("Input/output and misc settings") self.setStyleSheet("QGroupBox {color: purple;}") # Select input directory self.input_dir_select = QPushButton("Select input directory (or paste abs. path)") self.input_dir_select.setStyleSheet("background-color:lightgrey; font: 12pt;") self.input_dir_entry = QLineEdit() self.input_dir_entry.editingFinished.connect(self.set_input_dir) self.input_dir_select.pressed.connect(self.select_input_dir) # Save .params checkbox self.save_params_checkbox = QCheckBox("Save args in .params file") self.save_params_checkbox.stateChanged.connect(self.set_save_args) # Select output directory self.output_dir_select = QPushButton() self.output_dir_select.setText("Select output directory (or paste abs. path)") self.output_dir_select.setStyleSheet("background-color:lightgrey; font: 12pt;") self.output_dir_entry = QLineEdit() self.output_dir_entry.editingFinished.connect(self.set_output_dir) self.output_dir_select.pressed.connect(self.select_output_dir) # Save in separate files or in one huge tiff file self.bigtiff_checkbox = QCheckBox() self.bigtiff_checkbox.setText("Save slices in multipage tiffs") self.bigtiff_checkbox.setToolTip( "Will save images in bigtiff containers. \n" "Note that some temporary data is always saved in bigtiffs.\n" "Use bio-formats importer plugin for imagej or fiji to open the bigtiffs." ) self.bigtiff_checkbox.stateChanged.connect(self.set_big_tiff) # Crop in the reconstruction plane self.preproc_checkbox = QCheckBox() self.preproc_checkbox.setText("Preprocess with a generic ufo-launch pipeline, f.i.") self.preproc_checkbox.setToolTip( "Selected ufo filters will be applied to each " "image before reconstruction begins. \n" 'To print the list of filters use "ufo-query -l" command. \n' 'Parameters of each filter can be seen with "ufo-query -p filtername".' ) self.preproc_checkbox.stateChanged.connect(self.set_preproc) self.preproc_entry = QLineEdit() self.preproc_entry.editingFinished.connect(self.set_preproc_entry) # Names of directories with flats/darks/projections frames self.dir_name_label = QLabel() self.dir_name_label.setText("Name of flats/darks/tomo subdirectories in each CT data set") self.darks_entry = QLineEdit() self.darks_entry.editingFinished.connect(self.set_darks) self.flats_entry = QLineEdit() self.flats_entry.editingFinished.connect(self.set_flats) self.tomo_entry = QLineEdit() self.tomo_entry.editingFinished.connect(self.set_tomo) self.flats2_entry = QLineEdit() self.flats2_entry.editingFinished.connect(self.set_flats2) # Select flats/darks/flats2 for use in multiple reconstructions self.use_common_flats_darks_checkbox = QCheckBox() self.use_common_flats_darks_checkbox.setText( "Use common flats/darks across multiple experiments" ) self.use_common_flats_darks_checkbox.stateChanged.connect(self.set_flats_darks_checkbox) self.select_darks_button = QPushButton("Select path to darks (or paste abs. path)") self.select_darks_button.setToolTip("Background detector noise") self.select_darks_button.clicked.connect(self.select_darks_button_pressed) self.select_flats_button = QPushButton("Select path to flats (or paste abs. path)") self.select_flats_button.setToolTip("Images without sample in the beam") self.select_flats_button.clicked.connect(self.select_flats_button_pressed) self.select_flats2_button = QPushButton("Select path to flats2 (or paste abs. path)") self.select_flats2_button.setToolTip( "If selected, it will be assumed that flats were \n" "acquired before projections while flats2 after \n" "and interpolation will be used to compute intensity of flat image \n" "for each projection between flats and flats2" ) self.select_flats2_button.clicked.connect(self.select_flats2_button_pressed) self.darks_absolute_entry = QLineEdit() self.darks_absolute_entry.setText("Absolute path to darks") self.darks_absolute_entry.editingFinished.connect(self.set_common_darks) self.flats_absolute_entry = QLineEdit() self.flats_absolute_entry.setText("Absolute path to flats") self.flats_absolute_entry.editingFinished.connect(self.set_common_flats) self.use_flats2_checkbox = QCheckBox("Use common flats2") self.use_flats2_checkbox.clicked.connect(self.set_use_flats2) self.flats2_absolute_entry = QLineEdit() self.flats2_absolute_entry.editingFinished.connect(self.set_common_flats2) self.flats2_absolute_entry.setText("Absolute path to flats2") # Select temporary directory self.temp_dir_select = QPushButton() self.temp_dir_select.setText("Select temporary directory (or paste abs. path)") self.temp_dir_select.setToolTip( "Temporary data will be saved there.\n" "note that the size of temporary data can exceed 300 GB in some cases." ) self.temp_dir_select.pressed.connect(self.select_temp_dir) self.temp_dir_select.setStyleSheet("background-color:lightgrey; font: 12pt;") self.temp_dir_entry = QLineEdit() self.temp_dir_entry.editingFinished.connect(self.set_temp_dir) # Keep temp data selection self.keep_tmp_data_checkbox = QCheckBox() self.keep_tmp_data_checkbox.setText("Keep all temp data till the end of reconstruction") self.keep_tmp_data_checkbox.setToolTip( "Useful option to inspect how images change at each step" ) self.keep_tmp_data_checkbox.stateChanged.connect(self.set_keep_tmp_data) # IMPORT SETTINGS FROM FILE self.open_settings_file = QPushButton() self.open_settings_file.setText("Import parameters from file") self.open_settings_file.setStyleSheet("background-color:lightgrey; font: 12pt;") self.open_settings_file.pressed.connect(self.import_settings_button_pressed) # EXPORT SETTINGS TO FILE self.save_settings_file = QPushButton() self.save_settings_file.setText("Export parameters to file") self.save_settings_file.setStyleSheet("background-color:lightgrey; font: 12pt;") self.save_settings_file.pressed.connect(self.export_settings_button_pressed) # QUIT self.quit_button = QPushButton() self.quit_button.setText("Quit") self.quit_button.setStyleSheet("background-color:lightgrey; font: 13pt; font-weight: bold;") self.quit_button.clicked.connect(self.quit_button_pressed) # HELP self.help_button = QPushButton() self.help_button.setText("Help") self.help_button.setStyleSheet("background-color:lightgrey; font: 13pt; font-weight: bold") self.help_button.clicked.connect(self.help_button_pressed) # DELETE self.delete_reco_dir_button = QPushButton() self.delete_reco_dir_button.setText("Delete reco dir") self.delete_reco_dir_button.setStyleSheet( "background-color:lightgrey; font: 13pt; font-weight: bold" ) self.delete_reco_dir_button.clicked.connect(self.delete_button_pressed) # DRY RUN self.dry_run_button = QPushButton() self.dry_run_button.setText("Dry run") self.dry_run_button.setStyleSheet( "background-color:lightgrey; font: 13pt; font-weight: bold" ) self.dry_run_button.clicked.connect(self.dryrun_button_pressed) # RECONSTRUCT self.reco_button = QPushButton() self.reco_button.setText("Reconstruct") self.reco_button.setStyleSheet( "background-color:lightgrey;color:royalblue; font: 14pt; font-weight: bold;" ) self.reco_button.clicked.connect(self.reco_button_pressed) # OPEN IMAGE AFTER RECONSTRUCT self.open_image_after_reco_checkbox = QCheckBox() self.open_image_after_reco_checkbox.setText( "Load images and open viewer after reconstruction" ) self.open_image_after_reco_checkbox.clicked.connect(self.set_open_image_after_reco) self.set_layout() def set_layout(self): """ Sets the layout of buttons, labels, etc. for config group """ layout = QGridLayout() checkbox_groupbox = QGroupBox() checkbox_layout = QGridLayout() checkbox_layout.addWidget(self.save_params_checkbox, 0, 0) checkbox_layout.addWidget(self.bigtiff_checkbox, 1, 0) checkbox_layout.addWidget(self.open_image_after_reco_checkbox, 2, 0) checkbox_layout.addWidget(self.keep_tmp_data_checkbox, 3, 0) checkbox_groupbox.setLayout(checkbox_layout) layout.addWidget(checkbox_groupbox, 0, 4, 4, 1) layout.addWidget(self.input_dir_select, 0, 0) layout.addWidget(self.input_dir_entry, 0, 1, 1, 3) layout.addWidget(self.temp_dir_select, 1, 0) layout.addWidget(self.temp_dir_entry, 1, 1, 1, 3) layout.addWidget(self.output_dir_select, 2, 0) layout.addWidget(self.output_dir_entry, 2, 1, 1, 3) layout.addWidget(self.preproc_checkbox, 3, 0) layout.addWidget(self.preproc_entry, 3, 1, 1, 3) fdt_groupbox = QGroupBox() fdt_layout = QGridLayout() fdt_layout.addWidget(self.dir_name_label, 0, 0) fdt_layout.addWidget(self.darks_entry, 0, 1) fdt_layout.addWidget(self.flats_entry, 0, 2) fdt_layout.addWidget(self.tomo_entry, 0, 3) fdt_layout.addWidget(self.flats2_entry, 0, 4) fdt_layout.addWidget(self.use_common_flats_darks_checkbox, 1, 0) fdt_layout.addWidget(self.select_darks_button, 1, 1) fdt_layout.addWidget(self.select_flats_button, 1, 2) fdt_layout.addWidget(self.select_flats2_button, 1, 4) fdt_layout.addWidget(self.darks_absolute_entry, 2, 1) fdt_layout.addWidget(self.flats_absolute_entry, 2, 2) fdt_layout.addWidget(self.use_flats2_checkbox, 2, 3, Qt.AlignRight) fdt_layout.addWidget(self.flats2_absolute_entry, 2, 4) fdt_groupbox.setLayout(fdt_layout) layout.addWidget(fdt_groupbox, 4, 0, 1, 5) layout.addWidget(self.open_settings_file, 5, 0, 1, 3) layout.addWidget(self.save_settings_file, 5, 3, 1, 2) layout.addWidget(self.quit_button, 6, 0) layout.addWidget(self.help_button, 6, 1) layout.addWidget(self.delete_reco_dir_button, 6, 2) layout.addWidget(self.dry_run_button, 6, 3) layout.addWidget(self.reco_button, 6, 4) self.setLayout(layout) def load_values(self): """ Updates displayed values for config group """ self.input_dir_entry.setText(EZVARS['inout']['input-dir']['value']) self.save_params_checkbox.setChecked(EZVARS['inout']['save-params']['value']) self.output_dir_entry.setText(EZVARS['inout']['output-dir']['value']) self.bigtiff_checkbox.setChecked(EZVARS['inout']['bigtiff-output']['value']) self.preproc_checkbox.setChecked(EZVARS['inout']['preprocess']['value']) self.preproc_entry.setText(EZVARS['inout']['preprocess-command']['value']) self.darks_entry.setText(EZVARS['inout']['darks-dir']['value']) self.flats_entry.setText(EZVARS['inout']['flats-dir']['value']) self.tomo_entry.setText(EZVARS['inout']['tomo-dir']['value']) self.flats2_entry.setText(EZVARS['inout']['flats2-dir']['value']) self.temp_dir_entry.setText(EZVARS['inout']['tmp-dir']['value']) self.keep_tmp_data_checkbox.setChecked(EZVARS['inout']['keep-tmp']['value']) self.dry_run_button.setChecked(EZVARS['inout']['dryrun']['value']) self.open_image_after_reco_checkbox.setChecked(EZVARS['inout']['open-viewer']['value']) self.use_common_flats_darks_checkbox.setChecked(EZVARS['inout']['shared-flatsdarks']['value']) self.darks_absolute_entry.setText(EZVARS['inout']['path2-shared-darks']['value']) self.flats_absolute_entry.setText(EZVARS['inout']['path2-shared-flats']['value']) self.use_flats2_checkbox.setChecked(EZVARS['inout']['shared-flats-after']['value']) self.flats2_absolute_entry.setText(EZVARS['inout']['path2-shared-flats2']['value']) def select_input_dir(self): """ Saves directory specified by user in file-dialog for input tomographic data """ dir_explore = QFileDialog(self) dir = dir_explore.getExistingDirectory(directory=self.input_dir_entry.text()) if dir: self.input_dir_entry.setText(dir) self.set_input_dir() def set_input_dir(self): LOG.debug(str(self.input_dir_entry.text())) dict_entry = EZVARS['inout']['input-dir'] dir = self.input_dir_entry.text().strip() add_value_to_dict_entry(dict_entry, dir) self.input_dir_entry.setText(dir) def select_output_dir(self): dir_explore = QFileDialog(self) dir = dir_explore.getExistingDirectory(directory=self.output_dir_entry.text()) if dir: self.output_dir_entry.setText(dir) self.set_output_dir() def set_output_dir(self): LOG.debug(str(self.output_dir_entry.text())) dict_entry = EZVARS['inout']['output-dir'] dir = self.output_dir_entry.text().strip() add_value_to_dict_entry(dict_entry, dir) self.output_dir_entry.setText(dir) def set_big_tiff(self): LOG.debug("Bigtiff: " + str(self.bigtiff_checkbox.isChecked())) dict_entry = EZVARS['inout']['bigtiff-output'] add_value_to_dict_entry(dict_entry, self.bigtiff_checkbox.isChecked()) def set_preproc(self): LOG.debug("Preproc: " + str(self.preproc_checkbox.isChecked())) dict_entry = EZVARS['inout']['preprocess'] add_value_to_dict_entry(dict_entry, self.preproc_checkbox.isChecked()) def set_preproc_entry(self): LOG.debug(self.preproc_entry.text()) dict_entry = EZVARS['inout']['preprocess-command'] text = self.preproc_entry.text().strip() add_value_to_dict_entry(dict_entry, text) self.preproc_entry.setText(text) def set_open_image_after_reco(self): LOG.debug( "Switch to Image Viewer After Reco: " + str(self.open_image_after_reco_checkbox.isChecked()) ) dict_entry = EZVARS['inout']['open-viewer'] add_value_to_dict_entry(dict_entry, self.open_image_after_reco_checkbox.isChecked()) def set_darks(self): LOG.debug(self.darks_entry.text()) dict_entry = EZVARS['inout']['darks-dir'] dir = self.darks_entry.text().strip() add_value_to_dict_entry(dict_entry, dir) self.darks_entry.setText(dir) def set_flats(self): LOG.debug(self.flats_entry.text()) dict_entry = EZVARS['inout']['flats-dir'] dir = self.flats_entry.text().strip() add_value_to_dict_entry(dict_entry, dir) self.flats_entry.setText(dir) def set_tomo(self): LOG.debug(self.tomo_entry.text()) dict_entry = EZVARS['inout']['tomo-dir'] dir = self.tomo_entry.text().strip() add_value_to_dict_entry(dict_entry, dir) self.tomo_entry.setText(dir) def set_flats2(self): LOG.debug(self.flats2_entry.text()) dict_entry = EZVARS['inout']['flats2-dir'] dir = self.flats2_entry.text().strip() add_value_to_dict_entry(dict_entry, dir) self.flats2_entry.setText(dir) def set_fdt_names(self): self.set_darks() self.set_flats() self.set_flats2() self.set_tomo() def set_flats_darks_checkbox(self): LOG.debug( "Use same flats/darks across multiple experiments: " + str(self.use_common_flats_darks_checkbox.isChecked()) ) dict_entry = EZVARS['inout']['shared-flatsdarks'] add_value_to_dict_entry(dict_entry, self.use_common_flats_darks_checkbox.isChecked()) def select_darks_button_pressed(self): LOG.debug("Select path to darks pressed") dir_explore = QFileDialog(self) directory = dir_explore.getExistingDirectory(directory=EZVARS['inout']['input-dir']['value']) if directory: self.darks_absolute_entry.setText(directory) self.set_common_darks() def select_flats_button_pressed(self): LOG.debug("Select path to flats pressed") dir_explore = QFileDialog(self) directory = dir_explore.getExistingDirectory(directory=EZVARS['inout']['input-dir']['value']) if directory: self.flats_absolute_entry.setText(directory) self.set_common_flats() def select_flats2_button_pressed(self): LOG.debug("Select path to flats2 pressed") dir_explore = QFileDialog(self) directory = dir_explore.getExistingDirectory(directory=EZVARS['inout']['input-dir']['value']) if directory: self.flats2_absolute_entry.setText(directory) self.set_common_flats2() def set_common_darks(self): LOG.debug("Common darks path: " + str(self.darks_absolute_entry.text())) dict_entry = EZVARS['inout']['path2-shared-darks'] text = self.darks_absolute_entry.text().strip() add_value_to_dict_entry(dict_entry, text) self.darks_absolute_entry.setText(text) def set_common_flats(self): LOG.debug("Common flats path: " + str(self.flats_absolute_entry.text())) dict_entry = EZVARS['inout']['path2-shared-flats'] text = self.flats_absolute_entry.text().strip() add_value_to_dict_entry(dict_entry, text) self.flats_absolute_entry.setText(text) def set_use_flats2(self): LOG.debug("Use common flats2 checkbox: " + str(self.use_flats2_checkbox.isChecked())) dict_entry = EZVARS['inout']['shared-flats-after'] text = self.use_flats2_checkbox.text().strip() add_value_to_dict_entry(dict_entry, text) self.use_flats2_checkbox.setText(text) def set_common_flats2(self): LOG.debug("Common flats2 path: " + str(self.flats2_absolute_entry.text())) dict_entry = EZVARS['inout']['path2-shared-flats2'] text = self.flats2_absolute_entry.text().strip() add_value_to_dict_entry(dict_entry, text) self.flats2_absolute_entry.setText(text) def select_temp_dir(self): dir_explore = QFileDialog(self) tmp_dir = dir_explore.getExistingDirectory(directory=self.temp_dir_entry.text()) if tmp_dir: self.temp_dir_entry.setText(tmp_dir) self.set_temp_dir() def set_temp_dir(self): LOG.debug(str(self.temp_dir_entry.text())) dict_entry = EZVARS['inout']['tmp-dir'] text = self.temp_dir_entry.text().strip() add_value_to_dict_entry(dict_entry, text) self.temp_dir_entry.setText(text) def set_keep_tmp_data(self): LOG.debug("Keep tmp: " + str(self.keep_tmp_data_checkbox.isChecked())) dict_entry = EZVARS['inout']['keep-tmp'] add_value_to_dict_entry(dict_entry, self.keep_tmp_data_checkbox.isChecked()) def quit_button_pressed(self): """ Displays confirmation dialog and cleans temporary directories """ LOG.debug("QUIT") reply = QMessageBox.question( self, "Quit", "Are you sure you want to quit?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if reply == QMessageBox.Yes: # remove all directories with projections clean_tmp_dirs(EZVARS['inout']['tmp-dir']['value'], get_fdt_names()) # remove axis-search dir too tmp = os.path.join(EZVARS['inout']['tmp-dir']['value'], 'axis-search') QCoreApplication.instance().quit() else: pass def help_button_pressed(self): """ Displays pop-up help information """ LOG.debug("HELP") h = "This utility provides an interface to the ufo-kit software package.\n" h += "Use it for batch processing and optimization of reconstruction parameters.\n" h += "It creates a list of paths to all CT directories in the _input_ directory.\n" h += "A CT directory is defined as directory with at least \n" h += "_flats_, _darks_, _tomo_, and, optionally, _flats2_ subdirectories, \n" h += "which are not empty and contain only *.tif files. Names of CT\n" h += "directories are compared with the directory tree in the _output_ directory.\n" h += ( "(Note: relative directory tree in _input_ is preserved when writing results to the" " _output_.)\n" ) h += ( "Those CT sets will be reconstructed, whose names are not yet in the _output_" " directory." ) h += "Program will create an array of ufo/tofu commands according to defined parameters \n" h += ( "and then execute them sequentially. These commands can be also printed on the" " screen.\n" ) h += "Note2: if you bin in preprocess the center of rotation will change a lot; \n" h += 'Note4: set to "flats" if "flats2" exist but you need to ignore them; \n' h += ( "Sergei Gasilov, BMIT CLS, Dec. 2018 - 2024 \n" ) QMessageBox.information(self, "Help", h) def delete_button_pressed(self): """ Deletes the directory that contains reconstructed data """ LOG.debug("DELETE") msg = "Delete directory with reconstructed data?" dialog = QMessageBox.warning( self, "Warning: data can be lost", msg, QMessageBox.Yes | QMessageBox.No ) if dialog == QMessageBox.Yes: if os.path.exists(str(EZVARS['inout']['output-dir']['value'])): LOG.debug("YES") if EZVARS['inout']['output-dir']['value'] == EZVARS['inout']['input-dir']['value']: LOG.debug("Cannot delete: output directory is the same as input") else: try: rmtree(EZVARS['inout']['output-dir']['value']) except: warning_message('Error while deleting directory') LOG.debug("Directory with reconstructed data was removed") else: LOG.debug("Directory does not exist") else: LOG.debug("NO") def dryrun_button_pressed(self): """ Sets the dry-run parameter for Tofu to True and calls reconstruction """ LOG.debug("DRY") EZVARS['inout']['dryrun']['value'] = str(True) self.reco_button_pressed() def set_save_args(self): LOG.debug("Save args: " + str(self.save_params_checkbox.isChecked())) EZVARS['inout']['save-params']['value'] = bool(self.save_params_checkbox.isChecked()) def export_settings_button_pressed(self): """ Saves currently displayed GUI settings to an external .yaml file specified by user """ LOG.debug("Save settings pressed") options = QFileDialog.Options() fileName, _ = QFileDialog.getSaveFileName( self, "QFileDialog.getSaveFileName()", "", "YAML Files (*.yaml);; All Files (*)", options=options, ) if fileName: LOG.debug("Export YAML Path: " + fileName) file_extension = os.path.splitext(fileName) if file_extension[-1] == "": fileName = fileName + ".yaml" # Create and write to YAML file based on given fileName # self.yaml_io.write_yaml(fileName, parameters.params) export_values(fileName, ['ezvars', 'tofu', 'ezvars_aux']) def import_settings_button_pressed(self): """ Loads external settings from .yaml file specified by user Signal is sent to enable updating of displayed GUI values """ LOG.debug("Import settings pressed") options = QFileDialog.Options() filePath, _ = QFileDialog.getOpenFileName( self, "QFileDialog.getOpenFileName()", "", "YAML Files (*.yaml);; All Files (*)", options=options, ) if filePath: LOG.debug("Import YAML Path: " + filePath) import_values(filePath, ['ezvars', 'tofu', 'ezvars_aux']) self.signal_update_vals_from_params.emit() def reco_button_pressed(self): """ Gets the settings set by the user in the GUI These are then passed to execute_reconstruction """ #LOG.debug("RECO") self.set_fdt_names() self.set_common_darks() self.set_common_flats() self.set_common_flats2() self.set_big_tiff() self.set_input_dir() self.set_output_dir() self.set_temp_dir() self.set_preproc() self.set_preproc_entry() if EZVARS_aux['vert-sti']['dovertsti']['value']: # Warn if output dir collision if EZVARS['inout']['output-dir']['value'] == EZVARS_aux['vert-sti']['output-dir']['value']: vert_out = f"{EZVARS['inout']['output-dir']['value']}-vert-stitched" print("Vertical stitching output directory should not be the same as input.") print(f"Adjusting from {EZVARS_aux['vert-sti']['output-dir']['value']} to {vert_out}") add_value_to_dict_entry(EZVARS_aux['vert-sti']['output-dir'], vert_out) if EZVARS['COR']['search-method']['value'] == 5: # Check half-acq workdir "stitched-data" is empty before starting stitched_data_dir_name = os.path.join(EZVARS_aux['half-acq']['workdir']['value'], 'stitched-data') try: verify_safe2delete(self, stitched_data_dir_name, "Half-acq stitched data") except FileExistsError: return run_reco = partial(self.run_reconstruction, batch_run=False) #I had to add a little sleep as on some Linux ditributions params won't fully set before the main() begins QTimer.singleShot(100, run_reco) def run_reconstruction(self, batch_run): try: num_proc_sets = execute_reconstruction() msg = f"Processed {num_proc_sets} sets. See output in terminal for details." QMessageBox.information(self, "Finished", msg) if not EZVARS['inout']['dryrun']['value']: self.signal_reco_done.emit() EZVARS['inout']['dryrun']['value'] = bool(False) except InvalidInputError as err: msg = "Failed to run reconstruction. See output in terminal for details." err_arg = err.args msg += err.args[0] QMessageBox.information(self, "Invalid Input Error", msg) class InvalidInputError(Exception): """ Error to be raised when input values from GUI are out of range or invalid """ ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Main/filters.py000066400000000000000000000325721521054151500215520ustar00rootroot00000000000000import logging from PyQt5.QtWidgets import ( QButtonGroup, QGridLayout, QLabel, QRadioButton, QCheckBox, QGroupBox, QLineEdit, ) from PyQt5.QtCore import Qt from tofu.ez.params import EZVARS from tofu.config import SECTIONS from tofu.ez.util import add_value_to_dict_entry, get_int_validator, get_double_validator from PyQt5.QtCore import pyqtSignal LOG = logging.getLogger(__name__) class FiltersGroup(QGroupBox): mask_method_median_checked = pyqtSignal() def __init__(self): super().__init__() self.setTitle("Filters") self.setStyleSheet("QGroupBox {color: orange;}") self.remove_spots_checkBox = QCheckBox() self.remove_spots_checkBox.setText("Remove large bad spots from projections") self.remove_spots_checkBox.setToolTip( "Finds large bad spots in images \n and replaces them with suitable values" ) self.remove_spots_checkBox.stateChanged.connect(self.set_remove_spots) self.remove_spots_median_checkBox = QCheckBox() self.remove_spots_median_checkBox.setText("Use median method (tune params in Advanced tab)") self.remove_spots_median_checkBox.setToolTip( "Uses median method to locate bad spots \n You must tune parameters in the Advanced tab" ) self.remove_spots_median_checkBox.stateChanged.connect(self.remove_spots_method_median_checked) self.threshold_label = QLabel() self.threshold_label.setText("Threshold (prominence of the spot) [counts]") self.threshold_label.setToolTip( "Outliers will be considered as the part of the large spot" ) self.threshold_entry = QLineEdit() self.threshold_entry.setValidator(get_double_validator()) self.threshold_entry.editingFinished.connect(self.set_threshold) self.find_spots_gau_sigma_label = QLabel() self.find_spots_gau_sigma_label.setText("Low-pass filter sigma [pixels]") self.find_spots_gau_sigma_label.setToolTip('Low pass filter will be applied before spots are identified \n' 'to remove slow changes in the flat field intensity') self.find_spots_gau_sigma_entry = QLineEdit() self.find_spots_gau_sigma_entry.setValidator(get_int_validator()) self.find_spots_gau_sigma_entry.editingFinished.connect(self.set_find_spots_gau_sigma) self.enable_RR_checkbox = QCheckBox() self.enable_RR_checkbox.setText("Enable ring removal") self.enable_RR_checkbox.setToolTip( "To suppress ring artifacts" " stemming from intensity fluctuations and detector non-linearities" ) self.enable_RR_checkbox.stateChanged.connect(self.set_ring_removal) self.use_LPF_rButton = QRadioButton() self.use_LPF_rButton.setText("Use ufo Fourier-transform based filter") self.use_LPF_rButton.clicked.connect(self.select_rButton) self.use_LPF_rButton.setToolTip( "To suppress ring artifacts" " stemming from intensity fluctuations and detector non-linearities" ) self.sarepy_rButton = QRadioButton() self.sarepy_rButton.setText("Use sarepy sorting: ") self.sarepy_rButton.clicked.connect(self.select_rButton) self.sarepy_rButton.setToolTip( "Non-FFT based algorithms from \n /Nghia T. Vo et al, Opt. Express 26, 28396 (2018)" ) self.filter_rButton_group = QButtonGroup(self) self.filter_rButton_group.addButton(self.use_LPF_rButton) self.filter_rButton_group.addButton(self.sarepy_rButton) self.one_dimens_rButton = QRadioButton() self.one_dimens_rButton.setText("1D") self.one_dimens_rButton.clicked.connect(self.select_dimens_rButton) self.one_dimens_rButton.setToolTip("Only low-pass filter along the lines of sinogram") self.two_dimens_rButton = QRadioButton() self.two_dimens_rButton.setText("2D") self.two_dimens_rButton.clicked.connect(self.select_dimens_rButton) self.two_dimens_rButton.setToolTip( "Low-pass filter along the lines and high-pass filter along the columns" ) self.dimens_rButton_group = QButtonGroup(self) self.dimens_rButton_group.addButton(self.one_dimens_rButton) self.dimens_rButton_group.addButton(self.two_dimens_rButton) self.sigma_horizontal_label = QLabel() self.sigma_horizontal_label.setText("sigma horizontal") self.sigma_horizontal_label.setToolTip( "Width [pixels] of Gaussian-shaped low-pass filter in frequency domain" ) self.sigma_horizontal_entry = QLineEdit() self.sigma_horizontal_entry.setValidator(get_int_validator()) self.sigma_horizontal_entry.editingFinished.connect(self.set_sigma_horizontal) self.sigma_vertical_label = QLabel() self.sigma_vertical_label.setText("sigma vertical") self.sigma_vertical_label.setToolTip( "Width [pixels] of Gaussian-shaped high-pass filter in frequency domain" ) self.sigma_vertical_entry = QLineEdit() self.sigma_vertical_entry.setValidator(get_int_validator()) self.sigma_vertical_entry.editingFinished.connect(self.set_sigma_vertical) self.wind_size_label = QLabel() self.wind_size_label.setText("window size") self.wind_size_label.setToolTip("Window size in remove_stripe_based_sorting algorithm") self.wind_size_entry = QLineEdit() self.wind_size_entry.setValidator(get_int_validator()) self.wind_size_entry.editingFinished.connect(self.set_window_size) self.wind_size_entry.setToolTip("Typically in the range 31..51 ") self.remove_wide_checkbox = QCheckBox() self.remove_wide_checkbox.setText("Remove wide") self.remove_wide_checkbox.setToolTip("Window size in remove_large_stripe algorithm") self.remove_wide_checkbox.stateChanged.connect(self.set_remove_wide) self.remove_wide_label = QLabel() self.remove_wide_label.setText("window") self.remove_wide_label.setToolTip("Typically in the range 51..131 ") self.remove_wide_entry = QLineEdit() self.remove_wide_entry.setValidator(get_int_validator()) self.remove_wide_entry.editingFinished.connect(self.set_wind) self.SNR_label = QLabel() self.SNR_label.setText("SNR") self.SNR_label.setToolTip("SNR param in remove_large_stripe algorithm") self.SNR_entry = QLineEdit() self.SNR_entry.setValidator(get_int_validator()) self.SNR_entry.editingFinished.connect(self.set_SNR) self.set_layout() def set_layout(self): layout = QGridLayout() remove_spots_groupbox = QGroupBox() remove_spots_layout = QGridLayout() remove_spots_layout.addWidget(self.remove_spots_checkBox, 0, 0) remove_spots_layout.addWidget(self.remove_spots_median_checkBox, 0, 1) remove_spots_layout.addWidget(self.threshold_label, 1, 0) remove_spots_layout.addWidget(self.threshold_entry, 1, 1) remove_spots_layout.addWidget(self.find_spots_gau_sigma_label, 2, 0) remove_spots_layout.addWidget(self.find_spots_gau_sigma_entry, 2, 1) remove_spots_groupbox.setLayout(remove_spots_layout) layout.addWidget(remove_spots_groupbox) rr_groupbox = QGroupBox() rr_layout = QGridLayout() rr_layout.addWidget(self.enable_RR_checkbox, 3, 0) rr_layout.addWidget(self.use_LPF_rButton, 4, 0) rr_layout.addWidget(self.one_dimens_rButton, 4, 1) rr_layout.addWidget(self.two_dimens_rButton, 4, 2) rr_layout.addWidget(self.sigma_horizontal_label, 4, 3, Qt.AlignRight) rr_layout.addWidget(self.sigma_horizontal_entry, 4, 4) rr_layout.addWidget(self.sigma_vertical_label, 4, 5, Qt.AlignRight) rr_layout.addWidget(self.sigma_vertical_entry, 4, 6) rr_layout.addWidget(self.sarepy_rButton, 5, 0) rr_layout.addWidget(self.wind_size_label, 5, 1) rr_layout.addWidget(self.wind_size_entry, 5, 2) rr_layout.addWidget(self.remove_wide_checkbox, 5, 3) rr_layout.addWidget(self.remove_wide_label, 5, 4, Qt.AlignRight) rr_layout.addWidget(self.remove_wide_entry, 5, 5) rr_layout.addWidget(self.SNR_label, 5, 6) rr_layout.addWidget(self.SNR_entry, 5, 7) rr_groupbox.setLayout(rr_layout) layout.addWidget(rr_groupbox, 3, 0) self.setLayout(layout) def load_values(self): self.remove_spots_checkBox.setChecked(EZVARS['filters']['rm_spots']['value']) self.remove_spots_median_checkBox.setChecked(EZVARS['filters']['rm_spots_use_median']['value']) self.threshold_entry.setText(str(SECTIONS['find-large-spots']['spot-threshold']['value'])) self.find_spots_gau_sigma_entry.setText(str(SECTIONS['find-large-spots']['gauss-sigma']['value'])) self.enable_RR_checkbox.setChecked(EZVARS['RR']['enable-RR']['value']) if EZVARS['RR']['use-ufo']['value'] == True: self.use_LPF_rButton.setChecked(True) elif EZVARS['RR']['use-ufo']['value'] == False: self.use_LPF_rButton.setChecked(False) if EZVARS['RR']['ufo-2d']['value'] == True: self.one_dimens_rButton.setChecked(True) self.two_dimens_rButton.setChecked(False) elif EZVARS['RR']['ufo-2d']['value'] == False: self.one_dimens_rButton.setChecked(False) self.two_dimens_rButton.setChecked(True) self.sigma_horizontal_entry.setText(str(EZVARS['RR']['sx']['value'])) self.sigma_vertical_entry.setText(str(EZVARS['RR']['sy']['value'])) self.wind_size_entry.setText(str(EZVARS['RR']['spy-narrow-window']['value'])) self.remove_wide_checkbox.setChecked(EZVARS['RR']['spy-rm-wide']['value']) self.remove_wide_entry.setText(str(EZVARS['RR']['spy-wide-window']['value'])) self.SNR_entry.setText(str(EZVARS['RR']['spy-wide-SNR']['value'])) def set_remove_spots(self): LOG.debug("Remove large spots:" + str(self.remove_spots_checkBox.isChecked())) dict_entry = EZVARS['filters']['rm_spots'] add_value_to_dict_entry(dict_entry, self.remove_spots_checkBox.isChecked()) def set_threshold(self): LOG.debug(self.threshold_entry.text()) dict_entry = SECTIONS['find-large-spots']['spot-threshold'] add_value_to_dict_entry(dict_entry, self.threshold_entry.text()) self.threshold_entry.setText(str(dict_entry['value'])) def set_find_spots_gau_sigma(self): LOG.debug(self.find_spots_gau_sigma_entry.text()) dict_entry = SECTIONS['find-large-spots']['gauss-sigma'] add_value_to_dict_entry(dict_entry, self.find_spots_gau_sigma_entry.text()) self.find_spots_gau_sigma_entry.setText(str(dict_entry['value'])) def set_ring_removal(self): LOG.debug("RR: " + str(self.enable_RR_checkbox.isChecked())) dict_entry = EZVARS['RR']['enable-RR'] add_value_to_dict_entry(dict_entry, self.enable_RR_checkbox.isChecked()) def select_rButton(self): dict_entry = EZVARS['RR']['use-ufo'] if self.use_LPF_rButton.isChecked(): LOG.debug("Use LPF") add_value_to_dict_entry(dict_entry, True) elif self.sarepy_rButton.isChecked(): LOG.debug("Use Sarepy") add_value_to_dict_entry(dict_entry, False) def select_dimens_rButton(self): dict_entry = EZVARS['RR']['ufo-2d'] if self.one_dimens_rButton.isChecked(): LOG.debug("One dimension") add_value_to_dict_entry(dict_entry, True) elif self.two_dimens_rButton.isChecked(): LOG.debug("Two dimensions") add_value_to_dict_entry(dict_entry, False) def set_sigma_horizontal(self): LOG.debug(self.sigma_horizontal_entry.text()) dict_entry = EZVARS['RR']['sx'] add_value_to_dict_entry(dict_entry, self.sigma_horizontal_entry.text()) self.sigma_horizontal_entry.setText(str(dict_entry['value'])) def set_sigma_vertical(self): LOG.debug(self.sigma_vertical_entry.text()) dict_entry = EZVARS['RR']['sy'] add_value_to_dict_entry(dict_entry, self.sigma_vertical_entry.text()) self.sigma_vertical_entry.setText(str(dict_entry['value'])) def set_ufoRR_params_for_360_axis_search(self): self.set_sigma_vertical() self.set_sigma_horizontal() def set_window_size(self): LOG.debug(self.wind_size_entry.text()) dict_entry = EZVARS['RR']['spy-narrow-window'] add_value_to_dict_entry(dict_entry, self.wind_size_entry.text()) self.wind_size_entry.setText(str(dict_entry['value'])) def set_remove_wide(self): LOG.debug("Wide: " + str(self.remove_wide_checkbox.isChecked())) dict_entry = EZVARS['RR']['spy-rm-wide'] add_value_to_dict_entry(dict_entry, self.remove_wide_checkbox.text()) def set_wind(self): LOG.debug(self.remove_wide_entry.text()) dict_entry = EZVARS['RR']['spy-wide-window'] add_value_to_dict_entry(dict_entry, self.remove_wide_entry.text()) self.remove_wide_entry.setText(str(dict_entry['value'])) def set_SNR(self): LOG.debug(self.SNR_entry.text()) dict_entry = EZVARS['RR']['spy-wide-SNR'] add_value_to_dict_entry(dict_entry, self.SNR_entry.text()) self.SNR_entry.setText(str(dict_entry['value'])) def remove_spots_method_median_checked(self): self.mask_method_median_checked.emit()ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Main/phase_retrieval.py000066400000000000000000000120511521054151500232450ustar00rootroot00000000000000import logging import math from PyQt5.QtWidgets import QGridLayout, QLabel, QGroupBox, QLineEdit, QCheckBox from tofu.config import SECTIONS from tofu.ez.params import EZVARS from tofu.ez.util import add_value_to_dict_entry, reverse_tupleize, get_double_validator, get_tuple_validator LOG = logging.getLogger(__name__) class PhaseRetrievalGroup(QGroupBox): """ Phase Retrieval settings """ def __init__(self): super().__init__() self.setTitle("Phase Retrieval") self.setStyleSheet("QGroupBox {color: blue;}") self.enable_PR_checkBox = QCheckBox() self.enable_PR_checkBox.setText("Enable Paganin/TIE phase retrieval") self.enable_PR_checkBox.stateChanged.connect(self.set_PR) self.photon_energy_label = QLabel() self.photon_energy_label.setText("Photon energy [keV]") self.photon_energy_entry = QLineEdit() self.photon_energy_entry.setValidator(get_double_validator()) self.photon_energy_entry.editingFinished.connect(self.set_photon_energy) self.pixel_size_label = QLabel() self.pixel_size_label.setText("Pixel size [micron]") self.pixel_size_entry = QLineEdit() self.pixel_size_entry.setValidator(get_double_validator()) self.pixel_size_entry.editingFinished.connect(self.set_pixel_size) self.detector_distance_label = QLabel() self.detector_distance_label.setText("Sample-detector distance [m]") self.detector_distance_entry = QLineEdit() self.detector_distance_entry.setValidator(get_tuple_validator()) self.detector_distance_entry.editingFinished.connect(self.set_detector_distance) self.delta_beta_ratio_label = QLabel() self.delta_beta_ratio_label.setText("Delta/beta ratio: (try default if unsure)") self.delta_beta_ratio_entry = QLineEdit() self.delta_beta_ratio_entry.setValidator(get_double_validator()) self.delta_beta_ratio_entry.editingFinished.connect(self.set_delta_beta) self.set_layout() def set_layout(self): layout = QGridLayout() layout.addWidget(self.enable_PR_checkBox, 0, 0) layout.addWidget(self.photon_energy_label, 1, 0) layout.addWidget(self.photon_energy_entry, 1, 1) layout.addWidget(self.pixel_size_label, 2, 0) layout.addWidget(self.pixel_size_entry, 2, 1) layout.addWidget(self.detector_distance_label, 3, 0) layout.addWidget(self.detector_distance_entry, 3, 1) layout.addWidget(self.delta_beta_ratio_label, 4, 0) layout.addWidget(self.delta_beta_ratio_entry, 4, 1) self.setLayout(layout) def load_values(self): self.enable_PR_checkBox.setChecked(EZVARS['retrieve-phase']['apply-pr']['value']) self.photon_energy_entry.setText(str(SECTIONS['retrieve-phase']['energy']['value'])) self.pixel_size_entry.setText(str( round(self.meters_to_microns(SECTIONS['retrieve-phase']['pixel-size']['value']),6))) self.detector_distance_entry.setText(str( reverse_tupleize()(SECTIONS['retrieve-phase']['propagation-distance']['value']))) self.delta_beta_ratio_entry.setText(str( round(self.regularization_rate_to_delta_beta_ratio(SECTIONS['retrieve-phase']['regularization-rate']['value'])))) def set_PR(self): LOG.debug("PR: " + str(self.enable_PR_checkBox.isChecked())) dict_entry = EZVARS['retrieve-phase']['apply-pr'] add_value_to_dict_entry(dict_entry, self.enable_PR_checkBox.isChecked()) def set_photon_energy(self): LOG.debug(self.photon_energy_entry.text()) dict_entry = SECTIONS['retrieve-phase']['energy'] add_value_to_dict_entry(dict_entry, str(self.photon_energy_entry.text())) self.photon_energy_entry.setText(str(dict_entry['value'])) def set_pixel_size(self): LOG.debug(self.pixel_size_entry.text()) dict_entry = SECTIONS['retrieve-phase']['pixel-size'] add_value_to_dict_entry(dict_entry, self.microns_to_meters(float(self.pixel_size_entry.text()))) def set_detector_distance(self): LOG.debug(self.detector_distance_entry.text()) dict_entry = SECTIONS['retrieve-phase']['propagation-distance'] add_value_to_dict_entry(dict_entry, str(self.detector_distance_entry.text())) self.detector_distance_entry.setText(str(reverse_tupleize()(dict_entry['value']))) def set_delta_beta(self): LOG.debug(self.delta_beta_ratio_entry.text()) dict_entry = SECTIONS['retrieve-phase']['regularization-rate'] add_value_to_dict_entry(dict_entry, self.delta_beta_ratio_to_regularization_rate(float(self.delta_beta_ratio_entry.text()))) def meters_to_microns(self,value)->float: return value * 1e6 def microns_to_meters(self,value)->float: return value * 1e-6 def delta_beta_ratio_to_regularization_rate(self,value:float)->float: return math.log10(value) def regularization_rate_to_delta_beta_ratio(self,value)->float: return 10**valueufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Main/region_and_histogram.py000066400000000000000000000267421521054151500242660ustar00rootroot00000000000000import logging from PyQt5.QtWidgets import QGridLayout, QRadioButton, QLabel, QGroupBox, QLineEdit, QCheckBox from PyQt5.QtCore import Qt from tofu.ez.params import EZVARS from tofu.config import SECTIONS from tofu.ez.util import add_value_to_dict_entry, get_int_validator, get_double_validator, reverse_tupleize LOG = logging.getLogger(__name__) class ROIandHistGroup(QGroupBox): """ Binning settings """ def __init__(self): super().__init__() self.setTitle("Region of Interest and Histogram Settings") self.setStyleSheet("QGroupBox {color: red;}") self.select_rows_checkbox = QCheckBox() self.select_rows_checkbox.setText("Select rows which will be reconstructed") self.select_rows_checkbox.stateChanged.connect(self.set_select_rows) self.first_row_label = QLabel() self.first_row_label.setText("First row in projections") self.first_row_label.setToolTip("Counting from the top") self.first_row_entry = QLineEdit() self.first_row_entry.setValidator(get_int_validator()) self.first_row_entry.editingFinished.connect(self.set_first_row) self.num_rows_label = QLabel() self.num_rows_label.setText("Number of rows (ROI height)") self.num_rows_entry = QLineEdit() self.num_rows_entry.setValidator(get_int_validator()) self.num_rows_entry.editingFinished.connect(self.set_num_rows) self.nth_row_label = QLabel() self.nth_row_label.setText("Step (reconstruct every Nth row)") self.nth_row_entry = QLineEdit() self.nth_row_entry.setValidator(get_int_validator()) self.nth_row_entry.editingFinished.connect(self.set_reco_nth_rows) self.clip_histo_checkbox = QCheckBox() self.clip_histo_checkbox.setText("Clip histogram and save slices in") self.clip_histo_checkbox.stateChanged.connect(self.set_clip_histo) self.eight_bit_rButton = QRadioButton() self.eight_bit_rButton.setText("8-bit") self.eight_bit_rButton.setChecked(True) self.eight_bit_rButton.clicked.connect(self.set_bitdepth) self.sixteen_bit_rButton = QRadioButton() self.sixteen_bit_rButton.setText("16-bit") self.sixteen_bit_rButton.clicked.connect(self.set_bitdepth) self.min_val_label = QLabel() self.min_val_label.setText("Min value in 32-bit histogram") self.min_val_entry = QLineEdit() #self.min_val_entry.setValidator(get_double_validator()) self.min_val_entry.editingFinished.connect(self.set_min_val) self.max_val_label = QLabel() self.max_val_label.setText("Max value in 32-bit histogram") self.max_val_entry = QLineEdit() #self.max_val_entry.setValidator(get_double_validator()) self.max_val_entry.editingFinished.connect(self.set_max_val) self.crop_slices_checkbox = QCheckBox() self.crop_slices_checkbox.setText("Crop slices") self.crop_slices_checkbox.setToolTip("Crop slices in the reconstruction plane \n" "(x,y) - top left corner of selection \n" "(width, height) - size of selection") self.crop_slices_checkbox.stateChanged.connect(self.set_crop_slices) self.x_val_label = QLabel() self.x_val_label.setText("x") self.x_val_label.setToolTip("First column (counting from left)") self.x_val_entry = QLineEdit() self.x_val_entry.setValidator(get_int_validator()) self.x_val_entry.editingFinished.connect(self.set_x) self.width_val_label = QLabel() self.width_val_label.setText("width") self.width_val_entry = QLineEdit() self.width_val_entry.setValidator(get_int_validator()) self.width_val_entry.editingFinished.connect(self.set_width) self.y_val_label = QLabel() self.y_val_label.setText("y") self.y_val_label.setToolTip("First row (counting from top)") self.y_val_entry = QLineEdit() self.y_val_entry.setValidator(get_int_validator()) self.y_val_entry.editingFinished.connect(self.set_y) self.height_val_label = QLabel() self.height_val_label.setText("height") self.height_val_entry = QLineEdit() self.height_val_entry.setValidator(get_int_validator()) self.height_val_entry.editingFinished.connect(self.set_height) self.rotate_vol_label = QLabel() self.rotate_vol_label.setText("Rotate volume counterclockwise by [deg]") self.rotate_vol_entry = QLineEdit() self.rotate_vol_entry.setValidator(get_double_validator()) self.rotate_vol_entry.editingFinished.connect(self.set_rotate_volume) # self.setStyleSheet('background-color:Azure') self.set_layout() def set_layout(self): """ Sets the layout of buttons, labels, etc. for binning group """ layout = QGridLayout() layout.addWidget(self.select_rows_checkbox, 0, 0) layout.addWidget(self.first_row_label, 1, 0) layout.addWidget(self.first_row_entry, 1, 1, 1, 8) layout.addWidget(self.num_rows_label, 2, 0) layout.addWidget(self.num_rows_entry, 2, 1, 1, 8) layout.addWidget(self.nth_row_label, 3, 0) layout.addWidget(self.nth_row_entry, 3, 1, 1, 8) layout.addWidget(self.clip_histo_checkbox, 4, 0) layout.addWidget(self.eight_bit_rButton, 4, 1) layout.addWidget(self.sixteen_bit_rButton, 4, 2) layout.addWidget(self.min_val_label, 5, 0) layout.addWidget(self.min_val_entry, 5, 1, 1, 8) layout.addWidget(self.max_val_label, 6, 0) layout.addWidget(self.max_val_entry, 6, 1, 1, 8) layout.addWidget(self.crop_slices_checkbox, 7, 0) layout.addWidget(self.x_val_label, 7, 1)#, Qt.AlignRight) layout.addWidget(self.x_val_entry, 7, 2) layout.addWidget(self.width_val_label, 7, 3)#, Qt.AlignRight) layout.addWidget(self.width_val_entry, 7, 4) layout.addWidget(self.y_val_label, 7, 5) layout.addWidget(self.y_val_entry, 7, 6) layout.addWidget(self.height_val_label, 7, 7) layout.addWidget(self.height_val_entry, 7, 8) layout.addWidget(self.rotate_vol_label, 8, 0) layout.addWidget(self.rotate_vol_entry, 8, 1, 1, 8) self.setLayout(layout) def load_values(self): self.select_rows_checkbox.setChecked(EZVARS['inout']['input_ROI']['value']) self.first_row_entry.setText(str(SECTIONS['reading']['y']['value'])) self.num_rows_entry.setText(str(SECTIONS['reading']['height']['value'])) self.nth_row_entry.setText(str(SECTIONS['reading']['y-step']['value'])) if int(SECTIONS['general']['output-bitdepth']['value']) == 8: self.eight_bit_rButton.setChecked(True) self.sixteen_bit_rButton.setChecked(False) elif int(SECTIONS['general']['output-bitdepth']['value']) == 16: self.eight_bit_rButton.setChecked(False) self.sixteen_bit_rButton.setChecked(True) self.clip_histo_checkbox.setChecked(EZVARS['inout']['clip_hist']['value']) self.min_val_entry.setText(str(SECTIONS['general']['output-minimum']['value'])) self.max_val_entry.setText(str(SECTIONS['general']['output-maximum']['value'])) self.crop_slices_checkbox.setChecked(EZVARS['inout']['output-ROI']['value']) self.x_val_entry.setText(str(EZVARS['inout']['output-x']['value'])) self.width_val_entry.setText(str(EZVARS['inout']['output-width']['value'])) self.y_val_entry.setText(str(EZVARS['inout']['output-y']['value'])) self.height_val_entry.setText(str(EZVARS['inout']['output-height']['value'])) self.rotate_vol_entry.setText(str(reverse_tupleize()(SECTIONS['general-reconstruction']['volume-angle-z']['value']))) def set_select_rows(self): LOG.debug("Select rows: " + str(self.select_rows_checkbox.isChecked())) dict_entry = EZVARS['inout']['input_ROI'] add_value_to_dict_entry(dict_entry, self.select_rows_checkbox.isChecked()) def set_first_row(self): LOG.debug(self.first_row_entry.text()) dict_entry = SECTIONS['reading']['y'] add_value_to_dict_entry(dict_entry, str(self.first_row_entry.text())) self.first_row_entry.setText(str(dict_entry['value'])) def set_num_rows(self): LOG.debug(self.num_rows_entry.text()) dict_entry = SECTIONS['reading']['height'] add_value_to_dict_entry(dict_entry, str(self.num_rows_entry.text())) self.num_rows_entry.setText(str(dict_entry['value'])) def set_reco_nth_rows(self): LOG.debug(self.nth_row_entry.text()) dict_entry = SECTIONS['reading']['y-step'] add_value_to_dict_entry(dict_entry, str(self.nth_row_entry.text())) self.nth_row_entry.setText(str(dict_entry['value'])) def set_clip_histo(self): LOG.debug("Clip histo: " + str(self.clip_histo_checkbox.isChecked())) dict_entry = EZVARS['inout']['clip_hist'] add_value_to_dict_entry(dict_entry, self.clip_histo_checkbox.isChecked()) if EZVARS['inout']['clip_hist']['value']: return self.set_bitdepth() else: return '32' def set_bitdepth(self): dict_entry = SECTIONS['general']['output-bitdepth'] if self.eight_bit_rButton.isChecked(): LOG.debug("8 bit") add_value_to_dict_entry(dict_entry, str(8)) return '8' elif self.sixteen_bit_rButton.isChecked(): LOG.debug("16 bit") add_value_to_dict_entry(dict_entry, str(16)) return '16' def set_min_val(self): LOG.debug(self.min_val_entry.text()) dict_entry = SECTIONS['general']['output-minimum'] add_value_to_dict_entry(dict_entry, self.min_val_entry.text()) def set_max_val(self): LOG.debug(self.max_val_entry.text()) dict_entry = SECTIONS['general']['output-maximum'] add_value_to_dict_entry(dict_entry, self.max_val_entry.text()) def set_crop_slices(self): LOG.debug("Crop slices: " + str(self.crop_slices_checkbox.isChecked())) dict_entry = EZVARS['inout']['output-ROI'] add_value_to_dict_entry(dict_entry, self.crop_slices_checkbox.isChecked()) def set_x(self): LOG.debug(self.x_val_entry.text()) dict_entry = EZVARS['inout']['output-x'] add_value_to_dict_entry(dict_entry, str(self.x_val_entry.text())) self.x_val_entry.setText(str(dict_entry['value'])) def set_width(self): LOG.debug(self.width_val_entry.text()) dict_entry = EZVARS['inout']['output-width'] add_value_to_dict_entry(dict_entry, str(self.width_val_entry.text())) self.width_val_entry.setText(str(dict_entry['value'])) def set_y(self): LOG.debug(self.y_val_entry.text()) dict_entry = EZVARS['inout']['output-y'] add_value_to_dict_entry(dict_entry, str(self.y_val_entry.text())) self.y_val_entry.setText(str(dict_entry['value'])) def set_height(self): LOG.debug(self.height_val_entry.text()) dict_entry = EZVARS['inout']['output-height'] add_value_to_dict_entry(dict_entry, str(self.height_val_entry.text())) self.height_val_entry.setText(str(dict_entry['value'])) def set_rotate_volume(self): LOG.debug(self.rotate_vol_entry.text()) dict_entry = SECTIONS['general-reconstruction']['volume-angle-z'] add_value_to_dict_entry(dict_entry, str(self.rotate_vol_entry.text())) self.rotate_vol_entry.setText(str(reverse_tupleize()(dict_entry['value']))) ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Stitch_tools_tab/000077500000000000000000000000001521054151500221375ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Stitch_tools_tab/__init__.py000066400000000000000000000000001521054151500242360ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Stitch_tools_tab/ez_360_multi_stitch_qt.py000066400000000000000000000423041521054151500270160ustar00rootroot00000000000000from functools import partial from PyQt5.QtWidgets import ( QGroupBox, QPushButton, QCheckBox, QLabel, QLineEdit, QGridLayout, QFileDialog, QMessageBox, QRadioButton ) from PyQt5.QtCore import pyqtSignal import logging from shutil import rmtree import os import yaml from tofu.ez.Helpers.stitch_funcs import main_360_mp_depth2 from tofu.ez.GUI.message_dialog import warning_message # Params from tofu.ez.params import EZVARS_aux from tofu.ez.util import add_value_to_dict_entry, get_int_validator from tofu.ez.util import import_values, export_values LOG = logging.getLogger(__name__) class MultiStitch360Group(QGroupBox): get_fdt_names_on_stitch_pressed = pyqtSignal() def __init__(self): super().__init__() self.h_msg = "Stitches images horizontally to convert a bunch of half-acquisition " \ "mode scans to ordinary parallel beam CT sets.\n" self.h_msg += "Input directory structure must conform to the following template: \n" \ "Input/000, Input/001,...Input/00N. \n" self.h_msg += "Each 000, 001, ... 00N directory in the Input must be a legitimate tofu ez" \ "CT set with flats/darks/tomo subdirectories. \n" self.h_msg += "Frames in each flats/darks/tomo directory will be stitched horizontally pair-wise " \ "to produce ordinary projections from [0-180) and [180-360) halves of the " \ "half-acquisition mode scan and saved in the Output directory under the same relative path. \n" self.h_msg += "Script is also going to crop resulting frames to the same width." self.setTitle("360-MULTI-STITCH: convert half-acquisition mode CT sets to ordinary CT sets. " "Not recursive.") self.setToolTip(self.h_msg) self.setStyleSheet('QGroupBox {color: red;}') self.input_dir_button = QPushButton("Select input directory") self.input_dir_button.setToolTip("Contains one layer of CT directories with flats/darks/tomo subdirectories. \n" "Images in each will be stitched pair-wise [x and x+180 deg]. \n" "Doesn't work recursively") self.input_dir_button.clicked.connect(self.input_button_pressed) self.input_dir_entry = QLineEdit() self.input_dir_entry.editingFinished.connect(self.set_input_entry) self.output_dir_button = QPushButton("Directory to save stitched images") self.output_dir_button.clicked.connect(self.output_button_pressed) self.output_dir_entry = QLineEdit() self.output_dir_entry.editingFinished.connect(self.set_output_entry) self.crop_checkbox = QCheckBox("Crop all projections to match the width of smallest stitched projection") self.crop_checkbox.clicked.connect(self.set_crop_projections_checkbox) self.olap_val_switch_label = QLabel() self.olap_val_switch_label.setText('Define overlaps as') self.olap_val_int_rButton = QRadioButton() self.olap_val_int_rButton.setText('Min/max and interpolate between') self.olap_val_int_rButton.clicked.connect(self.set_rButton) self.olap_val_int_rButton.setChecked(True) self.olap_val_dict_rButton = QRadioButton() self.olap_val_dict_rButton.setText('Table') self.olap_val_dict_rButton.clicked.connect(self.set_rButton) self.olap_val_list_rButton = QRadioButton() self.olap_val_list_rButton.setText('List') self.olap_val_list_rButton.clicked.connect(self.set_rButton) self.oval_val_switch_group = QGroupBox() self.axis_bottom_label = QLabel() self.axis_bottom_label.setText("Overlap for first directory:") self.axis_bottom_entry = QLineEdit() self.axis_bottom_entry.editingFinished.connect(self.set_axis_bottom) self.axis_bottom_entry.setValidator(get_int_validator()) self.axis_top_label = QLabel("For last directory:") self.axis_top_entry = QLineEdit() self.axis_top_entry.editingFinished.connect(self.set_axis_top) self.axis_top_entry.setValidator(get_int_validator()) #### MANUAL ENTRY OF OVERLAPS self.axis_group = QGroupBox("Enter overlaps manually") self.axis_group.clicked.connect(self.set_axis_group) self.num_subdirs = 24 for i in range(self.num_subdirs): setattr(self, f"axis_z{i:03d}_label", QLabel(f"Dir {i:02d}:")) entry = QLineEdit() setattr(self, f"axis_z{i:03d}_entry", entry) entry.editingFinished.connect(partial(self.set_z_by_index, i)) getattr(self, f"axis_z{i:03d}_entry").setValidator(get_int_validator()) self.olap_list_label = QLabel() self.olap_list_label.setText("List of values comma separated") self.olap_list_entry = QLineEdit() self.olap_list_entry.setToolTip('Example: 48,50,5') self.olap_list_entry.editingFinished.connect(self.set_olap_list) self.stitch_button = QPushButton("Stitch") self.stitch_button.clicked.connect(self.stitch_button_pressed) self.stitch_button.setStyleSheet("color:royalblue;font-weight:bold") self.delete_button = QPushButton("Delete output dir") self.delete_button.clicked.connect(self.delete_button_pressed) self.help_button = QPushButton("Help") self.help_button.clicked.connect(self.help_button_pressed) self.import_parameters_button = QPushButton("Import Parameters from File") self.import_parameters_button.clicked.connect(self.import_parameters_button_pressed) self.save_parameters_button = QPushButton("Save Parameters to File") self.save_parameters_button.clicked.connect(self.save_parameters_button_pressed) self.set_layout() self.set_rButton() def set_layout(self): layout = QGridLayout() # layout.addWidget(self.input_dir_button, 0, 0, 1, 4) # layout.addWidget(self.input_dir_entry, 1, 0, 1, 4) # layout.addWidget(self.output_dir_button, 4, 0, 1, 4) # layout.addWidget(self.output_dir_entry, 5, 0, 1, 4) # layout.addWidget(self.crop_checkbox, 6, 0, 1, 4) layout.addWidget(self.input_dir_button, 0, 0, 1, 1) layout.addWidget(self.input_dir_entry, 0, 1, 1, 7) layout.addWidget(self.output_dir_button, 1, 0, 1, 1) layout.addWidget(self.output_dir_entry, 1, 1, 1, 7) layout.addWidget(self.crop_checkbox, 2, 0, 1, 4) l=3 olap_switch_layout = QGridLayout() olap_switch_layout.addWidget(self.olap_val_switch_label, 0, 0) olap_switch_layout.addWidget(self.olap_val_int_rButton, 0, 1) olap_switch_layout.addWidget(self.olap_val_dict_rButton, 0, 2) olap_switch_layout.addWidget(self.olap_val_list_rButton, 0, 3) self.oval_val_switch_group.setLayout(olap_switch_layout) layout.addWidget(self.oval_val_switch_group, l, 0, 1, 8) l= 4 # Interpolate overlaps layout.addWidget(self.axis_bottom_label, l, 0) layout.addWidget(self.axis_bottom_entry, l, 1) layout.addWidget(self.axis_top_label, l, 2) layout.addWidget(self.axis_top_entry, l, 3) # self.axis_group.setCheckable(True) # self.axis_group.setChecked(False) # Table of overlaps l=5 axis_layout = QGridLayout() ncols = 6 for i in range(self.num_subdirs): axis_layout.addWidget(getattr(self, f"axis_z{i:03d}_label"), i % ncols, i // ncols * 2) axis_layout.addWidget(getattr(self, f"axis_z{i:03d}_entry"), i % ncols, i // ncols * 2 + 1) self.axis_group.setLayout(axis_layout) layout.addWidget(self.axis_group, l, 0, 1, 8) l = 6 layout.addWidget(self.olap_list_label, l, 0) layout.addWidget(self.olap_list_entry, l, 1, 1, 7) # layout.addWidget(self.help_button, 11, 0) # layout.addWidget(self.delete_button, 11, 1) # layout.addWidget(self.stitch_button, 11, 2, 1, 2) # # layout.addWidget(self.import_parameters_button, 12, 0, 1, 2) # layout.addWidget(self.save_parameters_button, 12, 2, 1, 2) l = 7 layout.addWidget(self.delete_button, l, 0) layout.addWidget(self.help_button, l, 1) layout.addWidget(self.import_parameters_button, l, 2) layout.addWidget(self.save_parameters_button, l, 3) layout.addWidget(self.stitch_button, l, 4) self.setLayout(layout) def load_values(self): self.input_dir_entry.setText(str(EZVARS_aux['stitch360']['input-dir']['value'])) self.output_dir_entry.setText(str(EZVARS_aux['stitch360']['output-dir']['value'])) self.crop_checkbox.setChecked(EZVARS_aux['stitch360']['crop']['value']) self.axis_bottom_entry.setText(str(EZVARS_aux['stitch360']['olap_min']['value'])) self.axis_top_entry.setText(str(EZVARS_aux['stitch360']['olap_max']['value'])) self.set_rButton_from_params() def set_rButton_from_params(self): t = "One of the imported overlaps is not a number" if EZVARS_aux['stitch360']['olap_switch']['value'] == 0: self.olap_val_int_rButton.setChecked(True) self.olap_val_dict_rButton.setChecked(False) self.olap_val_list_rButton.setChecked(False) elif EZVARS_aux['stitch360']['olap_switch']['value'] == 1: self.olap_val_int_rButton.setChecked(False) self.olap_val_dict_rButton.setChecked(True) self.olap_val_list_rButton.setChecked(False) vals = EZVARS_aux['stitch360']['olap_list']['value'].split(',') if self.check_that_int_failed(vals, t): return else: self.set_table_vals(vals) elif EZVARS_aux['stitch360']['olap_switch']['value'] == 2: self.olap_val_int_rButton.setChecked(False) self.olap_val_dict_rButton.setChecked(False) self.olap_val_list_rButton.setChecked(True) self.olap_list_entry.setText(EZVARS_aux['stitch360']['olap_list']['value']) vals = EZVARS_aux['stitch360']['olap_list']['value'].split(',') if self.check_that_int_failed(vals, t): return else: self.set_table_vals(vals) def check_that_int_failed(self, vals, t): for i in range(len(vals)): try: int(vals[i]) except: qm = QMessageBox() qm.warning(self, '', t) return 1 return 0 def set_table_vals(self, vals): for i in range(len(vals)): try: getattr(self, f"axis_z{i:03d}_entry").setText(str(vals[i])) except: continue def set_rButton(self): dict_entry = EZVARS_aux['stitch360']['olap_switch'] if self.olap_val_int_rButton.isChecked(): add_value_to_dict_entry(dict_entry, 0) self.axis_bottom_entry.setEnabled(True) self.axis_top_entry.setEnabled(True) self.olap_list_entry.setEnabled(False) self.axis_group.setEnabled(False) elif self.olap_val_dict_rButton.isChecked(): add_value_to_dict_entry(dict_entry, 1) self.axis_bottom_entry.setEnabled(False) self.axis_top_entry.setEnabled(False) self.olap_list_entry.setEnabled(False) self.axis_group.setEnabled(True) elif self.olap_val_list_rButton.isChecked(): add_value_to_dict_entry(dict_entry, 2) # self.axis_bottom_label.setEnabled(False) self.axis_bottom_entry.setEnabled(False) # self.axis_top_label.setEnabled(False) self.axis_top_entry.setEnabled(False) self.olap_list_entry.setEnabled(True) self.axis_group.setEnabled(False) def input_button_pressed(self): LOG.debug("Input button pressed") dir_explore = QFileDialog(self) self.input_dir_entry.setText(dir_explore.getExistingDirectory()) self.set_input_entry() def set_input_entry(self): add_value_to_dict_entry(EZVARS_aux['stitch360']['input-dir'], str(self.input_dir_entry.text())) def output_button_pressed(self): LOG.debug("Output button pressed") dir_explore = QFileDialog(self) self.output_dir_entry.setText(dir_explore.getExistingDirectory()) self.set_output_entry() def set_output_entry(self): add_value_to_dict_entry(EZVARS_aux['stitch360']['output-dir'], str(self.output_dir_entry.text())) def set_crop_projections_checkbox(self): add_value_to_dict_entry(EZVARS_aux['stitch360']['crop'], self.crop_checkbox.isChecked()) def set_axis_bottom(self): add_value_to_dict_entry(EZVARS_aux['stitch360']['olap_min'], int(self.axis_bottom_entry.text())) def set_axis_top(self): add_value_to_dict_entry(EZVARS_aux['stitch360']['olap_max'], int(self.axis_top_entry.text())) def set_olap_type(self): add_value_to_dict_entry(EZVARS_aux['stitch360']['olap_switch'], 0) def set_olap_list(self): vals = self.olap_list_entry.text().split(',') if self.check_that_int_failed(vals): return else: add_value_to_dict_entry(EZVARS_aux['stitch360']['olap_list'], self.olap_list_entry.text()) def set_axis_group(self): if self.axis_group.isChecked(): self.axis_bottom_label.setEnabled(False) self.axis_bottom_entry.setEnabled(False) self.axis_top_label.setEnabled(False) self.axis_top_entry.setEnabled(False) else: self.axis_bottom_label.setEnabled(True) self.axis_bottom_entry.setEnabled(True) self.axis_top_label.setEnabled(True) self.axis_top_entry.setEnabled(True) def set_z_by_index(self, index): entry: QLineEdit = getattr(self, f"axis_z{index:03d}_entry") key = f"z{index:03d}" LOG.debug(f"{key} axis: {entry.text()}") self.update_olap_list() def update_olap_list(self): EZVARS_aux['stitch360']['olap_list']['value'] = '' for i in range(self.num_subdirs): try: EZVARS_aux['stitch360']['olap_list']['value'] += \ (str(int(getattr(self, f"axis_z{i:03d}_entry").text())) + ",") except: EZVARS_aux['stitch360']['olap_list']['value']=\ EZVARS_aux['stitch360']['olap_list']['value'][:-1] return def stitch_button_pressed(self): LOG.debug("Stitch button pressed") self.get_fdt_names_on_stitch_pressed.emit() if os.path.exists(EZVARS_aux['stitch360']['output-dir']['value']) and \ len(os.listdir(EZVARS_aux['stitch360']['output-dir']['value'])) > 0: qm = QMessageBox() rep = qm.warning(self, '', "Output directory exists and is not empty.") return print("======= Begin 360 Multi-Stitch =======") main_360_mp_depth2() params_file_path = os.path.join(EZVARS_aux['stitch360']['output-dir']['value'], 'ezvars_aux_from_multistitch.yaml') export_values(params_file_path, ['ezvars_aux']) print("==== Waiting for Next Task ====") QMessageBox.information(self, "Finished", "Finished stitching") def delete_button_pressed(self): LOG.debug("Delete button pressed") qm = QMessageBox() rep = qm.question(self, '', "Is it safe to delete the output directory?", qm.Yes | qm.No) if not os.path.exists(EZVARS_aux['stitch360']['output-dir']['value']): warning_message("Output directory does not exist") elif rep == qm.Yes: print("---- Deleting Data From Output Directory ----") try: rmtree(EZVARS_aux['stitch360']['output-dir']['value']) except: warning_message("Problems with deleting output directory") else: return def help_button_pressed(self): LOG.debug("Help button pressed") QMessageBox.information(self, "Help", self.h_msg) def import_parameters_button_pressed(self): LOG.debug("Import params button clicked") dir_explore = QFileDialog(self) params_file_path = dir_explore.getOpenFileName(filter="*.yaml") import_values(params_file_path[0], ['ezvars_aux']) self.load_values() def save_parameters_button_pressed(self): LOG.debug("Save params button clicked") dir_explore = QFileDialog(self) params_file_path = dir_explore.getSaveFileName(filter="*.yaml") garbage, file_name = os.path.split(params_file_path[0]) file_extension = os.path.splitext(file_name) # If the user doesn't enter the .yaml extension then append it to filepath if file_extension[-1] == "": file_path = params_file_path[0] + ".yaml" else: file_path = params_file_path[0] try: export_values(file_path, ['ezvars_aux']) print("Parameters file saved at: " + str(file_path)) except FileNotFoundError: print("You need to select a directory and use a valid file name") ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Stitch_tools_tab/ez_360_overlap_qt.py000066400000000000000000000357271521054151500257710ustar00rootroot00000000000000from PyQt5.QtWidgets import ( QGroupBox, QPushButton, QCheckBox, QLabel, QLineEdit, QGridLayout, QFileDialog, QMessageBox, ) from PyQt5.QtCore import pyqtSignal import logging from shutil import rmtree import os from tofu.ez.Helpers.find_360_overlap import find_overlap from tofu.ez.params import EZVARS_aux from tofu.ez.util import add_value_to_dict_entry from tofu.ez.util import import_values, export_values LOG = logging.getLogger(__name__) class Overlap360Group(QGroupBox): get_fdt_names_on_stitch_pressed = pyqtSignal() get_RR_params_on_start_pressed = pyqtSignal() def __init__(self): super().__init__() self.setTitle("360-AXIS-SEARCH: find overlap in half acq. mode CT sets. " "Works for all CT sets in the Input directory recursively.") self.setStyleSheet('QGroupBox {color: Orange;}') self.setToolTip("Upon 360 deg rotation in parallel beam tomography two data sets are " "effectively acquired" " if the axis of rotation is moved close to the edge of the detector.\n" "A half acquisition mode data set can be converted to an ordinary parallel" "beam data set by stitching matching pairs of projections from " "[0-180) and [180-360) deg intervals.\n" "In order to do that one must know position of the axis of rotation or, in other words," "the horizontal overlap between [0-180) and [180-360) projections.\n" "This tool helps to find index of the column where axis of rotation was " "by reconstructing CT slices for one detector row" "with different overlaps.\n" "Script suggests an automatic estimate which can be verified by " "visual inspection of the slices saved in the output directory.") self.input_dir_button = QPushButton("Select input directory") self.input_dir_button.clicked.connect(self.input_button_pressed) self.input_dir_entry = QLineEdit() self.input_dir_entry.editingFinished.connect(self.set_input_entry) self.temp_dir_button = QPushButton("Select temp directory") self.temp_dir_button.clicked.connect(self.temp_button_pressed) self.temp_dir_entry = QLineEdit() self.temp_dir_entry.editingFinished.connect(self.set_temp_entry) self.output_dir_button = QPushButton("Select output directory") self.output_dir_button.clicked.connect(self.output_button_pressed) self.output_dir_entry = QLineEdit() self.output_dir_entry.editingFinished.connect(self.set_output_entry) self.pixel_row_label = QLabel("Row to be reconstructed") self.pixel_row_label.setToolTip("TEST") self.pixel_row_entry = QLineEdit() self.pixel_row_entry.editingFinished.connect(self.set_pixel_row) self.patch_size_label = QLabel("\tPatch size") self.patch_size_label.setToolTip(EZVARS_aux['find360olap']['patch-size']['help']) self.patch_size_entry = QLineEdit() self.patch_size_entry.editingFinished.connect(self.set_patch_size) self.patch_size_entry.setToolTip(EZVARS_aux['find360olap']['patch-size']['help']) self.row1_dummy_label1 = QLabel("\t") self.row1_dummy_label2 = QLabel("\t") self.doRR = QCheckBox("Apply ring removal") #self.doRR.setEnabled(False) self.doRR.stateChanged.connect(self.set_RR_checkbox) self.doPR = QCheckBox("Apply phase retrieval") self.doPR.setToolTip("Warning! It can take a lot of time and space on the disk.") self.doPR.stateChanged.connect(self.set_PR_checkbox) self.range_label = QLabel("Overlap range for axis search:") self.min_label = QLabel("\tStart") self.min_entry = QLineEdit() self.min_entry.editingFinished.connect(self.set_lower_limit) self.max_label = QLabel("\tStop") self.max_entry = QLineEdit() self.max_entry.editingFinished.connect(self.set_upper_limit) self.step_label = QLabel("\t\tStep") self.step_entry = QLineEdit() self.step_entry.editingFinished.connect(self.set_increment) self.help_button = QPushButton("Help") self.help_button.clicked.connect(self.help_button_pressed) self.find_overlap_button = QPushButton("Generate slices") self.find_overlap_button.clicked.connect(self.overlap_button_pressed) self.find_overlap_button.setStyleSheet("color:royalblue;font-weight:bold") self.import_parameters_button = QPushButton("Import Parameters from File") self.import_parameters_button.clicked.connect(self.import_parameters_button_pressed) self.save_parameters_button = QPushButton("Save Parameters to File") self.save_parameters_button.clicked.connect(self.save_parameters_button_pressed) self.set_layout() def set_layout(self): layout = QGridLayout() #layout.addWidget(self.info_label, 0, 0, 1, 7) layout.addWidget(self.input_dir_button, 0, 0, 1, 1) layout.addWidget(self.input_dir_entry, 0, 1, 1, 6) layout.addWidget(self.temp_dir_button, 1, 0, 1, 1) layout.addWidget(self.temp_dir_entry, 1, 1, 1, 6) layout.addWidget(self.output_dir_button, 2, 0, 1, 1) layout.addWidget(self.output_dir_entry, 2, 1, 1, 6) # l = 3 layout.addWidget(self.range_label, l, 0) layout.addWidget(self.min_label, l, 1) layout.addWidget(self.min_entry, l, 2) layout.addWidget(self.max_label, l, 3) layout.addWidget(self.max_entry, l, 4) layout.addWidget(self.step_label, l, 5) layout.addWidget(self.step_entry, l, 6) # l = 4 layout.addWidget(self.pixel_row_label, l, 0) layout.addWidget(self.pixel_row_entry, l, 1) #layout.addWidget(self.row1_dummy_label1, l, 2) layout.addWidget(self.patch_size_label, l, 2) layout.addWidget(self.patch_size_entry, l, 3) layout.addWidget(self.row1_dummy_label2, l, 4) layout.addWidget(self.doPR, l, 5) layout.addWidget(self.doRR, l, 6) # l = 5 layout.addWidget(self.help_button, l, 0, 1, 1) layout.addWidget(self.import_parameters_button, l, 1, 1, 2) layout.addWidget(self.save_parameters_button, l, 3, 1, 2) layout.addWidget(self.find_overlap_button, l, 5, 1, 2) self.setLayout(layout) def load_values(self): self.input_dir_entry.setText(str(EZVARS_aux['find360olap']['input-dir']['value'])) self.temp_dir_entry.setText(str(EZVARS_aux['find360olap']['tmp-dir']['value'])) self.output_dir_entry.setText(str(EZVARS_aux['find360olap']['output-dir']['value'])) self.pixel_row_entry.setText(str(EZVARS_aux['find360olap']['row']['value'])) self.min_entry.setText(str(EZVARS_aux['find360olap']['start']['value'])) self.max_entry.setText(str(EZVARS_aux['find360olap']['stop']['value'])) self.step_entry.setText(str(EZVARS_aux['find360olap']['step']['value'])) self.patch_size_entry.setText(str(EZVARS_aux['find360olap']['patch-size']['value'])) self.doRR.setChecked(EZVARS_aux['find360olap']['doRR']['value']) self.doPR.setChecked(EZVARS_aux['find360olap']['doPR']['value']) def input_button_pressed(self): LOG.debug("Select input button pressed") dir_explore = QFileDialog(self) tmp = dir_explore.getExistingDirectory() self.input_dir_entry.setText(tmp) add_value_to_dict_entry(EZVARS_aux['find360olap']['input-dir'], tmp) def set_input_entry(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['input-dir'], str(self.input_dir_entry.text())) def temp_button_pressed(self): dir_explore = QFileDialog(self) tmp = dir_explore.getExistingDirectory() self.temp_dir_entry.setText(tmp) add_value_to_dict_entry(EZVARS_aux['find360olap']['tmp-dir'], tmp) def set_temp_entry(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['tmp-dir'], str(self.temp_dir_entry.text())) def output_button_pressed(self): LOG.debug("Select output button pressed") dir_explore = QFileDialog(self) tmp = dir_explore.getExistingDirectory() self.output_dir_entry.setText(tmp) add_value_to_dict_entry(EZVARS_aux['find360olap']['output-dir'], tmp) def set_output_entry(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['output-dir'], str(self.output_dir_entry.text())) def set_pixel_row(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['row'], int(self.pixel_row_entry.text())) def set_lower_limit(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['start'], int(self.min_entry.text())) def set_upper_limit(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['stop'], int(self.max_entry.text())) def set_increment(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['step'], int(self.step_entry.text())) def set_patch_size(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['patch-size'], int(self.patch_size_entry.text())) def enable_by_trigger_from_main_tab(self, enabled): self.input_dir_button.setEnabled(not enabled) self.input_dir_entry.setEnabled(not enabled) self.temp_dir_button.setEnabled(not enabled) self.temp_dir_entry.setEnabled(not enabled) self.output_dir_button.setEnabled(not enabled) self.output_dir_entry.setEnabled(not enabled) def set_RR_checkbox(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['doRR'], self.doRR.isChecked()) def set_PR_checkbox(self): add_value_to_dict_entry(EZVARS_aux['find360olap']['doPR'], self.doPR.isChecked()) def overlap_button_pressed(self): LOG.debug("Find overlap button pressed") print("Find overlap button pressed") self.get_fdt_names_on_stitch_pressed.emit() self.get_RR_params_on_start_pressed.emit() if os.path.exists(EZVARS_aux['find360olap']['output-dir']['value']) and \ len(os.listdir(EZVARS_aux['find360olap']['output-dir']['value'])) > 0: qm = QMessageBox() rep = qm.question(self, 'WARNING', "Output directory exists and not empty. Is it SAFE to delete it?", qm.Yes | qm.No) if rep == qm.Yes: try: rmtree(EZVARS_aux['find360olap']['output-dir']['value']) except: QMessageBox.information(self, "Problem", "Cannot delete existing output dir") return else: return if os.path.exists(EZVARS_aux['find360olap']['tmp-dir']['value']) and \ len(os.listdir(EZVARS_aux['find360olap']['tmp-dir']['value'])) > 0: qm = QMessageBox() rep = qm.question(self, 'WARNING', "Temporary dir exist and not empty. Is it SAFE to delete it?", qm.Yes | qm.No) if rep == qm.Yes: try: rmtree(EZVARS_aux['find360olap']['tmp-dir']['value']) except: QMessageBox.information(self, "Problem", "Cannot delete existing tmp dir") return else: return if not os.path.exists(EZVARS_aux['find360olap']['tmp-dir']['value']): os.makedirs(EZVARS_aux['find360olap']['tmp-dir']['value']) if not os.path.exists(EZVARS_aux['find360olap']['output-dir']['value']): os.makedirs(EZVARS_aux['find360olap']['output-dir']['value']) find_overlap() #adding the next line so that on export in MULTI-STITCH the list is #imported even if default interpolate is selected when 360-SEARCH is used EZVARS_aux['stitch360']['olap_switch']['value'] == 2 params_file_path = os.path.join(EZVARS_aux['find360olap']['output-dir']['value'], 'ezvars_aux_from_overlap_search.yaml') if export_values(params_file_path, ['ezvars_aux']): QMessageBox.information(self, "Problem", "Cannot export to yaml file") else: QMessageBox.information(self, "Done", "List of processed directories and " "overlap estimates saved in \n" f"{params_file_path}") def help_button_pressed(self): LOG.debug("Help button pressed") h = "This script helps to find the index of detector column in with tomographic axis of rotation " h += "situated during half acq. mode scan (or overlap between pairs of images separate by 180 deg)" h += "Input can be a bunch of CT scans that has been collected in 'half-acquisition' mode" h += "For each CT set for selected detector row this script will reconstruct a bunch of slices" h += "each for different amount of overlap in the user defined range." h += "The objective is to review this series of images and find the best looking slice" h += "in the very much the same way as it is done when you search for axis of rotation in normal scans" h += "The overlap can be computed by adding the index of the right slice (times the search step)" h += "to the first overlap value in the search range." h += "Script attempts to estimate the overlap and saves the value for each data set in the" h += "360_overlap_params.yaml in the ezvars_aux section. They can be used as an input for" h += "batch stitching of the half acq mode data. User can edit the values directly in the" h += "automatically formatted output yaml file if needed." QMessageBox.information(self, "Help", h) def import_parameters_button_pressed(self): LOG.debug("Import params button clicked") dir_explore = QFileDialog(self) params_file_path = dir_explore.getOpenFileName(filter="*.yaml") try: import_values(params_file_path[0], ['ezvars_aux']) self.load_values() except FileNotFoundError: print("You need to select a valid input file") def save_parameters_button_pressed(self): LOG.debug("Save params button clicked") dir_explore = QFileDialog(self) params_file_path = dir_explore.getSaveFileName(filter="*.yaml") garbage, file_name = os.path.split(params_file_path[0]) file_extension = os.path.splitext(file_name) # If the user doesn't enter the .yaml extension then append it to filepath if file_extension[-1] == "": file_path = params_file_path[0] + ".yaml" else: file_path = params_file_path[0] try: export_values(file_path, ['ezvars_aux']) print("Parameters file saved at: " + str(file_path)) except FileNotFoundError: print("You need to select a directory and use a valid file name") ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Stitch_tools_tab/ezmview_qt.py000066400000000000000000000243551521054151500247140ustar00rootroot00000000000000import os import logging from PyQt5.QtWidgets import ( QGroupBox, QPushButton, QLineEdit, QLabel, QCheckBox, QGridLayout, QFileDialog, QMessageBox, ) import yaml from tofu.ez.Helpers.mview_main import main_prep LOG = logging.getLogger(__name__) class EZMViewGroup(QGroupBox): def __init__(self): super().__init__() self.args = {} self.e_indir = "" self.e_nproj = 0 self.e_nflats = 0 self.e_ndarks = 0 self.e_nviews = 0 self.e_noflats2 = False #self.e_Andor = False self.setTitle("EZ-MVIEW") self.setToolTip("Splits a sequence of tif files over flats/darks/tomo directories") self.setStyleSheet("QGroupBox {color: green;}") self.input_dir_button = QPushButton() self.input_dir_button.setText("Directory with image sequence") self.input_dir_button.clicked.connect(self.select_directory) self.input_dir_entry = QLineEdit() self.input_dir_entry.editingFinished.connect(self.set_directory_entry) self.num_projections_label = QLabel() self.num_projections_label.setText("Number of projections") self.num_projections_entry = QLineEdit() self.num_projections_entry.editingFinished.connect(self.set_num_projections) self.num_flats_label = QLabel() self.num_flats_label.setText("Number of flats") self.num_flats_entry = QLineEdit() self.num_flats_entry.editingFinished.connect(self.set_num_flats) self.num_darks_label = QLabel() self.num_darks_label.setText("Number of darks") self.num_darks_entry = QLineEdit() self.num_darks_entry.editingFinished.connect(self.set_num_darks) self.num_vert_steps_label = QLabel() self.num_vert_steps_label.setText("Number of CT sets in the sequence") self.num_vert_steps_entry = QLineEdit() self.num_vert_steps_entry.editingFinished.connect(self.set_num_steps) self.no_trailing_flats_darks_checkbox = QCheckBox() self.no_trailing_flats_darks_checkbox.setText("No trailing flats/darks") self.no_trailing_flats_darks_checkbox.stateChanged.connect(self.set_trailing_checkbox) self.filenames_without_padding_checkbox = QCheckBox() self.filenames_without_padding_checkbox.setText("File names without zero padding") self.filenames_without_padding_checkbox.stateChanged.connect(self.set_file_names_checkbox) self.help_button = QPushButton() self.help_button.setText("Help") self.help_button.clicked.connect(self.help_button_pressed) self.undo_button = QPushButton() self.undo_button.setText("Undo") self.undo_button.clicked.connect(self.undo_button_pressed) self.convert_button = QPushButton() self.convert_button.setText("Convert") self.convert_button.clicked.connect(self.convert_button_pressed) self.convert_button.setStyleSheet("color:royalblue;font-weight:bold") self.save_parameters_button = QPushButton("Save Parameters to File") self.save_parameters_button.clicked.connect(self.save_parameters_button_pressed) self.import_parameters_button = QPushButton("Import Parameters from File") self.import_parameters_button.clicked.connect(self.import_parameters_button_pressed) self.set_layout() def set_layout(self): layout = QGridLayout() layout.addWidget(self.input_dir_button, 0, 0, 1, 1) layout.addWidget(self.input_dir_entry, 0, 1, 1, 2) layout.addWidget(self.num_projections_label, 3, 0) layout.addWidget(self.num_projections_entry, 3, 1, 1, 2) layout.addWidget(self.num_flats_label, 4, 0) layout.addWidget(self.num_flats_entry, 4, 1, 1, 2) layout.addWidget(self.num_darks_label, 5, 0) layout.addWidget(self.num_darks_entry, 5, 1, 1, 2) layout.addWidget(self.num_vert_steps_label, 2, 0) layout.addWidget(self.num_vert_steps_entry, 2, 1, 1, 2) layout.addWidget(self.no_trailing_flats_darks_checkbox, 6, 0) layout.addWidget(self.filenames_without_padding_checkbox, 6, 1, 1, 2) layout.addWidget(self.help_button, 7, 0, 1, 1) layout.addWidget(self.undo_button, 7, 1, 1, 1) layout.addWidget(self.convert_button, 7, 2, 1, 1) # layout.addWidget(self.import_parameters_button, 7, 3, 1, 1) # layout.addWidget(self.save_parameters_button, 7, 4, 1, 1) self.setLayout(layout) def init_values(self): self.parameters = {'parameters_type': 'ez_mview'} self.input_dir_entry.setText(os.getcwd()) self.parameters['ezmview_input_dir'] = os.getcwd() self.num_projections_entry.setText("3000") self.parameters['ezmview_num_projections'] = 3000 self.num_flats_entry.setText("10") self.parameters['ezmview_num_flats'] = 10 self.num_darks_entry.setText("10") self.parameters['ezmview_num_darks'] = 10 self.num_vert_steps_entry.setText("1") self.parameters['ezmview_num_sets'] = 1 self.no_trailing_flats_darks_checkbox.setChecked(False) self.parameters['ezmview_flats2'] = False self.filenames_without_padding_checkbox.setChecked(False) self.parameters['ezmview_no_zero_padding'] = False def update_parameters(self, new_parameters): LOG.debug("Update parameters") if new_parameters['parameters_type'] != 'ez_mview': print("Error: Invalid parameter file type: " + str(new_parameters['parameters_type'])) return -1 # Update parameters dictionary (which is passed to auto_stitch_funcs) self.parameters = new_parameters # Update displayed parameters for GUI self.input_dir_entry.setText(str(self.parameters['ezmview_input_dir'])) self.num_projections_entry.setText(str(self.parameters['ezmview_num_projections'])) self.num_flats_entry.setText(str(self.parameters['ezmview_num_flats'])) self.num_darks_entry.setText(str(self.parameters['ezmview_num_darks'])) self.num_vert_steps_entry.setText(str(self.parameters['ezmview_num_sets'])) self.no_trailing_flats_darks_checkbox.setChecked(bool(self.parameters['ezmview_flats2'])) self.filenames_without_padding_checkbox.setChecked(bool(self.parameters['ezmview_no_zero_padding'])) def select_directory(self): LOG.debug("Select directory button pressed") dir_explore = QFileDialog(self) directory = dir_explore.getExistingDirectory() self.input_dir_entry.setText(directory) self.parameters['ezmview_input_dir'] = directory def set_directory_entry(self): LOG.debug("Directory entry: " + str(self.input_dir_entry.text())) self.parameters['ezmview_input_dir'] = str(self.input_dir_entry.text()) def set_num_projections(self): LOG.debug("Num projections: " + str(self.num_projections_entry.text())) self.parameters['ezmview_num_projections'] = int(self.num_projections_entry.text()) def set_num_flats(self): LOG.debug("Num flats: " + str(self.num_flats_entry.text())) self.parameters['ezmview_num_flats'] = int(self.num_flats_entry.text()) def set_num_darks(self): LOG.debug("Num darks: " + str(self.num_darks_entry.text())) self.parameters['ezmview_num_darks'] = int(self.num_darks_entry.text()) def set_num_steps(self): LOG.debug("Num steps: " + str(self.num_vert_steps_entry.text())) self.parameters['ezmview_num_sets'] = int(self.num_vert_steps_entry.text()) def set_trailing_checkbox(self): LOG.debug("No trailing: " + str(self.no_trailing_flats_darks_checkbox.isChecked())) self.parameters['ezmview_flats2'] = bool(self.no_trailing_flats_darks_checkbox.isChecked()) def set_file_names_checkbox(self): LOG.debug("File names without zero padding: " + str(self.filenames_without_padding_checkbox.isChecked())) self.parameters['ezmview_no_zero_padding'] = \ bool(self.filenames_without_padding_checkbox.isChecked()) def convert_button_pressed(self): LOG.debug("Convert button pressed") LOG.debug(self.parameters) main_prep(self.parameters) def undo_button_pressed(self): LOG.debug("Undo button pressed") cmd = "find {} -type f -name \"*.tif\" -exec mv -t {} {{}} +" cmd = cmd.format(str(self.parameters['ezmview_input_dir']), str(self.parameters['ezmview_input_dir'])) os.system(cmd) def help_button_pressed(self): LOG.debug("Help button pressed") h = "Distributes a sequence of CT frames in flats/darks/tomo/flats2 directories\n" h += "assuming that acqusition sequence is flats->darks->tomo->flats2\n" h += 'Use only for sequences with flat fields acquired at 0 and 180!\n' h += "Conversions happens in-place but can be undone" QMessageBox.information(self, "Help", h) def import_parameters_button_pressed(self): LOG.debug("Import params button clicked") dir_explore = QFileDialog(self) params_file_path = dir_explore.getOpenFileName(filter="*.yaml") try: file_in = open(params_file_path[0], 'r') new_parameters = yaml.load(file_in, Loader=yaml.FullLoader) if self.update_parameters(new_parameters) == 0: print("Parameters file loaded from: " + str(params_file_path[0])) except FileNotFoundError: print("You need to select a valid input file") def save_parameters_button_pressed(self): LOG.debug("Save params button clicked") dir_explore = QFileDialog(self) params_file_path = dir_explore.getSaveFileName(filter="*.yaml") garbage, file_name = os.path.split(params_file_path[0]) file_extension = os.path.splitext(file_name) # If the user doesn't enter the .yaml extension then append it to filepath if file_extension[-1] == "": file_path = params_file_path[0] + ".yaml" else: file_path = params_file_path[0] try: file_out = open(file_path, 'w') yaml.dump(self.parameters, file_out) print("Parameters file saved at: " + str(file_path)) except FileNotFoundError: print("You need to select a directory and use a valid file name") ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/Stitch_tools_tab/ezstitch_qt.py000066400000000000000000000721461521054151500250640ustar00rootroot00000000000000import os from PyQt5.QtWidgets import ( QGroupBox, QPushButton, QCheckBox, QLabel, QLineEdit, QGridLayout, QVBoxLayout, QHBoxLayout, QRadioButton, QFileDialog, QMessageBox, ) from shutil import rmtree import logging from tofu.ez.GUI.verify_delete import verify_safe2delete from tofu.ez.params import EZVARS_aux, EZVARS from tofu.ez.Helpers.stitch_funcs import ( main_sti_mp, main_360sti_ufol_depth1, find_vert_olap_2_vsteps, validate_slice_range, get_cube_dims, ) from tofu.ez.GUI.message_dialog import warning_message from tofu.ez.util import add_value_to_dict_entry, get_int_validator, get_double_validator, get_fd_names from tofu.ez.util import import_values, export_values, read_image, get_dims import glob LOG = logging.getLogger(__name__) class EZStitchGroup(QGroupBox): def __init__(self): super().__init__() self.setTitle("EZ-STITCH") self.setToolTip("Reslicing and stitching tool") self.setStyleSheet('QGroupBox {color: purple;}') self.invoke_after_reco_checkbox = QCheckBox(f"Automatically produce stitched orthogonal sections" f" when reconstruction is over") self.invoke_after_reco_checkbox.stateChanged.connect(self.conf_auto_stitch) #self.invoke_after_reco_checkbox.setToolTip("Only works for batch reconstructions with ") self.input_dir_button = QPushButton() self.input_dir_button.setText("Select input directory") self.input_dir_button.setToolTip("Normally contains a bunch of directories at the first depth level\n" \ "each of which has a subdirectory with the same name (second depth level). \n" "Images with the same index in these second-level subdirectories will be stitched vertically.") self.input_dir_button.clicked.connect(self.input_button_pressed) self.input_dir_entry = QLineEdit() self.input_dir_entry.editingFinished.connect(self.set_input_entry) self.tmp_dir_button = QPushButton() self.tmp_dir_button.setText("Select temporary directory") self.tmp_dir_button.clicked.connect(self.temp_button_pressed) self.tmp_dir_entry = QLineEdit() self.tmp_dir_entry.editingFinished.connect(self.set_temp_entry) self.output_dir_button = QPushButton() self.output_dir_button.setText("Directory to save stitched images") self.output_dir_button.clicked.connect(self.output_button_pressed) self.output_dir_entry = QLineEdit() self.output_dir_entry.editingFinished.connect(self.set_output_entry) self.types_of_images_label = QLabel() tmpstr = "Name of subdirectories which contain the same type of images in every directory in the input" self.types_of_images_label.setToolTip(tmpstr) self.types_of_images_label.setText("Name of subdirectory with the same type of images to stitch") self.types_of_images_label.setToolTip("e.g. sli, tomo, proj-pr, etc.") self.types_of_images_entry = QLineEdit() self.types_of_images_entry.setToolTip(tmpstr) self.types_of_images_entry.editingFinished.connect(self.set_type_images) self.orthogonal_checkbox = QCheckBox() self.orthogonal_checkbox.setText("Stitch orthogonal sections") self.orthogonal_checkbox.setToolTip("Will reslice images in every subdirectory and then stitch") self.orthogonal_checkbox.stateChanged.connect(self.set_ort_checkbox) self.reslice_all = QCheckBox() self.reslice_all.setText("Reslice whole cube") self.reslice_all.stateChanged.connect(self.set_reslice_all) self.start_stop_step_label = QLabel() self.start_stop_step_label.setText("Which images to be stitched: start,stop,step:") self.start_stop_step_entry = QLineEdit() self.start_stop_step_entry.editingFinished.connect(self.get_start_stop_step) help_flip = f"There can be two options:\n" \ f"(1) first slice from z00 directory overlaps with " \ f"one of slices from the end of z01 directory \n" \ f"or\n" \ f"(2) last slice from z00 directory overlaps with " \ f"one of slices from the beginning of z01 directory. \n" \ f"In the former case images must be flipped upside " \ f"down before stitching" self.flipud_checkbox = QCheckBox() self.flipud_checkbox.setToolTip(help_flip) self.flipud_checkbox.setText("Flip images upside down before stitching") self.flipud_checkbox.stateChanged.connect(self.set_flipud) self.interpolate_regions_rButton = QRadioButton() self.interpolate_regions_rButton.setText("Interpolate overlapping regions and equalize intensity") self.interpolate_regions_rButton.clicked.connect(self.set_rButton) self.num_overlaps_label = QLabel() self.num_overlaps_label.setText("Number of overlapping rows") self.num_overlaps_entry = QLineEdit() self.num_overlaps_entry.editingFinished.connect(self.set_overlap) self.est_olap_checkbox = QCheckBox() self.est_olap_checkbox.setText("Estimate vertical overlap automatically. " "Will take a slice from z00 directory " "and compare it with selected slices in z01 directory") self.est_olap_checkbox.setToolTip(help_flip) self.est_olap_checkbox.stateChanged.connect(self.set_est_olap) self.slice_z00_label = QLabel() self.slice_z00_label.setText("Index of slice in z00 directory") self.slice_z00_entry = QLineEdit() self.slice_z00_entry.editingFinished.connect(self.get_z00_ind) self.slice_z00_entry.setToolTip(help_flip) self.est_olap_button = QPushButton() self.est_olap_button.setText("Show estimate") self.est_olap_button.setStyleSheet("font-weight:bold") self.est_olap_button.clicked.connect(self.est_olap_button_pressed) self.slice_range_label = QLabel() self.slice_range_label.setText("Search range in z01 directory") self.ind_start_z01_label = QLabel() self.ind_start_z01_label.setText(" First slice") #self.ind_start_z01_label.setAlignment(Qt.AlignRight) self.ind_start_z01_entry = QLineEdit() self.ind_start_z01_entry.editingFinished.connect(self.get_z01_ind_start) self.ind_start_z01_entry.setToolTip(help_flip) self.ind_stop_z01_label = QLabel() self.ind_stop_z01_label.setText(" Last slice") self.ind_stop_z01_entry = QLineEdit() self.ind_stop_z01_entry.editingFinished.connect(self.get_z01_ind_stop) self.ind_stop_z01_entry.setToolTip(help_flip) self.clip_histogram_checkbox = QCheckBox() self.clip_histogram_checkbox.setText("Clip histogram and convert slices to 8-bit before saving") self.clip_histogram_checkbox.stateChanged.connect(self.set_histogram_checkbox) self.min_value_label = QLabel() self.min_value_label.setText("Min value in 32-bit histogram") self.min_value_entry = QLineEdit() self.min_value_entry.editingFinished.connect(self.set_min_value) self.max_value_label = QLabel() self.max_value_label.setText("Max value in 32-bit histogram") self.max_value_entry = QLineEdit() self.max_value_entry.editingFinished.connect(self.set_max_value) self.concatenate_rButton = QRadioButton() self.concatenate_rButton.setText("Concatenate only") self.concatenate_rButton.clicked.connect(self.set_rButton) self.first_row_label = QLabel() self.first_row_label.setText("First row") self.first_row_entry = QLineEdit() self.first_row_entry.editingFinished.connect(self.set_first_row) self.last_row_label = QLabel() self.last_row_label.setText("Last row") self.last_row_entry = QLineEdit() self.last_row_entry.editingFinished.connect(self.set_last_row) self.half_acquisition_rButton = QRadioButton() self.half_acquisition_rButton.setText("Horizontal stitching of half-acq. mode data") self.half_acquisition_rButton.setToolTip("Applies to tif images in all depth-one subdirectories in the Input \n" "unlike 360-MULTI-STITCH which search images at the depth two ") #self.half_acquisition_rButtonYfor a half-acqusition mode data (even number of tif files in the Input directory)") self.half_acquisition_rButton.clicked.connect(self.set_rButton) self.column_of_axis_label = QLabel() self.column_of_axis_label.setText("In which column the axis of rotation is") self.column_of_axis_entry = QLineEdit() self.column_of_axis_entry.editingFinished.connect(self.set_axis_column) self.help_button = QPushButton() self.help_button.setText("Help") self.help_button.clicked.connect(self.help_button_pressed) self.delete_button = QPushButton() self.delete_button.setText("Delete output dir") self.delete_button.clicked.connect(self.delete_button_pressed) self.stitch_button = QPushButton() self.stitch_button.setText("Stitch") self.stitch_button.clicked.connect(self.stitch_button_pressed) self.stitch_button.setStyleSheet("color:royalblue;font-weight:bold") self.import_parameters_button = QPushButton("Import Parameters from File") self.import_parameters_button.clicked.connect(self.import_parameters_button_pressed) self.save_parameters_button = QPushButton("Save Parameters to File") self.save_parameters_button.clicked.connect(self.save_parameters_button_pressed) self.err = "" self.set_layout() def set_layout(self): layout = QGridLayout() vbox1 = QVBoxLayout() vbox1.addWidget(self.invoke_after_reco_checkbox) vbox1.addWidget(self.input_dir_button) vbox1.addWidget(self.input_dir_entry) vbox1.addWidget(self.tmp_dir_button) vbox1.addWidget(self.tmp_dir_entry) vbox1.addWidget(self.output_dir_button) vbox1.addWidget(self.output_dir_entry) layout.addItem(vbox1, 0, 0) grid0 = QGridLayout() grid0.addWidget(self.types_of_images_label, 0, 0) grid0.addWidget(self.types_of_images_entry, 0, 1) grid0.addWidget(self.orthogonal_checkbox, 1, 0) grid0.addWidget(self.reslice_all, 2, 0) grid0.addWidget(self.start_stop_step_label, 1, 1) grid0.addWidget(self.start_stop_step_entry, 2, 1) grid0.addWidget(self.flipud_checkbox, 3, 0) layout.addItem(grid0, 1, 0) grid1 = QGridLayout() grid1.addWidget(self.interpolate_regions_rButton, 0, 0, 1, 5) grid1.addWidget(self.num_overlaps_label, 1, 0) grid1.addWidget(self.num_overlaps_entry, 1, 1) grid1.addWidget(self.est_olap_checkbox, 2, 0, 1, 5) grid1.addWidget(self.slice_z00_label, 3, 0) grid1.addWidget(self.slice_z00_entry, 3, 1) grid1.addWidget(self.est_olap_button, 3, 3, 1, 2) grid1.addWidget(self.slice_range_label, 4, 0) grid1.addWidget(self.ind_start_z01_label, 4, 1) grid1.addWidget(self.ind_start_z01_entry, 4, 2) grid1.addWidget(self.ind_stop_z01_label, 4, 3) grid1.addWidget(self.ind_stop_z01_entry, 4, 4) layout.addItem(grid1, 2, 0) grid1_b = QGridLayout() grid1_b.addWidget(self.clip_histogram_checkbox, 0, 0) grid1_b.addWidget(self.min_value_label, 1, 0) grid1_b.addWidget(self.min_value_entry, 1, 1) grid1_b.addWidget(self.max_value_label, 2, 0) grid1_b.addWidget(self.max_value_entry, 2, 1) layout.addItem(grid1_b, 3, 0) grid2 = QGridLayout() grid2.addWidget(self.concatenate_rButton, 0, 0, 1, 2) grid2.addWidget(self.first_row_label, 1, 0) grid2.addWidget(self.first_row_entry, 1, 1) grid2.addWidget(self.last_row_label, 1, 2) grid2.addWidget(self.last_row_entry, 1, 3) layout.addItem(grid2, 4, 0) grid3 = QGridLayout() grid3.addWidget(self.half_acquisition_rButton, 0, 0, 1, 2) grid3.addWidget(self.column_of_axis_label, 1, 0) grid3.addWidget(self.column_of_axis_entry, 1, 1) layout.addItem(grid3, 5, 0) grid4 = QGridLayout() grid4.addWidget(self.help_button, 0, 0) grid4.addWidget(self.delete_button, 0, 1) grid4.addWidget(self.stitch_button, 0, 2) grid4.addWidget(self.import_parameters_button, 1, 0, 1, 2) grid4.addWidget(self.save_parameters_button, 1, 2) layout.addItem(grid4, 6, 0) self.setLayout(layout) def load_values(self): self.invoke_after_reco_checkbox.blockSignals(True) self.invoke_after_reco_checkbox.setChecked(EZVARS_aux['vert-sti']['dovertsti']['value']) self.invoke_after_reco_checkbox.blockSignals(False) self.input_dir_entry.setText(str(EZVARS_aux['vert-sti']['input-dir']['value'])) self.tmp_dir_entry.setText(str(EZVARS_aux['vert-sti']['tmp-dir']['value'])) self.output_dir_entry.setText(str(EZVARS_aux['vert-sti']['output-dir']['value'])) self.types_of_images_entry.setText(str(EZVARS_aux['vert-sti']['subdir-name']['value'])) self.orthogonal_checkbox.setChecked(EZVARS_aux['vert-sti']['ort']['value']) self.start_stop_step_entry.setText(f"{EZVARS_aux['vert-sti']['start']['value']}," f"{EZVARS_aux['vert-sti']['stop']['value']}," f"{EZVARS_aux['vert-sti']['step']['value']}") self.reslice_all.setChecked(EZVARS_aux['vert-sti']['reslice_all']['value']) self.set_reslice_all() self.flipud_checkbox.setChecked(EZVARS_aux['vert-sti']['flipud']['value']) if EZVARS_aux['vert-sti']['task_type']['value'] == 0: self.interpolate_regions_rButton.setChecked(True) elif EZVARS_aux['vert-sti']['task_type']['value'] == 1: self.concatenate_rButton.setChecked(True) elif EZVARS_aux['vert-sti']['task_type']['value'] == 2: self.half_acquisition_rButton.setChecked(True) self.num_overlaps_entry.setText(str(EZVARS_aux['vert-sti']['num_olap_rows']['value'])) self.est_olap_checkbox.setChecked(EZVARS_aux['vert-sti']['estimate_num_olap_rows']['value']) self.slice_z00_entry.setText(str(EZVARS_aux['vert-sti']['ind_z00']['value'])) self.ind_start_z01_entry.setText(str(EZVARS_aux['vert-sti']['ind_z01_start']['value'])) self.ind_stop_z01_entry.setText(str(EZVARS_aux['vert-sti']['ind_z01_stop']['value'])) self.clip_histogram_checkbox.setChecked(EZVARS_aux['vert-sti']['clip_hist']['value']) self.min_value_entry.setText(str(EZVARS_aux['vert-sti']['min_int_val']['value'])) self.max_value_entry.setText(str(EZVARS_aux['vert-sti']['max_int_val']['value'])) self.first_row_entry.setText(str(EZVARS_aux['vert-sti']['conc_row_top']['value'])) self.last_row_entry.setText(str(EZVARS_aux['vert-sti']['conc_row_bottom']['value'])) self.column_of_axis_entry.setText(str(EZVARS_aux['vert-sti']['cor']['value'])) self.conf_auto_stitch() #TODO: change output/tmp on signal from the main tab if auto sttiched is requested # and respective entries are modified in the main tab def conf_auto_stitch(self): if self.invoke_after_reco_checkbox.isChecked(): add_value_to_dict_entry(EZVARS_aux['vert-sti']['dovertsti'], True) self.input_dir_entry.setEnabled(False) if self.output_dir_entry.text() in ( EZVARS['inout']['output-dir']['value'], EZVARS_aux['vert-sti']['output-dir']['ezdefault']): self.output_dir_entry.setText(f"{EZVARS['inout']['output-dir']['value']}-vert-stitched") self.set_output_entry() self.tmp_dir_entry.setText(f"{EZVARS['inout']['tmp-dir']['value']}") self.set_temp_entry() self.types_of_images_entry.setText('sli') self.set_type_images() self.types_of_images_entry.setEnabled(False) self.half_acquisition_rButton.setEnabled(False) self.column_of_axis_entry.setEnabled(False) else: add_value_to_dict_entry(EZVARS_aux['vert-sti']['dovertsti'], False) self.input_dir_entry.setEnabled(True) self.types_of_images_entry.setEnabled(True) self.half_acquisition_rButton.setEnabled(True) self.column_of_axis_entry.setEnabled(True) def set_rButton(self): if self.interpolate_regions_rButton.isChecked(): LOG.debug("Interpolate regions") add_value_to_dict_entry(EZVARS_aux['vert-sti']['task_type'], 0) elif self.concatenate_rButton.isChecked(): LOG.debug("Concatenate only") add_value_to_dict_entry(EZVARS_aux['vert-sti']['task_type'], 1) elif self.half_acquisition_rButton.isChecked(): LOG.debug("Half-acquisition mode") add_value_to_dict_entry(EZVARS_aux['vert-sti']['task_type'], 2) def input_button_pressed(self): LOG.debug("Input button pressed") dir_explore = QFileDialog(self) add_value_to_dict_entry(EZVARS_aux['vert-sti']['input-dir'], dir_explore.getExistingDirectory()) self.input_dir_entry.setText(EZVARS_aux['vert-sti']['input-dir']['value']) def set_input_entry(self): LOG.debug("Input: " + str(self.input_dir_entry.text())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['input-dir'], str(self.input_dir_entry.text())) def temp_button_pressed(self): LOG.debug("Temp button pressed") dir_explore = QFileDialog(self) add_value_to_dict_entry(EZVARS_aux['vert-sti']['tmp-dir'], dir_explore.getExistingDirectory()) self.tmp_dir_entry.setText(EZVARS_aux['vert-sti']['tmp-dir']['value']) def set_temp_entry(self): LOG.debug("Temp: " + str(self.tmp_dir_entry.text())) EZVARS_aux['vert-sti']['tmp-dir']['value'] = str(self.tmp_dir_entry.text()) add_value_to_dict_entry(EZVARS_aux['vert-sti']['tmp-dir'], str(self.tmp_dir_entry.text())) def output_button_pressed(self): LOG.debug("Output button pressed") dir_explore = QFileDialog(self) add_value_to_dict_entry(EZVARS_aux['vert-sti']['output-dir'], dir_explore.getExistingDirectory()) self.output_dir_entry.setText(EZVARS_aux['vert-sti']['output-dir']['value']) def set_output_entry(self): LOG.debug("Output: " + str(self.output_dir_entry.text())) #EZVARS_aux['vert-sti']['output-dir']['value'] = str(self.output_dir_entry.text()) add_value_to_dict_entry(EZVARS_aux['vert-sti']['output-dir'], str(self.output_dir_entry.text())) def set_type_images(self): LOG.debug("Type of images: " + str(self.types_of_images_entry.text())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['subdir-name'], str(self.types_of_images_entry.text())) def set_ort_checkbox(self): LOG.debug("Stitch orthogonal: " + str(self.orthogonal_checkbox.isChecked())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['ort'], bool(self.orthogonal_checkbox.isChecked())) def get_start_stop_step(self): LOG.debug("Images to be stitched: " + str(self.start_stop_step_entry.text())) sli_range = str(self.start_stop_step_entry.text()).split(",") add_value_to_dict_entry(EZVARS_aux['vert-sti']['start'], sli_range[0]) add_value_to_dict_entry(EZVARS_aux['vert-sti']['stop'], sli_range[1]) add_value_to_dict_entry(EZVARS_aux['vert-sti']['step'], sli_range[2]) def set_reslice_all(self): add_value_to_dict_entry(EZVARS_aux['vert-sti']['reslice_all'], bool(self.reslice_all.isChecked())) if self.reslice_all.isChecked(): self.start_stop_step_entry.setEnabled(False) else: self.start_stop_step_entry.setEnabled(True) def set_flipud(self): LOG.debug("Sample moved down: " + str(self.flipud_checkbox.isChecked())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['flipud'], bool(self.flipud_checkbox.isChecked())) def set_est_olap(self): estimate_overlap = bool(self.est_olap_checkbox.isChecked()) LOG.debug("Estimate vertical overlap automatically: " + str(estimate_overlap)) add_value_to_dict_entry(EZVARS_aux['vert-sti']['estimate_num_olap_rows'], estimate_overlap) self.num_overlaps_entry.setDisabled(estimate_overlap) def set_overlap(self): LOG.debug("Num overlapping rows: " + str(self.num_overlaps_entry.text())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['num_olap_rows'], int(self.num_overlaps_entry.text())) def set_histogram_checkbox(self): LOG.debug("Clip histogram: " + str(self.clip_histogram_checkbox.isChecked())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['clip_hist'], bool(self.clip_histogram_checkbox.isChecked())) def set_min_value(self): LOG.debug("Min value: " + str(self.min_value_entry.text())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['min_int_val'], float(self.min_value_entry.text())) def set_max_value(self): LOG.debug("Max value: " + str(self.max_value_entry.text())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['max_int_val'], float(self.max_value_entry.text())) def set_first_row(self): LOG.debug("First row: " + str(self.first_row_entry.text())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['conc_row_top'], int(self.first_row_entry.text())) def set_last_row(self): LOG.debug("Last row: " + str(self.last_row_entry.text())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['conc_row_bottom'], int(self.last_row_entry.text())) def set_axis_column(self): LOG.debug("Column of axis: " + str(self.column_of_axis_entry.text())) add_value_to_dict_entry(EZVARS_aux['vert-sti']['cor'], int(self.column_of_axis_entry.text())) def get_z00_ind(self): add_value_to_dict_entry(EZVARS_aux['vert-sti']['ind_z00'], int(self.slice_z00_entry.text())) def get_z01_ind_start(self): add_value_to_dict_entry(EZVARS_aux['vert-sti']['ind_z01_start'], int(self.ind_start_z01_entry.text())) def get_z01_ind_stop(self): add_value_to_dict_entry(EZVARS_aux['vert-sti']['ind_z01_stop'], int(self.ind_stop_z01_entry.text())) def validate_row_entries(self): self.validate_input_structure_1set() nslices, N, M = get_cube_dims() if EZVARS_aux['vert-sti']['ind_z01_stop']['value'] > nslices: QMessageBox.warning(self, "Error", f'Stop index of the search range ' f'exceeds the total number of slices (max {nslices})') return 1 elif EZVARS_aux['vert-sti']['ind_z01_start']['value'] > nslices: QMessageBox.warning(self, "Error", f'Start index of the search range ' f'exceeds the total number of slices (max {nslices})') return 1 elif EZVARS_aux['vert-sti']['ind_z00']['value'] > nslices: QMessageBox.warning(self, "Error", f'Index of the reference slice ' f'exceeds the total number of slices (max {nslices})') return 1 return 0 def validate_input_structure_1set(self): Vsteps = sorted(os.listdir(EZVARS_aux['vert-sti']['input-dir']['value'])) for i in range(len(Vsteps)): if not os.path.exists(os.path.join(EZVARS_aux['vert-sti']['input-dir']['value'], Vsteps[i], EZVARS_aux['vert-sti']['subdir-name']['value'])): h = "Unacceptable input directory structure.\n" h += "Check that your Input only contains directories\n" h += "each of which has a subdirectory with CT slices, e.g.\n" tmp = EZVARS_aux['vert-sti']['subdir-name']['value'] h += f"Input/z00/{tmp}, Input/z01/{tmp} .. Input/z0N/{tmp}" QMessageBox.warning(self, "Error", h) return 1 return 0 def est_olap_button_pressed(self): if self.validate_input_structure_1set() or self.validate_row_entries(): return olap = find_vert_olap_2_vsteps(EZVARS_aux['vert-sti']['input-dir']['value'], EZVARS_aux['vert-sti']['ind_z00']['value'], EZVARS_aux['vert-sti']['ind_z01_start']['value'], EZVARS_aux['vert-sti']['ind_z01_stop']['value']) tmp = f"Number of overlapping lines is {olap}." QMessageBox.information(self, "Overlap estimate", tmp) def help_button_pressed(self): LOG.debug("Help button pressed") h = "Stitches images vertically\n" h += "Directory structure is, f.i., Input/000, Input/001,...Input/00N\n" h += "Each 000, 001, ... 00N directory must have identical subdirectory \"Type\"\n" h += "Selected range of images from \"Type\" directory will be stitched vertically\n" h += "across all subdirectories in the Input directory" h += "to be added as options:\n" h += "(1) orthogonal reslicing, (2) interpolation, (3) horizontal stitching" QMessageBox.information(self, "Help", h) def delete_button_pressed(self): if os.path.exists(EZVARS_aux['vert-sti']['output-dir']['value']): qm = QMessageBox() rep = qm.question(self, '', f"{EZVARS_aux['vert-sti']['output-dir']['value']} \n" "will be removed. Continue?", qm.Yes | qm.No) if rep == qm.Yes: try: rmtree(EZVARS_aux['vert-sti']['output-dir']['value']) except: warning_message('Error while deleting directory') return else: return def stitch_button_pressed(self): LOG.debug("Stitch button pressed") try: verify_safe2delete(self, EZVARS_aux['vert-sti']['tmp-dir']['value'], "Temporary") except FileExistsError: return try: verify_safe2delete(self, EZVARS_aux['vert-sti']['output-dir']['value'], "Output") except FileExistsError: return # if self.validate_input_structure_1set() or self.validate_row_entries(): # return print("======= Begin Stitching =======") # Interpolate overlapping regions and equalize intensity if EZVARS_aux['vert-sti']['task_type']['value'] == 0 or \ EZVARS_aux['vert-sti']['task_type']['value'] == 1: try: validate_slice_range() except ValueError as e: LOG.error(e) warning_message("Problem with validating slice range: cannot read dimensions of Input slices.") return main_sti_mp() else: # main_360_mp_depth1(self.parameters['ezstitch_input_dir'], # EZVARS_aux['vert-sti']['output-dir']['value'], # self.parameters['ezstitch_axis_of_rotation'], 0) reduction_mode = EZVARS['flat-correction']['reduction-mode']['value'] fd_names = get_fd_names() main_360sti_ufol_depth1(EZVARS_aux['vert-sti']['input-dir']['value'], EZVARS_aux['vert-sti']['output-dir']['value'], EZVARS_aux['vert-sti']['cor']['value'], cro=0, reduction_mode=reduction_mode, fd_names=fd_names, ) if os.path.isdir(EZVARS_aux['vert-sti']['output-dir']['value']): params_file_path = os.path.join(EZVARS_aux['vert-sti']['output-dir']['value'], 'ezmview_params.yaml') export_values(params_file_path, ['ezvars_aux']) print("==== Waiting for Next Task ====") def import_parameters_button_pressed(self): LOG.debug("Import params button clicked") dir_explore = QFileDialog(self) params_file_path = dir_explore.getOpenFileName(filter="*.yaml") import_values(params_file_path[0], ['ezvars_aux']) self.load_values() def save_parameters_button_pressed(self): LOG.debug("Save params button clicked") dir_explore = QFileDialog(self) params_file_path = dir_explore.getSaveFileName(filter="*.yaml") garbage, file_name = os.path.split(params_file_path[0]) file_extension = os.path.splitext(file_name) # If the user doesn't enter the .yaml extension then append it to filepath if file_extension[-1] == "": file_path = params_file_path[0] + ".yaml" else: file_path = params_file_path[0] try: export_values(file_path, ['ezvars_aux']) print("Parameters file saved at: " + str(file_path)) except FileNotFoundError: print("You need to select a directory and use a valid file name") ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/__init__.py000066400000000000000000000000011521054151500207330ustar00rootroot00000000000000 ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/ezufo_launcher.py000066400000000000000000000321411521054151500222170ustar00rootroot00000000000000import logging import os import sys try: from PyQt5 import QtWidgets as qtw except ModuleNotFoundError: raise ModuleNotFoundError("Cannot import modules for ez, please install tofu with [ez] extras.") from tofu.ez.GUI.Main.centre_of_rotation import CentreOfRotationGroup from tofu.ez.GUI.Main.filters import FiltersGroup from tofu.ez.GUI.Advanced.ffc import FFCGroup from tofu.ez.GUI.Advanced.find_large_spots import FindSpotsGroup from tofu.ez.GUI.Main.phase_retrieval import PhaseRetrievalGroup from tofu.ez.GUI.Main.region_and_histogram import ROIandHistGroup from tofu.ez.GUI.Main.config import ConfigGroup from tofu.ez.main import clean_tmp_dirs from tofu.ez.GUI.image_viewer import ImageViewerGroup from tofu.ez.params import EZVARS, EZVARS_aux from tofu.config import SECTIONS from tofu.ez.util import load_values_from_ezdefault, get_fdt_names from tofu.ez.GUI.Advanced.advanced import AdvancedGroup from tofu.ez.GUI.Advanced.optimization import OptimizationGroup from tofu.ez.GUI.Advanced.nlmdn import NLMDNGroup from tofu.ez.GUI.Stitch_tools_tab.ez_360_multi_stitch_qt import MultiStitch360Group from tofu.ez.GUI.Stitch_tools_tab.ezstitch_qt import EZStitchGroup from tofu.ez.GUI.Stitch_tools_tab.ezmview_qt import EZMViewGroup from tofu.ez.GUI.Stitch_tools_tab.ez_360_overlap_qt import Overlap360Group from tofu.ez.GUI.Advanced.Batch360 import Batch360Group # from tofu.ez.Helpers.batch_search_stitch_360 import Batcher from tofu.ez.GUI.login_dialog import Login LOG = logging.getLogger(__name__) class GUI(qtw.QWidget): """ Creates main GUI and sets defaults and creates some signals. First, load_vales_from_ezdefault has to be called in order to create ['value'] key for every parameter in EZVAR and SECTIONS dictionaries. Each element of those dictionaries is supposed to have 'ezdefault' key which is used to set the default value. These ['value']s will be used to set default values of all entries in GUI by calling load_value function in respective QGroupBoxes. These values can be changed by user and editing of each Qt feature must trigger set_ method in respective QGroupBoxe to update the ['value']. These dictionaries can be exported to yaml file and when imported later will be assigned to Qt GUI entries and to ['values'] of EZVAR and CONFIG dictionaries. When parameters are imported a signal is emitted which calls update_values function defined below which has to call load_values which must exist in every groupbox in the GUI """ def __init__(self, *args, **kwargs): super(GUI, self).__init__(*args, **kwargs) self.setWindowTitle("EZ-UFO") self.setStyleSheet("font: 10pt; font-family: Arial") # initialize dictionary entries load_values_from_ezdefault(EZVARS) load_values_from_ezdefault(SECTIONS) load_values_from_ezdefault(EZVARS_aux) # Call login dialog # self.login_parameters = {} # QTimer.singleShot(0, self.login) # Initialize tab screen self.tabs = qtw.QTabWidget() self.tab1 = qtw.QWidget() self.tab2 = qtw.QWidget() self.tab3 = qtw.QWidget() self.tab4 = qtw.QWidget() self.tab5 = qtw.QWidget() self.tab6 = qtw.QWidget() # Create and setup classes for each section of GUI # Main Tab self.config_group = ConfigGroup() self.config_group.load_values() self.centre_of_rotation_group = CentreOfRotationGroup() self.centre_of_rotation_group.load_values() self.filters_group = FiltersGroup() self.filters_group.load_values() self.ffc_group = FFCGroup() self.ffc_group.load_values() self.phase_retrieval_group = PhaseRetrievalGroup() self.phase_retrieval_group.load_values() self.binning_group = ROIandHistGroup() self.binning_group.load_values() # Image Viewer self.image_group = ImageViewerGroup() # Advanced Tab self.advanced_group = AdvancedGroup() self.advanced_group.load_values() self.optimization_group = OptimizationGroup() self.optimization_group.load_values() self.nlmdn_group = NLMDNGroup() self.nlmdn_group.load_values() self.find_spots_group = FindSpotsGroup() self.find_spots_group.load_values() self.batch360_group = Batch360Group() self.batch360_group.load_values() # Stitch_tools_tab Tab # ----((P)Completed up to here) ----# self.multi_stitch_group = MultiStitch360Group() self.multi_stitch_group.load_values() self.ezmview_group = EZMViewGroup() self.ezmview_group.init_values() self.ezstitch_group = EZStitchGroup() self.ezstitch_group.load_values() self.overlap_group = Overlap360Group() self.overlap_group.load_values() ####################################################### self.set_layout() self.resize(0, 0) # window to minimum size # When new settings are imported signal is sent and this catches it to update params for each GUI object self.config_group.signal_update_vals_from_params.connect(self.update_values) # When RECO is done send signal from config self.config_group.signal_reco_done.connect(self.switch_to_image_tab) # To pass directory names from config tab to stitch tab when button pressed self.multi_stitch_group.get_fdt_names_on_stitch_pressed.connect(self.config_group.set_fdt_names) self.overlap_group.get_fdt_names_on_stitch_pressed.connect(self.config_group.set_fdt_names) # To pass RR params from filters section to 360-search tab when button pressed self.overlap_group.get_RR_params_on_start_pressed.connect( self.filters_group.set_ufoRR_params_for_360_axis_search) # Enable GroupBoxes in Advanced setting when certain options are # selected in the Main tab self.filters_group.mask_method_median_checked.connect( self.find_spots_group.enable_by_trigger_from_main_tab ) self.centre_of_rotation_group.enable_360Batch_Group_in_Advanced.connect( self.batch360_group.enable_by_trigger_from_main_tab ) self.centre_of_rotation_group.enable_360Batch_Group_in_Advanced.connect( self.overlap_group.enable_by_trigger_from_main_tab ) # self.batch360_group.imported_good_list_signal.connect( # self.update_overlap_list # ) finish = qtw.QAction("Quit", self) finish.triggered.connect(self.closeEvent) self.show() def set_layout(self): """ Set the layout of groups/tabs for the overall application layout """ layout = qtw.QVBoxLayout(self) main_layout = qtw.QGridLayout() main_layout.addWidget(self.centre_of_rotation_group, 0, 0) main_layout.addWidget(self.filters_group, 0, 1) main_layout.addWidget(self.phase_retrieval_group, 1, 0) main_layout.addWidget(self.binning_group, 1, 1) main_layout.addWidget(self.config_group, 2, 0, 2, 0) image_layout = qtw.QGridLayout() image_layout.addWidget(self.image_group, 0, 0) advanced_layout = qtw.QGridLayout() #advanced_layout.addWidget(self.batch360_group, 0, 0) advanced_layout.addWidget(self.find_spots_group, 0, 0) advanced_layout.addWidget(self.advanced_group, 1, 0) advanced_layout.addWidget(self.optimization_group, 1, 1) advanced_layout.addWidget(self.nlmdn_group, 0, 1) advanced_layout.addWidget(self.ffc_group, 2, 0) advanced_layout.addWidget(self.ezmview_group, 2, 1) helpers_layout = qtw.QGridLayout() helpers_layout.addWidget(self.overlap_group, 0, 0, 2, 1) helpers_layout.addWidget(self.batch360_group, 3, 1, 1, 1) helpers_layout.addWidget(self.multi_stitch_group, 2, 0, 2, 1) helpers_layout.addWidget(self.ezstitch_group, 0, 1, 3, 1) # Add tabs self.tabs.addTab(self.tab1, "Main") self.tabs.addTab(self.tab2, "Reco+") self.tabs.addTab(self.tab3, "Stitching tools") self.tabs.addTab(self.tab5, "Image Viewer") # Create main tab self.tab1.layout = main_layout self.tab1.setLayout(self.tab1.layout) # Create advanced tab self.tab2.layout = advanced_layout self.tab2.setLayout(self.tab2.layout) # Create helpers tab self.tab3.layout = helpers_layout self.tab3.setLayout(self.tab3.layout) # Create image tab self.tab5.layout = image_layout self.tab5.setLayout(self.tab5.layout) # Add tabs to widget layout.addWidget(self.tabs) self.setLayout(layout) def update_values(self): """ Updates displayed values when loaded in from external .yaml file of parameters """ LOG.debug("Update Values from dictionary entries") self.centre_of_rotation_group.load_values() self.filters_group.load_values() self.find_spots_group.load_values() self.ffc_group.load_values() self.phase_retrieval_group.load_values() self.binning_group.load_values() self.config_group.load_values() self.nlmdn_group.load_values() self.advanced_group.load_values() self.optimization_group.load_values() self.overlap_group.load_values() self.batch360_group.load_values() self.ezstitch_group.load_values() def switch_to_image_tab(self): """ Function is called after reconstruction when checkbox "Load images and open viewer after reconstruction" is enabled Automatically loads images from the output reconstruction directory for viewing """ if EZVARS['inout']['open-viewer']['value'] is True: LOG.debug("Switch to Image Tab") self.tabs.setCurrentWidget(self.tab2) if os.path.isdir(str(EZVARS['inout']['output-dir']['value'] + '/sli')): files = os.listdir(str(EZVARS['inout']['output-dir']['value'] + '/sli')) #Start thread here to load images ##CHECK IF ONLY SINGLE IMAGE THEN USE OPEN IMAGE -- OTHERWISE OPEN STACK if len(files) == 1: print("Only one file in {}: Opening single image {}". format(EZVARS['inout']['output-dir']['value'] + '/sli', files[0])) filePath = str(EZVARS['inout']['output-dir']['value'] + '/sli/' + str(files[0])) self.image_group.open_image_from_filepath(filePath) else: print("Multiple files in {}: Opening stack of images". format(str(EZVARS['inout']['output-dir']['value'] + '/sli'))) self.image_group.open_stack_from_path( str(EZVARS['inout']['output-dir']['value'] + '/sli')) else: print("No output directory found") def closeEvent(self, event): """ Creates verification message box Cleans up temporary directories when user quits application """ logging.debug("QUIT") reply = qtw.QMessageBox.question(self, 'Quit', 'Are you sure you want to quit?', qtw.QMessageBox.Yes | qtw.QMessageBox.No, qtw.QMessageBox.No) if reply == qtw.QMessageBox.Yes: # remove all directories with projections clean_tmp_dirs(EZVARS['inout']['tmp-dir']['value'], get_fdt_names()) # remove axis-search dir too tmp = os.path.join(EZVARS['inout']['tmp-dir']['value'], 'axis-search') event.accept() else: event.ignore() def login(self): login_dialog = Login(self.login_parameters) if login_dialog.exec_() != qtw.QDialog.Accepted: self.exit() else: #self.file_writer_group.root_dir_entry.setText(self.login_parameters['expdir']) self.config_group.input_dir_entry.setText(self.login_parameters['expdir'] + "/raw") self.config_group.set_input_dir() self.config_group.output_dir_entry.setText(self.login_parameters['expdir'] + "/rec") self.config_group.set_output_dir() ''' td = date.today() tdstr = "{}.{}.{}".format(td.year, td.month, td.day) logfname = os.path.join(self.login_parameters['expdir'], 'exp-log-' + tdstr + '.log') if self.login_parameters.has_key('project'): logfname = os.path.join(self.login_parameters['expdir'], '{}-log-{}-{}.log'. format(self.login_parameters['project'], self.login_parameters['bl'], tdstr)) try: open(logfname, 'a').close() except: warning_message('Cannot create log file in the selected directory. \n' 'Check permissions and restart.') self.exit() ''' def exit(self): self.close() def main_qt(args=None): app = qtw.QApplication(sys.argv) window = GUI() sys.exit(app.exec_()) if __name__ == "__main__": main_qt() ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/image_viewer.py000066400000000000000000000357521521054151500216640ustar00rootroot00000000000000import os import logging import pyqtgraph as pg import numpy as np import tifffile from PyQt5.QtWidgets import ( QPushButton, QGroupBox, QLabel, QDoubleSpinBox, QRadioButton, QScrollBar, QVBoxLayout, QGridLayout, QFileDialog, QMessageBox, ) from PyQt5.QtCore import Qt import tofu.ez.image_read_write as image_read_write #TODO Integrate axis search tab ob tofu gui into this interface LOG = logging.getLogger(__name__) class ImageViewerGroup(QGroupBox): def __init__(self): super().__init__() #TODO: initialize on every opening with explicit data type #mmatching the data format being opened. #must check that there is enough RAM before loading!! self.tiff_arr = np.empty([0, 0, 0]) # float32 self.img_arr = np.empty([0, 0]) self.bit_depth = 32 self.open_file_button = QPushButton("Open Image File") self.open_file_button.clicked.connect(self.open_image_from_file) self.open_file_button.setStyleSheet("background-color: lightgrey; font: 11pt") self.open_stack_button = QPushButton("Open Image Stack") self.open_stack_button.clicked.connect(self.open_stack_from_directory) self.open_stack_button.setStyleSheet("background-color: lightgrey; font: 11pt") self.save_file_button = QPushButton("Save Image File") self.save_file_button.clicked.connect(self.save_image_to_file) self.save_file_button.setStyleSheet("background-color: lightgrey; font: 11pt") self.save_stack_button = QPushButton("Save Image Stack") self.save_stack_button.clicked.connect(self.save_stack_to_directory) self.save_stack_button.setStyleSheet("background-color: lightgrey; font: 11pt") self.open_big_tiff_button = QPushButton("Open BigTiff") self.open_big_tiff_button.clicked.connect(self.open_big_tiff) self.open_big_tiff_button.setStyleSheet("background-color: lightgrey; font: 11pt") self.save_big_tiff_button = QPushButton("Save BigTiff") self.save_big_tiff_button.clicked.connect(self.save_stack_to_big_tiff) self.save_big_tiff_button.setStyleSheet("background-color: lightgrey; font: 11pt") self.save_8bit_rButton = QRadioButton() self.save_8bit_rButton.setText("Save as 8-bit") self.save_8bit_rButton.clicked.connect(self.set_8bit) self.save_8bit_rButton.setChecked(False) self.save_16bit_rButton = QRadioButton() self.save_16bit_rButton.setText("Save as 16-bit") self.save_16bit_rButton.clicked.connect(self.set_16bit) self.save_16bit_rButton.setChecked(False) self.save_32bit_rButton = QRadioButton() self.save_32bit_rButton.setText("Save as 32-bit") self.save_32bit_rButton.clicked.connect(self.set_32bit) self.save_32bit_rButton.setChecked(True) self.hist_min_label = QLabel("Histogram Min:") self.hist_min_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.hist_max_label = QLabel("Histogram Max:") self.hist_max_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) self.hist_min_input = QDoubleSpinBox() self.hist_min_input.setDecimals(12) self.hist_min_input.setRange(-10, 10) self.hist_min_input.valueChanged.connect(self.min_spin_changed) self.hist_max_input = QDoubleSpinBox() self.hist_max_input.setDecimals(12) self.hist_max_input.setRange(-10, 10) self.hist_max_input.valueChanged.connect(self.max_spin_changed) self.apply_histogram_button = QPushButton("Apply Histogram to Image Stack") self.apply_histogram_button.clicked.connect(self.apply_histogram_button_clicked) self.image_window = pg.ImageView() self.image_window.ui.histogram.gradient.hide() self.histo = self.image_window.getHistogramWidget() self.scroller = QScrollBar(Qt.Horizontal) self.scroller.orientation() self.scroller.setEnabled(False) self.scroller.valueChanged.connect(self.scroll_changed) self.set_layout() def set_layout(self): vbox = QVBoxLayout() vbox.addWidget(self.save_8bit_rButton) vbox.addWidget(self.save_16bit_rButton) vbox.addWidget(self.save_32bit_rButton) gridbox = QGridLayout() gridbox.addWidget(self.hist_max_label, 0, 0) gridbox.addWidget(self.hist_max_input, 0, 1) gridbox.addWidget(self.hist_min_label, 1, 0) gridbox.addWidget(self.hist_min_input, 1, 1) layout = QGridLayout() layout.addWidget(self.open_file_button, 0, 0) layout.addWidget(self.save_file_button, 1, 0) layout.addWidget(self.open_stack_button, 0, 1) layout.addWidget(self.save_stack_button, 1, 1) layout.addWidget(self.open_big_tiff_button, 0, 2) layout.addWidget(self.save_big_tiff_button, 1, 2) layout.addItem(vbox, 0, 3, 2, 1) layout.addItem(gridbox, 0, 4, 2, 1) layout.addWidget(self.apply_histogram_button, 0, 5) layout.addWidget(self.image_window, 2, 0, 1, 6) layout.addWidget(self.scroller, 4, 0, 1, 5) self.setLayout(layout) self.resize(640, 480) self.show() def scroll_changed(self): """ Updated the currently displayed image based on position of scroll bar :return: None """ self.image_window.setImage(self.tiff_arr[self.scroller.value()].T) def open_image_from_file(self): """ Opens and displays a single image (.tif) specified by the user in the file dialog :return: None """ LOG.debug("Open image button pressed") options = QFileDialog.Options() filePath, _ = QFileDialog.getOpenFileName( self, "Open .tif Image File", "", "Tiff Files (*.tif *.tiff)", options=options ) if filePath: LOG.debug("Import image path: " + filePath) self.img_arr = image_read_write.read_image(filePath) self.image_window.setImage(self.img_arr.T) self.scroller.setEnabled(False) def open_image_from_filepath(self, filePath): """ Opens and displays a single image (.tif) contained in a directory - (used when one slice is reconstructed) :param filePath: Full path and filename :return: None """ LOG.debug("Open image from filepath: " + str(filePath)) if filePath: LOG.debug("Import image path: " + filePath) self.img_arr = image_read_write.read_image(filePath) self.image_window.setImage(self.img_arr.T) self.scroller.setEnabled(False) def save_image_to_file(self): """ Saves the currently displayed image to a file (.tif) specified by the user in the file dialog :return: None """ LOG.debug("Save image to file") options = QFileDialog.Options() filepath, _ = QFileDialog.getSaveFileName( self, "QFileDialog.getSaveFileName()", "", "Tiff Files (*.tif *.tiff)", options=options ) if filepath: LOG.debug(filepath) bit_depth_string = self.check_bit_depth(self.bit_depth) img = self.image_window.imageItem.qimage # https://www.programmersought.com/article/73475006380/ size = img.size() s = img.bits().asstring( size.width() * size.height() * img.depth() // 8 ) # format 0xffRRGGBB arr = np.fromstring(s, dtype=np.uint8).reshape( (size.height(), size.width(), img.depth() // 8) ) image_read_write.write_image( arr.T[0].T, os.path.dirname(filepath), os.path.basename(filepath), bit_depth_string ) def open_stack_from_directory(self): """ Opens all images (.tif) in a directory and displays them. Allows for scrolling through images with slider :return: None """ LOG.debug("Open image stack button pressed") dir_explore = QFileDialog() directory = dir_explore.getExistingDirectory() if directory: try: tiff_list = (".tif", ".tiff") msg = QMessageBox() msg.setIcon(QMessageBox.Information) msg.setWindowTitle("Loading Images...") msg.setText("Loading Images from Directory") msg.show() self.tiff_arr = image_read_write.read_all_images(directory, tiff_list) self.scroller.setRange(0, self.tiff_arr.shape[0] - 1) self.scroller.setEnabled(True) self.image_window.setImage(self.tiff_arr[0].T) msg.close() mid_index = self.tiff_arr.shape[0] // 2 self.scroller.setValue(mid_index) except image_read_write.InvalidDataSetError: print("Invalid Data Set") def open_stack_from_path(self, dir_path: str): """ Read images (.tif) from directory path into RAM as 3D numpy array :param dir_path: Path to directory containing multiple .tiff image files """ LOG.debug("Open stack from path") try: tiff_list = (".tif", ".tiff") msg = QMessageBox() msg.setIcon(QMessageBox.Information) msg.setWindowTitle("Loading Images...") msg.setText("Loading Images from Directory") msg.show() self.tiff_arr = image_read_write.read_all_images(dir_path, tiff_list) self.scroller.setRange(0, self.tiff_arr.shape[0] - 1) self.scroller.setEnabled(True) self.image_window.setImage(self.tiff_arr[0].T) msg.close() mid_index = self.tiff_arr.shape[0] // 2 self.scroller.setValue(mid_index) except image_read_write.InvalidDataSetError: print("Invalid Data Set") def save_stack_to_directory(self): """ Saves images stored in numpy array to individual files (.tif) in directory specified by user dialog Saves these images as BigTiff if checkbox is set to True """ LOG.debug("Save stack to directory button pressed") LOG.debug("Saving with bitdepth: " + str(self.bit_depth)) dir_explore = QFileDialog() directory = dir_explore.getExistingDirectory() LOG.debug("Writing to directory: " + directory) if directory: bit_depth_string = self.check_bit_depth(self.bit_depth) msg = QMessageBox() msg.setIcon(QMessageBox.Information) msg.setWindowTitle("Saving Images...") msg.setText("Saving Images to Directory") msg.show() self.apply_histogram_to_images() image_read_write.write_all_images(self.tiff_arr, directory, bit_depth_string) msg.close() def open_big_tiff(self): """ Opens images stored in a big tiff file (.tif) and displays them. Allows user to view them using scrollbar. :return: None """ LOG.debug("Open big tiff button pressed") options = QFileDialog.Options() filePath, _ = QFileDialog.getOpenFileName( self, "QFileDialog.getOpenFileName()", "", "All Files (*)", options=options ) if filePath: LOG.debug("Import image path: " + filePath) msg = QMessageBox() msg.setIcon(QMessageBox.Information) msg.setWindowTitle("Loading Images...") msg.setText("Loading Images from BigTiff") msg.show() self.tiff_arr = tifffile.imread(filePath).astype(dtype=np.float32) self.scroller.setRange(0, self.tiff_arr.shape[0] - 1) self.scroller.setEnabled(True) self.image_window.setImage(self.tiff_arr[0].T) msg.close() mid_index = self.tiff_arr.shape[0] // 2 self.scroller.setValue(mid_index) def save_stack_to_big_tiff(self): """ Saves the stack of images currently loaded into RAM to a single bigtif file :return: None """ LOG.debug("Save stack to bigtiff button pressed") LOG.debug("Saving with bitdepth: " + str(self.bit_depth)) dir_explore = QFileDialog() options = QFileDialog.Options() filepath, _ = QFileDialog.getSaveFileName( self, "QFileDialog.getSaveFileName()", "", "Tiff Files (*.tif *.tiff)", options=options ) if filepath: msg = QMessageBox() msg.setIcon(QMessageBox.Information) msg.setWindowTitle("Saving Images...") msg.setText("Saving Images to BigTiff") msg.show() # self.apply_histogram_to_images() bit_depth_string = self.check_bit_depth(self.bit_depth) tifffile.imwrite(filepath, self.tiff_arr, bigtiff=True, dtype=bit_depth_string) msg.close() def min_spin_changed(self): """ Changes the levels of the histogram widget if the min spinbox has been changed :return: None """ histo = self.image_window.getHistogramWidget() levels = histo.getLevels() min_level = self.hist_min_input.value() self.image_window.setLevels(min_level, levels[1]) def max_spin_changed(self): """ Changes the levels of the histogram widget if the max spinbox has been changed :return: None """ histo = self.image_window.getHistogramWidget() levels = histo.getLevels() max_level = self.hist_max_input.value() self.image_window.setLevels(levels[0], max_level) def apply_histogram_button_clicked(self): LOG.debug("Apply Histogram Button Clicked") print("Applying histogram to images. This may take a moment.") self.apply_histogram_to_images() def apply_histogram_to_images(self): """ Gets the histogram levels of the currently displayed image and applies them to all images in RAM :return: None """ levels = self.histo.getLevels() self.tiff_arr = np.clip(self.tiff_arr, levels[0], levels[1]) def check_bit_depth(self, bit_depth: int) -> str: """ Returns a string indicating the bitdepth to store the images based on value of bit-depth radio buttons :param bit_depth: :return: String specifying datatype for numpy array """ if bit_depth == 8: return "uint8" elif bit_depth == 16: return "uint16" elif bit_depth == 32: return "uint32" def set_8bit(self): """ Sets value of bit_depth variable based on radio button selection :return: None """ LOG.debug("Set 8-bit") self.bit_depth = 8 def set_16bit(self): """ Sets value of bit_depth variable based on radio button selection :return: None """ LOG.debug("Set 16-bit") self.bit_depth = 16 def set_32bit(self): """ Sets value of bit_depth variable based on radio button selection :return: None """ LOG.debug("Set 32-bit") self.bit_depth = 32 ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/login_dialog.py000066400000000000000000000124521521054151500216400ustar00rootroot00000000000000import re from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QDialog, QLineEdit, QPushButton, QLabel, QGridLayout, QFileDialog, QComboBox, ) from tofu.ez.GUI.message_dialog import error_message import os class Login(QDialog): def __init__(self, login_parameters_dict, **kwargs): super(Login, self).__init__(**kwargs) # Pass a method from main GUI self.login_parameters_dict = login_parameters_dict self.setWindowTitle("USER LOGIN") self.setWindowModality(Qt.ApplicationModal) self.setAttribute(Qt.WA_DeleteOnClose) self.welcome_label = QLabel() self.welcome_label.setText("Welcome to BMIT!") self.prompt_label_bl = QLabel() self.prompt_label_bl.setText("Please select the beamline and project:") self.bl_label = QLabel() self.bl_label.setText("Beamline:") self.bl_entry = QComboBox() self.bl_entry.addItems(["BM", "ID"]) self.proj_label = QLabel() self.proj_label.setText("Project:") self.proj_entry = QLineEdit() self.prompt_label_expdir = QLabel() self.prompt_label_expdir.setText("OR select the path to the working directory") self.expdir_entry = QLineEdit() # self.expdir_entry.setText("/data/gui-test") self.expdir_entry.setReadOnly(True) self.expdir_select_button = QPushButton("...") self.expdir_select_button.clicked.connect(self.select_expdir_func) self.login_button = QPushButton("LOGIN") self.login_button.clicked.connect(self.on_login_button_clicked) self.set_layout() def set_layout(self): layout = QGridLayout() self.welcome_label.setAlignment(Qt.AlignCenter) self.prompt_label_bl.setAlignment(Qt.AlignCenter) self.prompt_label_expdir.setAlignment(Qt.AlignCenter) layout.addWidget(self.welcome_label, 0, 0, 1, 2) layout.addWidget(self.prompt_label_bl, 1, 0, 1, 2) layout.addWidget(self.bl_label, 2, 0, 1, 1) layout.addWidget(self.bl_entry, 2, 1, 1, 1) layout.addWidget(self.proj_label, 3, 0, 1, 1) layout.addWidget(self.proj_entry, 3, 1, 1, 1) layout.addWidget(self.prompt_label_expdir, 4, 0, 1, 2) layout.addWidget(self.expdir_entry, 5, 0, 1, 1) layout.addWidget(self.expdir_select_button, 5, 1, 1, 1) layout.addWidget(self.login_button, 6, 0, 1, 2) layout.setSpacing(15) layout.setContentsMargins(25, 25, 25, 25) self.setLayout(layout) def select_expdir_func(self): options = QFileDialog.Options() options |= QFileDialog.DontUseNativeDialog root_dir = QFileDialog.getExistingDirectory( self, "Select working directory", "/data/gui-test", options=options ) if root_dir: self.expdir_entry.setText(root_dir) def uppercase_project_entry(self): self.proj_entry.setText(self.proj_entry.text().upper()) def strip_spaces_from_user_entry(self): self.user_entry.setText(self.user_entry.text().replace(" ", "")) @property def project_name(self): return self.proj_entry.text() @property def user_name(self): return self.user_entry.text() @property def expdir_name(self): return self.expdir_entry.text() @property def bl_name(self): return self.bl_entry.currentText() def validate_entries(self): self.uppercase_project_entry() # self.strip_spaces_from_user_entry() project_valid = bool(re.match(r"^[0-9]{2}[A-Z][0-9]{5}$", self.project_name)) # username_valid = bool(re.match(r"^[a-zA-Z0-9]*$", self.user_name)) # return project_valid, username_valid return project_valid def validate_dir(self, pdr): return os.access(pdr, os.W_OK) def on_login_button_clicked(self): # project_valid, username_valid = self.validate_entries() if self.project_name != "": prj_dir_name = os.path.join( "/beamlinedata/BMIT/projects/prj" + self.project_name, "raw" ) project_valid = self.validate_entries() can_write = self.validate_dir(prj_dir_name) if project_valid and can_write: self.login_parameters_dict.update({"bl": self.bl_name}) self.login_parameters_dict.update({"project": self.project_name}) # add fileExistsError exception later in Py3 self.login_parameters_dict.update({"expdir": prj_dir_name}) self.accept() # elif not username_valid: # error_message("Username should be alpha-numeric ") elif not project_valid: error_message( "The project should be in format: CCTNNNNN, \n" "where CC is cycle number, " "T is one-letter type, " "and NNNNN is project number" ) elif not can_write: error_message("Cannot write in the project directory") elif self.expdir_name != "": if self.validate_dir(self.expdir_entry.text()): self.login_parameters_dict.update({"expdir": self.expdir_name}) self.accept() else: error_message("Cannot write in the selected directory") ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/message_dialog.py000066400000000000000000000006661521054151500221600ustar00rootroot00000000000000from PyQt5.QtWidgets import QMessageBox def message_dialog(window_title, message_text): alert = QMessageBox() alert.setWindowTitle(window_title) alert.setText(message_text) alert.exec_() def error_message(message_text): message_dialog("Error", message_text) def warning_message(message_text): message_dialog("Warning", message_text) def info_message(message_text): message_dialog("Info", message_text) ufo-kit-tofu-ed0e5bd/tofu/ez/GUI/verify_delete.py000066400000000000000000000012641521054151500220360ustar00rootroot00000000000000import os from shutil import rmtree from PyQt5.QtWidgets import QMessageBox from tofu.ez.GUI.message_dialog import warning_message def verify_safe2delete(parent, dir_path, dir_type): if os.path.exists(dir_path) and len(os.listdir(dir_path)) > 0: qm = QMessageBox() rep = qm.question(parent, '', f"{dir_type} dir is not empty. Is it safe to delete it?\n\n{dir_path}", qm.Yes | qm.No) if rep == qm.Yes: try: rmtree(dir_path) except: warning_message(f"Error while deleting {dir_type} directory") raise FileExistsError else: raise FileExistsErrorufo-kit-tofu-ed0e5bd/tofu/ez/Helpers/000077500000000000000000000000001521054151500176115ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/Helpers/__init__.py000066400000000000000000000000001521054151500217100ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/ez/Helpers/batch_search_stitch_360.py000066400000000000000000000067541521054151500245530ustar00rootroot00000000000000from tofu.ez.ctdir_walker import substitute_shared_flatsdarks from tofu.ez.params import EZVARS_aux, EZVARS import os from tofu.ez.util import add_value_to_dict_entry, get_fd_names, export_values from tofu.ez.Helpers.stitch_funcs import main_360sti_ufol_depth1, compute_crop from tofu.ez.Helpers.find_360_overlap import find_overlap from tofu.util import get_first_filename, get_image_shape def batch_stitch(): #TODO: check that directory is empty - if loaded from params check is not applied stitched_data_dir_name = os.path.join(EZVARS_aux['half-acq']['workdir']['value'], 'stitched-data') if os.path.exists(stitched_data_dir_name) and \ len(os.listdir(stitched_data_dir_name)) > 0: print("# Clean directory for stitched data") return 1 print(EZVARS_aux['axes-list']) for outscandir, innerloopdict in EZVARS_aux['axes-list'].items(): print(f"# Stitching half acq mode data in {outscandir}") outscandir_out = os.path.join(stitched_data_dir_name, os.path.basename(outscandir)) if not os.path.exists(outscandir_out): os.makedirs(outscandir_out) # Export the parameters used to get stitched-data export_values(os.path.join(outscandir_out, "tofuez_all_parameters.yaml"), ['ezvars', 'tofu', 'ezvars_aux']) innerloopdirs = list(innerloopdict) dax = list(innerloopdict.values()) print(f'# Stitching inner loop CT scans in {outscandir} with ' f"overlaps {dax}") print(f'Overlaps: {dax}') first_tomo_dir = os.path.join(outscandir, innerloopdirs[0], EZVARS['inout']['tomo-dir']['value']) image_shape = get_image_shape(get_first_filename(first_tomo_dir)) cra = compute_crop(dax, image_shape) print(f'Crop by: {cra}') substitute_subdirs = substitute_shared_flatsdarks() reduction_mode = EZVARS['flat-correction']['reduction-mode']['value'] fd_names = get_fd_names() for innerloopdir, ax, crop in zip(innerloopdirs, dax, cra): ctdir = os.path.join(outscandir, innerloopdir) outdir = os.path.join(outscandir_out, innerloopdir) print("================================================================") print(" -> Working On: " + str(ctdir)) print(f" axis position {ax}, margin to crop {crop} pixels") try: main_360sti_ufol_depth1(indir=ctdir, outdir=outdir, ax=ax, cro=crop, substitute_subdirs=substitute_subdirs, reduction_mode=reduction_mode, fd_names=fd_names, ) except: return 1 return 0 def batch_olap_search(): EZVARS_aux['find360olap']['input-dir']['value'] = \ EZVARS['inout']['input-dir']['value'] EZVARS_aux['find360olap']['tmp-dir']['value'] = \ os.path.join(EZVARS_aux['half-acq']['workdir']['value'], 'temporary-data') EZVARS_aux['find360olap']['output-dir']['value'] = \ os.path.join(EZVARS_aux['half-acq']['workdir']['value'], 'overlap-search-results') try: find_overlap() except: return 1 return 0ufo-kit-tofu-ed0e5bd/tofu/ez/Helpers/find_360_overlap.py000066400000000000000000000305201521054151500232230ustar00rootroot00000000000000""" This script takes as input a CT scan that has been collected in "half-acquisition" mode and produces a series of reconstructed slices, each of which are generated by cropping and concatenating opposing projections together over a range of "overlap" values (i.e. the pixel column at which the images are cropped and concatenated). The objective is to review this series of images to determine the pixel column at which the axis of rotation is located (much like the axis search function commonly used in reconstruction software). """ import os import numpy as np import tifffile from tofu.ez.params import EZVARS, EZVARS_aux from tofu.ez.Helpers.stitch_funcs import findCTdirs, stitch from tofu.util import get_filenames, get_image_shape, TiffSequenceReader from tofu.ez.ufo_cmd_gen import get_filter2d_sinos_cmd #from tofu.ez.find_axis_cmd_gen import evaluate_images_simp from tofu.ez.evaluate_sharpness import evaluate_metrics_360_olap_search from tofu.ez.Helpers.stitch_funcs import main_360sti_ufol_depth1 from tofu.ez.util import get_dims, get_fd_names from tofu.ez.ufo_cmd_gen import get_pre_cmd from tofu.ez.tofu_cmd_gen import fmt_pr_cmd def extract_row(dir_name, row): tsr = TiffSequenceReader(dir_name) tmp = tsr.read(0) (N, M) = tmp.shape if (row < 0) or (row > N): row = N//2 num_images = tsr.num_images if num_images == 1: print(f"Single image in {dir_name}, duplicating.") num_images = 2 tsr._filenames = 2 * tsr._filenames assert num_images == tsr.num_images if num_images % 2 == 1: # print(f"odd number of images ({num_images}) in {dir_name}, " # f"discarding the last one before stitching pairs") num_images-=1 A = np.empty((num_images, M), dtype=np.uint16) for i in range(num_images): A[i, :] = tsr.read(i)[row, :] tsr.close() return A def find_overlap(): print("Finding CTDirs...") ctdirs, lvl0 = findCTdirs(EZVARS_aux['find360olap']['input-dir']['value'], EZVARS['inout']['tomo-dir']['value']) print(ctdirs) if len(ctdirs) < 1: return None olap_estimates = [] # TODO: fix shared flats/darks - path doesn't have to be # joined as shared variable is an absolute path already dirdark = EZVARS['inout']['darks-dir']['value'] dirflats = EZVARS['inout']['flats-dir']['value'] dirflats2 = EZVARS['inout']['flats2-dir']['value'] if EZVARS['inout']['shared-flatsdarks']['value']: dirdark = EZVARS['inout']['path2-shared-darks']['value'] dirflats = EZVARS['inout']['path2-shared-flats']['value'] dirflats2 = EZVARS['inout']['path2-shared-flats2']['value'] sin_tmp_dir = os.path.join(EZVARS_aux['find360olap']['tmp-dir']['value'], 'sinos') if not os.path.exists(sin_tmp_dir): os.makedirs(sin_tmp_dir) ax_range = range(EZVARS_aux['find360olap']['start']['value'], EZVARS_aux['find360olap']['stop']['value'] + EZVARS_aux['find360olap']['step']['value'], EZVARS_aux['find360olap']['step']['value']) # flush records from previous run EZVARS_aux['axes-list'] = {} # concatenate images with various overlap and generate sinograms for ctset in ctdirs: outerloopdirname = os.path.dirname(ctset) if outerloopdirname not in EZVARS_aux['axes-list']: EZVARS_aux['axes-list'].update({outerloopdirname: {}}) index_dir = os.path.basename(os.path.normpath(ctset)) print(f"Generating slices for ctset {index_dir}") e = 0 if EZVARS_aux['find360olap']['doPR']['value']: e = make_sinos_PR(ctset, dirflats, dirdark, dirflats2, ax_range, sin_tmp_dir) else: e = make_sinos_noPR(ctset, dirflats, dirdark, dirflats2, ax_range, sin_tmp_dir) if e: print("Cannot create sinograms; continue with the next ct set") continue outname = do_reco(sin_tmp_dir, index_dir, ax_range) # estimating overlap mettxtpref = os.path.join(os.path.join( EZVARS_aux['find360olap']['output-dir']['value'], index_dir)) results = evaluate_metrics_360_olap_search(os.path.dirname(outname), mettxtpref, ax_range, metrics_1d={"std": np.std}, detrend=True) mtc_key = 'std' if EZVARS_aux['find360olap']['doPR']['value']: mtc_key = 'sag' olap_est = int(EZVARS_aux['find360olap']['start']['value'] + \ EZVARS_aux['find360olap']['step']['value'] * np.argmax(results[mtc_key])) print("****************************************") print(f"Finished processing: {index_dir}, estimated overlap: {olap_est}") EZVARS_aux['axes-list'][outerloopdirname].update({index_dir: olap_est}) olap_estimates.append(olap_est) print("****************************************") #shutil.rmtree(EZVARS_aux['find360olap']['tmp-dir']) print("Finished processing of all subdirectories in " + str(EZVARS_aux['find360olap']['input-dir']['value'])) return dict(zip(ctdirs, olap_estimates)) def make_sinos_noPR(ctset, dirflats, dirdark, dirflats2, ax_range, sin_tmp_dir): reduction_func = np.median if EZVARS['flat-correction']['reduction-mode']['value'] == 'average': reduction_func = np.mean try: row_flat = reduction_func(extract_row( os.path.join(ctset, dirflats), EZVARS_aux['find360olap']['row']['value'])) except: print(f"Problem loading flats in {ctset}") return 1 try: row_dark = reduction_func(extract_row( os.path.join(ctset, dirdark), EZVARS_aux['find360olap']['row']['value'])) except: print(f"Problem loading darks in {ctset}") return 1 try: row_tomo = extract_row( os.path.join(ctset, EZVARS['inout']['tomo-dir']['value']), EZVARS_aux['find360olap']['row']['value']) except: print(f"Problem loading projections from " f"{os.path.join(ctset, EZVARS['inout']['tomo-dir']['value'])}") return 1 row_flat2 = None tmpstr = os.path.join(ctset, dirflats2) if os.path.exists(tmpstr): try: row_flat2 = reduction_func(extract_row(tmpstr, EZVARS_aux['find360olap']['row']['value'])) except: print(f"Problem loading flats2 in {ctset}") return 1 (num_proj, M) = row_tomo.shape print('Flat-field correction...') # Flat-correction tmp_flat = np.tile(row_flat, (num_proj, 1)) if row_flat2 is not None: tmp_flat2 = np.tile(row_flat2, (num_proj, 1)) ramp = np.linspace(0, 1, num_proj) ramp = np.transpose(np.tile(ramp, (M, 1))) tmp_flat = tmp_flat * (1 - ramp) + tmp_flat2 * ramp del ramp, tmp_flat2 tmp_dark = np.tile(row_dark, (num_proj, 1)) tomo_ffc = -np.log((row_tomo - tmp_dark) / np.float32(tmp_flat - tmp_dark)) del row_tomo, row_dark, row_flat, tmp_flat, tmp_dark np.nan_to_num(tomo_ffc, copy=False, nan=0.0, posinf=0.0, neginf=0.0) # create interpolated sinogram of flats on the # same row as we use for the projections, then flat/dark correction print('Creating stitched sinograms...') for axis in ax_range: cro = EZVARS_aux['find360olap']['stop']['value'] - axis if axis > M // 2: cro = axis - EZVARS_aux['find360olap']['start']['value'] A = stitch( tomo_ffc[: num_proj // 2, :], tomo_ffc[num_proj // 2:, ::-1], axis, cro, False) tifffile.imwrite(os.path.join( sin_tmp_dir, 'sin-axis-' + str(axis).zfill(4) + '.tif'), A.astype(np.float32)) return 0 def do_reco(sin_tmp_dir, index_dir, ax_range): if EZVARS_aux['find360olap']['doRR']['value']: sinfilt_tmp_dir = os.path.join(EZVARS_aux['find360olap']['tmp-dir']['value'], 'sinos-filt') if not os.path.exists(sinfilt_tmp_dir): os.makedirs(sinfilt_tmp_dir) # formatting reco command sin_width = get_image_shape(get_filenames(sin_tmp_dir)[0])[-1] sin_height = get_image_shape(get_filenames(sin_tmp_dir)[0])[-2] outname = os.path.join(os.path.join( EZVARS_aux['find360olap']['output-dir']['value'], index_dir, f"{index_dir}-sli")) # old command # cmd = f'tofu tomo --axis {sin_width // 2} --output {os.path.join(outname)}' # new command to enable slice crop # convert sinos to projections first nsli = len(ax_range) cmd = f'tofu sinos --number {nsli}' if EZVARS_aux['find360olap']['doRR']['value']: print("Applying ring removal filter") rrcmd = get_filter2d_sinos_cmd(EZVARS_aux['find360olap']['tmp-dir']['value'], EZVARS['RR']['sx']['value'], EZVARS['RR']['sy']['value'], sin_height, sin_width) os.system(rrcmd) cmd += f' --projections {sinfilt_tmp_dir}' else: cmd += f' --projections {sin_tmp_dir}' proj_file_name = os.path.join(EZVARS_aux['find360olap']['tmp-dir']['value'], 'proj.tif') cmd += f" --output {proj_file_name}" os.system(cmd) # now the reco command cmd = f'tofu reco --projections {proj_file_name} --output {outname}' cmd += f' --overall-angle 180 --center-position-x {sin_width // 2} ' cmd += f' --number {sin_height} --region={-(nsli // 2)},{nsli - nsli // 2},{1}' p_width = EZVARS_aux['find360olap']['patch-size']['value'] // 2 cmd += " --x-region={},{},{}".format(int(-p_width), int(p_width), 1) cmd += " --y-region={},{},{}".format(int(-p_width), int(p_width), 1) print('Reconstructing slices...') os.system(cmd) return outname def make_sinos_PR(ctset, dirflats, dirdark, dirflats2, ax_range, sin_tmp_dir): path2crop_frames = os.path.join(EZVARS_aux['find360olap']['tmp-dir']['value'], 'vertcrop') mrg = 32 # hardcoding the margins to avoid boundary artifacts when retrieving phase for one row nviews, wh = validate_row(mrg, ctset, EZVARS['inout']['tomo-dir']['value']) pre_cmd = f"crop y={EZVARS_aux['find360olap']['row']['value'] - mrg + 1} height={2*mrg}" typ = 3 tmpstr = os.path.join(ctset, dirflats2) if os.path.exists(tmpstr): typ = 4 cmds = get_pre_cmd((ctset, typ), pre_cmd, path2crop_frames) print(f"Creating a cropped copy of the input data set with {2*mrg} rows only") for cmd in cmds: #print(cmd) os.system(cmd) reduction_mode = EZVARS['flat-correction']['reduction-mode']['value'] fd_names = get_fd_names() for axis in ax_range: print(f"Making sinogram for axis {axis}") cro = EZVARS_aux['find360olap']['stop']['value'] - axis if axis > wh[1] // 2: cro = axis - EZVARS_aux['find360olap']['start']['value'] stitched = os.path.join(EZVARS_aux['find360olap']['tmp-dir']['value'], 'stitched', f"{axis:04}") print(f"Stitching flats/darks/tomo") main_360sti_ufol_depth1(path2crop_frames, stitched, axis, cro, reduction_mode=reduction_mode, fd_names=fd_names, ) dirs = ( os.path.join(stitched, dirdark), os.path.join(stitched, dirflats), os.path.join(stitched, 'proj-step1'), os.path.join(stitched, dirflats2) if os.path.exists(os.path.join(stitched, dirflats2)) else None ) stitched_pr = os.path.join(EZVARS_aux['find360olap']['tmp-dir']['value'], 'stitched-pr', f"{axis:04}") out_pattern = os.path.join(stitched_pr, "proj-%04i.tif") print(f"Phase retrieval") cmd = fmt_pr_cmd(*dirs, out_pattern, reduction_mode=reduction_mode) os.system(cmd) print(f"Generating sinogram for the target row") cmd = "tofu sinos" cmd += " --projections {}".format(stitched_pr) cmd += " --output {}".format(os.path.join(sin_tmp_dir, 'sin-axis-' + str(axis).zfill(4) + '.tif')) cmd += " --number {}".format(nviews//2) cmd += f" --y={mrg} --height=1" os.system(cmd) return 0 def validate_row(dy, ctset, dirflats): nviews, wh, multipage = get_dims(os.path.join(ctset, dirflats)) y = EZVARS_aux['find360olap']['row']['value'] if (y+dy) >= wh[1]: y = wh[1] - dy elif (y-dy) <=0: y = dy if ((y+dy) > wh[1]) or ((y-dy) <=0): print("Input projections must be at least 16 rows large to perform phase retrieval accurately") return 1 return nviews, wh ufo-kit-tofu-ed0e5bd/tofu/ez/Helpers/halfacqmode-mpi-stitch.py000066400000000000000000000027731521054151500245170ustar00rootroot00000000000000#!/usr/bin/env python3 import sys import time import tifffile from mpi4py import MPI from tofu.ez.Helpers.stitch_funcs import stitch from tofu.util import TiffSequenceReader path_to_script, ax, crop, bigtif_name, out_fmt = sys.argv comm = MPI.COMM_WORLD rank = comm.Get_rank() size = comm.Get_size() # t0 = time.time() #print(f"{t0:.2f}: Private {rank} of {size} is at your service") tfs = TiffSequenceReader(bigtif_name) npairs = tfs.num_images//2 n_my_pairs = int(npairs/size) + (1 if npairs%size > rank else 0) #print(f'Private {rank} got {n_my_pairs} pairs to process out of total {npairs}') for pair_number in range(n_my_pairs): idx = rank + pair_number * size # print(f'Private {rank} processing pair {idx} - {idx+npairs}') first = tfs.read(idx) second = tfs.read(idx+npairs)[:, ::-1] #stitched = stitch(first, second, int(ax), int(crop)) if first.dtype == "float32": stitched = stitch(first, second, int(ax), int(crop), False) else: stitched = stitch(first, second, int(ax), int(crop)) tifffile.imwrite(out_fmt.format(idx), stitched) tfs.close() #print(f"Private {rank} stitched {n_my_pairs} pairs in {time.time()-t0:.2f} s! Am I first?") # Important - release communicator! try: parent_comm = comm.Get_parent() parent_comm.Disconnect() except MPI.Exception: pass # # def main(): # comm = MPI.COMM_WORLD # size = comm.Get_size() # rank = comm.Get_rank() # print(f"I am {rank} of {size}") # # if __name__ == "__main__": # main() ufo-kit-tofu-ed0e5bd/tofu/ez/Helpers/mview_main.py000066400000000000000000000074321521054151500223240ustar00rootroot00000000000000#!/bin/python import os import numpy from tofu.util import get_filenames import re def check_folders(p, noflats2): if not os.path.exists(p): os.makedirs(p) tmp = p + "/darks" if not os.path.exists(tmp): os.makedirs(tmp) tmp = p + "/flats" if not os.path.exists(tmp): os.makedirs(tmp) if noflats2 == False: tmp = p + "/flats2" if not os.path.exists(tmp): os.makedirs(tmp) tmp = p + "/tomo" if not os.path.exists(tmp): os.makedirs(tmp) def rename_Andor(indir): names = get_filenames(os.path.join(indir, "*.tif")) maxnum = re.match(".*?([0-9]+)$", names[0][:-4]).group(1) n_dgts = len(maxnum) trnc_len = n_dgts + 4 prefix = names[0][:-trnc_len] maxnum = int(maxnum) for name in names: num = int(re.match(".*?([0-9]+)$", name[:-4]).group(1)) maxnum = num if (num > maxnum) else maxnum n_dgts = len(str(maxnum)) lin_fmt = prefix + "{:0" + str(n_dgts) + "}.tif" for name in names: num = re.match(".*?([0-9]+)$", name[:-4]).group(1) if name == lin_fmt.format(int(num)): continue else: cmd = "mv {} {}".format(name, lin_fmt.format(int(num))) os.system(cmd) def main_prep(params): if params['ezmview_no_zero_padding']: rename_Andor(params['ezmview_input_dir']) frames = get_filenames(os.path.join(params['ezmview_input_dir'], "*.tif")) nframes = len(frames) if nframes == 0: tmp = "Check INPUT directory: there are no tif files there" raise ValueError(tmp) # replace first frame with the second to get rid of # corrupted first file in the PCO Edge sequencies # Happened long ago in CamWare ... cmd = "rm {}; cp {} {}".format(frames[0], frames[1], frames[0]) os.system(cmd) FFinterval = params["ezmview_num_projections"] int_tot = params['ezmview_num_sets'] # (args.nproj/FFinterval)*args.nviews #int_1view = 1.0 # args.nproj/FFinterval #remainder of a more general FF correction files_in_int = params['ezmview_num_flats'] + params['ezmview_num_darks'] + FFinterval files_input = files_in_int * int_tot if params['ezmview_flats2'] == False: files_input += params['ezmview_num_flats'] #+ params['ezmview_num_darks'] if files_input != nframes: tmp = ( "Sequence length (found {} files) does not match ".format(nframes) + "one calculated from input parameters " + "(expected {} files)".format(files_input) ) raise ValueError(tmp) for i in range(params['ezmview_num_sets']): if params['ezmview_num_sets'] > 1: pout = os.path.join(params['ezmview_input_dir'], "z{:02d}".format(i)) else: pout = params['ezmview_input_dir'] check_folders(pout, params['ezmview_flats2']) # offset to heading flats and darks o = i * files_in_int for i in range(params['ezmview_num_flats']): cmd = "mv {} {}/flats/".format(frames[o + i], pout) os.system(cmd) # print(cmd) o += params['ezmview_num_flats'] for i in range(params['ezmview_num_darks']): cmd = "mv {} {}/darks/".format(frames[o + i], pout) os.system(cmd) # print(cmd) o += params['ezmview_num_darks'] for i in range(params["ezmview_num_projections"]): cmd = "mv {} {}/tomo/".format(frames[o + i], pout) os.system(cmd) # print(cmd) o += params["ezmview_num_projections"] if params['ezmview_flats2']: continue for i in range(params['ezmview_num_flats']): cmd = "cp {} {}/flats2/".format(frames[o + i], pout) os.system(cmd) # print(cmd) print("========== Done ==========") ufo-kit-tofu-ed0e5bd/tofu/ez/Helpers/stitch_funcs.py000066400000000000000000000626461521054151500226750ustar00rootroot00000000000000""" Last modified on Apr 1, 2022 @author: sergei gasilov """ import glob import os import shutil import numpy as np import tifffile from tofu.ez.ctdir_walker import substitute_shared_flatsdarks from tofu.util import read_image, get_image_shape, TiffSequenceReader, SequenceReaderError, \ get_first_filename from tofu.ez.util import get_data_cube_info, add_value_to_dict_entry, get_dims, enquote, get_fd_names from tofu.ez.image_read_write import get_image_dtype import multiprocessing as mp from functools import partial import time from tofu.ez.ufo_cmd_gen import fmt_stitch_cmd from skimage.metrics import structural_similarity as ssim from tofu.ez.params import EZVARS_aux, EZVARS import sys try: from mpi4py import MPI except ImportError: print("Ufo-launch GPU filter will be used to stitch half acq mode data", file=sys.stderr) def findCTdirs(root: str, tomo_name: str): """ Walks directories rooted at "Input ctset" location Appends their absolute path to ctdir if they contain a ctset with same name as "tomo" entry in GUI """ lvl0 = os.path.abspath(root) ctdirs = [] for root, dirs, files in os.walk(lvl0): for name in dirs: if name == tomo_name: ctdirs.append(root) ctdirs.sort() return ctdirs, lvl0 def make_ort_sections(ctset_path, tmp_dir_path): """ Generate orthogonal sections using tofu sinos command. Returns the original path if orthogonal sections are not generated, otherwise returns the path to the orthogonal sections. :param ctset_path: Path to the directory containing the vertical views (one CTDir with Z00-Z0N subdirs) :param tmp_dir_path: Path to the temporary directory where the orthogonal sections will be stored. :return: Path to the directory containing the orthogonal sections and dtype of the input images """ Vsteps = sorted_sub_directories(ctset_path) #determine input data type tmp = os.path.join(ctset_path, Vsteps[0], EZVARS_aux['vert-sti']['subdir-name']['value']) nslices, N, M, indtype_digit, indtype, npasses, ext = get_data_cube_info(tmp) indir = ctset_path if EZVARS_aux['vert-sti']['ort']['value']: print(" - Creating orthogonal sections") for vstep in Vsteps: in_name = enquote(os.path.join(ctset_path, vstep, EZVARS_aux['vert-sti']['subdir-name']['value'], f"*{ext}")) out_name = os.path.join(tmp_dir_path, vstep, EZVARS_aux['vert-sti']['subdir-name']['value'], 'sli-%04i.tif') # todo: size check and num-passes argument cmd = 'tofu sinos --projections {} --output {}'.format(in_name, out_name) cmd += " --y {} --height {} --y-step {}".format(EZVARS_aux['vert-sti']['start']['value'], EZVARS_aux['vert-sti']['stop']['value'] - EZVARS_aux['vert-sti']['start']['value'], EZVARS_aux['vert-sti']['step']['value']) cmd += " --output-bytes-per-file 0" if indtype_digit == '8' or indtype_digit == '16': cmd += f" --output-bitdepth {indtype_digit}" cmd += f" --pass-size {npasses}" print(cmd) os.system(cmd) time.sleep(10) indir = tmp_dir_path return indir, indtype def sorted_sub_directories(path: os.PathLike) -> list[str]: """Return a sorted list of sub-directories sorted by sub-directory name""" with os.scandir(path) as entries: subdirs = sorted([entry.name for entry in entries if entry.is_dir()]) return subdirs def get_cube_dims(search_dir=None, subdir_name=None): """ Find the first set of slices and determine the cube dimensions Returns (number of slices, image_rows, image_columns) """ if search_dir is None: search_dir = EZVARS_aux['vert-sti']['input-dir']['value'] if subdir_name is None: subdir_name = EZVARS_aux['vert-sti']['subdir-name']['value'] path_to_slices = None for root, dirs, files in os.walk(search_dir): for name in dirs: if name == subdir_name: path_to_slices = os.path.join(root, name) break else: continue break if path_to_slices is None: raise FileNotFoundError(f"Could not find a directory named {subdir_name} in {search_dir}") nslices, hw, multipage = get_dims(path_to_slices) return nslices, hw[0], hw[1] def validate_slice_range(): nslices, N, M = get_cube_dims() if EZVARS_aux['vert-sti']['reslice_all']['value']: EZVARS_aux['vert-sti']['start']['value'] = 0 EZVARS_aux['vert-sti']['stop']['value'] = N EZVARS_aux['vert-sti']['step']['value'] = 1 else: if EZVARS_aux['vert-sti']['start']['value'] > EZVARS_aux['vert-sti']['stop']['value']: tmp = EZVARS_aux['vert-sti']['start']['value'] EZVARS_aux['vert-sti']['start']['value'] = EZVARS_aux['vert-sti']['stop']['value'] EZVARS_aux['vert-sti']['stop']['value'] = tmp if EZVARS_aux['vert-sti']['stop']['value'] > N: EZVARS_aux['vert-sti']['stop']['value'] = N def load_an_image_from_the_input_dir(input_dir, slice_dir): return 0 def main_sti_mp(): if not os.path.exists(EZVARS_aux['vert-sti']['output-dir']['value']): os.makedirs(EZVARS_aux['vert-sti']['output-dir']['value']) if EZVARS_aux['vert-sti']['task_type']['value'] == 0: stitch_func = sti_one_set elif EZVARS_aux['vert-sti']['task_type']['value'] == 1: stitch_func = conc_one_set else: raise ValueError("Wrong task type value. Must be 0 for stitching or 1 for concatenation") vert_sets = [] for root, dirs, files in os.walk(EZVARS_aux['vert-sti']['input-dir']['value']): if EZVARS_aux['vert-sti']['subdir-name']['value'] in dirs: vert_sets.append(os.path.dirname(root)) vert_sets = sorted(list(set(vert_sets))) if len(vert_sets) == 1: print(" - Working with one CT directory which contains multiple vertical views") elif len(vert_sets) > 1: print(" - Working with several CT directories which contain multiple vertical views") keep_tmp_dir = EZVARS['inout']['keep-tmp']['value'] tmp_root_dir = EZVARS_aux['vert-sti']['tmp-dir']['value'] for vert_set in vert_sets: ctdir = os.path.relpath(vert_set, start=EZVARS_aux['vert-sti']['input-dir']['value']) print(f"-> Working on {str(ctdir)} dataset") tmpdir = os.path.join(tmp_root_dir, ctdir) outdir = os.path.join(EZVARS_aux['vert-sti']['output-dir']['value'], ctdir) if not os.path.exists(outdir): os.makedirs(outdir) stitch_func(vert_set, tmpdir, outdir) if not keep_tmp_dir and os.path.isdir(tmpdir): if os.path.samefile(tmp_root_dir, tmpdir): for dir in os.listdir(tmp_root_dir): shutil.rmtree(os.path.join(tmp_root_dir, dir)) else: shutil.rmtree(tmpdir) def sti_one_set(in_dir_path, tmp_dir_path, out_dir_path): indir, indtype = make_ort_sections(in_dir_path, tmp_dir_path) outfilepattern = os.path.join(out_dir_path, EZVARS_aux['vert-sti']['subdir-name']['value'] + '-sti-{:>04}.tif') if EZVARS_aux['vert-sti']['estimate_num_olap_rows']['value']: olap = find_vert_olap_2_vsteps(in_dir_path, EZVARS_aux['vert-sti']['ind_z00']['value'], EZVARS_aux['vert-sti']['ind_z01_start']['value'], EZVARS_aux['vert-sti']['ind_z01_stop']['value']) add_value_to_dict_entry(EZVARS_aux['vert-sti']['num_olap_rows'], olap) dx = int(EZVARS_aux['vert-sti']['num_olap_rows']['value']) # second: stitch them Vsteps = sorted_sub_directories(indir) tmp = glob.glob(os.path.join(indir, Vsteps[0], EZVARS_aux['vert-sti']['subdir-name']['value'], '*.tif'))[0] first = read_image(tmp) N, M = first.shape Nnew = N - dx if Nnew < 0: raise ValueError(f"Vertical overlap {dx} larger than input image height {N}") ramp = np.linspace(0, 1, dx) J = range((EZVARS_aux['vert-sti']['stop']['value'] - EZVARS_aux['vert-sti']['start']['value']) // EZVARS_aux['vert-sti']['step']['value']) pool = mp.Pool(processes=mp.cpu_count()) exec_func = partial(exec_sti_mp, indir, outfilepattern, N, Nnew, Vsteps, dx, M, ramp, indtype) print(" - Adjusting tiles and stitching") # start = time.time() pool.map(exec_func, J) print("========== Done ==========") def exec_sti_mp(indir, pout, N, Nnew, Vsteps, dx, M, ramp, indtype, j): index = EZVARS_aux['vert-sti']['start']['value'] + j*EZVARS_aux['vert-sti']['step']['value'] Large = np.empty((Nnew*len(Vsteps)+dx, M), dtype=np.float32) for i, vstep in enumerate(Vsteps[:-1]): tmp = os.path.join(indir, Vsteps[i], EZVARS_aux['vert-sti']['subdir-name']['value'], '*.tif') tmp1 = os.path.join(indir, Vsteps[i+1], EZVARS_aux['vert-sti']['subdir-name']['value'], '*.tif') if EZVARS_aux['vert-sti']['ort']['value']: tmp = sorted(glob.glob(tmp))[j] tmp1 = sorted(glob.glob(tmp1))[j] else: tmp = sorted(glob.glob(tmp))[index] tmp1 = sorted(glob.glob(tmp1))[index] first = read_image(tmp) second = read_image(tmp1) # sample moved downwards if EZVARS_aux['vert-sti']['flipud']['value']: first, second = np.flipud(first), np.flipud(second) k = np.mean(first[N - dx:, :]) / np.mean(second[:dx, :]) second = second * k a, b, c = i*Nnew, (i+1)*Nnew, (i+2)*Nnew Large[a:b, :] = first[:N-dx, :] Large[b:b+dx, :] = np.transpose(np.transpose(first[N-dx:, :])*(1 - ramp) + np.transpose(second[:dx, :]) * ramp) Large[b+dx:c+dx, :] = second[dx:, :] pout = pout.format(index) if not EZVARS_aux['vert-sti']['clip_hist']['value'] and \ (indtype == 'uint8' or indtype == 'uint16'): Large = np.clip(Large, np.iinfo(indtype).min, np.iinfo(indtype).max).astype(indtype) tifffile.imwrite(pout, Large.astype(indtype)) elif EZVARS_aux['vert-sti']['clip_hist']['value']: Large = 255.0/(EZVARS_aux['vert-sti']['max_int_val']['value'] - EZVARS_aux['vert-sti']['min_int_val']['value']) * \ (np.clip(Large, EZVARS_aux['vert-sti']['min_int_val']['value'], EZVARS_aux['vert-sti']['max_int_val']['value']) - EZVARS_aux['vert-sti']['min_int_val']['value']) tifffile.imwrite(pout, Large.astype(np.uint8)) else: tifffile.imwrite(pout, Large.astype(np.float32)) def conc_one_set(in_dir_path, tmp_dir_path, out_dir_path): indir, indtype = make_ort_sections(in_dir_path, tmp_dir_path) outfilepattern = os.path.join(out_dir_path, EZVARS_aux['vert-sti']['subdir-name']['value'] + '-sti-{:>04}.tif') zfold = sorted_sub_directories(indir) l = len(zfold) tmp = glob.glob(os.path.join(indir, zfold[0], EZVARS_aux['vert-sti']['subdir-name']['value'], '*.tif')) J = range((EZVARS_aux['vert-sti']['stop']['value'] - EZVARS_aux['vert-sti']['start']['value']) // EZVARS_aux['vert-sti']['step']['value']) pool = mp.Pool(processes=mp.cpu_count()) exec_func = partial(exec_conc_mp, tmp[0], l, zfold, indir, outfilepattern, indtype) print(" - Concatenating") # start = time.time() pool.map(exec_func, J) # print "Images stitched in {:.01f} sec".format(time.time()-start) print("============ Done ============") def exec_conc_mp(example_im, l, zfold, indir, pout, indtype, j): index = EZVARS_aux['vert-sti']['start']['value'] + j*EZVARS_aux['vert-sti']['step']['value'] Large, N, dtype = make_buf(example_im, l, EZVARS_aux['vert-sti']['conc_row_top']['value'], EZVARS_aux['vert-sti']['conc_row_bottom']['value']) for i, vert in enumerate(zfold): tmp = os.path.join(indir, vert, EZVARS_aux['vert-sti']['subdir-name']['value'], '*.tif') if EZVARS_aux['vert-sti']['ort']['value']: fname = sorted(glob.glob(tmp))[j] else: fname = sorted(glob.glob(tmp))[index] frame = read_image(fname)[EZVARS_aux['vert-sti']['conc_row_top']['value']: EZVARS_aux['vert-sti']['conc_row_bottom']['value'], :] if EZVARS_aux['vert-sti']['flipud']['value']: Large[i*N:N*(i+1), :] = np.flipud(frame) else: Large[i*N:N*(i+1), :] = frame tifffile.imwrite(pout.format(index), Large.astype(indtype)) def make_buf(tmp, l, a, b): first = read_image(tmp) N, M = first[a:b, :].shape return np.empty((N*l, M), dtype=first.dtype), N, first.dtype ############################## HALF ACQ ############################## def stitch(first, second, axis, crop, check_16bit_range=True): h, w = first.shape if axis > w // 2: axis = w - axis first = np.fliplr(first) second = np.fliplr(second) dx = int(2 * axis + 0.5) tmp = np.copy(first) first = second second = tmp result = np.empty((h, 2 * w - dx), dtype=first.dtype) ramp = np.linspace(0, 1, dx) # Mean values of the overlapping regions must match, which corrects flat-field inconsistency # between the two projections # We clip the values in second so that there are no saturated pixel overflow problems k = np.mean(first[:, w - dx:]) / np.mean(second[:, :dx]) if check_16bit_range: second = np.clip(second * k, np.iinfo(np.uint16).min, np.iinfo(np.uint16).max).astype(np.uint16) result[:, :w - dx] = first[:, :w - dx] result[:, w - dx:w] = first[:, w - dx:] * (1 - ramp) + second[:, :dx] * ramp result[:, w:] = second[:, dx:] return result[:, slice(int(crop), int(2*(w - axis) - crop), 1)] def main_360_mp_depth1(indir, outdir, ax, cro): if not os.path.exists(outdir): os.makedirs(outdir) subdirs = sorted_sub_directories(indir) for i, sdir in enumerate(subdirs): print(f"Stitching images in {sdir}") tfs = TiffSequenceReader(os.path.join(indir, sdir)) if tfs.num_images < 2: print("Warning: less than 2 files, skipping this dir") continue else: print(f"{tfs.num_images//2} pairs will be stitched in {sdir}") tfs.close() os.makedirs(os.path.join(outdir, sdir)) out_fmt = os.path.join(outdir, sdir, 'sti-{:>04}.tif') tmp = os.path.dirname(os.path.abspath(__file__)) path_to_script = os.path.join(tmp, "halfacqmode-mpi-stitch.py") if os.path.isfile(path_to_script): tstart = time.time() child_comm = MPI.COMM_WORLD.Spawn( sys.executable, [path_to_script, f"{ax}", f"{cro}", os.path.join(indir, sdir), out_fmt], maxprocs=12) child_comm.Disconnect() print(f"Child finished in {time.time() - tstart} yay!") else: print('Cannot see the script for parallel stitching of bigtiff files') break print("========== Done ==========") def main_360sti_ufol_depth1(indir, outdir, ax, cro, substitute_subdirs=None, reduction_mode=None, fd_names=(), ): """ indir: path to ctset with tomo/flats/darks/flats2 subdirectories outdir: path to output directory ax: overlap value cro: desired crop substitute_subdirs: dictionary of subdirs and replacement paths (used for shared flats/darks) reduction_mode: Flats / darks will be reduced by "average" or "median" before stitching fd_names: Sequence of flats / darks / flats2 subdir names in the input ctset """ if substitute_subdirs is None: substitute_subdirs = {} if not os.path.exists(outdir): os.makedirs(outdir) subdirs_list = sorted_sub_directories(indir) subdirs = {sd: os.path.join(indir, sd) for sd in subdirs_list} subdirs.update(substitute_subdirs) for sdir, inpath in subdirs.items(): print(f"Stitching images in {sdir}") if sdir in substitute_subdirs: print(f"Using shared path for {sdir}: {inpath}") numfiles = len(sorted(glob.glob(os.path.join(inpath, '*.tif')))) tfs = TiffSequenceReader(inpath) numim = tfs.num_images if numim < 1: print("Warning: no images, skipping this dir") continue if (numfiles > 1) and (numfiles != numim): print("Warning: cannot work with several bigtiff files. Input must be either" "one bigtiff container or single tif per each image") continue bigtiff = False if numfiles == 1: bigtiff = True if reduction_mode is not None and sdir in fd_names: numpairs = numim current_reduction_mode = reduction_mode print(f"{current_reduction_mode} of {numpairs} images will be stitched for {sdir}") else: numpairs = numim // 2 current_reduction_mode = None if numpairs: print(f"{numpairs} pairs will be stitched in {sdir}") elif numpairs == 0: print(f"Single image in {sdir}, duplicating.") im = tfs.read(0) tfs.close() h, w = im.shape bits = 32 if im.dtype == 'uint8': bits = 8 elif im.dtype == 'uint16': bits = 16 outpath = os.path.join(outdir, sdir) cmd = fmt_stitch_cmd(inpath, bigtiff, bits, outpath, numpairs, w, ax, cro, current_reduction_mode) #print(cmd) os.system(cmd) def compute_crop(dax, image_shape): """Compute the crop values required to output matching image widths after stitching Required for vertical stitching after reconstruction. Returns a sequence of crop values the same length as dax """ cra = np.max(dax) - dax M = image_shape[-1] if np.min(dax) > M//2: cra = dax - np.min(dax) return cra def main_360_mp_depth2(): ctdirs, lvl0 = findCTdirs(EZVARS_aux['stitch360']['input-dir']['value'], EZVARS['inout']['tomo-dir']['value']) num_sets = len(ctdirs) if num_sets < 1: print(f"Didn't find any CT dirs in the input. Check directory structure and permissions. \n" f"Program expects to see a number of subdirectories in the input each of with \n" f"contains at least one directory with CT projections (currently name set to " f"{EZVARS['inout']['tomo-dir']['value']}. \n"+ f"The tif files in all " \ f" {EZVARS['inout']['tomo-dir']['value']}, " f" {EZVARS['inout']['flats-dir']['value']}, " f" {EZVARS['inout']['darks-dir']['value']} \n" f"subdirectories will be stitched to convert half-acquisition mode scans to ordinary \n" f"180-deg parallel-beam scans") return ctdirs_rel_paths = [os.path.relpath(ctdir, start=EZVARS_aux['stitch360']['input-dir']['value']) for ctdir in ctdirs] print(f"Found the {num_sets} directories in the input with relative paths: {ctdirs_rel_paths}") # make_ort_sections axis and crop arrays if EZVARS_aux['stitch360']['olap_switch']['value'] == 0: dax = np.round(np.linspace(EZVARS_aux['stitch360']['olap_min']['value'], EZVARS_aux['stitch360']['olap_max']['value'], num_sets)).astype(np.int16) else: #dax = np.array(list(parameters['360multi_axis_dict'].values()), np.int16)[:num_sets] dax = EZVARS_aux['stitch360']['olap_list']['value'].split(',') for i in range(len(dax)): dax[i] = int(dax[i]) print(f'Overlaps: {dax}') # compute crop: # Axis on the right ? Must open one file to find out >< first_tomo_dir = os.path.join(ctdirs[0], EZVARS['inout']['tomo-dir']['value']) image_shape = get_image_shape(get_first_filename(first_tomo_dir)) cra = compute_crop(dax, image_shape) print(f'Crop by: {cra}') substitute_subdirs = substitute_shared_flatsdarks() reduction_mode = EZVARS['flat-correction']['reduction-mode']['value'] fd_names = get_fd_names() for i, ctdir in enumerate(ctdirs): print("================================================================") print(" -> Working On: " + str(ctdir)) print(f" axis position {dax[i]}, margin to crop {cra[i]} pixels") #main_360_mp_depth1 main_360sti_ufol_depth1(ctdir, os.path.join(EZVARS_aux['stitch360']['output-dir']['value'], ctdirs_rel_paths[i]), int(dax[i]), int(cra[i]), substitute_subdirs=substitute_subdirs, reduction_mode=reduction_mode, fd_names=fd_names, ) # print(ctdir, os.path.join(parameters['360multi_output_dir'], ctdirs_rel_paths[i]), dax[i], cra[i]) def check_last_index(axis_list): """ Return the index of item in list immediately before first 'None' type :param axis_list: :return: the index of last non-None value """ last_index = 0 for index, item in enumerate(axis_list): if item == 'None': last_index = index - 1 return last_index last_index = index return last_index def find_vert_olap_all_vsteps(ctset_path, ind_z00, ind_start_z01, ind_stop_z01): Vsteps = sorted_sub_directories(ctset_path) num_vsteps = len(Vsteps) olaps = np.zeros((num_vsteps-1, 1)) for i in range(num_vsteps-1): # if os.path.exists(os.path.join(EZVARS_aux['vert-sti']['input-dir']['value'], subdirs[0], # EZVARS_aux['vert-sti']['subdir-name']['value'])): z00_name = os.path.join(ctset_path, Vsteps[i], EZVARS_aux['vert-sti']['subdir-name']['value']) z01_name = os.path.join(ctset_path, Vsteps[i+1], EZVARS_aux['vert-sti']['subdir-name']['value']) olaps[i] = find_vert_overlap(z00_name, z01_name, ind_z00, ind_start_z01, ind_stop_z01) return np.median(olaps) #ind_start_z01+olaps-ind_z00 def find_vert_olap_2_vsteps(ctset_path, ind_z00, ind_start_z01, ind_stop_z01): #taking two sub-scans near the center of the sample vsteps = sorted_sub_directories(ctset_path) num_vsteps = len(vsteps) z00_name = os.path.join(ctset_path, vsteps[num_vsteps//2-1], EZVARS_aux['vert-sti']['subdir-name']['value']) z01_name = os.path.join(ctset_path, vsteps[num_vsteps//2], EZVARS_aux['vert-sti']['subdir-name']['value']) print(f"Estimating overlap between {vsteps[num_vsteps//2-1]} and {vsteps[num_vsteps//2]} " f"scans in {ctset_path}") tmp = find_vert_overlap(z00_name, z01_name, ind_z00, ind_start_z01, ind_stop_z01) print(f"Estimated overlap is {tmp} rows") return tmp def find_vert_overlap(z00_name, z01_name, ind_z00, ind_start_z01, ind_stop_z01): tmp = os.path.join(z00_name, '*.tif') im0 = read_image(sorted(glob.glob(tmp))[ind_z00]) tsr = TiffSequenceReader(z01_name) num_ref_im = ind_stop_z01 - ind_start_z01 ssim_ind = np.zeros((num_ref_im, 1)) #TODO: must be parallelized for i in range(num_ref_im): try: im1 = tsr.read(ind_start_z01 + i) except SequenceReaderError: continue ssim_ind[i] = ssim(im0, im1, data_range=(max(im0.max(), im1.max()) - min(im0.min(), im1.min()))) print(f"Similarity with slice {ind_start_z01 + i} = {ssim_ind[i]}") print(f"The most similar slice to {ind_z00} is {ind_start_z01 + np.argmax(ssim_ind)}") M = len(sorted(glob.glob(tmp))) # olap = ind_start_z01 + np.argmax(ssim_ind) - ind_z00 if ind_z00 < M//2: return M - (ind_start_z01 + np.argmax(ssim_ind) - ind_z00) else: return (ind_start_z01 + np.argmax(ssim_ind)) - (M - ind_z00) # def find_ver_overlap_mp(z00_name, z01_name, ind_z00, ind_start_z01, ind_stop_z01): # tmp = os.path.join(z00_name, '*.tif') # im0 = read_image(sorted(glob.glob(tmp))[ind_z00]) # N, M = im0.shape # tsr = TiffSequenceReader(z01_name) # num_ref_im = ind_stop_z01 - ind_start_z01 # A = np.zeros((num_ref_im, N, M)) # ssim_ind = np.zeros((num_ref_im, 1)) # for i in range(num_ref_im): # im1 = tsr.read(ind_start_z01 + i) # A[i,:,:] = im1 # del(A) # def comp_ssim(im0, A, i): # ssim_ind = ssim(im0, A[i,:,:], data_range=(max(im0.max(), A[i,:,:].max()) - # min(im0.min(), A[i,:,:].min()))) # print(f"Similarity with slice {i} = {ssim_ind}") # return [i, ssim_ind] # # # nslices = len(sorted(glob.glob(tmp))) # # olap = ind_start_z01 + np.argmax(ssim_ind) - ind_z00 # if ind_z00 < nslices // 2: # return nslices - (ind_start_z01 + np.argmax(ssim_ind) - ind_z00) # else: # return (ind_start_z01 + np.argmax(ssim_ind)) - (nslices - ind_z00) def complete_message(): print(" __.-/|") print(" \\`o_O'") print(" =( )= +-----+") print(" U| | FIN |") print(" /\\ /\\ / | +-----+") print(" ) /^\\) ^\\/ _)\\ |") print(" ) /^\\/ _) \\ |") print(" ) _ / / _) \\___|_") print(" /\\ )/\\/ || | )_)\\___,|))") print("< > |(,,) )__) |") print(" || / \\)___)\\") print(" | \\____( )___) )____") print(" \\______(_______;;;)__;;;)") ufo-kit-tofu-ed0e5bd/tofu/ez/RR_external.py000066400000000000000000000145261521054151500210160ustar00rootroot00000000000000#!/usr/bin/env python3 """ Created on Aug 3, 2018 @author: SGasilov Initially it has been simplest median sorting Replaced by non-FFT based methods proposed by Nghia T. Vo and published in https://doi.org/10.1364/OE.26.028396 """ import os import argparse from tofu.util import read_image import numpy as np from tofu.util import get_filenames import multiprocessing as mp from functools import partial from scipy.ndimage import median_filter from scipy.ndimage import binary_dilation from tifffile import imwrite def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("--sinos", type=str, help="Input directory") parser.add_argument("--mws", type=int, help="Window size for small rings (sorting algorithm)") parser.add_argument("--mws2", type=int, help="Window size for large rings") parser.add_argument("--snr", type=int, help="Median window size along columns") parser.add_argument("--sort_only", type=int, help="Only sorting or both") return parser.parse_args() def RR_wide_sort(mws, mws2, snr, odir, fname): filt_sin_name = os.path.join(odir, os.path.split(fname)[1]) im = read_image(fname).astype(np.float32) im = remove_large_stripe(im, snr, mws2) im = remove_stripe_based_sorting(im, mws) imwrite(filt_sin_name, im.astype(np.float32)) def RR_sort(mws, odir, fname): filt_sin_name = os.path.join(odir, os.path.split(fname)[1]) imwrite( filt_sin_name, remove_stripe_based_sorting(read_image(fname).astype(np.float32), mws).astype(np.float32), ) def remove_stripe_based_sorting(sinogram, size, dim=1): # taken from sarepy, Author: Nghia T. Vo https://doi.org/10.1364/OE.26.028396 """ Remove stripe artifacts in a sinogram using the sorting technique, algorithm 3 in Ref. [1]. Angular direction is along the axis 0. Parameters ---------- sinogram : array_like 2D array. Sinogram image. size : int Window size of the median filter. dim : {1, 2}, optional Dimension of the window. """ sinogram = np.transpose(sinogram) (nrow, ncol) = sinogram.shape list_index = np.arange(0.0, ncol, 1.0) mat_index = np.tile(list_index, (nrow, 1)) mat_comb = np.asarray(np.dstack((mat_index, sinogram))) mat_sort = np.asarray([row[row[:, 1].argsort()] for row in mat_comb]) if dim == 2: mat_sort[:, :, 1] = median_filter(mat_sort[:, :, 1], (size, size)) else: mat_sort[:, :, 1] = median_filter(mat_sort[:, :, 1], (size, 1)) mat_sort_back = np.asarray([row[row[:, 0].argsort()] for row in mat_sort]) return np.transpose(mat_sort_back[:, :, 1]) def detect_stripe(list_data, snr): # taken from sarepy, Author: Nghia T. Vo https://doi.org/10.1364/OE.26.028396 """ Locate stripe positions using Algorithm 4 in Ref. [1]. Parameters ---------- list_data : array_like 1D array. Normalized data. snr : float Ratio used to segment stripes from background noise. """ npoint = len(list_data) list_sort = np.sort(list_data) listx = np.arange(0, npoint, 1.0) ndrop = np.int16(0.25 * npoint) (slope, intercept) = np.polyfit(listx[ndrop : -ndrop - 1], list_sort[ndrop : -ndrop - 1], 1) y_end = intercept + slope * listx[-1] noise_level = np.abs(y_end - intercept) noise_level = np.clip(noise_level, 1e-6, None) val1 = np.abs(list_sort[-1] - y_end) / noise_level val2 = np.abs(intercept - list_sort[0]) / noise_level list_mask = np.zeros(npoint, dtype=np.float32) if val1 >= snr: upper_thresh = y_end + noise_level * snr * 0.5 list_mask[list_data > upper_thresh] = 1.0 if val2 >= snr: lower_thresh = intercept - noise_level * snr * 0.5 list_mask[list_data <= lower_thresh] = 1.0 return list_mask def remove_large_stripe(sinogram, size, snr=3, drop_ratio=0.1, norm=True): # taken from sarepy, Author: Nghia T. Vo https://doi.org/10.1364/OE.26.028396 """ Remove large stripes, algorithm 5 in Ref. [1], by: locating stripes, normalizing to remove full stripes, and using the sorting technique (Ref. [1]) to remove partial stripes. Angular direction is along the axis 0. Parameters ---------- sinogram : array_like 2D array. Sinogram image snr : float Ratio used to segment stripes from background noise. size : int Window size of the median filter. drop_ratio : float, optional Ratio of pixels to be dropped, which is used to reduce the false detection of stripes. norm : bool, optional Apply normalization if True. """ sinogram = np.copy(sinogram) # Make it mutable drop_ratio = np.clip(drop_ratio, 0.0, 0.8) (nrow, ncol) = sinogram.shape ndrop = int(0.5 * drop_ratio * nrow) sino_sort = np.sort(sinogram, axis=0) sino_smooth = median_filter(sino_sort, (1, size)) list1 = np.mean(sino_sort[ndrop : nrow - ndrop], axis=0) list2 = np.mean(sino_smooth[ndrop : nrow - ndrop], axis=0) list_fact = np.divide(list1, list2, out=np.ones_like(list1), where=list2 != 0) list_mask = detect_stripe(list_fact, snr) list_mask = np.float32(binary_dilation(list_mask, iterations=1)) mat_fact = np.tile(list_fact, (nrow, 1)) if norm is True: sinogram = sinogram / mat_fact # Normalization sino_tran = np.transpose(sinogram) list_index = np.arange(0.0, nrow, 1.0) mat_index = np.tile(list_index, (ncol, 1)) mat_comb = np.asarray(np.dstack((mat_index, sino_tran))) mat_sort = np.asarray([row[row[:, 1].argsort()] for row in mat_comb]) mat_sort[:, :, 1] = np.transpose(sino_smooth) mat_sort_back = np.asarray([row[row[:, 0].argsort()] for row in mat_sort]) sino_cor = np.transpose(mat_sort_back[:, :, 1]) listx_miss = np.where(list_mask > 0.0)[0] sinogram[:, listx_miss] = sino_cor[:, listx_miss] return sinogram def main(): args = parse_args() sinos = get_filenames(os.path.join(args.sinos, "*.tif")) # create output directory wdir = os.path.split(args.sinos)[0] odir = os.path.join(wdir, "sinos-filt") if not os.path.exists(odir): os.makedirs(odir) pool = mp.Pool(processes=mp.cpu_count()) if args.sort_only: exec_func = partial(RR_sort, args.mws, odir) else: exec_func = partial(RR_wide_sort, args.mws, args.mws2, args.snr, odir) pool.map(exec_func, sinos) if __name__ == "__main__": main() ufo-kit-tofu-ed0e5bd/tofu/ez/__init__.py000066400000000000000000000000011521054151500203070ustar00rootroot00000000000000 ufo-kit-tofu-ed0e5bd/tofu/ez/ctdir_walker.py000066400000000000000000000201311521054151500212300ustar00rootroot00000000000000""" Created on Apr 5, 2018 @author: gasilos """ import os from tofu.ez.params import EZVARS VALID_EXTS = ['.tif', '.tiff', '.edf'] class WalkCTdirs: """ Walks in the directory structure and creates list of paths to CT folders Determines flats before/after and checks that folders contain only tiff files fdt_names = flats/darks/tomo directory names """ def __init__(self, inpath, fdt_names, verb=True): self.lvl0 = os.path.abspath(inpath) self.ctdirs = [] self.types = [] self.ctsets = [] self.typ = [] self.total = 0 self.good = 0 self.verb = verb self._fdt_names = fdt_names self.common_flats = EZVARS['inout']['path2-shared-flats']['value'] self.common_darks = EZVARS['inout']['path2-shared-darks']['value'] self.common_flats2 = EZVARS['inout']['path2-shared-flats2']['value'] self.use_common_flats2 = EZVARS['inout']['shared-flats-after']['value'] def print_tree(self): print("We start in {}".format(self.lvl0)) def findCTdirs(self): """ Walks directories rooted at "Input Directory" location Appends their absolute path to ctdir if they contain a directory with same name as "tomo" entry in GUI """ for root, dirs, files in os.walk(self.lvl0): for name in dirs: if name == self._fdt_names[2]: self.ctdirs.append(root) self.ctdirs = list(set(self.ctdirs)) self.ctdirs.sort() def checkCTdirs(self): """ Determine whether directory is of type 3 or type 4 and store in self.typ with index corresponding to ctdir Type3: Has flats, darks and not flats2 -- or flats==flats2 Type4: Has flats, darks and flats2 """ for ctdir in self.ctdirs: # flats/darks and no flats2 or flats2==flats -> type 3 if ( os.path.exists(os.path.join(ctdir, self._fdt_names[1])) and os.path.exists(os.path.join(ctdir, self._fdt_names[0])) and ( not os.path.exists(os.path.join(ctdir, self._fdt_names[3])) or self._fdt_names[1] == self._fdt_names[3] ) ): self.typ.append(3) # flats/darks/flats2 -> type4 elif ( os.path.exists(os.path.join(ctdir, self._fdt_names[1])) and os.path.exists(os.path.join(ctdir, self._fdt_names[0])) and os.path.exists(os.path.join(ctdir, self._fdt_names[3])) ): self.typ.append(4) else: print(os.path.basename(ctdir)) self.typ.append(0) def checkcommonfdt(self): """ Verifies that paths to directories specified by common_flats, common_darks, and common_flats2 exist :return: True if directories exist, False if they do not exist """ for ctdir in self.ctdirs: if self.use_common_flats2 is True: self.typ.append(4) elif self.use_common_flats2 is False: self.typ.append(3) if self.use_common_flats2 is True: if ( os.path.exists(self.common_flats) and os.path.exists(self.common_darks) and os.path.exists(self.common_flats2) ): return True elif self.use_common_flats2 is False: if (os.path.exists(self.common_flats) and os.path.exists(self.common_darks)): return True return False def checkcommonfdtFiles(self): """ Verifies that directories of tomo and common flats/darks/flats contain only .tif files :return: True if directories exist, False if they do not exist """ for i, ctdir in enumerate(self.ctdirs): ctdir_tomo_path = os.path.join(ctdir, self._fdt_names[2]) if not self._checktifs(ctdir_tomo_path): print("Invalid files found in " + str(ctdir_tomo_path)) self.typ[i] = 0 return False if not self._checktifs(self.common_flats): print("Invalid files found in " + str(self.common_flats)) return False if not self._checktifs(self.common_darks): print("Invalid files found in " + str(self.common_darks)) return False if self.use_common_flats2 and not self._checktifs(self.common_flats2): print("Invalid files found in " + str(self.common_flats2)) return False return True def checkCTfiles(self): """ Checks whether each ctdir is of type 3 or 4 by comparing index of self.typ[] to corresponding index of ctdir[] Then for each directory of type 3 or 4 it checks sub-directories contain only .tif files If it contains invalid data then typ[] is set to 0 for corresponding index location """ for i, ctdir in enumerate(self.ctdirs): if ( self.typ[i] == 3 and self._checktifs(os.path.join(ctdir, self._fdt_names[1])) and self._checktifs(os.path.join(ctdir, self._fdt_names[0])) and self._checktifs(os.path.join(ctdir, self._fdt_names[2])) ): continue elif ( self.typ[i] == 4 and self._checktifs(os.path.join(ctdir, self._fdt_names[1])) and self._checktifs(os.path.join(ctdir, self._fdt_names[0])) and self._checktifs(os.path.join(ctdir, self._fdt_names[2])) and self._checktifs(os.path.join(ctdir, self._fdt_names[3])) ): continue else: self.typ[i] = 0 def _checktifs(self, tmpath): """ Checks each whether item in directory tmppath is a supported file :param tmpath: Path to directory :return: 0 if invalid item found in directory - 1 if no invalid items found in directory """ for i in os.listdir(tmpath): if os.path.isdir(i): print(f"Directory {tmpath} contains a subdirectory") return 0 if os.path.splitext(i)[1] not in VALID_EXTS: print(f"Directory {tmpath} has files which are not supported images or containers") return 0 return 1 def sortbadgoodsets(self): """ Reduces type of all directories to either Good with flats 2 (1) or good without flats2 (0) or bad (<0) """ self.total = len(self.ctdirs) self.ctsets = sorted(zip(self.ctdirs, self.typ), key=lambda s: s[0]) self.total = len(self.ctsets) self.good = [int(y) > 2 for x, y in self.ctsets].count(True) tmp = len(self.lvl0) if self.verb: print("Total folders {}, good folders {}".format(self.total, self.good)) print("{:>20}\t{}".format("Path to CT set", "Typ: 0 bad, 3 no flats2, 4 with flats2")) for ctdir in self.ctsets: msg1 = ctdir[0][tmp:] if msg1 == "": msg1 = "." print("{:>20}\t{}".format(msg1, ctdir[1])) # keep paths to directories with good ct data only: self.ctsets = [q for q in self.ctsets if int(q[1] > 0)] def Getlvl0(self): return self.lvl0 def substitute_shared_flatsdarks() -> dict[str, str]: """ Return a dictionary with structure {"flats": "/path/to/shared/flats"} which respects EZVARS settings """ substitutes = {} if EZVARS['inout']['shared-flatsdarks']['value']: for type in ['darks', 'flats', 'flats2']: if type == 'flats2' and not EZVARS['inout']['shared-flats-after']['value']: continue key = f'path2-shared-{type}' path = EZVARS['inout'][key]['value'] if not os.path.exists(path): print(f"Shared {type} not found: {path}") continue name = EZVARS['inout'][f'{type}-dir']['value'] substitutes[name] = path return substitutes ufo-kit-tofu-ed0e5bd/tofu/ez/evaluate_sharpness.py000066400000000000000000000346771521054151500224760ustar00rootroot00000000000000import argparse import glob import multiprocessing import os import time import numpy as np from functools import partial from tofu.util import read_image from scipy.stats import skew, kurtosis from scipy import signal from tofu.util import TiffSequenceReader def sum_abs_gradient(data): """Sum of absolute gradients.""" return np.sum(np.abs(np.gradient(data))) def mad(data): """Median absolute deviation.""" return np.median(np.abs(data - np.median(data))) def abs_sum(data): """Sum of the absolute values.""" return np.sum(np.abs(data)) def entropy(data, bins=256): """Image entropy.""" hist, bins = np.histogram(data, bins=bins) hist = hist.astype(float) hist /= hist.sum() valid = np.where(hist > 0) return -np.sum(np.dot(hist[valid], np.log2(hist[valid]))) def inverted(func, *args, **kwargs): """Return -func(*args, **kwargs).""" return -func(*args, **kwargs) def filter_data(data, fwhm=32.0): """Filter low frequencies in 1D *data* (needed when the axis is far away by axis evaluation). *fwhm* is the FwhM of the gaussian used to filter out low frequencies in real space. The window is then computed as fft(1 - gauss). """ mean = np.mean(data) sigma = fwhm / (2 * np.sqrt(2 * np.log(2))) # We compute the gaussian in Fourier space, so convert sigma first f_sigma = 1.0 / (2 * np.pi * sigma) x = np.fft.fftfreq(len(data)) fltr = 1 - np.exp(-(x ** 2) / (2 * f_sigma ** 2)) return np.fft.ifft(np.fft.fft(data) * fltr).real + mean METRICS_1D = { "mean": np.mean, "std": np.std, "skew": skew, "kurtosis": kurtosis, "mad": mad, "asum": abs_sum, "min": np.min, "max": np.max, "entropy": entropy, } METRICS_2D = {"sag": sum_abs_gradient} for key in list(METRICS_1D): METRICS_1D["m" + key] = partial(inverted, METRICS_1D[key]) for key in list(METRICS_2D): METRICS_2D["m" + key] = partial(inverted, METRICS_2D[key]) # for key in METRICS_1D.keys(): # METRICS_1D['m' + key] = partial(inverted, METRICS_1D[key]) # for key in METRICS_2D.keys(): # METRICS_2D['m' + key] = partial(inverted, METRICS_2D[key]) def evaluate( image, metrics_1d=None, metrics_2d=None, global_min=None, global_max=None, metrics_1d_kwargs=None, blur_fwhm=None, ): """Evaluate *metrics_1d* which work on a flattened image and *metrics_2d* in an *image* which can either be a file path or an imageIf the metrics are None all the default ones are used. *global_min* and *global_max* are the mean extrema of the whole sequence used to cut off outlier values. Extrema are used only by 1d metrics. *metrics_1d_kwargs* are additional keyword arguments passed to the functions, they are specified in dictioinary {func_name: kwargs}. """ if metrics_1d is None: metrics_1d = METRICS_1D if metrics_2d is None: metrics_2d = METRICS_2D results = {} if type(image) == str: image = read_image(image) if blur_fwhm: from scipy.ndimage import gaussian_filter image = gaussian_filter(image, blur_fwhm / (2 * np.sqrt(2 * np.log(2)))) if global_min is None or global_max is None: flattened = image.flatten() else: # Use global cutoff flattened = image[np.where((image >= global_min) & (image <= global_max))] if metrics_1d is not None: for metric in metrics_1d: kwargs = {} if metrics_1d_kwargs and metric in metrics_1d_kwargs: kwargs = metrics_1d_kwargs[metric] results[metric] = metrics_1d[metric](flattened, **kwargs) if metrics_2d is not None: for metric in metrics_2d: results[metric] = metrics_2d[metric](image) return results def evaluate_metrics(images, out_prefix, *args, **kwargs): """Evaluate many *images* which are either file paths or images. *out_prefix* is the metric results file prefix. Metric names and file extension are appended to it. *args* and *kwargs* are passed to :func:`evaluate`. Except for *fwhm* in *kwargs* which is used to filter low frequencies from the results. """ fwhm = kwargs.pop("fwhm") if "fwhm" in kwargs else None pool = multiprocessing.Pool(processes=multiprocessing.cpu_count()) exec_func = partial(evaluate, *args, **kwargs) results = pool.map(exec_func, images) merged = {} for metric in results[0].keys(): merged[metric] = np.array([result[metric] for result in results]) if fwhm: # Filter out low frequencies merged[metric] = filter_data(merged[metric], fwhm=fwhm) if out_prefix is not None: path = out_prefix + "_" + metric + ".txt" np.savetxt(path, merged[metric], fmt="%g") return merged def evaluate_metrics_360_olap_search(images, out_prefix, x_data, *args, **kwargs): """Evaluate many *images* which are either file paths or images. *out_prefix* is the metric results file prefix. Metric names and file extension are appended to it. *args* and *kwargs* are passed to :func:`evaluate`. Except for *fwhm* in *kwargs* which is used to filter low frequencies from the results. """ dtrnd = kwargs.pop("detrend") if "detrend" in kwargs else None merged = {} results = [] tfs = TiffSequenceReader(images) for i in range(tfs.num_images): results.append(evaluate(tfs.read(i), *args, ** kwargs)) tfs.close() for metric in results[0].keys(): merged[metric] = np.array([result[metric] for result in results]) if dtrnd: # Filter out slope merged[metric] = signal.detrend(merged[metric]) if out_prefix is not None: path = out_prefix + "/" + metric np.savetxt(path+".txt", merged[metric], fmt="%g") #and plot it as well try: from matplotlib import pyplot as plt except ImportError: pass else: plt.figure() plt.plot(x_data, merged[metric]) plt.title(metric) plt.grid() plt.savefig(path + ".png") plt.close() return merged def process( names, num_images_for_stats=0, metric_names=None, out_prefix=None, fwhm=None, metrcs_1d_kwargs=None, blur_fwhm=None, ): """Process many files given by *names*. *out_prefix* is the output file prefix where the metric results will be written to. *fwhm* is used to filter our low frequencies from the results. *metrics_1d_kwargs* are additional keyword arguments passed to the functions, they are specified in dictioinary {func_name: kwargs}. """ if num_images_for_stats: if num_images_for_stats == -1: num_images_for_stats = len(names) extrema_metrics = {"min": np.min, "max": np.max} extrema = evaluate_metrics( names[:num_images_for_stats], None, metrics_1d=extrema_metrics, fwhm=fwhm, blur_fwhm=blur_fwhm, ) global_min = np.mean(extrema["min"]) global_max = np.mean(extrema["max"]) else: global_min = global_max = None metrics_1d, metrics_2d = make_metrics(metric_names) return evaluate_metrics( names, out_prefix, metrics_1d=metrics_1d, metrics_2d=metrics_2d, global_min=global_min, global_max=global_max, fwhm=fwhm, metrics_1d_kwargs=metrcs_1d_kwargs, blur_fwhm=blur_fwhm, ) def main(): args = parse_args() names = sorted(glob.glob(args.input)) if args.dims == 2: axis_length = int(np.sqrt(len(names))) size_str = "{} x {}".format(axis_length, axis_length) else: axis_length = len(names) size_str = str(axis_length) print("Data size: {}".format(size_str)) kwargs = {"entropy": {"bins": args.entropy_num_bins}} print(kwargs) for key in kwargs.keys(): kwargs["m" + key] = kwargs[key] st = time.time() results = process( names, num_images_for_stats=args.num_images_for_stats, metric_names=args.metrics, fwhm=args.fwhm, metrcs_1d_kwargs=kwargs, blur_fwhm=args.blur_fwhm, ) if args.verbose: print("Duration: {} s".format(time.time() - st)) x_data = y_data = None for metric, data in results.iteritems(): if x_data is None: x_data = construct_range(args.x_from, args.x_to, len(data), unit=args.x_unit) y_data = construct_range(args.y_from, args.y_to, len(data), unit=args.y_unit) write( args.output, metric, data, axis_length, x_data=x_data, y_data=y_data, save_raw=args.save_raw, save_txt=args.save_txt, save_plot=args.save_plot, ) argmax = np.argmax(data) if args.dims == 2: argmax = np.unravel_index(argmax, (axis_length, axis_length)) y_argmax = y_data[argmax[0]].magnitude x_argmax = x_data[argmax[1]].magnitude retval = (x_argmax, y_argmax) else: x_argmax = x_data[argmax].magnitude retval = x_argmax print(retval) def write( out_dir, metric, data, axis_length, x_data=None, y_data=None, save_raw=False, save_txt=False, save_plot=False, ): out_path = os.path.join(out_dir, metric) if not os.path.exists(out_dir): os.makedirs(out_dir, mode=0o755) if axis_length == len(data): # 1D if save_raw: np.save(out_path + ".npy", data) if save_plot: write_1d_plot(out_path, data, metric, x_data=x_data) else: reshaped = data.reshape(axis_length, axis_length) if save_raw: write_libtiff(out_path + "_raw" + ".tif", reshaped.astype(np.float32)) if save_plot: write_2d_plot(out_path, reshaped, metric, x_data=x_data, y_data=y_data) if save_txt: data = np.array((x_data.magnitude, data)) # Convenient to be read by pgfplots np.savetxt(out_path + ".txt", data.T, fmt="%g", delimiter="\t", comments="", header="x\ty") def write_1d_plot(out_path, data, metric, x_data=None): from matplotlib import pyplot as plt plt.figure() if x_data is not None: plt.plot(x_data.magnitude, data) plt.xlabel(x_data.units) else: plt.plot(data) plt.title(metric) plt.grid() plt.savefig(out_path + ".tif") plt.close() def write_2d_plot(out_path, data, metric, x_data=None, y_data=None): from matplotlib import pyplot as plt, cm plt.figure() plt.imshow(data, cmap=cm.gray) if x_data is not None: x_from = x_data[0].magnitude x_to = x_data[-1].magnitude num_x_ticks = min(data.shape[1], 9) x_locs = np.linspace(-0.5, data.shape[1] - 0.5, num_x_ticks) x_labels = np.linspace(x_from, x_to, num_x_ticks) plt.xticks(x_locs, x_labels) plt.xlabel(x_data.units) if y_data is not None: y_from = y_data[0].magnitude y_to = y_data[-1].magnitude num_y_ticks = min(data.shape[0], 9) y_locs = np.linspace(-0.5, data.shape[0] - 0.5, num_y_ticks) y_labels = np.linspace(y_from, y_to, num_y_ticks) plt.yticks(y_locs, y_labels) plt.ylabel(y_data.units) plt.title(metric) plt.savefig(out_path + ".tif") plt.close() def construct_range(start, stop, num, unit=""): start = 0 if start is None else start stop = num if stop is None else stop region = np.linspace(start, stop, num=num, endpoint=False) return q.Quantity(region, unit) def make_metrics(keys): """Buld 1d and 2d metrics dictionaries from *keys*.""" if keys is None: metrics_1d = METRICS_1D metrics_2d = METRICS_2D else: metrics_1d = {key: METRICS_1D[key] for key in keys if key in METRICS_1D} metrics_2d = {key: METRICS_2D[key] for key in keys if key in METRICS_2D} return metrics_1d, metrics_2d def parse_args(): parser = argparse.ArgumentParser( description="Evaluate sharpness metrics based on parameter changes in 3D reconstruction" ) parser.add_argument("input", type=str, help="Input path pattern") parser.add_argument( "dims", type=int, choices=(1, 2), help="Number of scanned parameters in the data set" ) parser.add_argument("--output", type=str, default=".", help="Output directory") parser.add_argument( "--metrics", type=str, nargs="*", choices=METRICS_1D.keys() ,#+ METRICS_2D.keys(), help="Metrics to determine (m prefix means -metric)", ) parser.add_argument("--x-from", type=float, help="X data from") parser.add_argument("--x-to", type=float, help="X data to") parser.add_argument("--x-unit", type=str, default="", help="X axis units") parser.add_argument("--y-from", type=float, help="Y data from") parser.add_argument("--y-to", type=float, help="Y data to") parser.add_argument("--y-unit", type=str, default="", help="Y axis units") parser.add_argument( "--num-images-for-stats", type=int, default=0, help=( "If not zero, an " "image sequence is first read and the mean min and max intensities are " "used as a global range of values to work on (-1 means read all images)" ), ) parser.add_argument( "--fwhm", type=float, help="FwhM of 1 - Gauss in real space used to filter out low frequencies.", ) parser.add_argument( "--entropy-num-bins", type=int, default=256, help="Number of bins to use for histogram calculation by entropy", ) parser.add_argument( "--blur-fwhm", type=float, help="FwhM of the Gaussian blur applied to images" ) parser.add_argument("--save-raw", action="store_true", help="Store raw data (1D npy, 2D tiff)?") parser.add_argument("--save-txt", action="store_true", help="Store raw data as text files") parser.add_argument("--save-plot", action="store_true", help="Store plot data") parser.add_argument("--verbose", action="store_true", help="Verbose output") args = parser.parse_args() if (args.x_from is None) ^ (args.x_to is None): raise ValueError("Either both x-from and x-to are set or both are not") if (args.y_from is None) ^ (args.y_to is None): raise ValueError("Either both y-from and y-to are set or both are not") return args if __name__ == "__main__": main() ufo-kit-tofu-ed0e5bd/tofu/ez/find_axis_cmd_gen.py000066400000000000000000000123141521054151500222020ustar00rootroot00000000000000#!/bin/python """ Created on Apr 6, 2018 @author: gasilos """ import glob, os, tifffile import numpy as np from tofu.ez.evaluate_sharpness import process as process_metrics from tofu.ez.util import enquote, make_inpaths from tofu.util import get_filenames, read_image from tofu.ez.params import EZVARS from tofu.config import SECTIONS from tofu.ez.tofu_cmd_gen import check_lamino, gpu_optim def find_axis_std(ctset, tmpdir, ax_range, p_width, nviews, wh, reduction_mode="median"): indir = make_inpaths(ctset[0], ctset[1]) cmd = 'tofu reco' if EZVARS['advanced']['more-reco-params']['value'] is True: cmd += check_lamino() elif EZVARS['advanced']['more-reco-params']['value'] is False: cmd += " --overall-angle 180" cmd += " --darks {} --flats {} --reduction-mode {} --projections {}".format( indir[0], indir[1], reduction_mode, enquote(indir[2]) ) cmd += " --number {}".format(nviews) if EZVARS['COR']['min-std-apply-pr']['value']: cmd += f" --disable-projection-crop --delta 1e-6" \ f" --energy {SECTIONS['retrieve-phase']['energy']['value']} " \ f" --propagation-distance {SECTIONS['retrieve-phase']['propagation-distance']['value'][0]}" \ f" --pixel-size {SECTIONS['retrieve-phase']['pixel-size']['value']} " \ f" --regularization-rate {SECTIONS['retrieve-phase']['regularization-rate']['value']:0.2f}" else: cmd += " --absorptivity --fix-nan-and-inf" if ctset[1] == 4: cmd += " --flats2 {}".format(indir[3]) out_pattern = os.path.join(tmpdir, "axis-search/sli") cmd += " --output {}".format(enquote(out_pattern)) cmd += " --x-region={},{},{}".format(int(-p_width / 2), int(p_width / 2), 1) cmd += " --y-region={},{},{}".format(int(-p_width / 2), int(p_width / 2), 1) image_height = wh[0] ax_range_list = ax_range.split(",") range_min = ax_range_list[0] range_max = ax_range_list[1] step = ax_range_list[2] range_string = str(range_min) + "," + str(range_max) + "," + str(step) cmd += " --region={}".format(range_string) res = [float(num) for num in ax_range.split(",")] cmd += " --output-bytes-per-file 0" cmd += ' --z-parameter center-position-x' cmd += ' --z {}'.format(EZVARS['COR']['search-row']['value'] - int(image_height/2)) cmd += gpu_optim() print(cmd) os.system(cmd) points, maximum = evaluate_images_simp(out_pattern + "*.tif", "msag") return res[0] + res[2] * maximum def find_axis_corr(ctset, vcrop, y, height, multipage): #TODO use tiffsequencereader indir = make_inpaths(ctset[0], ctset[1]) """Use correlation to estimate center of rotation for tomography.""" from scipy.signal import fftconvolve def flat_correct(flat, radio): nonzero = np.where(radio != 0) result = np.zeros_like(radio) result[nonzero] = flat[nonzero] / radio[nonzero] # log(1) = 0 result[result <= 0] = 1 return np.log(result) if multipage: with tifffile.TiffFile(get_filenames(indir[2])[0]) as tif: first = tif.pages[0].asarray().astype(float) with tifffile.TiffFile(get_filenames(indir[2])[-1]) as tif: last = tif.pages[-1].asarray().astype(float) with tifffile.TiffFile(get_filenames(indir[0])[-1]) as tif: dark = tif.pages[-1].asarray().astype(float) with tifffile.TiffFile(get_filenames(indir[1])[0]) as tif: flat1 = tif.pages[-1].asarray().astype(float) - dark else: first = read_image(get_filenames(indir[2])[0]).astype(float) last = read_image(get_filenames(indir[2])[-1]).astype(float) dark = read_image(get_filenames(indir[0])[-1]).astype(float) flat1 = read_image(get_filenames(indir[1])[-1]) - dark first = flat_correct(flat1, first - dark) if ctset[1] == 4: if multipage: with tifffile.TiffFile(get_filenames(indir[3])[0]) as tif: flat2 = tif.pages[-1].asarray().astype(float) - dark else: flat2 = read_image(get_filenames(indir[3])[-1]) - dark last = flat_correct(flat2, last - dark) else: last = flat_correct(flat1, last - dark) if vcrop: y_region = slice(y, min(y + height, first.shape[0]), 1) first = first[y_region, :] last = last[y_region, :] width = first.shape[1] first = first - first.mean() last = last - last.mean() conv = fftconvolve(first, last[::-1, :], mode="same") center = np.unravel_index(conv.argmax(), conv.shape)[1] return (width / 2.0 + center) / 2.0 # Find midpoint width of image and return its value def find_axis_image_midpoint(height_width): return height_width[1] // 2 def evaluate_images_simp( input_pattern, metric, num_images_for_stats=0, out_prefix=None, fwhm=None, blur_fwhm=None, verbose=False, ): # simplified version of original evaluate_images function # from Tomas's optimize_parameters script names = sorted(glob.glob(input_pattern)) res = process_metrics( names, num_images_for_stats=num_images_for_stats, metric_names=(metric,), out_prefix=out_prefix, fwhm=fwhm, blur_fwhm=blur_fwhm, )[metric] return res, np.argmax(res) ufo-kit-tofu-ed0e5bd/tofu/ez/image_read_write.py000066400000000000000000000105541521054151500220550ustar00rootroot00000000000000import os, glob import numpy as np import tifffile from tifffile import imread, imwrite from tofu.util import TiffSequenceReader class InvalidDataSetError(Exception): """ Error to be raised on attempt to read data from empty or non-existing data set """ def validate_files_path(files_path: str, supported_file_types: list) -> bool: """ Validates specified path :param supported_file_types: List of supported extensions :param files_path: Path to validate :return: True if path exists and contains at least one file of supported type, else False """ try: valid_files_list = get_valid_files_list( files_path=files_path, supported_file_types=supported_file_types ) except InvalidDataSetError: return False return len(valid_files_list) > 0 def get_valid_files_list(files_path: str, supported_file_types: list) -> list: """ Get the list of files of supported type in directory :param supported_file_types: List of supported extensions :param files_path: Path to directory with files :return: List of full paths to files """ # Check if directory exists if not os.path.exists(files_path): raise InvalidDataSetError(f"No such directory: {files_path}") files_list = os.listdir(files_path) valid_files_list = [ os.path.join(files_path, file_name) for file_name in files_list if os.path.splitext(file_name)[1] in supported_file_types ] return sorted(valid_files_list) def read_image(image_file_path: str, data_type=np.float32) -> np.ndarray: """ Reads image file to numpy.ndarray of specified type :param data_type: Data type to store the image :param image_file_path: Full path to image to read :return: """ return imread(image_file_path).astype(dtype=data_type) def write_image(image: np.ndarray, target_directory: str, target_name: str, data_type=np.float32): """ Writes image data to file :param image: Image data :param target_directory: Path to directory to write image :param target_name: Target image file name :param data_type: Data type to write the image :return: """ os.makedirs(target_directory, exist_ok=True) data_file_path = os.path.join(target_directory, target_name) imwrite(data_file_path, data=image.astype(dtype=data_type)) def write_all_images(tiff_arr: np.ndarray, target_directory: str, data_type=np.float32): """ Writes all images in numpy array as individual files in a directory :param tiff_arr: Array containing images :param target_directory: Path to directory to write images :param data_type: Data type to write the images :return: """ print("Writing Images to Directory") # We determine the number of leading zeros to append. # Find number of digits from number of files to write, then add +1 number of leading zeros index = 1 length_str = str(tiff_arr.shape[0]) num_digits = len(length_str) for image in tiff_arr: write_image( image, target_directory, "Image_" + str(index).zfill(num_digits + 1) + ".tif", data_type ) index += 1 print("Finished Writing Images to Directory") def read_all_images( image_files_path: str, supported_image_types: list, data_type=np.float32 ) -> np.ndarray: """ Reads all images of the supported type from specified directory :param supported_image_types: List of supported extensions :param image_files_path: Path to directory with images :param data_type: Data type to store the images :return: 3-dimensional numpy.ndarray of specified type, first index being image index """ valid_files_list = get_valid_files_list( files_path=image_files_path, supported_file_types=supported_image_types ) if len(valid_files_list) == 0: raise InvalidDataSetError( f"Directory {image_files_path} " f"does not contain files of supported types {supported_image_types}" ) data_array = imread(valid_files_list).astype(dtype=data_type) return np.array(data_array) def get_image_dtype(file_prefix): tsr = TiffSequenceReader(file_prefix) tmp = tsr.read(0).dtype tsr.close() if tmp == 'uint8': return '8', 'uint8' elif tmp == 'uint16': return '16', 'uint16' elif tmp == 'float32': return '32', 'float32' else: return tmp ufo-kit-tofu-ed0e5bd/tofu/ez/main.py000066400000000000000000000537431521054151500175210ustar00rootroot00000000000000""" Created on Apr 5, 2018 @author: sergei gasilov """ import logging import os import warnings warnings.filterwarnings("ignore") import time from tofu.ez.ctdir_walker import WalkCTdirs from tofu.ez.tofu_cmd_gen import * from tofu.ez.ufo_cmd_gen import * from tofu.ez.find_axis_cmd_gen import * from tofu.ez.util import * from tifffile import imwrite from tofu.ez.params import EZVARS from tofu.config import SECTIONS from tofu.ez.Helpers.batch_search_stitch_360 import batch_stitch, batch_olap_search from tofu.ez.Helpers.stitch_funcs import find_vert_olap_2_vsteps, main_sti_mp, \ complete_message, validate_slice_range LOG = logging.getLogger(__name__) def get_CTdirs_list(inpath, fdt_names): """ Determines whether directories containing CT data are valid. Returns list of subdirectories with valid CT data :param inpath: Path to the CT directory containing subdirectories with flats/darks/tomo (and flats2 if used) :param fdt_names: Names of the directories which store flats/darks/tomo (and flats2 if used) :return: W.ctsets: List of "good" CTSets and W.lvl0: Path to root of CT sets """ # Constructor call to create WalkCTDirs object W = WalkCTdirs(inpath, fdt_names) # Find any directories containing "tomo" directory W.findCTdirs() # If "Use common flats/darks across multiple experiments" is enabled if EZVARS['inout']['shared-flatsdarks']['value']: logging.debug("Use common darks/flats") logging.debug("Path to darks: " + str(EZVARS['inout']['path2-shared-darks']['value'])) logging.debug("Path to flats: " + str(EZVARS['inout']['path2-shared-flats']['value'])) logging.debug("Path to flats2: " + str(EZVARS['inout']['path2-shared-flats2']['value'])) logging.debug("Use flats2: " + str(EZVARS['inout']['shared-flats-after']['value'])) # Determine whether paths to common flats/darks/flats2 exist if not W.checkcommonfdt(): print("Invalid path to common flats/darks") return W.ctsets, W.lvl0 else: LOG.debug("Paths to common flats/darks exist") # Check whether directories contain only .tif files if not W.checkcommonfdtFiles(): return W.ctsets, W.lvl0 else: # Sort good bad sets W.sortbadgoodsets() return W.ctsets, W.lvl0 # If "Use common flats/darks across multiple experiments" is not enabled else: LOG.debug("Use flats/darks in same directory as tomo") # Check if common flats/darks/flats2 are type 3 or 4 W.checkCTdirs() # Need to check if common flats/darks contain only .tif files W.checkCTfiles() W.sortbadgoodsets() return W.ctsets, W.lvl0 def frmt_ufo_cmds(cmds, ctset, out_pattern, ax, nviews, wh, n_per_pass, reduction_mode="median"): """formats list of processing commands for a CT set""" # two helper variables to note that PR/FFC has been done at some step swiFFC = True # FFC is always required swiPR = EZVARS['retrieve-phase']['apply-pr']['value'] # PR is an optional operation ####### PREPROCESSING ######### #if we need to use shared flat/darks we have to do it only once so we need to keep track of that #will be set to False in util/make_inpaths as soon as it was used add_value_to_dict_entry(EZVARS['inout']['shared-df-used'], False) if (EZVARS['filters']['rm_spots']['value'] or EZVARS['filters']['rm_spots_use_median']['value'])\ and not EZVARS['inout']['dryrun']['value']: # copy one flat to tmpdir now as path might change if preprocess is enabled if not EZVARS['inout']['shared-flatsdarks']['value']: path2flat = os.path.join(ctset[0], EZVARS['inout']['flats-dir']['value']) else: path2flat = os.path.join(ctset[0], EZVARS['inout']['path2-shared-flats']['value']) medflat_file = os.path.join(EZVARS['inout']['tmp-dir']['value'], "flat-median.tif") script_str = "import sys; from tifffile import imwrite; from tofu.ez.util import get_median_flat; imwrite(sys.argv[1], get_median_flat(sys.argv[2]))" cmds.append(f'python -c \'{script_str}\' "{medflat_file}" "{path2flat}"') if EZVARS['inout']['preprocess']['value']: cmds.append('echo " - Applying filter(s) to images "') cmds_prepro = get_pre_cmd(ctset, EZVARS['inout']['preprocess-command']['value'], EZVARS['inout']['tmp-dir']['value']) cmds.extend(cmds_prepro) # reset location of input data ctset = (EZVARS['inout']['tmp-dir']['value'], ctset[1]) ################################################### if EZVARS['filters']['rm_spots']['value'] or EZVARS['filters']['rm_spots_use_median']['value']: # generate commands to remove sci. spots from projections cmds.append('echo " - Creating mask of large bad spots in flat field"') cmds.append(get_find_spots_cmd(EZVARS['inout']['tmp-dir']['value'])) cmds.append('echo " - Flat-correcting and removing large spots"') cmds_inpaint = get_inp_cmd(ctset, EZVARS['inout']['tmp-dir']['value'], wh[0], nviews, reduction_mode=reduction_mode) # reset location of input data ctset = (EZVARS['inout']['tmp-dir']['value'], ctset[1]) cmds.extend(cmds_inpaint) swiFFC = False # no need to do FFC anymore ######## PHASE-RETRIEVAL ####### # Do PR separately if sinograms must be generate # todo? also if vertical ROI is defined to speed up the phase retrieval if EZVARS['retrieve-phase']['apply-pr']['value'] and EZVARS['RR']['enable-RR']['value']: # or (SECTIONS['retrieve-phase']['enable-phase']['value'] and EZVARS['inout']['input_ROI']['value']): if swiFFC: # we still need need flat correction #Inpaint No cmds.append('echo " - Phase retrieval with flat-correction"') if EZVARS['flat-correction']['smart-ffc']['value']: cmds.append(get_pr_sinFFC_cmd(ctset, reduction_mode=reduction_mode)) cmds.append(get_pr_tofu_cmd_sinFFC(ctset)) elif not EZVARS['flat-correction']['smart-ffc']['value']: cmds.append(get_pr_tofu_cmd(ctset, reduction_mode=reduction_mode)) else: # Inpaint Yes cmds.append('echo " - Phase retrieval from flat-corrected projections"') cmds.extend(get_pr_ufo_cmd(nviews, wh)) swiPR = False # no need to do PR anymore swiFFC = False # no need to do FFC anymore ################# RING REMOVAL ####################### if EZVARS['RR']['enable-RR']['value']: # Generate sinograms first if swiFFC: # we still need to do flat-field correction if EZVARS['flat-correction']['smart-ffc']['value']: # Create flat corrected images using sinFFC cmds.append(get_sinFFC_cmd(ctset, reduction_mode=reduction_mode)) # Feed the flat corrected images to sino gram generation cmds.append(get_sinos_noffc_cmd(ctset[0], EZVARS['inout']['tmp-dir']['value'], nviews, wh, n_per_pass)) elif not EZVARS['flat-correction']['smart-ffc']['value']: cmds.append('echo " - Make sinograms with flat-correction"') cmds.append(get_sinos_ffc_cmd(ctset, EZVARS['inout']['tmp-dir']['value'], nviews, wh, n_per_pass, reduction_mode=reduction_mode)) else: # we do not need flat-field correction cmds.append('echo " - Make sinograms without flat-correction"') cmds.append(get_sinos_noffc_cmd(ctset[0], EZVARS['inout']['tmp-dir']['value'], nviews, wh, n_per_pass)) swiFFC = False # Filter sinograms if EZVARS['RR']['use-ufo']['value']: if EZVARS['RR']['ufo-2d']['value']: cmds.append('echo " - Ring removal - ufo 1d stripes filter"') cmds.append(get_filter1d_sinos_cmd(EZVARS['inout']['tmp-dir']['value'], EZVARS['RR']['sx']['value'], nviews)) else: cmds.append('echo " - Ring removal - ufo 2d stripes filter"') cmds.append(get_filter2d_sinos_cmd(EZVARS['inout']['tmp-dir']['value'], \ EZVARS['RR']['sx']['value'], EZVARS['RR']['sy']['value'], nviews, wh[1])) else: cmds.append('echo " - Ring removal - sarepy filter(s)"') # note - calling an external program, not an ufo-kit script tmp = os.path.dirname(os.path.abspath(__file__)) path_to_filt = os.path.join(tmp, "RR_external.py") if os.path.isfile(path_to_filt): tmp = os.path.join(EZVARS['inout']['tmp-dir']['value'], "sinos") cmdtmp = 'python {} --sinos {} --mws {} --mws2 {} --snr {} --sort_only {}' \ .format(path_to_filt, tmp, EZVARS['RR']['spy-narrow-window']['value'], EZVARS['RR']['spy-wide-window']['value'], EZVARS['RR']['spy-wide-SNR']['value'], int(not EZVARS['RR']['spy-rm-wide']['value'])) cmds.append(cmdtmp) else: cmds.append('echo "Omitting RR because file with filter does not exist"') if not EZVARS['inout']['keep-tmp']['value']: cmds.append("rm -rf {}".format(os.path.join(EZVARS['inout']['tmp-dir']['value'], "sinos"))) # Convert filtered sinograms back to projections cmds.append('echo " - Generating proj from filtered sinograms"') cmds.append(get_sinos2proj_cmd(wh[0], n_per_pass)) # reset location of input data ctset = (EZVARS['inout']['tmp-dir']['value'], ctset[1]) # Finally - call to tofu reco cmds.append('echo " - CT with axis {}; ffc:{}, PR:{}"'.format(ax, swiFFC, swiPR)) if EZVARS['flat-correction']['smart-ffc']['value'] and swiFFC: cmds.append(get_sinFFC_cmd(ctset, reduction_mode=reduction_mode)) cmds.append(get_reco_cmd(ctset, out_pattern, ax, nviews, wh, False, swiPR, reduction_mode=reduction_mode)) else: # If not using sinFFC cmds.append(get_reco_cmd(ctset, out_pattern, ax, nviews, wh, swiFFC, swiPR, reduction_mode=reduction_mode)) return nviews, wh def execute_reconstruction(): # array with the list of commands cmds = [] # create temporary directory if not os.path.exists(EZVARS['inout']['tmp-dir']['value']): os.makedirs(EZVARS['inout']['tmp-dir']['value']) if EZVARS['inout']['clip_hist']['value']: if SECTIONS['general']['output-minimum']['value'] > SECTIONS['general']['output-maximum']['value']: raise ValueError('hmin must be smaller than hmax to convert to 8bit without contrast inversion') reduction_mode = EZVARS['flat-correction']['reduction-mode']['value'] # get list of all good CT directories to be reconstructed print('*********** Analyzing input directory ************') fdt_names = get_fdt_names() # Find valid CT directories in the input directory W, lvl0 = get_CTdirs_list(EZVARS['inout']['input-dir']['value'], fdt_names) # W is an array of tuples (path to CT directory, directory type) # if we deal with unstitched half acqusition mode data we have to # convert all frames to ordinary 180-deg projections first shared_flatsdarks_orig_value = None if EZVARS['COR']['search-method']['value'] == 5: if EZVARS_aux['half-acq']['task_type']['value']: # task_type=1 meaning overlaps must be estimated first print('# +++++++++++++++++++++++++++++++++++++++++++++++', flush=True) print('# Estimating overlaps for half acq mode data sets') print('# +++++++++++++++++++++++++++++++++++++++++++++++') if batch_olap_search(): print(f"Something went wrong during search of overlaps in half acq mode data") return export_values(os.path.join(EZVARS_aux['find360olap']['output-dir']['value'], 'ezvars_aux_from_overlap_search.yaml' ), ['ezvars_aux']) # at this point we are ready to stitch projections #list of overlaps is in the EZVARS_aux['axes-list'] print('# +++++++++++++++++++++++++++++++++++++++++++++++', flush=True) print("# Batch stitching half acq mode data sets") print('# +++++++++++++++++++++++++++++++++++++++++++++++') if batch_stitch(): print(f"Something went wrong with stitching of half acq mode data " "examine output for errors") return # Disable shared-flatsdarks as the stitched versions are now present in each # stitched-data ctset shared_flatsdarks_orig_value = EZVARS['inout']['shared-flatsdarks']['value'] if shared_flatsdarks_orig_value is True: add_value_to_dict_entry(EZVARS['inout']['shared-flatsdarks'], False) # at this point we have stitched projections in working directory for batch 360 # so we get CT sets from this directory instead of the original input W, lvl0 = get_CTdirs_list(os.path.join( EZVARS_aux['half-acq']['workdir']['value'], 'stitched-data'), fdt_names) # then proceed as usual # get list of already reconstructed sets recd_sets = findSlicesDirs(EZVARS['inout']['output-dir']['value']) # find axis of rotation and populate list of reconstruction commands print("*********** AXIS INFO ************", flush=True) # if axis is defined by user then make appropriate list if EZVARS['COR']['search-method']['value'] == 3: axlist = EZVARS['COR']['user-defined-ax']['value'].split(',') if len(axlist) == 1: ax = float(axlist[0]) axlist = [] for i in range(len(W)): axlist.append(ax + i*EZVARS['COR']['user-defined-dax']['value']) else: if len(W) != len(axlist): return "There are more data sets in the input dir than entries in the list of axes" for i in range(len(axlist)): axlist[i] = float(axlist[i]) print(axlist) num_proc_sets = 0 for i, ctset in enumerate(W): # ctset is a tuple containing a path and a type (3 or 4) if not already_recd(ctset[0], lvl0, recd_sets): setid = ctset[0][len(lvl0) + 1:] num_proc_sets += 1 # determine initial number of projections and their shape path2proj = os.path.join(ctset[0], fdt_names[2]) nviews, wh, multipage = get_dims(path2proj) ram_amount_bytes = os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') nrows = wh[0] if EZVARS['inout']['input_ROI']['value']: nrows = int(SECTIONS['reading']['height']['value']/SECTIONS['reading']['y-step']['value']) if bad_vert_ROI(multipage, path2proj, SECTIONS['reading']['y']['value'],SECTIONS['reading']['height']['value']): print('{}\t{}'.format('CTset:', ctset[0])) print('{:>30}\t{}'.format('Axis:', 'na')) print('Vertical ROI does not contain any rows.') print("{:>30}\t{}, dimensions: {}".format("Number of projections:", nviews, wh)) continue elif (SECTIONS['reading']['y']['value'] + SECTIONS['reading']['height']['value']) > wh[0]: print('Vertical ROI exceeds the number of rows') print('Resetting the interval to match the number of rows') SECTIONS['reading']['height']['value'] = wh[0] - SECTIONS['reading']['y']['value'] nrows = int(SECTIONS['reading']['height']['value']/SECTIONS['reading']['y-step']['value']) n_per_pass = int(0.9*ram_amount_bytes/ (wh[1] * nrows * 4)) # print(f" RAM {0.9*ram_amount_bytes}, width {wh[1]}, nrows {nrows}, proj size {(wh[1] * nrows * 4)}, " # f"n_per_pass {int(0.9*ram_amount_bytes/ (wh[1] * nrows * 4))}") # If EZVARS['COR']['search-method']['value'] == 4 then bypass axis search and use image midpoint if EZVARS['COR']['search-method']['value'] < 4: # Find axis of rotation using auto: correlate first/last projections if EZVARS['COR']['search-method']['value'] == 1: ax = find_axis_corr(ctset, EZVARS['inout']['input_ROI']['value'], SECTIONS['reading']['y']['value'], SECTIONS['reading']['height']['value'], multipage) # Find axis of rotation using auto: minimize STD of a slice elif EZVARS['COR']['search-method']['value'] == 2: cmds.append("echo \"Cleaning axis-search in tmp directory\"") os.system('rm -rf {}'.format(os.path.join(EZVARS['inout']['tmp-dir']['value'], 'axis-search'))) ax = find_axis_std(ctset, #EZVARS['inout']['tmp-dir']['value'], os.path.join(EZVARS['inout']['output-dir']['value'], setid), EZVARS['COR']['search-interval']['value'], EZVARS['COR']['patch-size']['value'], nviews, wh, reduction_mode=reduction_mode) else: ax = axlist[i]#EZVARS['COR']['user-defined-ax']['value'] + i * EZVARS['COR']['user-defined-dax']['value'] # If EZVARS['COR']['search-method']['value'] >= 4 then bypass axis search and use image midpoint elif EZVARS['COR']['search-method']['value'] >= 4: ax = find_axis_image_midpoint(wh) print("Bypassing axis search and using image midpoint: {}".format(ax)) add_value_to_dict_entry(SECTIONS['cone-beam-weight']['center-position-x'], str(ax)) out_pattern = os.path.join(EZVARS['inout']['output-dir']['value'], setid, 'sli/sli') cmds.append('echo ">>>>> PROCESSING {}"'.format(setid)) # rm files in temporary directory first of all to # format paths correctly and to avoid problems # when reconstructing ct sets with variable number of rows or projections cmds.append('echo "Cleaning temporary directory"') script_str = "import sys; from tofu.ez.util import clean_tmp_dirs; clean_tmp_dirs(sys.argv[1], sys.argv[2:])" args_str = " ".join(f'"{name}"' for name in fdt_names) cmds.append(f'python -c \'{script_str}\' "{EZVARS["inout"]["tmp-dir"]["value"]}" {args_str}') # call function which formats commands for this data set reset_proj_steps() nviews, wh = frmt_ufo_cmds(cmds, ctset, out_pattern, ax, nviews, wh, n_per_pass, reduction_mode=reduction_mode) save_params(setid, ax, nviews, wh) print('{}\t{}'.format('CTset:', ctset[0])) print('{:>30}\t{}'.format('Axis:', ax)) print("{:>30}\t{}, dimensions: {}".format("Number of projections:", nviews, wh)) # tmp = "Number of projections: {}, dimensions: {}".format(nviews, wh) # cmds.append("echo \"{}\"".format(tmp)) if EZVARS['nlmdn']['do-after-reco']['value']: logging.debug("Using Non-Local Means Denoising") head, tail = os.path.split(out_pattern) slidir = os.path.dirname(os.path.join(head, 'sli')) nlmdn_output = os.path.join(slidir+"-nlmdn", "sli-nlmdn-%04i.tif") cmds.append(fmt_nlmdn_ufo_cmd(slidir, nlmdn_output)) else: print("{} has been already reconstructed".format(ctset[0])) # execute commands = start reconstruction start = time.time() print("*********** PROCESSING ************", flush=True) for cmd in cmds: if not EZVARS['inout']['dryrun']['value']: os.system(cmd) else: print(cmd) if not EZVARS['inout']['keep-tmp']['value']: clean_tmp_dirs(EZVARS['inout']['tmp-dir']['value'], fdt_names) if shared_flatsdarks_orig_value and shared_flatsdarks_orig_value != EZVARS['inout']['shared-flatsdarks']['value']: add_value_to_dict_entry(EZVARS['inout']['shared-flatsdarks'], shared_flatsdarks_orig_value) print("========================================") if EZVARS_aux['vert-sti']['dovertsti']['value']: print("======= Begin Vertical Stitching =======", flush=True) add_value_to_dict_entry(EZVARS_aux['vert-sti']['input-dir'], EZVARS['inout']['output-dir']['value']) # validate slice range (common problem) try: validate_slice_range() except Exception as e: print(f"Cannot validate slice range for vertical stitching. Check directory structure") LOG.error(e) else: main_sti_mp() print("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") if (EZVARS['COR']['search-method']['value'] == 5) and EZVARS_aux['vert-sti']['dovertsti']['value']: complete_message() print("*** Done. Total processing time {} sec.".format(int(time.time() - start))) print("*** Waiting for the next job...........") return num_proc_sets def already_recd(ctset, indir, recd_sets): x = False if ctset[len(indir) + 1 :] in recd_sets: x = True return x def findSlicesDirs(lvl0): recd_sets = [] for root, dirs, files in os.walk(lvl0): for name in dirs: if name == "sli": recd_sets.append(root[len(lvl0) + 1 :]) return recd_sets def execute_from_params(params): # initialize dictionary entries load_values_from_ezdefault(EZVARS) load_values_from_ezdefault(SECTIONS) load_values_from_ezdefault(EZVARS_aux) LOG.debug("Import YAML Path: " + params.ezvars) import_values(params.ezvars, ['ezvars', 'tofu', 'ezvars_aux']) execute_reconstruction()ufo-kit-tofu-ed0e5bd/tofu/ez/params.py000066400000000000000000000361621521054151500200540ustar00rootroot00000000000000# This file is used to share params as a global variable import yaml import os from collections import OrderedDict from tofu.util import restrict_value params = {} def save_parameters(params, file_path): file_out = open(file_path, 'w') yaml.dump(params, file_out) print("Parameters file saved at: " + str(file_path)) EZVARS = OrderedDict() EZVARS_aux = OrderedDict() EZVARS_aux['find360olap'] = { 'input-dir': { 'ezdefault': os.path.join(os.path.expanduser('~'),""), 'type': str, 'help': "TODO"}, 'output-dir': { 'ezdefault': os.path.join(os.path.expanduser('~'),"ezufo_360_olap_search"), 'type': str, 'help': "TODO"}, 'tmp-dir' : { 'ezdefault': os.path.join(os.path.expanduser('~'),"ezufo_360_tmp"), 'type': str, 'help': "TODO"}, 'doRR': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'doPR': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'step': { 'ezdefault': 1, 'type': restrict_value((0, None), dtype=int), 'help': "Increment in axis of rotation"}, 'start': { 'ezdefault': 150, 'type': restrict_value((0, None), dtype=int), 'help': "Starting overlap"}, 'stop': { 'ezdefault': 200, 'type': restrict_value((0, None), dtype=int), 'help': "Maximum overlap"}, 'row': { 'ezdefault': 500, 'type': restrict_value((0, None), dtype=int), 'help': "Maximum overlap"}, 'patch-size': { 'ezdefault': 2048, 'type': restrict_value((0, None), dtype=int), 'help': "Size of the slice fragment which will be reconstructed"}, } EZVARS_aux['stitch360'] = { 'input-dir': { 'ezdefault': os.path.join(os.path.expanduser('~'),""), 'type': str, 'help': "TODO"}, 'output-dir': { 'ezdefault': os.path.join(os.path.expanduser('~'),"ezufo_360_stitched"), 'type': str, 'help': "TODO"}, 'crop': { 'ezdefault': True, 'type': bool, 'help': "TODO"}, 'olap_switch': { 'ezdefault': 0, 'type': restrict_value((0, 2), dtype=int), 'help': ""}, 'olap_min': { 'ezdefault': 1, 'type': restrict_value((0, None), dtype=int), 'help': ""}, 'olap_max': { 'ezdefault': 1, 'type': restrict_value((0, None), dtype=int), 'help': ""}, 'olap_list': { 'ezdefault': '', 'type': str, 'help': "Comma-separated list of overlaps without spaces"}, } EZVARS_aux['half-acq'] = { 'workdir': { 'ezdefault': os.path.join(os.path.expanduser('~'),"ezufo-halfacq"), 'type': str, 'help': "TODO"}, 'task_type': { 'ezdefault': 1, 'type': restrict_value((0, 1), dtype=int), 'help': "What task was requested by user"}, 'list_dirs': { 'ezdefault': '/data/foo', 'type': str, 'help': "Comma-separated list of outer loop directories which contain" "subdirectories with inner loop CT scans"}, 'list_olaps': { 'ezdefault': '50', 'type': str, 'help': "Comma-separated list of overlaps for every subdirectory in" "lexicographic order"}, } EZVARS_aux['vert-sti'] = { 'dovertsti': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'input-dir': { 'ezdefault': os.path.join(os.path.expanduser('~'), ""), 'type': str, 'help': "TODO"}, 'output-dir': { 'ezdefault': os.path.join(os.path.expanduser('~'), "ezufo_stitched_images"), 'type': str, 'help': "TODO"}, 'tmp-dir': { 'ezdefault': os.path.join(os.path.expanduser('~'), "ezufo_stitched_tmp"), 'type': str, 'help': "TODO"}, 'subdir-name': { 'ezdefault': "sli", 'type': str, 'help': "TODO"}, 'ort': { 'ezdefault': True, 'type': bool, 'help': "TODO"}, 'step': { 'ezdefault': 200, 'type': restrict_value((0, None), dtype=int), 'help': "Increment"}, 'start': { 'ezdefault': 200, 'type': restrict_value((0, None), dtype=int), 'help': "Start"}, 'stop': { 'ezdefault': 2000, 'type': restrict_value((0, None), dtype=int), 'help': ""}, 'reslice_all': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'flipud': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'task_type': { 'ezdefault': 0, 'type': restrict_value((0, 2), dtype=int), 'help': "What task was requested by user"}, 'num_olap_rows': { 'ezdefault': 60, 'type': restrict_value((0, None), dtype=int), 'help': "Increment in axis of rotation"}, 'estimate_num_olap_rows': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'ind_z00': { 'ezdefault': 0, 'type': restrict_value((0, None), dtype=int), 'help': "Index of slice in 00 directory which will be used to find the overlap"}, 'ind_z01_start': { 'ezdefault': 800, 'type': restrict_value((0, None), dtype=int), 'help': "Index of the beginning of the range of slices in 01 directory which will be used to find the overlap"}, 'ind_z01_stop': { 'ezdefault': 850, 'type': restrict_value((0, None), dtype=int), 'help': "Stop index in z01 directory for the range of slices used to find the vertical overlap"}, 'clip_hist': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'min_int_val': { 'ezdefault': -0.0003, 'type': float, 'help': "TODO"}, 'max_int_val': { 'ezdefault': 0.0002, 'type': float, 'help': "TODO"}, 'conc_row_top': { 'ezdefault': 40, 'type': restrict_value((0, None), dtype=int), 'help': "First row in the image"}, 'conc_row_bottom': { 'ezdefault': 440, 'type': restrict_value((0, None), dtype=int), 'help': "Last row in the image"}, 'cor': { 'ezdefault': 245, 'type': restrict_value((0, None), dtype=int), 'help': "Axis of rotation for half acq mode stitching"} } EZVARS_aux['axes-list'] = { # Template: # '/data/TestBatch/foo2': # outer loop scan # {'z02': 40, 'z03': 41}, # several inner loop scans # '/data/TestBatch': # {'z02': 0}, # '/data/TestBatch/TestCT360-single-tifs': # {z00: 50, z01: 49} } EZVARS['inout'] = { 'input-dir': { 'ezdefault': os.path.join(os.path.expanduser('~'),""), 'type': str, 'help': "TODO"}, 'output-dir': { 'ezdefault': os.path.join(os.path.expanduser('~'),"ezufo-rec"), 'type': str, 'help': "TODO"}, 'tmp-dir' : { 'ezdefault': os.path.join(os.path.expanduser('~'),"ezufo-tmp"), 'type': str, 'help': "TODO"}, 'darks-dir': { 'ezdefault': "darks", 'type': str, 'help': "TODO"}, 'flats-dir': { 'ezdefault': "flats", 'type': str, 'help': "TODO"}, 'tomo-dir': { 'ezdefault': "tomo", 'type': str, 'help': "TODO"}, 'flats2-dir': { 'ezdefault': "flats2", 'type': str, 'help': "TODO"}, 'bigtiff-output': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'input_ROI': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'clip_hist': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'preprocess': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'preprocess-command': { 'ezdefault': "remove-outliers size=3 threshold=500 sign=1", 'type': str, 'help': "TODO"}, 'output-ROI': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'output-x': { 'ezdefault': 0, 'type': restrict_value((0,None),dtype=int), 'help': "Crop slices: x"}, 'output-width': { 'ezdefault': 0, 'type': restrict_value((0,None),dtype=int), 'help': "Crop slices: width"}, 'output-y': { 'ezdefault': 0, 'type': restrict_value((0,None),dtype=int), 'help': "Crop slices: y"}, 'output-height': { 'ezdefault': 0, 'type': restrict_value((0,None),dtype=int), 'help': "Crop slices: height"}, 'dryrun': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'save-params': { 'ezdefault': True, 'type': bool, 'help': "TODO"}, 'keep-tmp': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'open-viewer': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'shared-flatsdarks': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'path2-shared-darks': { 'ezdefault': "Absolute path to darks", 'type': str, 'help': "TODO"}, 'path2-shared-flats': { 'ezdefault': "Absolute path to flats", 'type': str, 'help': "TODO"}, 'shared-flats-after': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'path2-shared-flats2': { 'ezdefault': "Absolute path to flats2", 'type': str, 'help': "TODO"}, 'shared-df-used': { 'ezdefault': False, 'type': bool, 'help': "Internal variable; must be set to True once " "shared flats/darks were used in the recontruction pipeline"}, 'halfacq_dir': { 'ezdefault': "Absolute path to half acqusition mode working directory", 'type': str, 'help': "TODO"}, } EZVARS['COR'] = { 'search-method': { 'ezdefault': 1, 'type': int, 'help': "TODO"}, 'search-interval': { 'ezdefault': "1010,1030,0.5", 'type': str, 'help': "TODO"}, 'patch-size': { 'ezdefault': 800, 'type': restrict_value((0,None),dtype=int), 'help': "Size of reconstructed patch [pixel]"}, 'search-row': { 'ezdefault': 400, 'type': restrict_value((0,None), dtype=int), 'help': "Search in slice from row number"}, 'min-std-apply-pr': { 'ezdefault': False, 'type': bool, 'help': "Will apply phase retreival but only while estimating the axis"}, # 'user-defined-ax': { # 'ezdefault': 0.0, # 'type': restrict_value((0,None), dtype=float), # 'help': "Axis is in column No [pixel]"}, 'user-defined-ax': { 'ezdefault': '0.0', 'type': str, 'help': "Axis is in column No [pixel] \n" "If "}, 'user-defined-dax': { 'ezdefault': 0.0, 'type': float, 'help': "TODO"}, } EZVARS['retrieve-phase']= { 'apply-pr': { 'default': False, 'ezdefault': False, 'type': bool, 'help': "Applies phase retrieval if checked"} } EZVARS['filters'] = { 'rm_spots': { 'ezdefault': False, 'type': bool, 'help': "TODO-G"}, 'rm_spots_use_median': { 'ezdefault': False, 'type': bool, 'help': "TODO-G"}, # 'spot-threshold': { # 'ezdefault': 1000, # 'type': restrict_value((0,None), dtype=float), # 'help': "TODO-G"} } EZVARS['RR'] = { 'enable-RR': { 'ezdefault': False, 'type': bool, 'help': "TODO-G"}, 'use-ufo': { 'ezdefault': True, 'type': bool, 'help': "TODO-G"}, 'ufo-2d': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'sx': { 'ezdefault': 13, 'type': restrict_value((0,None),dtype=int), 'help': "ufo ring-removal sigma horizontal (try 3..31)"}, 'sy': { 'ezdefault': 1, 'type': restrict_value((0,None),dtype=int), 'help': "ufo ring-removal sigma vertical (try 1..5)"}, 'spy-narrow-window': { 'ezdefault': 21, 'type': restrict_value((0,None),dtype=int), 'help': "window size"}, 'spy-rm-wide': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'spy-wide-window': { 'ezdefault': 91, 'type': restrict_value((0,None),dtype=int), 'help': "wind"}, 'spy-wide-SNR': { 'ezdefault': 3, 'type': restrict_value((0,None),dtype=int), 'help': "SNR"}, } EZVARS['flat-correction'] = { 'smart-ffc': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'smart-ffc-method': { 'ezdefault': "eigen", 'type': str, 'help': "TODO"}, 'eigen-pco-reps': { 'ezdefault': 4, 'type': restrict_value((0,None),dtype=int), 'help': "Flat Field Correction: Eigen PCO Repetitions"}, 'eigen-pco-downsample': { 'ezdefault': 2, 'type': restrict_value((0,None),dtype=int), 'help': "Flat Field Correction: Eigen PCO Downsample"}, 'downsample': { 'ezdefault': 4, 'type': restrict_value((0,None),dtype=int), 'help': "Flat Field Correction: Downsample"}, 'dark-scale': { 'ezdefault': 1.0, 'type': float, 'help': "Scaling dark"}, #(?) has the same name in SECTION 'flat-scale': { 'ezdefault': 1.0, 'type': float, 'help': "Scaling falt"}, #(?) has the same name in SECTION 'reduction-mode': { 'ezdefault': "average", 'type': str, 'help': "Reduction mode for dark/flat fields: median or average"}, } #TODO ADD CHECKING NLMDN SETTINGS EZVARS['nlmdn'] = { 'do-after-reco': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'input-dir': { 'ezdefault': os.getcwd(), 'type': str, 'help': "TODO"}, 'input-is-1file': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'output_pattern': { 'ezdefault': os.getcwd() + '-nlmfilt', 'type': str, 'help': "TODO"}, 'bigtiff_output': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'search-radius': { 'ezdefault': 10, 'type': int, 'help': "TODO"}, 'patch-radius': { 'ezdefault': 3, 'type': int, 'help': "TODO"}, 'h': { 'ezdefault': 0.0, 'type': float, 'help': "TODO"}, 'sigma': { 'ezdefault': 0.0, 'type': float, 'help': "TODO"}, 'window': { 'ezdefault': 0.0, 'type': float, 'help': "TODO"}, 'fast': { 'ezdefault': True, 'type': bool, 'help': "TODO"}, 'estimate-sigma': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'dryrun': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, } EZVARS['advanced'] = { 'more-reco-params': { 'ezdefault': False, 'type': bool, 'help': "TODO"}, 'parameter-type': { 'ezdefault': "", 'type': str, 'help': "TODO"}, 'enable-optimization': { 'ezdefault': False, 'type': bool, 'help': "TODO" } }ufo-kit-tofu-ed0e5bd/tofu/ez/tofu_cmd_gen.py000066400000000000000000000465301521054151500212220ustar00rootroot00000000000000#!/bin/python """ Created on Apr 6, 2018 @author: gasilos """ import os import numpy as np from tofu.ez.ufo_cmd_gen import fmt_in_out_path from tofu.ez.params import EZVARS from tofu.config import SECTIONS from tofu.ez.util import make_inpaths, fmt_in_out_path # TODO: check amount of RAM and generate sinograms in multiple passes if needed def check_lamino(): cmd = '' if not SECTIONS['cone-beam-weight']['axis-angle-x']['value'][0] == '': cmd += ' --axis-angle-x {}'.format(SECTIONS['cone-beam-weight']['axis-angle-x']['value'][0]) if not SECTIONS['general-reconstruction']['overall-angle']['value'] == '': cmd += ' --overall-angle {}'.format(SECTIONS['general-reconstruction']['overall-angle']['value']) if not SECTIONS['cone-beam-weight']['center-position-z']['value'][0] == '': cmd += ' --center-position-z {}'.format(SECTIONS['cone-beam-weight']['center-position-z']['value'][0]) if not SECTIONS['general-reconstruction']['axis-angle-y']['value'][0] == '': cmd += ' --axis-angle-y {}'.format(SECTIONS['general-reconstruction']['axis-angle-y']['value'][0]) return cmd def gpu_optim(): cmd = '' if SECTIONS['general']['verbose']['value']: cmd += ' --verbose' if EZVARS['advanced']['enable-optimization']['value']: cmd += ' --slice-memory-coeff={}'.format(SECTIONS['general-reconstruction']['slice-memory-coeff']['value']) if not SECTIONS['general-reconstruction']['slices-per-device']['value'] is None: cmd += ' --slices-per-device {}'.format(SECTIONS['general-reconstruction']['slices-per-device']['value']) if not SECTIONS['general-reconstruction']['data-splitting-policy']['value'] is None: cmd += ' --data-splitting-policy {}'.format( SECTIONS['general-reconstruction']['data-splitting-policy']['value']) return cmd def check_8bit(cmd, gray256, bit, hmin, hmax): if gray256: cmd += " --output-bitdepth {}".format(bit) # cmd += " --output-minimum \" {}\" --output-maximum \" {}\""\ # .format(hmin, hmax) cmd += ' --output-minimum " {}" --output-maximum " {}"'.format(hmin, hmax) return cmd def check_vcrop(cmd, vcrop, y, yheight, ystep, ori_height): if vcrop: cmd += " --y {} --height {} --y-step {}".format(y, yheight, ystep) else: cmd += " --height {}".format(ori_height) return cmd def check_bigtif(cmd, swi): if not swi: cmd += " --output-bytes-per-file 0" return cmd def get_1step_ct_cmd(ctset, out_pattern, ax, nviews, wh, reduction_mode="median"): # direct CT reconstruction from input dir to output dir; # obsolete, replaced by tofu reco, see get_reco_cmd() lower indir = make_inpaths(ctset[0], ctset[1]) # correct location of proj folder in case if prepro was done in_proj_dir, quatsch = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], ctset[0], EZVARS['inout']['tomo-dir']['value'], False) indir[2] = os.path.join(os.path.split(indir[2])[0], os.path.split(in_proj_dir)[1]) # format command cmd = "tofu tomo --absorptivity --fix-nan-and-inf" cmd += " --darks {} --flats {} --reduction-mode {} --projections {}".format(indir[0], indir[1], reduction_mode, indir[2]) if ctset[1] == 4: # must be equivalent to len(indir)>3 cmd += " --flats2 {}".format(indir[3]) cmd += " --output {}".format(out_pattern) cmd += " --axis {}".format(ax) cmd += " --offset {}".format(SECTIONS['general-reconstruction']['volume-angle-z']['value'][0]) cmd += " --number {}".format(nviews) if SECTIONS['reading']['step']['value'] > 0.0: cmd += ' --angle {}'.format(SECTIONS['reading']['step']['value']) cmd = check_vcrop(cmd, EZVARS['inout']['input_ROI']['value'], SECTIONS['reading']['y']['value'], SECTIONS['reading']['height']['value'], SECTIONS['reading']['y-step']['value'], wh[0]) cmd = check_8bit(cmd, EZVARS['inout']['clip_hist']['value'], SECTIONS['general']['output-bitdepth']['value'], SECTIONS['general']['output-minimum']['value'], SECTIONS['general']['output-maximum']['value']) cmd = check_bigtif(cmd, EZVARS['inout']['bigtiff-output']['value']) return cmd def get_ct_proj_cmd( out_pattern, ax, nviews, wh): # CT reconstruction from pre-processed and flat-corrected projections in_proj_dir, quatsch = fmt_in_out_path( EZVARS['inout']['tmp-dir']['value'], "obsolete;if-you-need-fix-it", EZVARS['inout']['tomo-dir']['value'], False ) cmd = "tofu tomo --projections {}".format(in_proj_dir) cmd += " --output {}".format(out_pattern) cmd += " --axis {}".format(ax) cmd += " --offset {}".format(SECTIONS['general-reconstruction']['volume-angle-z']['value'][0]) cmd += " --number {}".format(nviews) if SECTIONS['reading']['step']['value'] > 0.0: cmd += ' --angle {}'.format(SECTIONS['reading']['step']['value']) cmd = check_vcrop(cmd, EZVARS['inout']['input_ROI']['value'], SECTIONS['reading']['y']['value'], SECTIONS['reading']['height']['value'], SECTIONS['reading']['y-step']['value'], wh[0]) cmd = check_8bit(cmd, EZVARS['inout']['clip_hist']['value'], SECTIONS['general']['output-bitdepth']['value'], SECTIONS['general']['output-minimum']['value'], SECTIONS['general']['output-maximum']['value']) cmd = check_bigtif(cmd, EZVARS['inout']['bigtiff-output']['value']) return cmd def get_ct_sin_cmd(out_pattern, ax, nviews, wh): sinos_dir = os.path.join(EZVARS['inout']['tmp-dir']['value'], 'sinos-filt') cmd = 'tofu tomo --sinograms {}'.format(sinos_dir) cmd += ' --output {}'.format(out_pattern) cmd += ' --axis {}'.format(ax) cmd += ' --offset {}'.format(SECTIONS['general-reconstruction']['volume-angle-z']['value'][0]) if EZVARS['inout']['input_ROI']['value']: cmd += ' --number {}'.format(int(SECTIONS['reading']['height']['value'] / SECTIONS['reading']['y-step']['value'])) else: cmd += " --number {}".format(wh[0]) cmd += " --height {}".format(nviews) if SECTIONS['reading']['step']['value'] > 0.0: cmd += ' --angle {}'.format(SECTIONS['reading']['step']['value']) cmd = check_8bit(cmd, EZVARS['inout']['clip_hist']['value'], SECTIONS['general']['output-bitdepth']['value'], SECTIONS['general']['output-minimum']['value'], SECTIONS['general']['output-maximum']['value']) cmd = check_bigtif(cmd, EZVARS['inout']['bigtiff-output']['value']) return cmd def get_sinos_ffc_cmd(ctset, tmpdir, nviews, wh, n_per_pass, reduction_mode="median"): indir = make_inpaths(ctset[0], ctset[1]) in_proj_dir, out_pattern = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], ctset[0], EZVARS['inout']['tomo-dir']['value'], False) cmd = 'tofu sinos --absorptivity --fix-nan-and-inf' cmd += ' --darks {} --flats {} --reduction-mode {} '.format(indir[0], indir[1], reduction_mode) if ctset[1] == 4: cmd += " --flats2 {}".format(indir[3]) cmd += " --projections {}".format(in_proj_dir) cmd += " --output {}".format(os.path.join(tmpdir, "sinos/sin-%04i.tif")) cmd += " --number {}".format(nviews) cmd = check_vcrop(cmd, EZVARS['inout']['input_ROI']['value'], SECTIONS['reading']['y']['value'], SECTIONS['reading']['height']['value'], SECTIONS['reading']['y-step']['value'], wh[0]) if not EZVARS['RR']['use-ufo']['value']: # because second RR algorithm does not know how to work with multipage tiffs cmd += " --output-bytes-per-file 0" cmd += ' --flat-scale {}'.format(EZVARS['flat-correction']['flat-scale']['value']) cmd += f" --pass-size {n_per_pass}" return cmd def get_sinos_noffc_cmd(ctsetpath, tmpdir, nviews, wh, n_per_pass): in_proj_dir, out_pattern = fmt_in_out_path( EZVARS['inout']['tmp-dir']['value'], ctsetpath, EZVARS['inout']['tomo-dir']['value'], False ) cmd = "tofu sinos" cmd += " --projections {}".format(in_proj_dir) cmd += " --output {}".format(os.path.join(tmpdir, "sinos/sin-%04i.tif")) cmd += " --number {}".format(nviews) cmd = check_vcrop(cmd, EZVARS['inout']['input_ROI']['value'], SECTIONS['reading']['y']['value'], SECTIONS['reading']['height']['value'], SECTIONS['reading']['y-step']['value'], wh[0]) if not EZVARS['RR']['use-ufo']['value']: # because second RR algorithm does not know how to work with multipage tiffs cmd += " --output-bytes-per-file 0" cmd += f" --pass-size {n_per_pass}" return cmd def get_sinos2proj_cmd(proj_height, n_per_pass): quatsch, out_pattern = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], 'quatsch', EZVARS['inout']['tomo-dir']['value'], True) in_proj_dir = os.path.join(EZVARS['inout']['tmp-dir']['value'], 'sinos-filt') cmd = 'tofu sinos' cmd += ' --projections {}'.format(in_proj_dir) cmd += ' --output {}'.format(out_pattern) if not EZVARS['inout']['input_ROI']['value']: cmd += ' --number {}'.format(proj_height) else: cmd += ' --number {}'.format(int(SECTIONS['reading']['height']['value'] / SECTIONS['reading']['y-step']['value'])) cmd += f" --pass-size {n_per_pass}" return cmd def get_sinFFC_cmd(ctset, reduction_mode="median"): indir = make_inpaths(ctset[0], ctset[1]) in_proj_dir, out_pattern = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], ctset[0], EZVARS['inout']['tomo-dir']['value']) cmd = 'bmit_sin --fix-nan' cmd += ' --darks {} --flats {} --reduction-mode {} --projections {}'.format(indir[0], indir[1], reduction_mode, in_proj_dir) if ctset[1] == 4: cmd += ' --flats2 {}'.format(indir[3]) cmd += ' --output {}'.format(os.path.dirname(out_pattern)) cmd += ' --method {}'.format(EZVARS['flat-correction']['smart-ffc-method']['value']) cmd += ' --multiprocessing' cmd += ' --eigen-pco-repetitions {}'.format(EZVARS['flat-correction']['eigen-pco-reps']['value']) cmd += ' --eigen-pco-downsample {}'.format(EZVARS['flat-correction']['eigen-pco-downsample']['value']) cmd += ' --downsample {}'.format(EZVARS['flat-correction']['downsample']['value']) return cmd def get_pr_sinFFC_cmd(ctset, reduction_mode="median"): indir = make_inpaths(ctset[0], ctset[1]) in_proj_dir, out_pattern = fmt_in_out_path( EZVARS['inout']['tmp-dir']['value'], ctset[0], EZVARS['inout']['tomo-dir']['value']) cmd = 'bmit_sin --fix-nan' cmd += ' --darks {} --flats {} --reduction-mode {} --projections {}'.format(indir[0], indir[1], reduction_mode, in_proj_dir) if ctset[1] == 4: cmd += ' --flats2 {}'.format(indir[3]) cmd += ' --output {}'.format(os.path.dirname(out_pattern)) cmd += ' --method {}'.format(EZVARS['flat-correction']['smart-ffc-method']['value']) cmd += ' --multiprocessing' cmd += ' --eigen-pco-repetitions {}'.format(EZVARS['flat-correction']['eigen-pco-reps']['value']) cmd += ' --eigen-pco-downsample {}'.format(EZVARS['flat-correction']['eigen-pco-downsample']['value']) cmd += ' --downsample {}'.format(EZVARS['flat-correction']['downsample']['value']) return cmd def get_pr_tofu_cmd_sinFFC(ctset): # indir will format paths to flats darks and tomo2 correctly even if they were # pre-processed, however path to the input directory with projections # cannot be formatted with that command correctly # indir = make_inpaths(ctset[0], ctset[1]) # so we need a separate "universal" command which considers all previous steps in_proj_dir, out_pattern = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], ctset[0], EZVARS['inout']['tomo-dir']['value']) # Phase retrieval cmd = 'tofu preprocess --delta 1e-6' cmd += ' --energy {} --propagation-distance {}' \ ' --pixel-size {} --regularization-rate {:0.2f}' \ .format(SECTIONS['retrieve-phase']['energy']['value'], SECTIONS['retrieve-phase']['propagation-distance']['value'][0], SECTIONS['retrieve-phase']['pixel-size']['value'], SECTIONS['retrieve-phase']['regularization-rate']['value']) cmd += ' --projections {}'.format(in_proj_dir) cmd += ' --output {}'.format(out_pattern) cmd += ' --projection-crop-after filter' return cmd def get_pr_tofu_cmd(ctset, reduction_mode="median"): # indir will format paths to flats darks and tomo2 correctly even if they were # pre-processed, however path to the input directory with projections # cannot be formatted with that command correctly indir = make_inpaths(ctset[0], ctset[1]) # so we need a separate "universal" command which considers all previous steps in_proj_dir, out_pattern = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], ctset[0], EZVARS['inout']['tomo-dir']['value']) flats2_dir = indir[3] if ctset[1] == 4 else None return fmt_pr_cmd(indir[0], indir[1], in_proj_dir, flats2_dir, out_pattern, reduction_mode=reduction_mode) def fmt_pr_cmd(darks_dir, flats_dir, tomo_dir, flats2_dir, out_pattern, reduction_mode="median"): cmd = 'tofu preprocess --fix-nan-and-inf --projection-filter none --delta 1e-6' cmd += ' --darks {} --flats {} --reduction-mode {} --projections {}'.\ format(darks_dir, flats_dir, reduction_mode, tomo_dir) if flats2_dir: cmd += ' --flats2 {}'.format(flats2_dir) cmd += ' --output {}'.format(out_pattern) cmd += ' --energy {} --propagation-distance {}' \ ' --pixel-size {} --regularization-rate {:0.2f}' \ .format(SECTIONS['retrieve-phase']['energy']['value'], SECTIONS['retrieve-phase']['propagation-distance']['value'][0], SECTIONS['retrieve-phase']['pixel-size']['value'], SECTIONS['retrieve-phase']['regularization-rate']['value']) cmd += ' --flat-scale {}'.format(EZVARS['flat-correction']['flat-scale']['value']) return cmd def get_reco_cmd(ctset, out_pattern, ax, nviews, wh, ffc, pr, reduction_mode="median"): # direct CT reconstruction from input dir to output dir; # or CT reconstruction after preprocessing only indir = make_inpaths(ctset[0], ctset[1]) # correct location of proj folder in case if prepro was done in_proj_dir, quatsch = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], ctset[0], EZVARS['inout']['tomo-dir']['value'], False) cmd = 'tofu reco' # Laminography ? if EZVARS['advanced']['more-reco-params']['value'] is True: cmd += check_lamino() elif EZVARS['advanced']['more-reco-params']['value'] is False: cmd += ' --overall-angle 180' ############## cmd += ' --projections {}'.format(in_proj_dir) cmd += ' --output {}'.format(out_pattern) if ffc: cmd += ' --fix-nan-and-inf' cmd += ' --darks {} --flats {} --reduction-mode {}'.format(indir[0], indir[1], reduction_mode) if ctset[1] == 4: # must be equivalent to len(indir)>3 cmd += ' --flats2 {}'.format(indir[3]) if not pr: cmd += ' --absorptivity' cmd += ' --flat-scale {}'.format(EZVARS['flat-correction']['flat-scale']['value']) if pr: cmd += ( " --disable-projection-crop" " --delta 1e-6" " --energy {} --propagation-distance {}" " --pixel-size {} --regularization-rate {:0.2f}" \ .format(SECTIONS['retrieve-phase']['energy']['value'], SECTIONS['retrieve-phase']['propagation-distance']['value'][0], SECTIONS['retrieve-phase']['pixel-size']['value'], SECTIONS['retrieve-phase']['regularization-rate']['value']) ) cmd += " --center-position-x {}".format(ax) # if args.nviews==0: cmd += " --number {}".format(nviews) # elif args.nviews>0: # cmd += ' --number {}'.format(args.nviews) cmd += ' --volume-angle-z {:0.5f}'.format(SECTIONS['general-reconstruction']['volume-angle-z']['value'][0]) # rows-slices to be reconstructed # full ROI b = int(np.ceil(wh[0] / 2.0)) a = -int(wh[0] / 2.0) c = 1 if EZVARS['inout']['input_ROI']['value']: if EZVARS['RR']['enable-RR']['value']: h2 = SECTIONS['reading']['height']['value'] / SECTIONS['reading']['y-step']['value'] / 2.0 b = np.ceil(h2) a = -int(h2) else: h2 = int(wh[0] / 2.0) a = SECTIONS['reading']['y']['value'] - h2 b = SECTIONS['reading']['y']['value'] + SECTIONS['reading']['height']['value'] - h2 c = SECTIONS['reading']['y-step']['value'] cmd += ' --region={},{},{}'.format(a, b, c) # crop of reconstructed slice in the axial plane b = wh[1] / 2 if EZVARS['inout']['output-ROI']['value']: if EZVARS['inout']['output-x']['value'] != 0 or EZVARS['inout']['output-width']['value'] != 0: cmd += ' --x-region={},{},{}'.format(EZVARS['inout']['output-x']['value'] - b, EZVARS['inout']['output-x']['value'] + EZVARS['inout']['output-width']['value'] - b, 1) if EZVARS['inout']['output-y']['value'] != 0 or EZVARS['inout']['output-height']['value'] != 0: cmd += ' --y-region={},{},{}'.format(EZVARS['inout']['output-y']['value'] - b, EZVARS['inout']['output-y']['value'] + EZVARS['inout']['output-height']['value'] - b, 1) # cmd = check_vcrop(cmd, EZVARS['inout']['input_ROI']['value'], SECTIONS['reading']['y']['value'], SECTIONS['reading']['height']['value'], SECTIONS['reading']['y-step']['value'], wh[0]) cmd = check_8bit(cmd, EZVARS['inout']['clip_hist']['value'], SECTIONS['general']['output-bitdepth']['value'], SECTIONS['general']['output-minimum']['value'], SECTIONS['general']['output-maximum']['value']) cmd = check_bigtif(cmd, EZVARS['inout']['bigtiff-output']['value']) # Optimization cmd += gpu_optim() return cmd def get_find_spots_cmd(tmpdir): ######### CREATE MASK ######### flat1_file = os.path.join(tmpdir, "flat-median.tif") mask_file = os.path.join(tmpdir, "mask.tif") cmd = 'tofu find-large-spots' cmd += f" --images {flat1_file}" cmd += f" --output {mask_file} --output-bytes-per-file 0" cmd += f" --spot-threshold {SECTIONS['find-large-spots']['spot-threshold']['value']}" cmd += f" --gauss-sigma {SECTIONS['find-large-spots']['gauss-sigma']['value']}" if SECTIONS['find-large-spots']['method']['value'] == 'median': cmd += f" --method median" cmd += f" --spot-threshold-mode {SECTIONS['find-large-spots']['spot-threshold-mode']['value']}" cmd += f" --median-direction {SECTIONS['find-large-spots']['median-direction']['value']}" cmd += f" --median-width {SECTIONS['find-large-spots']['median-width']['value']}" cmd += f" --grow-threshold {SECTIONS['find-large-spots']['grow-threshold']['value']}" cmd += f" --dilation-disk-radius {SECTIONS['find-large-spots']['dilation-disk-radius']['value']}" return cmd ufo-kit-tofu-ed0e5bd/tofu/ez/ufo_cmd_gen.py000066400000000000000000000274551521054151500210430ustar00rootroot00000000000000#!/bin/python """ Created on Apr 6, 2018 @author: gasilos """ import os from tofu.util import next_power_of_two from tofu.ez.params import EZVARS from tofu.config import SECTIONS from tofu.ez.util import enquote, make_inpaths, fmt_in_out_path def make_outpaths(lvl0, flats2): """ Creates a list of paths to flats/darks/tomo directories in tmp data only used in one place to format paths in the temporary directory :param lvl0: Root of directory containing flats/darks/tomo :param flats2: The type of directory: 3 contains flats/darks/tomo 4 contains flats/darks/tomo/flats2 :return: List of paths to the filtered darks/flats/tomo and flats2 (if used) """ indir = [] for i in [EZVARS['inout']['darks-dir']['value'], EZVARS['inout']['flats-dir']['value'], EZVARS['inout']['tomo-dir']['value']]: indir.append(os.path.join(lvl0, i)) if flats2 - 3: indir.append(os.path.join(lvl0, EZVARS['inout']['flats2-dir']['value'])) return indir def check_vcrop(cmd, vcrop, y, yheight, ystep): if vcrop: cmd += " --y {} --height {} --y-step {}".format(y, yheight, ystep) return cmd def check_bigtif(cmd, swi): if not swi: cmd += " bytes-per-file=0 tiff-bigtiff=False" return cmd def get_pr_ufo_cmd(nviews, wh): in_proj_dir, out_pattern = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], "quatsch", EZVARS['inout']['tomo-dir']['value']) cmds = [] pad_width = next_power_of_two(wh[1] + 50) pad_height = next_power_of_two(wh[0] + 50) pad_x = (pad_width - wh[1]) / 2 pad_y = (pad_height - wh[0]) / 2 cmd = 'ufo-launch read path={} height={} number={}'.format(in_proj_dir, wh[0], nviews) cmd += ' ! pad x={} width={} y={} height={}'.format(pad_x, pad_width, pad_y, pad_height) cmd += ' addressing-mode=clamp_to_edge' cmd += ' ! fft dimensions=2 ! retrieve-phase' cmd += ' energy={} distance={} pixel-size={} regularization-rate={:0.2f}' \ .format(SECTIONS['retrieve-phase']['energy']['value'], SECTIONS['retrieve-phase']['propagation-distance']['value'][0], SECTIONS['retrieve-phase']['pixel-size']['value'], SECTIONS['retrieve-phase']['regularization-rate']['value']) cmd += ' ! ifft dimensions=2 crop-width={} crop-height={}' \ .format(pad_width, pad_height) cmd += ' ! crop x={} width={} y={} height={}'.format(pad_x, wh[1], pad_y, wh[0]) cmd += ' ! opencl kernel=\'absorptivity\' ! opencl kernel=\'fix_nan_and_inf\' !' cmd += ' write filename={}'.format(enquote(out_pattern)) cmds.append(cmd) if not EZVARS['inout']['keep-tmp']['value']: cmds.append('rm -rf {}'.format(in_proj_dir)) return cmds def get_filter1d_sinos_cmd(tmpdir, RR, nviews): sin_in = os.path.join(tmpdir, 'sinos') out_pattern = os.path.join(tmpdir, 'sinos-filt/sin-%04i.tif') pad_height = next_power_of_two(nviews + 500) pad_y = (pad_height - nviews) / 2 cmd = 'ufo-launch read path={}'.format(sin_in) cmd += ' ! pad y={} height={}'.format(pad_y, pad_height) cmd += ' addressing-mode=clamp_to_edge' cmd += ' ! transpose ! fft dimensions=1 ! filter-stripes1d strength={}'.format(RR) cmd += ' ! ifft dimensions=1 ! transpose' cmd += ' ! crop y={} height={}'.format(pad_y, nviews) cmd += ' ! write filename={}'.format(enquote(out_pattern)) return cmd def get_filter2d_sinos_cmd(tmpdir, sig_hor, sig_ver, nviews, w): sin_in = os.path.join(tmpdir, "sinos") out_pattern = os.path.join(tmpdir, "sinos-filt/sin-%04i.tif") pad_height = next_power_of_two(nviews + 500) pad_y = (pad_height - nviews) / 2 pad_width = next_power_of_two(w + 500) pad_x = (pad_width - w) / 2 cmd = "ufo-launch read path={}".format(sin_in) cmd += " ! pad x={} width={} y={} height={}".format(pad_x, pad_width, pad_y, pad_height) cmd += " addressing-mode=mirrored_repeat" cmd += " ! fft dimensions=2 ! filter-stripes horizontal-sigma={} vertical-sigma={}".format( sig_hor, sig_ver ) cmd += " ! ifft dimensions=2 crop-width={} crop-height={}".format(pad_width, pad_height) cmd += " ! crop x={} width={} y={} height={}".format(pad_x, w, pad_y, nviews) cmd += " ! write filename={}".format(enquote(out_pattern)) return cmd def get_pre_cmd( ctset, pre_cmd, tmpdir): indir = make_inpaths(ctset[0], ctset[1]) outdir = make_outpaths(tmpdir, ctset[1]) # add index to the name of the output directory with projections # if enabled preprocessing is always the first step outdir[2] = os.path.join(tmpdir, "proj-step1") # we also must create this directory to format paths correctly if not os.path.exists(outdir[2]): os.makedirs(outdir[2]) cmds = [] for i, fol in enumerate(indir): in_pattern = os.path.join(fol, "*.tif") out_pattern = os.path.join(outdir[i], "frame-%04i.tif") cmds.append("ufo-launch") cmds[i] += " read path={} ! ".format(enquote(in_pattern)) cmds[i] += pre_cmd cmds[i] += " ! write filename={}".format(enquote(out_pattern)) return cmds def get_inp_cmd(ctset, tmpdir, N, nviews, reduction_mode="median"): indir = make_inpaths(ctset[0], ctset[1]) cmds = [] # ######### CREATE MASK ######### # flat1_file = os.path.join(tmpdir, "flat-median.tif") mask_file = os.path.join(tmpdir, "mask.tif") # # generate mask # cmd = 'tofu find-large-spots --images {}'.format(flat1_file) # cmd += ' --spot-threshold {} --gauss-sigma {}'.format( # SECTIONS['find-large-spots']['spot-threshold']['value'], # SECTIONS['find-large-spots']['gauss-sigma']['value']) # # cmd += " --method median --median-width 20 --dilation-disk-radius 3 --gauss-sigma 100.0" \ # # " --spot-threshold 3800.0 --spot-threshold-mode absolute --grow-threshold 350.0" \ # # " --find-large-spots-padding-mode mirrored_repeat" # cmd += ' --output {} --output-bytes-per-file 0'.format(mask_file) # cmds.append(cmd) ######### FLAT-CORRECT ######### in_proj_dir, out_pattern = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], ctset[0], EZVARS['inout']['tomo-dir']['value']) if EZVARS['flat-correction']['smart-ffc']['value']: cmd = 'bmit_sin --fix-nan' cmd += ' --darks {} --flats {}'.format(indir[0], indir[1]) cmd += ' --projections {}'.format(in_proj_dir) cmd += ' --output {}'.format(os.path.dirname(out_pattern)) cmd += ' --multiprocessing' #cmd += ' --output {}'.format(out_pattern) if ctset[1] == 4: cmd += ' --flats2 {}'.format(indir[3]) # Add options for eigen-pco-repetitions etc. cmd += ' --eigen-pco-repetitions {}'.format(EZVARS['flat-correction']['eigen-pco-reps']['value']) cmd += ' --eigen-pco-downsample {}'.format(EZVARS['flat-correction']['eigen-pco-downsample']['value']) cmd += ' --downsample {}'.format(EZVARS['flat-correction']['downsample']['value']) #if not SECTIONS['retrieve-phase']['enable-phase']['value']: # cmd += ' --absorptivity' ???? # Todo: check if takes neglog? or only computes transmission? # in case of latter add absorptivity and fix nans cmds.append(cmd) elif not EZVARS['flat-correction']['smart-ffc']['value']: cmd = 'tofu flatcorrect --fix-nan-and-inf' cmd += ' --darks {} --flats {} --reduction-mode {}'.format(indir[0], indir[1], reduction_mode) cmd += ' --projections {}'.format(in_proj_dir) cmd += ' --output {}'.format(out_pattern) if ctset[1] == 4: cmd += ' --flats2 {}'.format(indir[3]) if not EZVARS['retrieve-phase']['apply-pr']['value']: cmd += ' --absorptivity --fix-nan-and-inf' cmd += ' --flat-scale {}'.format(EZVARS['flat-correction']['flat-scale']['value']) cmds.append(cmd) if not EZVARS['inout']['keep-tmp']['value'] and EZVARS['inout']['preprocess']['value']: cmds.append('rm -rf {}'.format(indir[0])) cmds.append('rm -rf {}'.format(indir[1])) cmds.append('rm -rf {}'.format(in_proj_dir)) if len(indir) > 3: cmds.append("rm -rf {}".format(indir[3])) ######### INPAINT ######### in_proj_dir, out_pattern = fmt_in_out_path(EZVARS['inout']['tmp-dir']['value'], ctset[0], EZVARS['inout']['tomo-dir']['value']) cmd = "ufo-launch [read path={} height={} number={}".format(in_proj_dir, N, nviews) cmd += ", read path={}]".format(mask_file) cmd += " ! horizontal-interpolate ! " cmd += "write filename={}".format(enquote(out_pattern)) cmds.append(cmd) if not EZVARS['inout']['keep-tmp']['value']: cmds.append("rm -rf {}".format(in_proj_dir)) return cmds def get_crop_sli(out_pattern): cmd = 'ufo-launch read path={}/*.tif ! '.format(os.path.dirname(out_pattern)) cmd += 'crop x={} width={} y={} height={} ! '. \ format(EZVARS['inout']['output-x']['value'], EZVARS['inout']['output-width']['value'], EZVARS['inout']['output-y']['value'], EZVARS['inout']['output-height']['value']) cmd += 'write filename={}'.format(out_pattern) if EZVARS['inout']['clip_hist']['value']: cmd += ' bits=8 rescale=False' return cmd def fmt_nlmdn_ufo_cmd(inpath: str, outpath: str): """ :param inp: Path to input directory before NLMDN applied :param out: Path to output directory after NLMDN applied :return: """ cmd = 'ufo-launch read path={}'.format(inpath) cmd += ' ! non-local-means patch-radius={}'.format(EZVARS['nlmdn']['patch-radius']['value']) cmd += ' search-radius={}'.format(EZVARS['nlmdn']['search-radius']['value']) cmd += ' h={}'.format(EZVARS['nlmdn']['h']['value']) cmd += ' sigma={}'.format(EZVARS['nlmdn']['sigma']['value']) cmd += ' window={}'.format(EZVARS['nlmdn']['window']['value']) cmd += ' fast={}'.format(EZVARS['nlmdn']['fast']['value']) cmd += ' estimate-sigma={}'.format(EZVARS['nlmdn']['estimate-sigma']['value']) cmd += ' ! write filename={}'.format(enquote(outpath)) if not EZVARS['nlmdn']['bigtiff_output']['value']: cmd += " bytes-per-file=0 tiff-bigtiff=False" if EZVARS['inout']['clip_hist']['value']: cmd += f" bits={SECTIONS['general']['output-bitdepth']['value']} rescale=False" return cmd def fmt_stitch_cmd(inpath, bigtiff, bits, outpath, num, w, ax, cro=0, reduction_mode=None): cmd = 'ufo-launch' st = 'start' if bigtiff: st = 'image-start' start = num if num == 0: # Single image treated as a pair (darks / flats) start = 0 num = 1 set_1 = f"read path={inpath} {st}={start} number={num}" set_2 = f"read path={inpath} number={num}" if reduction_mode == 'average': set_2 = set_2 + " ! average" set_1 = set_2 elif reduction_mode == 'median': set_2 = set_2 + f" ! stack number={num} ! flatten mode=median" set_1 = set_2 if ax <= w // 2: cmd += f" [{set_1} ! flip," \ f" {set_2}]" \ f" ! stitch shift={w - 2*ax}" else: cmd += f" [{set_2} ! flip,"\ f" {set_1}]" \ f" ! stitch shift={w - 2*ax}" ax = w - ax cmd += " blend=True adjust-mean=TRUE !" # crop if cro != 0: cmd += f" crop x={cro} width={2*(w - ax - cro)} !" # checking that after multiplication values are within the valid range for the # respective input data type lim = 65535 if bits == 8: lim = 255 if (bits == 16) or (bits == 8): cmd += f" calculate expression=\"'v < 0 ? 0 : (v > {lim} ? {lim} : v)'\" !" cmd += f" write filename={os.path.join(outpath, 'stitched-%04i.tif')}" if (bits == 16) or (bits == 8): cmd += f" bits={bits} rescale=FALSE" #print(cmd) return cmdufo-kit-tofu-ed0e5bd/tofu/ez/util.py000066400000000000000000000544341521054151500175500ustar00rootroot00000000000000""" Created on Apr 20, 2020 @author: gasilos """ import os, glob, tifffile import shutil from tofu.ez.ctdir_walker import VALID_EXTS from tofu.ez.params import EZVARS, EZVARS_aux from tofu.config import SECTIONS from tofu.util import get_filenames, get_first_filename, get_image_shape, read_image, restrict_value, tupleize from PyQt5.QtCore import QRegExp from PyQt5.QtGui import QRegExpValidator import argparse from tofu.util import TiffSequenceReader import numpy as np import yaml import logging def get_dims(pth): # get number of projections and projections dimensions first_proj = get_first_filename(pth, valid_exts=VALID_EXTS) multipage = False try: shape = get_image_shape(first_proj) except ImportError: raise except: raise ValueError(f"Failed to determine size and number of images in {pth} from {first_proj}") if len(shape) == 2: # single page input return len(get_filenames(pth)), [shape[-2], shape[-1]], multipage elif len(shape) == 3: # multipage input nviews = 0 for i in get_filenames(pth): nviews += get_image_shape(i)[0] multipage = True return nviews, [shape[-2], shape[-1]], multipage return -6, [-6, -6] def get_data_cube_info(pth): ext = os.path.splitext(get_first_filename(pth, valid_exts=VALID_EXTS))[1] im_names = glob.glob(os.path.join(pth, '*' + ext)) nslices = len(im_names) im = read_image(im_names[0]) N, M = im.shape tmp = im.dtype bit = 0; dt = 'unsupported' if tmp == 'uint8': bit = 8; dt = 'uint8' elif tmp == 'uint16': bit = 16; dt = 'uint16' elif tmp == 'float32': bit = 32; dt = 'float32' ram_amount_bytes = os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') n_per_pass = int(0.9 * ram_amount_bytes / (N * M * 4)) return nslices, N, M, bit, dt, n_per_pass, ext def bad_vert_ROI(multipage, path2proj, y, height): if multipage: with tifffile.TiffFile(get_filenames(path2proj)[0]) as tif: proj = tif.pages[0].asarray().astype(float) else: proj = read_image(get_filenames(path2proj)[0]).astype(float) y_region = slice(y, min(y + height, proj.shape[0]), 1) proj = proj[y_region, :] if proj.shape[0] == 0: return True else: return False def make_copy_of_flat(flatdir, flat_copy_name, dryrun): first_flat_file = get_first_filename(flatdir) try: shape = get_image_shape(first_flat_file) except: raise ValueError("Failed to determine size and number of flats in {}".format(flatdir)) cmd = "" if len(shape) == 2: last_flat_file = get_filenames(flatdir)[-1] cmd = "cp {} {}".format(last_flat_file, flat_copy_name) else: flat = read_image(get_filenames(flatdir)[-1])[-1] if dryrun: cmd = 'echo Will save a copy of flat into "{}"'.format(flat_copy_name) else: tifffile.imwrite(flat_copy_name, flat) # something isn't right in this logic? It used to work but then # stopped to create a copy of flat correctly. Going to point to all flats simply return cmd def clean_tmp_dirs(tmpdir, fdt_names): tmp_pattern = ["proj", "sino", "mask", "flat", "dark", "radi"] tmp_pattern += fdt_names # clean directories in tmpdir if their names match pattern if os.path.exists(tmpdir): for filename in os.listdir(tmpdir): if filename[:4] in tmp_pattern: path = os.path.join(tmpdir, filename) if os.path.isdir(path): shutil.rmtree(path) else: os.remove(path) def make_inpaths(lvl0, flats2): """ Creates a list of paths to flats/darks/tomo directories :param lvl0: Root of directory containing flats/darks/tomo :param flats2: The type of directory: 3 contains flats/darks/tomo 4 contains flats/darks/tomo/flats2 :return: List of abs paths to the directories containing darks/flats/tomo and flats2 (if used) """ indir = [] # If using flats/darks/flats2 in same dir as tomo # or darks/flats were processed and are already in temporary directory if not EZVARS['inout']['shared-flatsdarks']['value'] or \ EZVARS['inout']['shared-df-used']['value']: for i in [EZVARS['inout']['darks-dir']['value'], EZVARS['inout']['flats-dir']['value'], EZVARS['inout']['tomo-dir']['value']]: indir.append(os.path.join(lvl0, i)) if flats2 - 3: indir.append(os.path.join(lvl0, EZVARS['inout']['flats2-dir']['value'])) return indir # If using common flats/darks/flats2 across multiple reconstructions # and that is the first occasion when they are required elif EZVARS['inout']['shared-flatsdarks']['value'] and \ not EZVARS['inout']['shared-df-used']['value']: indir.append(EZVARS['inout']['path2-shared-darks']['value']) indir.append(EZVARS['inout']['path2-shared-flats']['value']) indir.append(os.path.join(lvl0, EZVARS['inout']['tomo-dir']['value'])) if EZVARS['inout']['shared-flats-after']['value']: indir.append(EZVARS['inout']['path2-shared-flats2']['value']) if (EZVARS['COR']['search-method']['value'] != 1) and (EZVARS['COR']['search-method']['value'] != 2): # if axis search is using shared darks/flats, we still have to use them once more for ffc add_value_to_dict_entry(EZVARS['inout']['shared-df-used'], True) return indir _PROJ_STEPS = 0 def reset_proj_steps(): global _PROJ_STEPS _PROJ_STEPS = 0 def fmt_in_out_path(tmpdir, indir, raw_proj_dir_name, croutdir=True): global _PROJ_STEPS # suggests input and output path to directory with proj # depending on number of processing steps applied so far if _PROJ_STEPS == 0: # no projections in temporary directory in_proj_dir = os.path.join(indir, raw_proj_dir_name) elif _PROJ_STEPS > 0: # there are directories proj-stepX in tmp dir in_proj_dir = os.path.join(tmpdir, f"proj-step{_PROJ_STEPS}") else: raise ValueError("Something is wrong with in/out filenames") out_step = _PROJ_STEPS + 1 out_proj_dir = os.path.join(tmpdir, f"proj-step{out_step}") # create output directory and advance the pipeline step if croutdir: _PROJ_STEPS = out_step if not os.path.exists(out_proj_dir): os.makedirs(out_proj_dir) # return names of input directory and output pattern with abs path return in_proj_dir, os.path.join(out_proj_dir, "proj-%04i.tif") def enquote(string, escape=False): addition = '\\"' if escape else '"' return addition + string + addition def extract_values_from_dict(dict): """Return a list of values to be saved as a text file""" new_dict = {} for key1 in dict.keys(): new_dict[key1] = {} for key2 in dict[key1].keys(): dict_entry = dict[key1][key2] if 'value' in dict_entry: new_dict[key1][key2] = {} value_type = type(dict_entry['value']) #print(key1, key2, dict_entry) if dict_entry['value'] is None: new_dict[key1][key2]['value'] = None elif value_type is list or value_type is tuple: new_dict[key1][key2]['value'] = str(reverse_tupleize()(dict_entry['value'])) else: new_dict[key1][key2]['value'] = dict_entry['value'] if key1 == 'axes-list': new_dict[key1][key2] = dict_entry return new_dict def import_values_from_dict(dict, imported_dict): """Import a list of values from an imported dictionary""" for key1 in imported_dict.keys(): if key1 == 'axes-list': for key2 in imported_dict[key1].keys(): dict[key1][key2] = imported_dict[key1][key2] else: for key2 in imported_dict[key1].keys(): add_value_to_dict_entry(dict[key1][key2], imported_dict[key1][key2]['value']) def export_values(filePath, param_sections): """Export the values of EZVARS and SECTIONS as a YAML file""" combined_dict = {} for i in param_sections: if i == 'ezvars_aux': try: combined_dict['ezvars_aux'] = extract_values_from_dict(EZVARS_aux) except: print("Error: cannot import EZVARS_aux section") return 1 if i == 'tofu': try: combined_dict['sections'] = extract_values_from_dict(SECTIONS) except: print("Error: cannot import TOFU section") return 1 if i == 'ezvars': try: combined_dict['ezvars'] = extract_values_from_dict(EZVARS) except: print("Error: cannot import EZVARS section") return 1 print("Exporting values to: " + str(filePath)) #print(combined_dict) write_yaml(filePath, combined_dict) print("Finished exporting") return 0 def import_values(filePath, param_sections): """Import EZVARS and SECTIONS from a YAML file""" #param_sections options: ['ezvars', 'tofu', 'ezvars_aux'] print("Importing values from: " +str(filePath)) yaml_data = dict(read_yaml(filePath)) for i in param_sections: if i == 'ezvars': try: import_values_from_dict(EZVARS, yaml_data['ezvars']) except: print("Error: cannot import EZVARS section") return 1 if i == 'tofu': try: import_values_from_dict(SECTIONS, yaml_data['sections']) except: print("Error: cannot import TOFU section") return 1 if i == 'ezvars_aux': try: import_values_from_dict(EZVARS_aux, yaml_data['ezvars_aux']) except: print("Error: cannot import EZVARS_aux section") return 1 print("Finished importing") return 0 #print(yaml_data) def save_params(ctsetname, ax, nviews, wh): if not EZVARS['inout']['dryrun']['value'] and not os.path.exists(EZVARS['inout']['output-dir']['value']): os.makedirs(EZVARS['inout']['output-dir']['value']) tmp = os.path.join(EZVARS['inout']['output-dir']['value'], ctsetname) if not EZVARS['inout']['dryrun']['value'] and not os.path.exists(tmp): os.makedirs(tmp) if not EZVARS['inout']['dryrun']['value'] and EZVARS['inout']['save-params']['value']: # Dump the params .yaml file try: filepath = os.path.join(tmp, "tofuez_all_parameters.yaml") export_values(filepath, ['ezvars', 'tofu', 'ezvars_aux']) except FileNotFoundError: print("Something went wrong when exporting the .yaml parameters file") # Dump the reco.params output file fname = os.path.join(tmp, 'reco_params_simple.txt') f = open(fname, 'w') f.write('*** General ***\n') f.write('Input directory {}\n'.format(EZVARS['inout']['input-dir']['value'])) if ctsetname == '': ctsetname = '.' f.write('CT set {}\n'.format(ctsetname)) if EZVARS['COR']['search-method']['value'] == 1 or EZVARS['COR']['search-method']['value'] == 2: f.write('Center of rotation {} (auto estimate)\n'.format(ax)) elif EZVARS['COR']['search-method']['value'] == 3: f.write('Center of rotation {} (user defined)\n'.format(ax)) else: f.write('Center of rotation {} (half acq mode data)\n'.format(ax)) f.write('Dimensions of projections {} x {} (height x width)\n'.format(wh[0], wh[1])) f.write('Number of projections {}\n'.format(nviews)) f.write('*** Preprocessing ***\n') tmp = 'None' if EZVARS['inout']['preprocess']['value']: tmp = EZVARS['inout']['preprocess-command']['value'] f.write(' '+tmp+'\n') f.write('*** Image filters ***\n') if EZVARS['filters']['rm_spots']['value']: f.write(' Remove large spots enabled\n') f.write(' threshold {}\n'.format(SECTIONS['find-large-spots']['spot-threshold']['value'])) f.write(' sigma {}\n'.format(SECTIONS['find-large-spots']['gauss-sigma']['value'])) if EZVARS['filters']['rm_spots_use_median']['value']: f.write(' Median filter was used to find spots\n') # for i in SECTIONS['find-large-spots'].keys(): # f.write(f"\t{i}\t{SECTIONS['find-large-spots'][i]['value']}\n") f.write(f"\tMedian width {SECTIONS['find-large-spots']['median-width']['value']}\n") f.write(f"\tDilation disk radius {SECTIONS['find-large-spots']['dilation-disk-radius']['value']}\n") f.write(f"\tGrow threshold {SECTIONS['find-large-spots']['grow-threshold']['value']}\n") f.write(f"\tThreshold mode {SECTIONS['find-large-spots']['spot-threshold-mode']['value']}\n") f.write(f"\tMedian direction {SECTIONS['find-large-spots']['median-direction']['value']}\n") else: f.write(' Remove large spots disabled\n') if EZVARS['retrieve-phase']['apply-pr']['value']: f.write(' Phase retrieval enabled\n') f.write(' energy {} keV\n'.format(SECTIONS['retrieve-phase']['energy']['value'])) f.write(' pixel size {:0.1f} um\n'.format(SECTIONS['retrieve-phase']['pixel-size']['value'] * 1e6)) f.write(' sample-detector distance {} m\n'.format(SECTIONS['retrieve-phase']['propagation-distance']['value'][0])) f.write(f" delta/beta ratio {10**SECTIONS['retrieve-phase']['regularization-rate']['value']}\n") else: f.write(' Phase retrieval disabled\n') f.write('*** Ring removal ***\n') if EZVARS['RR']['enable-RR']['value']: if EZVARS['RR']['use-ufo']['value']: tmp = '2D' if EZVARS['RR']['ufo-2d']['value']: tmp = '1D' f.write(' RR with ufo {} stripes filter\n'.format(tmp)) f.write(f' sigma horizontal {EZVARS["RR"]["sx"]["value"]}') f.write(f' sigma vertical {EZVARS["RR"]["sy"]["value"]}') else: if EZVARS['RR']['spy-rm-wide']['value']: tmp = ' RR with ufo sarepy remove wide filter, ' tmp += 'window {}, SNR {}\n'.format( EZVARS['RR']['spy-wide-window']['value'], EZVARS['RR']['spy-wide-SNR']['value']) f.write(tmp) f.write(' ' 'RR with ufo sarepy sorting filter, window {}\n'. format(EZVARS['RR']['spy-narrow-window']['value']) ) else: f.write('RR disabled\n') f.write('*** Region of interest ***\n') if EZVARS['inout']['input_ROI']['value']: f.write('Vertical ROI defined\n') f.write(' first row {}\n'.format(SECTIONS['reading']['y']['value'])) f.write(' height {}\n'.format(SECTIONS['reading']['height']['value'])) f.write(' reconstruct every {}th row\n'.format(SECTIONS['reading']['y-step']['value'])) else: f.write('Vertical ROI: all rows\n') if EZVARS['inout']['output-ROI']['value']: f.write('ROI in slice plane defined\n') f.write(' x {}\n'.format(EZVARS['inout']['output-x']['value'])) f.write(' width {}\n'.format(EZVARS['inout']['output-width']['value'])) f.write(' y {}\n'.format(EZVARS['inout']['output-y']['value'])) f.write(' height {}\n'.format(EZVARS['inout']['output-height']['value'])) else: f.write('ROI in slice plane not defined\n') f.write('*** Reconstructed values ***\n') if EZVARS['inout']['clip_hist']['value']: f.write(' {} bit\n'.format(SECTIONS['general']['output-bitdepth']['value'])) f.write(' Min value in 32-bit histogram {}\n'.format(SECTIONS['general']['output-minimum']['value'])) f.write(' Max value in 32-bit histogram {}\n'.format(SECTIONS['general']['output-maximum']['value'])) else: f.write(' 32bit, histogram untouched\n') f.write('*** Optional reco parameters ***\n') if SECTIONS['general-reconstruction']['volume-angle-z']['value'][0] > 0: f.write(' Rotate volume by: {:0.3f} deg\n'.format(SECTIONS['general-reconstruction']['volume-angle-z']['value'][0])) f.close() ### ALL The following was added by Philmo Gu. I moved it to tofu/ez/utils. . # The important function def add_value_to_dict_entry(dict_entry, value): """Add a value to a dictionary entry. An empty string will insert the ezdefault value""" if 'action' in dict_entry: # no 'type' can be defined in dictionary entries with 'action' key dict_entry['value'] = bool(value) return elif value == '' or value == None: # takes default value if empty string or null if dict_entry['ezdefault'] is None: dict_entry['value'] = dict_entry['ezdefault'] else: dict_entry['value'] = dict_entry['type'](dict_entry['ezdefault']) else: try: dict_entry['value'] = dict_entry['type'](value) except argparse.ArgumentTypeError: # Outside of range of type dict_entry['value'] = dict_entry['type'](value, clamp=True) except ValueError: # int can't convert string with decimal (e.g. "1.0" -> 1) dict_entry['value'] = dict_entry['type'](float(value)) # Few things are helpful but most are not used or not fully implemented def get_ascii_validator(): """Returns a validator that only allows the input of visible ASCII characters""" regexp = "[-A-Za-z0-9_]*" return QRegExpValidator(QRegExp(regexp)) def get_alphabet_lowercase_validator(): """Returns a validator that only allows the input of lowercase ASCII characters""" regexp = "[a-z]*" return QRegExpValidator(QRegExp(regexp)) def get_int_validator(): """Returns a validator that only allows the input of integers""" # Note: QIntValidator allows commas, which is undesirable regexp = "[\-]?[0-9]*" return QRegExpValidator(QRegExp(regexp)) def get_double_validator(): """Returns a validator that only allows the input of floating point number""" # Note: QDoubleValidator allows commas before period, which is undesirable regexp = "[\-]?[0-9]*[.]?[0-9]*" return QRegExpValidator(QRegExp(regexp)) def get_tuple_validator(): """Returns a validator that only allows a tuple of floating point numbers""" regexp = "[-0-9,.]*" return QRegExpValidator(QRegExp(regexp)) def load_values_from_ezdefault(dict): """Add or replace values from ezdefault in a dictionary""" for key1 in dict.keys(): for key2 in dict[key1].keys(): dict_entry = dict[key1][key2] if 'ezdefault' in dict_entry: add_value_to_dict_entry(dict_entry, '') # Add default value def restrict_tupleize(limits, num_items=None, conv=float, dtype=tuple): """Convert a string of numbers separated by commas to tuple with *dtype* and make sure it is within *limits* (included) specified as tuple (min, max). If one of the limits values is None it is ignored.""" def check(value=None, clamp=False): if value is None: return limits results = tupleize(num_items, conv, dtype)(value) for v in results: restrict_value(limits, dtype=conv)(v, clamp) return results return check def reverse_tupleize(num_items=None, conv=float): """Convert a tuple into a comma-separted string of *value*""" def combine_to_string(value): """Combine a tuple of numbers into a comma-separated string""" result = "" if num_items and len(result) != num_items: # A certain number of output is expected raise argparse.ArgumentTypeError('Expected {} items'.format(num_items)) if (len(value) == 0): # No tuple to convert into string return result # Tuple with non-zero lengthh for v in value: result = result + "," + str(conv(v)) result = result[1:] # Remove the erroneous first period return result return combine_to_string def get_median_flat(path2flat): tsr = TiffSequenceReader(path2flat) tmp = tsr.read(0) data = np.empty((tsr.num_images, tmp.shape[0], tmp.shape[1]), np.uint16) for i in range(tsr.num_images): data[i, :, :] = tsr.read(i) tsr.close() x = np.median(data, axis=0) del data return x def get_mean_flat(path2flat): tsr = TiffSequenceReader(path2flat) tmp = tsr.read(0) data = np.empty((tsr.num_images, tmp.shape[0], tmp.shape[1]), np.uint16) for i in range(tsr.num_images): data[i, :, :] = tsr.read(i) tsr.close() x = np.mean(data, axis=0) del data return x def read_yaml(filePath): with open(filePath) as f: data = yaml.load(f, Loader=yaml.FullLoader) return data def write_yaml(filePath, params): try: file = open(filePath, "w") except FileNotFoundError: print('Cannot write yaml file') else: yaml.dump(params, file) file.close() def check_that_num_failed(vals): vals = vals.split(',') # check that all comma separated entries # in the input string for i in range(len(vals)): try: float(vals[i]) except: return 1 return 0 def get_fdt_names(): return [EZVARS['inout']['darks-dir']['value'], EZVARS['inout']['flats-dir']['value'], EZVARS['inout']['tomo-dir']['value'], EZVARS['inout']['flats2-dir']['value']] def get_fd_names(): return tuple(EZVARS['inout'][f'{fd_type}-dir']['value'] for fd_type in ['darks', 'flats', 'flats2']) ufo-kit-tofu-ed0e5bd/tofu/find_large_spots.py000066400000000000000000000136201521054151500214670ustar00rootroot00000000000000import logging from gi.repository import Ufo from tofu.util import ( get_filtering_padding, set_node_props, determine_shape, read_image, setup_read_task, setup_padding, write_image ) from tofu.tasks import get_task, get_writer LOG = logging.getLogger(__name__) def find_large_spots_median(args): import numpy as np import skimage.morphology as sm import tifffile from skimage.filters import median from skimage.measure import label from skimage.restoration import estimate_sigma from scipy.ndimage import binary_fill_holes images = read_image(args.images, allow_multi=True) if args.averaging_mode == "first": image = images[0] if args.averaging_mode == "mean": image = np.mean(images, axis=0) if args.averaging_mode == "median": image = np.median(images, axis=0) mask = np.zeros_like(image, dtype=np.uint8) if args.median_direction == 'both': kernel = sm.disk(args.median_width // 2) else: kernel = np.ones(args.median_width) if args.median_direction == 'vertical': kernel = kernel[:, np.newaxis] else: kernel = kernel[np.newaxis] med = median(image, kernel) diff = image.astype(float) - med if args.spot_threshold_mode == "absolute": diff = np.abs(diff) elif args.spot_threshold_mode == "below": diff = -diff if args.blurred_output: tifffile.imsave(args.blurred_output, diff.astype(image.dtype)) if args.spot_threshold == 0: hist, bins = np.histogram(diff, bins=256) pdf = hist / diff.size args.spot_threshold = bins[np.where(np.cumsum(pdf) > 0.99)][0] LOG.info(f"Automatically determined spot-threshold: {args.spot_threshold}") if args.grow_threshold == 0: # 4.29 for FWTM args.grow_threshold = 4.29 * estimate_sigma(diff) LOG.info(f"Automatically determined grow-threshold: {args.grow_threshold}") # First, pixels which are above threshold are marked mask[diff > args.spot_threshold] = 1 label_high = label(mask) # Then the ones which are connected to the bright ones and above a lower threshold mask_low = np.zeros_like(mask) mask_low[diff > args.grow_threshold] = 1 label_low = label(mask_low) mask_low[:] = 0 for i in range(1, label_high.max() + 1): indices = np.where(label_high == i) low_i = label_low[indices].max() low_indices = np.where(label_low == low_i) if len(low_indices[0]) <= args.max_spot_size: mask_low[low_indices] = 1 mask = sm.dilation(mask_low, sm.disk(args.dilation_disk_radius)) mask = binary_fill_holes(mask) write_image(args.output, mask.astype(np.float32)) def find_large_spots(args): graph = Ufo.TaskGraph() sched = Ufo.FixedScheduler() reader = get_task('read') writer = get_writer(args) if args.gauss_sigma and args.blurred_output: broadcast = Ufo.CopyTask() blurred_writer = get_task('write') if hasattr(blurred_writer.props, 'bytes_per_file'): blurred_writer.props.bytes_per_file = 0 if hasattr(blurred_writer.props, 'tiff_bigtiff'): blurred_writer.props.tiff_bigtiff = False blurred_writer.props.filename = args.blurred_output find = get_task('find-large-spots') set_node_props(find, args) find.props.addressing_mode = args.find_large_spots_padding_mode set_node_props(reader, args) setup_read_task(reader, args.images, args) if args.gauss_sigma: width, height = determine_shape(args, path=args.images) pad = get_task('pad') crop = get_task('crop') if args.vertical_sigma: pad_width = 0 pad_height = get_filtering_padding(height) fft = get_task('fft', dimensions=2) ifft = get_task('ifft', dimensions=2) filter_stripes = get_task( 'filter-stripes', vertical_sigma=args.gauss_sigma, horizontal_sigma=0.0 ) graph.connect_nodes(reader, pad) if args.transpose_input: transpose = get_task('transpose') itranspose = get_task('transpose') graph.connect_nodes(pad, transpose) graph.connect_nodes(transpose, fft) else: graph.connect_nodes(pad, fft) graph.connect_nodes(fft, filter_stripes) graph.connect_nodes(filter_stripes, ifft) if args.transpose_input: graph.connect_nodes(ifft, itranspose) graph.connect_nodes(itranspose, crop) else: graph.connect_nodes(ifft, crop) last = crop else: reader_2 = get_task('read') set_node_props(reader_2, args) setup_read_task(reader_2, args.images, args) opencl = get_task('opencl', kernel='diff', filename='opencl.cl') gauss_size = int(10 * args.gauss_sigma) pad_width = pad_height = gauss_size LOG.debug("Gauss size: %d", gauss_size) blur = get_task('blur', sigma=args.gauss_sigma, size=gauss_size) graph.connect_nodes_full(reader, opencl, 0) graph.connect_nodes(reader_2, pad) graph.connect_nodes(pad, blur) graph.connect_nodes(blur, crop) graph.connect_nodes_full(crop, opencl, 1) last = opencl setup_padding(pad, width, height, args.find_large_spots_padding_mode, crop=crop, pad_width=pad_width, pad_height=pad_height) if args.blurred_output: graph.connect_nodes(last, broadcast) graph.connect_nodes(broadcast, blurred_writer) source = broadcast else: source = last graph.connect_nodes(source, find) else: graph.connect_nodes(reader, find) graph.connect_nodes(find, writer) sched.run(graph) ufo-kit-tofu-ed0e5bd/tofu/flow/000077500000000000000000000000001521054151500165405ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/flow/__init__.py000066400000000000000000000000001521054151500206370ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/flow/composites/000077500000000000000000000000001521054151500207255ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/flow/composites/ffc-links.cm000066400000000000000000000221021521054151500231170ustar00rootroot00000000000000{ "name": "CFlatFieldCorrect", "caption": "CFlatFieldCorrect", "models": { "Flat Field Correct": { "model": { "caption": "Flat Field Correct", "properties": { "fix-nan-and-inf": [ true, true ], "absorption-correct": [ true, true ], "sinogram-input": [ false, false ], "dark-scale": [ 1.0, false ], "flat-scale": [ 1.0, false ] } }, "visible": true, "position": { "x": 1253.0, "y": 490.0 }, "name": "flat_field_correct" }, "Read 2": { "model": { "caption": "Read 2", "properties": { "path": [ ".", true ], "start": [ 0, false ], "number": [ 4294967295, true ], "step": [ 1, false ], "y": [ 0, false ], "height": [ 0, false ], "y-step": [ 1, false ], "convert": [ true, false ], "raw-width": [ 0, false ], "raw-height": [ 0, false ], "raw-bitdepth": [ 0, false ], "raw-pre-offset": [ 0, false ], "raw-post-offset": [ 0, false ], "type": [ "unspecified", false ], "retries": [ 0, false ], "retry-timeout": [ 1, false ] } }, "visible": true, "position": { "x": 417.0, "y": 504.0 }, "name": "read" }, "Average": { "model": { "caption": "Average", "properties": { "number": [ 4294967295, true ] } }, "visible": true, "position": { "x": 822.0, "y": 508.0 }, "name": "average" }, "Read 3": { "model": { "caption": "Read 3", "properties": { "path": [ ".", true ], "start": [ 0, false ], "number": [ 4294967295, true ], "step": [ 1, false ], "y": [ 0, false ], "height": [ 0, false ], "y-step": [ 1, false ], "convert": [ true, false ], "raw-width": [ 0, false ], "raw-height": [ 0, false ], "raw-bitdepth": [ 0, false ], "raw-pre-offset": [ 0, false ], "raw-post-offset": [ 0, false ], "type": [ "unspecified", false ], "retries": [ 0, false ], "retry-timeout": [ 1, false ] } }, "visible": true, "position": { "x": 413.0, "y": 735.0 }, "name": "read" }, "Average 2": { "model": { "caption": "Average 2", "properties": { "number": [ 4294967295, true ] } }, "visible": true, "position": { "x": 822.0, "y": 741.0 }, "name": "average" }, "Read": { "model": { "caption": "Read", "properties": { "path": [ ".", true ], "start": [ 0, false ], "number": [ 23212, true ], "step": [ 1, false ], "y": [ 0, false ], "height": [ 0, false ], "y-step": [ 1, false ], "convert": [ true, false ], "raw-width": [ 0, false ], "raw-height": [ 0, false ], "raw-bitdepth": [ 0, false ], "raw-pre-offset": [ 0, false ], "raw-post-offset": [ 0, false ], "type": [ "unspecified", false ], "retries": [ 0, false ], "retry-timeout": [ 1, false ] } }, "visible": true, "position": { "x": 418.0, "y": 245.0 }, "name": "read" } }, "connections": [ [ "Read", 0, "Flat Field Correct", 0 ], [ "Average", 0, "Flat Field Correct", 1 ], [ "Average 2", 0, "Flat Field Correct", 2 ], [ "Read 2", 0, "Average", 0 ], [ "Read 3", 0, "Average 2", 0 ] ], "links": [ [ [ "Read 2", "number" ], [ "Average", "number" ] ], [ [ "Read 3", "number" ], [ "Average 2", "number" ] ] ] } ufo-kit-tofu-ed0e5bd/tofu/flow/composites/pr.cm000066400000000000000000000115161521054151500216730ustar00rootroot00000000000000{ "name": "CPhaseRetrieve", "caption": "CPhaseRetrieve", "models": { "Fft": { "model": { "caption": "Fft", "properties": { "auto-zeropadding": [ true, true ], "dimensions": [ 2, true ], "size-x": [ 1, true ], "size-y": [ 1, true ], "size-z": [ 1, true ] } }, "visible": true, "position": { "x": 112.0, "y": 245.0 }, "name": "fft" }, "Ifft": { "model": { "caption": "Ifft", "properties": { "dimensions": [ 2, true ], "crop-width": [ -1, true ], "crop-height": [ -1, true ] } }, "visible": true, "position": { "x": 772.0, "y": 250.0 }, "name": "ifft" }, "Retrieve Phase": { "model": { "caption": "Retrieve Phase", "num-inputs": 1, "properties": { "method": [ "tie", true ], "energy": [ 20.0, true ], "distance": [ 0.0, true ], "distance-x": [ 0.0, true ], "distance-y": [ 0.0, true ], "pixel-size": [ 7.500000265281415e-07, true ], "regularization-rate": [ 2.5, true ], "thresholding-rate": [ 0.10000000149011612, true ], "frequency-cutoff": [ 3.4028234663852886e+38, true ], "output-filter": [ false, true ] } }, "visible": true, "position": { "x": 544.0, "y": 515.0 }, "name": "retrieve_phase" }, "Pad": { "model": { "caption": "Pad", "properties": { "width": [ 0, true ], "height": [ 0, true ], "x": [ 0, true ], "y": [ 0, true ], "addressing-mode": [ "clamp", true ] } }, "visible": true, "position": { "x": 0.0, "y": 570.0 }, "name": "pad" } }, "connections": [ [ "Pad", 0, "Fft", 0 ], [ "Fft", 0, "Retrieve Phase", 0 ], [ "Retrieve Phase", 0, "Ifft", 0 ] ], "links": [ [ [ "Fft", "dimensions" ], [ "Ifft", "dimensions" ] ], [ [ "Fft", "size-x" ], [ "Pad", "width" ] ], [ [ "Fft", "size-y" ], [ "Pad", "height" ] ] ] } ufo-kit-tofu-ed0e5bd/tofu/flow/config.json000066400000000000000000000067711521054151500207130ustar00rootroot00000000000000{ "models": { "average": { "hidden-properties": [ "number" ] }, "flat-field-correct": { "port-captions": { "input": { "0": "radios", "1": "darks", "2": "flats" }, "output": { "0": "" } }, "hidden-properties": [ "sinogram-input", "dark-scale", "flat-scale" ] }, "general-backproject": { "hidden-properties": [ "z", "burst", "source-position-x", "source-position-y", "source-position-z", "detector-position-x", "detector-position-y", "detector-position-z", "detector-angle-x", "detector-angle-y", "detector-angle-z", "axis-angle-x", "axis-angle-y", "axis-angle-z", "volume-angle-x", "volume-angle-y", "volume-angle-z", "compute-type", "result-type", "store-type", "addressing-mode", "gray-map-min", "gray-map-max" ], "range-properties": { "region": [3, true], "x-region": [3, true], "y-region": [3, true], "center-position-x": [null, true], "center-position-z": [null, true], "source-position-x": [null, true], "source-position-y": [null, true], "source-position-z": [null, true], "detector-position-x": [null, true], "detector-position-y": [null, true], "detector-position-z": [null, true], "detector-angle-x": [null, true], "detector-angle-y": [null, true], "detector-angle-z": [null, true], "axis-angle-x": [null, true], "axis-angle-y": [null, true], "axis-angle-z": [null, true], "volume-angle-x": [null, true], "volume-angle-y": [null, true], "volume-angle-z": [null, true] } }, "horizontal-interpolate": { "port-captions": { "input": { "0": "image", "1": "mask" }, "output": { "0": "" } } }, "read": { "hidden-properties": [ "start", "step", "y", "height", "y-step", "convert", "raw-width", "raw-height", "raw-bitdepth", "raw-pre-offset", "raw-post-offset", "type", "retries", "retry-timeout" ] }, "write": { "hidden-properties": [ "counter-start", "counter-step", "bytes-per-file", "append", "bits", "minimum", "maximum", "rescale", "jpeg-quality", "tiff-bigtiff" ] } } } ufo-kit-tofu-ed0e5bd/tofu/flow/execution.py000066400000000000000000000203571521054151500211240ustar00rootroot00000000000000import gi import logging import networkx as nx try: gi.require_version('Ufo', '0.0') except ValueError: gi.require_version('Ufo', '1.0') from gi.repository import Ufo from PyQt5.QtCore import QObject, pyqtSignal from qtpynodeeditor import PortType from threading import Thread from tofu.flow.models import ARRAY_DATA_TYPE, UFO_DATA_TYPE, UfoTaskModel from tofu.flow.util import FlowError LOG = logging.getLogger(__name__) class UfoExecutor(QObject): """Class holding GPU resources and organizing UFO graph execution.""" number_of_inputs_changed = pyqtSignal(int) # Number of inputs has been determined processed_signal = pyqtSignal(int) # Image has been processed execution_started = pyqtSignal() # Graph execution started execution_finished = pyqtSignal() # Graph execution finished exception_occured = pyqtSignal(str) def __init__(self): super().__init__(parent=None) self._resources = Ufo.Resources() self._reset() # If True only log the exception and emit the signal but don't re-raise it in the executing # thread self.swallow_run_exceptions = False def _reset(self): self._aborted = False self._schedulers = [] self.num_generated = 0 def abort(self): LOG.debug('Execution aborted') try: self._aborted = True for scheduler in self._schedulers: scheduler.abort() finally: self.execution_finished.emit() def on_processed(self, ufo_task): self.processed_signal.emit(self.num_generated) self.num_generated += 1 def setup_ufo_graph(self, graph, gpu=None, region=None, signalling_model=None): ufo_graph = Ufo.TaskGraph() ufo_tasks = {} for source, dest, ports in graph.edges.data(): if hasattr(source, 'create_ufo_task') and hasattr(dest, 'create_ufo_task'): if dest not in ufo_tasks: ufo_tasks[dest] = dest.create_ufo_task(region=region) if source not in ufo_tasks: ufo_tasks[source] = source.create_ufo_task(region=region) ufo_graph.connect_nodes_full(ufo_tasks[source], ufo_tasks[dest], ports[PortType.input]) LOG.debug(f'{source.name}->{dest.name}@{ports[PortType.input]}') if source == signalling_model: ufo_tasks[source].connect('generated', self.on_processed) if gpu is not None: for task in ufo_tasks.values(): if task.uses_gpu(): task.set_proc_node(gpu) return ufo_graph def _run_ufo_graph(self, ufo_graph, use_fixed_scheduler): LOG.debug(f'Executing graph, fixed scheduler: {use_fixed_scheduler}') try: scheduler = Ufo.FixedScheduler() if use_fixed_scheduler else Ufo.Scheduler() self._schedulers.append(scheduler) scheduler.set_resources(self._resources) scheduler.run(ufo_graph) LOG.info(f'Execution time: {scheduler.props.time} s') except Exception as e: # Do not continue execution of other batches self._aborted = True LOG.error(e, exc_info=True) self.exception_occured.emit(str(e)) if not self.swallow_run_exceptions: raise e def check_graph(self, graph): """ Check that *graph* starts with an UfoTaskModel and ends with either that or an UfoModel but no UfoTaskModel successor exists (there can be only one UFO path in the graph). """ roots = [n for n in graph.nodes if graph.in_degree(n) == 0] leaves = [n for n in graph.nodes if graph.out_degree(n) == 0] for root in roots: for leave in leaves: for path in nx.simple_paths.all_simple_paths(graph, root, leave): if not isinstance(path[0], UfoTaskModel): raise FlowError('Flow must start with an UFO node') ufo_ended = False for (i, succ) in enumerate(path[1:]): model = path[i] edge_data = graph.get_edge_data(model, succ) if len(edge_data) > 1: # There cannot be multiple edges between nodes raise FlowError('Multiple edges not allowed but detected ' 'between {model} and {succ}') out_index = edge_data[0]['output'] # We don't need to check if input data type is ARRAY_DATA_TYPE because # UFO_DATA_TYPE cannot be connected to ARRAY_DATA_TYPE in the scene if ufo_ended: # From now on only non-UFO tasks are allowed if model.data_type['output'][out_index] != ARRAY_DATA_TYPE: raise FlowError('After a non-UFO node cannot come another UFO node') elif model.data_type['output'][out_index] != UFO_DATA_TYPE: # Output is non-UFO, UFO ends here ufo_ended = True def run(self, graph): self._reset() self.check_graph(graph) gpus = self._resources.get_gpu_nodes() num_inputs = -1 signalling_model = None for model in graph.nodes: if graph.in_degree(model) == 0: if 'number' in model: current = model['number'] if current > num_inputs: num_inputs = current signalling_model = model batches = [[(None, None)]] gpu_splitting_model = None gpu_splitting_models = get_gpu_splitting_models(graph) if len(gpu_splitting_models) > 1: # There cannot be multiple splitting models raise FlowError('Only one gpu splitting model is allowed') elif gpu_splitting_models: gpu_splitting_model = gpu_splitting_models[0] batches = gpu_splitting_model.split_gpu_work(self._resources.get_gpu_nodes()) for model in graph.nodes: # Reset internal model state if hasattr(model, 'reset_batches'): model.reset_batches() LOG.debug(f'{len(batches)} batches: {batches}') if signalling_model: self.number_of_inputs_changed.emit(len(batches) * num_inputs) LOG.debug(f'Number of inputs: {len(batches) * num_inputs}, defined ' f'by {signalling_model}') def execute_batches(): self.execution_started.emit() try: for (i, parallel_batch) in enumerate(batches): LOG.info(f'starting batch {i}: {parallel_batch}') threads = [] for gpu_index, region in parallel_batch: if self._aborted: break gpu = None if gpu_index is None else gpus[gpu_index] ufo_graph = self.setup_ufo_graph(graph, gpu=gpu, region=region, signalling_model=signalling_model) t = Thread(target=self._run_ufo_graph, args=(ufo_graph, len(gpu_splitting_models) > 0)) t.daemon = True threads.append(t) t.start() for t in threads: t.join() if self._aborted: break except Exception as e: LOG.error(e, exc_info=True) self.exception_occured.emit(str(e)) raise e finally: self.execution_finished.emit() gt = Thread(target=execute_batches) gt.daemon = True gt.start() def get_gpu_splitting_models(graph): gpu_splitting_models = [] for model in graph.nodes: if isinstance(model, UfoTaskModel) and model.can_split_gpu_work: gpu_splitting_models.append(model) return gpu_splitting_models ufo-kit-tofu-ed0e5bd/tofu/flow/filedirdialog.py000066400000000000000000000013131521054151500217060ustar00rootroot00000000000000import os from PyQt5.QtWidgets import QFileDialog class FileDirDialog(QFileDialog): """ A workaround for being able to select both files and directories. Source: https://stackoverflow.com/questions/27520304/qfiledialog-that-accepts-a-single-file-or-a-single-directory """ def __init__(self, parent=None): super().__init__(parent=parent) self.setOption(QFileDialog.DontUseNativeDialog) self.setFileMode(QFileDialog.Directory) self.currentChanged.connect(self._selected) def _selected(self, name): if os.path.isdir(name): self.setFileMode(QFileDialog.Directory) else: self.setFileMode(QFileDialog.ExistingFile) ufo-kit-tofu-ed0e5bd/tofu/flow/main.py000066400000000000000000000572731521054151500200540ustar00rootroot00000000000000import json import logging import os import pathlib import sys from PyQt5.QtCore import Qt, QObject, QPoint, pyqtSignal from PyQt5.QtWidgets import (QApplication, QFileDialog, QWidget, QVBoxLayout, QMenuBar, QMessageBox, QProgressBar, QMainWindow, QStyle) from qtpynodeeditor import DataModelRegistry, FlowView import xdg.BaseDirectory from tofu.flow.execution import UfoExecutor from tofu.flow.models import (BaseCompositeModel, get_composite_model_classes_from_json, get_composite_model_classes, get_ufo_model_classes, ImageViewerModel, UfoGeneralBackprojectModel, UfoMemoryOutModel, UfoOpenCLModel, UfoReadModel, UfoRetrievePhaseModel, UfoWriteModel) from tofu.flow.scene import UfoScene from tofu.flow.propertylinkswidget import PropertyLinks from tofu.flow.runslider import RunSlider from tofu.flow.util import FlowError LOG = logging.getLogger(__name__) class ApplicationWindow(QMainWindow): def __init__(self, ufo_scene): super().__init__() self.ufo_scene = ufo_scene self.property_links_widget = PropertyLinks(ufo_scene.node_model, ufo_scene.property_links_model, parent=self) self.run_slider = RunSlider(parent=self) self.executor = UfoExecutor() self.console = None self.run_slider_key = (None, None) self.last_dirs = {'scene': None, 'composite': None} self._creating_composite = False self._expanding_composite = False central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) self.flow_view = FlowView(self.ufo_scene) self.progress_bar = QProgressBar() self.progress_bar.setMinimum(0) menu_bar = QMenuBar() flow_menu = menu_bar.addMenu('Flow') new_action = flow_menu.addAction("New") new_action.setShortcut('Ctrl+N') new_action.triggered.connect(self.on_new) save_action = flow_menu.addAction("Save") save_action.setShortcut('Ctrl+S') save_action.triggered.connect(self.on_save) save_json_action = flow_menu.addAction("Save json") save_json_action.setShortcut('Ctrl+J') save_json_action.triggered.connect(self.on_save_json) load_action = flow_menu.addAction("Open") load_action.setShortcut('Ctrl+O') load_action.triggered.connect(self.on_open) self.run_action = flow_menu.addAction(self.style().standardIcon(QStyle.SP_MediaPlay), 'Run') self.run_action.setShortcut('Ctrl+R') self.run_action.triggered.connect(self.on_run) abort_action = flow_menu.addAction(self.style().standardIcon(QStyle.SP_MediaStop), 'Abort') abort_action.setShortcut('Ctrl+Shift+X') abort_action.triggered.connect(self.executor.abort) exit_action = flow_menu.addAction('Exit') exit_action.setShortcut('Ctrl+Q') exit_action.triggered.connect(self.close) # Nodes submenu selection_menu = menu_bar.addMenu('Nodes') selection_menu.setToolTipsVisible(True) selection_menu.aboutToShow.connect(self.on_selection_menu_about_to_show) self.skip_action = selection_menu.addAction('Skip Toggle') self.skip_action.setShortcut('S') self.skip_action.triggered.connect(self.ufo_scene.skip_nodes) auto_fill_action = selection_menu.addAction('Auto fill') auto_fill_action.triggered.connect(self.ufo_scene.auto_fill) copy_action = selection_menu.addAction("Duplicate") copy_action.setShortcut('Ctrl+Shift+D') copy_action.triggered.connect(self.ufo_scene.copy_nodes) # Composite create_composite_action = selection_menu.addAction("Create Composite") create_composite_action.setShortcut('Ctrl+Shift+C') create_composite_action.triggered.connect(self.on_create_composite) import_composites_action = selection_menu.addAction("Import Composites") import_composites_action.setToolTip('Import one or more composite nodes ' 'from a file or files') import_composites_action.setShortcut('Ctrl+I') import_composites_action.triggered.connect(self.on_import_composites) self.export_composite_action = selection_menu.addAction("Export Composite") self.export_composite_action.triggered.connect(self.on_export_composite) self.edit_composite_action = selection_menu.addAction("Edit Composite") self.edit_composite_action.triggered.connect(self.on_edit_composite) self.expand_composite_action = selection_menu.addAction("Expand Composite") self.expand_composite_action.setShortcut('Ctrl+Shift+E') self.expand_composite_action.triggered.connect(self.on_expand_composite) view_menu = menu_bar.addMenu('View') reset_view_action = view_menu.addAction("Reset Zoom") reset_view_action.setShortcut('Ctrl+0') reset_view_action.triggered.connect(self.on_reset_view) property_links_action = view_menu.addAction("Link Properties") property_links_action.setShortcut('Ctrl+L') property_links_action.triggered.connect(self.on_property_links_action) console_action = view_menu.addAction("Open Python Console") console_action.setShortcut('Ctrl+Shift+P') console_action.triggered.connect(self.on_console_action) run_slider_action = view_menu.addAction("Run Slider") run_slider_action.setShortcut('Ctrl+Shift+S') run_slider_action.triggered.connect(self.on_run_slider_action) self.fix_run_slider = view_menu.addAction("Fix Run Slider") self.fix_run_slider.setCheckable(True) self.fix_run_slider.setShortcut('Ctrl+Alt+Shift+S') main_layout.addWidget(menu_bar) main_layout.addWidget(self.flow_view) main_layout.addWidget(self.progress_bar) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) self.resize(1280, 1000) # Signals self.executor.exception_occured.connect(self.on_exception_occured) self.executor.execution_finished.connect(self.on_execution_finished) self.executor.number_of_inputs_changed.connect(self.on_number_of_inputs_changed) self.executor.processed_signal.connect(self.on_processed) self.ufo_scene.node_deleted.connect(self.on_node_deleted) self.ufo_scene.nodes_duplicated.connect(self.on_nodes_duplicated) self.ufo_scene.item_focus_in.connect(self.on_item_focus_in) self.run_slider.value_changed.connect(self.on_run_slider_value_changed) self.setWindowTitle('tofu flow') def on_save(self): if self.last_dirs['scene']: path = self.last_dirs['scene'] else: path = xdg.BaseDirectory.save_data_path('tofu', 'flows') if not os.path.exists(path): os.makedirs(path) file_name, _ = QFileDialog.getSaveFileName(self, "Select File Name", str(path), "Flow Scene Files (*.flow)") if file_name: self.last_dirs['scene'] = os.path.dirname(file_name) self.ufo_scene.save(file_name) def on_new(self): self.run_slider.reset() self.ufo_scene.clear_scene() self.setWindowTitle('tofu flow') def on_open(self): if self.last_dirs['scene']: path = self.last_dirs['scene'] else: path = xdg.BaseDirectory.save_data_path('tofu', 'flows') if not os.path.exists(path): path = pathlib.Path.home() file_name, _ = QFileDialog.getOpenFileName(self, "Open Flow Scene", str(path), "Flow Scene Files (*.flow)") if file_name: self.last_dirs['scene'] = os.path.dirname(file_name) self.ufo_scene.load(file_name) self.run_slider.reset() self.setWindowTitle(file_name) def on_exception_occured(self, text): msg = QMessageBox(parent=self) msg.setIcon(QMessageBox.Critical) msg.setText(text) msg.setWindowTitle("Error") msg.exec_() def on_number_of_inputs_changed(self, value): self.progress_bar.setMaximum(value) def on_processed(self, value): self.progress_bar.setValue(value + 1) def on_node_deleted(self, node): slider_model, prop_name = self.run_slider_key if slider_model: if (isinstance(node.model, BaseCompositeModel) and node.model.is_model_inside(slider_model) and not (self._expanding_composite or self._creating_composite)): self.run_slider.reset() self.run_slider_key = (None, None) elif node.model == slider_model and not self._creating_composite: self.run_slider.reset() self.run_slider_key = (None, None) def on_nodes_duplicated(self, selected_nodes, new_nodes): min_y = float('inf') y_1 = float('-inf') for node in selected_nodes: height = node.model.embedded_widget().height() y = node.graphics_object.y() if y < min_y: min_y = y if y + height > y_1: y_1 = y + height for node in selected_nodes: dy = node.graphics_object.y() - min_y new_pos = QPoint(int(node.graphics_object.x()), int(dy + y_1 + 100)) new_nodes[node].graphics_object.setPos(new_pos) def on_item_focus_in(self, item, prop_name, caption, model): if not self.fix_run_slider.isChecked() or not self.run_slider.view_item: if self.run_slider.setup(item): self.run_slider_key = (model, prop_name) self.run_slider.setWindowTitle(f'{caption}->{prop_name}') def on_selection_menu_about_to_show(self): composites = False num_selected = len(self.ufo_scene.selected_nodes()) for node in self.ufo_scene.selected_nodes(): if isinstance(node.model, BaseCompositeModel): composites = True break self.edit_composite_action.setEnabled(num_selected == 1 and composites) self.export_composite_action.setEnabled(num_selected == 1 and composites) self.expand_composite_action.setEnabled(composites) self.skip_action.setEnabled(self.ufo_scene.selected_nodes() != []) def on_edit_composite(self): if self.ufo_scene.is_selected_one_composite(): # Check again in case this was invoked by the keyboard shortcut node = self.ufo_scene.selected_nodes()[0] node.model.edit_in_window(self) def on_create_composite(self): self._creating_composite = True try: path = None prop_name = self.run_slider_key[1] if self.run_slider_key[0]: for node in self.ufo_scene.selected_nodes(): if isinstance(node.model, BaseCompositeModel): if node.model.is_model_inside(self.run_slider_key[0]): path = node.model.get_path_from_model(self.run_slider_key[0]) elif node.model == self.run_slider_key[0]: path = [self.run_slider_key[0]] composite_model = self.ufo_scene.create_composite().model if path: str_path = [model.caption for model in path] new_model = composite_model.get_model_from_path(str_path) new_view_item = new_model.get_view_item(prop_name) # Do not make complete setup, that would reset limits, just update the view item self.run_slider.view_item = new_view_item self.run_slider_key = (new_model, prop_name) title = '->'.join([composite_model.caption] + str_path + [prop_name]) self.run_slider.setWindowTitle(title) finally: self._creating_composite = False def on_expand_composite(self): self._expanding_composite = True try: slider_model, prop_name = self.run_slider_key for node in self.ufo_scene.selected_nodes(): if isinstance(node.model, BaseCompositeModel): if slider_model: str_path = None if node.model.is_model_inside(slider_model): str_path = [model.caption for model in node.model.get_path_from_model(slider_model)] new_nodes = self.ufo_scene.expand_composite(node)[0] # Pass the new node to the run slider if it was contained in this composite if slider_model and str_path: if slider_model.caption in new_nodes: # runslider linked to a simple node after expanstion slider_model = new_nodes[slider_model.caption].model self.run_slider_key = (slider_model, prop_name) new_view_item = slider_model.get_view_item(prop_name) # Do not make complete setup, that would reset limits, just update the # view item self.run_slider.view_item = new_view_item self.run_slider.setWindowTitle(f'{slider_model.caption}->{prop_name}') else: # runslider linked to another composite node (nesting) after expanstion for node in new_nodes.values(): if isinstance(node.model, BaseCompositeModel): if node.model.contains_path(str_path[2:]): new_model = node.model.get_model_from_path(str_path[2:]) self.run_slider_key = (new_model, prop_name) new_view_item = new_model.get_view_item(prop_name) # Do not make complete setup, that would reset limits, just # update the view item self.run_slider.view_item = new_view_item title = '->'.join(str_path[1:] + [prop_name]) self.run_slider.setWindowTitle(title) self.run_slider_key = (new_model, prop_name) break finally: self._expanding_composite = False def on_import_composites(self): if self.last_dirs['composite']: path = self.last_dirs['composite'] else: path = xdg.BaseDirectory.save_data_path('tofu', 'flows', 'composites') if not os.path.exists(path): path = pathlib.Path.home() file_names, _ = QFileDialog.getOpenFileNames(self, "Select File Names", str(path), "Composite Model Files (*.cm)") if not file_names: return self.last_dirs['composite'] = os.path.dirname(file_names[0]) overwriting = {} for file_name in file_names: LOG.debug(f'Loading composite from {file_name}') with open(file_name, 'r') as f: state = json.load(f) for model in get_composite_model_classes_from_json(state): if model.name in self.ufo_scene.registry.registered_model_creators(): overwriting[model.name] = os.path.basename(file_name) self.ufo_scene.registry.register_model(model, category='Composite', registry=self.ufo_scene.registry) if overwriting: msg = QMessageBox(parent=self) msg.setIcon(QMessageBox.Warning) msg.setText('Composite nodes with same names detected. Files from which ' 'the nodes have been loaded are listed in details.') msg.setDetailedText('\n'.join([f'Node name "{name}" from file "{file_name}"' for (name, file_name) in overwriting.items()])) msg.setWindowTitle('Warning') msg.exec_() def export_composite(self, node, file_name): state = node.model.save() with open(file_name, 'w') as f: json.dump(state, f, indent=4) def on_export_composite(self): if not self.ufo_scene.is_selected_one_composite(): # Check again in case this was invoked by the keyboard shortcut return if self.last_dirs['composite']: path = self.last_dirs['composite'] else: path = xdg.BaseDirectory.save_data_path('tofu', 'flows', 'composites') if not os.path.exists(path): os.makedirs(path) file_name, _ = QFileDialog.getSaveFileName(self, "Select File Name", str(path), "Composite Model Files (*.cm)") if file_name: self.last_dirs['composite'] = os.path.dirname(file_name) if not file_name.endswith('.cm'): file_name += '.cm' self.export_composite(self.ufo_scene.selected_nodes()[0], file_name) def on_reset_view(self): for view in self.ufo_scene.views(): transform = view.transform() transform.reset() view.setTransform(transform) def on_property_links_action(self): self.property_links_widget.show() # Make sure it goes to the front if it is currently burried under other windows self.property_links_widget.raise_() def on_console_action(self): if self.console: self.console.show() return try: from pyqtconsole.console import PythonConsole from pyqtconsole.highlighter import format self.console = PythonConsole(formats={ 'keyword': format('darkBlue', 'bold') }) self.console.setWindowFlag(Qt.SubWindow, True) self.console.ctrl_d_exits_console(True) self.console.push_local_ns('scene', self.ufo_scene) self.console.resize(640, 480) self.console.show() self.console.eval_queued() except ImportError as e: LOG.error(e, exc_info=True) self.on_exception_occured(str(e)) def on_run_slider_action(self): if not self.run_slider.view_item: msg = QMessageBox(parent=self) msg.setIcon(QMessageBox.Information) msg.setText('Click on an input field in the flow to connect the slider') msg.exec_() else: self.run_slider.show() # Make sure it goes to the front if it is currently burried under other windows self.run_slider.raise_() def on_run_slider_value_changed(self, value): if self.run_action.isEnabled(): self.on_run() def on_run(self): graphs = self.ufo_scene.get_simple_node_graphs() if len(graphs) != 1: raise FlowError('Scene must contain one fully connected graph') if not self.ufo_scene.is_fully_connected(): raise FlowError('Not all node ports are connected') self.executor.run(graphs[0]) self.run_action.setEnabled(False) self.ufo_scene.set_enabled(False) def on_save_json(self): graphs = self.ufo_scene.get_simple_node_graphs() if len(graphs) != 1: raise FlowError('Scene must contain one fully connected graph') if not self.ufo_scene.is_fully_connected(): raise FlowError('Not all node ports are connected') if not self.ufo_scene.are_all_ufo_tasks(graphs=graphs): raise FlowError('Flow contains other than pure UFO nodes (nodes with different ' 'data types, e.g. Memory Out or Image Viewer)') ufo_graph = self.executor.setup_ufo_graph(graphs[0]) if self.last_dirs['scene']: path = self.last_dirs['scene'] else: path = xdg.BaseDirectory.save_data_path('tofu', 'flows') if not os.path.exists(path): os.makedirs(path) file_name, _ = QFileDialog.getSaveFileName(self, "Select File Name", str(path), "json-File (*.json)") if file_name: self.last_dirs['scene'] = os.path.dirname(file_name) if not file_name.endswith('.json'): file_name += '.json' ufo_graph.save_to_json(file_name) def on_execution_finished(self): self.progress_bar.reset() self.run_action.setEnabled(True) self.ufo_scene.set_enabled(True) class GlobalExceptionHandler(QObject): """ Intercept exceptions, log them and inform user if they are UI-related. Emit a signal when the error message should be shown to the user so that e.g. a message can be shown in the main thread. """ exception_occured = pyqtSignal(str) def excepthook(self, exc_type, exc_value, exc_traceback): LOG.error(exc_value, exc_info=(exc_type, exc_value, exc_traceback)) if issubclass(exc_type, FlowError): self.exception_occured.emit(str(exc_value)) def get_filled_registry(): registry = DataModelRegistry() for model in get_ufo_model_classes(): category = 'Processing' if model.num_ports['input'] == 0: category = 'Input' if model.num_ports['output'] == 0: category = 'Output' registry.register_model(model, category=category, scrollable=True) registry.register_model(UfoGeneralBackprojectModel, category='Processing') registry.register_model(UfoOpenCLModel, category='Processing') registry.register_model(UfoRetrievePhaseModel, category='Processing') registry.register_model(UfoMemoryOutModel, category='Data') registry.register_model(ImageViewerModel, category='Output') registry.register_model(UfoWriteModel, category='Output') registry.register_model(UfoReadModel, category='Input') for models in get_composite_model_classes(): for model in models: if model.name not in registry.registered_model_creators(): registry.register_model(model, category='Composite', registry=registry) return registry def main(): app = QApplication(sys.argv) scene = UfoScene(registry=get_filled_registry()) main_window = ApplicationWindow(scene) # Exception interception exception_handler = GlobalExceptionHandler() exception_handler.exception_occured.connect(main_window.on_exception_occured) # Do not use threading.excepthook because it needs at least python 3.8., i.e. all exceptions in # threads have to be handled properly (logged, signal emitted so that a message can be displayed # in the main thread to the user, see tofu.flow.execution for example). sys.excepthook = exception_handler.excepthook main_window.show() sys.exit(app.exec_()) if __name__ == '__main__': main() ufo-kit-tofu-ed0e5bd/tofu/flow/models.py000066400000000000000000001727121521054151500204070ustar00rootroot00000000000000""" All classes needed for :class:`qtpynodeeditor.NodeDataModel` implementation of UFO and composite tasks. """ import gi import glob import json import logging import networkx as nx import numpy as np import pkg_resources import os import re try: gi.require_version('Ufo', '0.0') except ValueError: gi.require_version('Ufo', '1.0') from gi.repository import Ufo from PyQt5 import QtCore from PyQt5.QtCore import QObject, Qt, pyqtSignal from PyQt5.QtGui import QDoubleValidator, QValidator from PyQt5.QtWidgets import (QCheckBox, QComboBox, QGroupBox, QInputDialog, QLabel, QLineEdit, QScrollArea, QWidget, QFileDialog, QFormLayout, QVBoxLayout, QMenu) from qtpynodeeditor import (NodeData, NodeDataModel, NodeDataType, FlowScene, FlowView, Port, PortType, opposite_port) from threading import Lock from tofu.flow.util import (CompositeConnection, FlowError, get_config_key, MODEL_ROLE, NODE_ROLE, PROPERTY_ROLE, saved_kwargs) from tofu.flow.filedirdialog import FileDirDialog LOG = logging.getLogger(__name__) UFO_PLUGIN_MANAGER = Ufo.PluginManager() UFO_DATA_TYPE = NodeDataType(id="UfoBuffer", name=None) ARRAY_DATA_TYPE = NodeDataType(id="NumpyArray", name=None) class UfoIntValidator(QValidator): """Combined int and unsigned int validator.""" def __init__(self, minimum, maximum, parent=None): super().__init__(parent=parent) self.minimum = minimum self.maximum = maximum def bottom(self): return self.minimum def top(self): return self.maximum def validate(self, input_str, pos): try: if self.minimum <= int(input_str) <= self.maximum: result = (QValidator.Acceptable, input_str, pos) else: result = (QValidator.Intermediate, input_str, pos) except ValueError: if not input_str or input_str == '-' and self.minimum < 0: result = (QValidator.Intermediate, input_str, pos) else: result = (QValidator.Invalid, input_str, pos) return result class UfoRangeValidator(QValidator): """ Range separated by comma validator. *num_items* specifies how many numbers must be in the string. *is_float* specifies if the numbers are floating point (integer or unsigned integer otherwise). """ def __init__(self, num_items=None, is_float=True, parent=None): super().__init__(parent=parent) self.num_items = num_items self.is_float = is_float def validate(self, input_str, pos): float_regexp = r'[+-]|[+-]?(\d+(\.\d*)?|\.\d*)([eE][+-]?\d*)?' numbers = input_str.split(',') intermediate = False if self.num_items is not None and len(numbers) > self.num_items: # Incorrect number of items return (QValidator.Invalid, input_str, pos) for (i, number) in enumerate(numbers): number = number.lower().strip() if ('e' in number or '.' in number) and not self.is_float: # Integer expected return (QValidator.Invalid, input_str, pos) if self.is_float: try: float(number) except: if (not number or re.fullmatch(float_regexp, number)): # Partial floating point number (e.g. ends with "e") intermediate = True continue else: return (QValidator.Invalid, input_str, pos) else: try: int(number) except: if not number or number == '-': intermediate = True continue else: return (QValidator.Invalid, input_str, pos) if intermediate or (self.num_items is not None and len(numbers) < self.num_items): # Not enough arguments received or some numbers are incomplete return (QValidator.Intermediate, input_str, pos) return (QValidator.Acceptable, input_str, pos) class ViewItem(QObject): property_changed = pyqtSignal(QObject) def __init__(self, widget, default_value=None, tooltip=''): super().__init__(parent=None) self.widget = widget self.focus_info = False if tooltip: self.widget.setToolTip(tooltip) if default_value is not None: self.set(default_value) def on_changed(self, *args): """ Only user interaction must emit signals in the descendants. Signal is emitted only if the user input is valid. """ try: self.get() self.property_changed.emit(self) except: LOG.debug(f'{self}: invalid input') def get(self): ... def set(self, value): ... class CheckBoxViewItem(ViewItem): def __init__(self, checked=False, tooltip=''): widget = QCheckBox() super().__init__(widget, default_value=checked, tooltip=tooltip) widget.clicked.connect(self.on_changed) def get(self): return self.widget.isChecked() def set(self, value): self.widget.setChecked(value) class ComboBoxViewItem(ViewItem): def __init__(self, items, default_value=None, tooltip=''): widget = QComboBox() for item in items: widget.addItem(item) super().__init__(widget, default_value=default_value, tooltip=tooltip) widget.activated.connect(self.on_changed) def get(self): return self.widget.currentText() def set(self, value): self.widget.setCurrentText(value) class FocusInterceptQLineEdit(QLineEdit): focus_in = pyqtSignal(QObject) def focusInEvent(self, event): self.focus_in.emit(self) return super().focusInEvent(event) class QLineEditViewItem(ViewItem): focus_in = pyqtSignal(QObject) def __init__(self, default_value=None, tooltip='', intercept_focus=False): if intercept_focus: widget = FocusInterceptQLineEdit() widget.focus_in.connect(self.on_focus_in) else: widget = QLineEdit() super().__init__(widget, default_value=default_value, tooltip=tooltip) if intercept_focus: self.focus_info = True widget.textEdited.connect(self.on_changed) def on_focus_in(self, widget): self.focus_in.emit(self) def get(self): return self.widget.text() def set(self, value): self.widget.setText(str(value)) class NumberQLineEditViewItem(QLineEditViewItem): def __init__(self, minimum, maximum, default_value=None, tooltip=''): if default_value < minimum or default_value > maximum: raise ValueError(f'default value {default_value} not in limits [{minimum}, {maximum}]') tooltip += ' (range: {} - {})'.format(minimum, maximum) super().__init__(default_value=default_value, tooltip=tooltip, intercept_focus=True) validator = QDoubleValidator(float(minimum), float(maximum), 100) self.widget.setValidator(validator) def get(self): return float(super().get()) class IntQLineEditViewItem(QLineEditViewItem): def __init__(self, minimum, maximum, default_value=None, tooltip=''): if default_value < minimum or default_value > maximum: raise ValueError(f'default value {default_value} not in limits [{minimum}, {maximum}]') tooltip += ' (range: {} - {})'.format(minimum, maximum) super().__init__(default_value=default_value, tooltip=tooltip, intercept_focus=True) validator = UfoIntValidator(minimum, maximum) self.widget.setValidator(validator) def get(self): return int(super().get()) class RangeQLineEditViewItem(QLineEditViewItem): def __init__(self, default_value='', tooltip='', num_items=None, is_float=True): super().__init__(default_value=default_value, tooltip=tooltip, intercept_focus=True) validator = UfoRangeValidator(num_items=num_items, is_float=is_float) self.widget.setValidator(validator) def set(self, values): text = ','.join([str(value) for value in values]) if values else '' self.widget.setText(text) def get(self): text = super().get() if text: values = [float(num) for num in text.split(',')] else: values = [] return values def get_ufo_qline_edit_item(glib_prop, default_value, range_num_items=None, range_is_float=True): if glib_prop.value_type.name == 'GValueArray': item = RangeQLineEditViewItem(tooltip=glib_prop.blurb, default_value=default_value, num_items=range_num_items, is_float=range_is_float) elif glib_prop.value_type.name in ['gdouble', 'gfloat']: item = NumberQLineEditViewItem(glib_prop.minimum, glib_prop.maximum, default_value=default_value, tooltip=glib_prop.blurb) elif hasattr(glib_prop, 'minimum') and hasattr(glib_prop, 'maximum'): item = IntQLineEditViewItem(glib_prop.minimum, glib_prop.maximum, default_value=default_value, tooltip=glib_prop.blurb) else: item = QLineEditViewItem(default_value=str(default_value), tooltip=glib_prop.blurb) return item class PropertyViewRecord: """Attribute-access to a view's item.""" def __init__(self, view_item, label, visible): self.view_item = view_item self.label = label self.visible = visible def __str__(self): return repr(self) def __repr__(self): fmt = 'PropertyViewRecord(widget={}, visible={})' return fmt.format(self.view_item.widget, self.visible) class MultiPropertyViewRecord: """Attribute-access to a multiple property view's item.""" def __init__(self, model, widget, visible): self.model = model self.widget = widget self.visible = visible def __str__(self): return repr(self) def __repr__(self): fmt = 'MultiPropertyViewRecord(model={}, widget={}, visible={})' return fmt.format(self.model, self.widget, self.visible) class PropertyView(QWidget): property_changed = pyqtSignal(str, object) item_focus_in = pyqtSignal(ViewItem, str) def __init__(self, properties=None, parent=None, scrollable=True): super().__init__(parent=parent) form_layout = QFormLayout() form_layout.setVerticalSpacing(0) self._properties = {} if properties: for (name, (item, active)) in properties.items(): if name in self._properties: raise ValueError("Item '{}' already exists".format(name)) # Set the parent properly, so that set_property_visible won't try to show the item # widget and the label in their own windows before the view is shown item.widget.setParent(self) label = QLabel(name, parent=self) form_layout.addRow(label, item.widget) self._properties[name] = PropertyViewRecord(item, label, active) self.set_property_visible(name, active) item.property_changed.connect(self.on_property_changed) if item.focus_info: item.focus_in.connect(self.on_item_focus_in) if scrollable: widget = QWidget() widget.setLayout(form_layout) scroll = QScrollArea() scroll.setWidget(widget) scroll.setWidgetResizable(True) main_layout = QVBoxLayout() main_layout.addWidget(scroll) self.setLayout(main_layout) else: self.setLayout(form_layout) @property def property_names(self): return self._properties.keys() def get_property(self, name): return self._properties[name].view_item.get() def set_property(self, name, value): return self._properties[name].view_item.set(value) def get_record(self, name): return self._properties[name] def on_property_changed(self, item): # Get item's name for (name, record) in self._properties.items(): if item == record.view_item: break self.property_changed.emit(name, item.get()) def on_item_focus_in(self, view_item): for (name, it) in self._properties.items(): if it.view_item.widget == view_item.widget: self.item_focus_in.emit(view_item, name) break def is_property_visible(self, name): return self._properties[name].visible def set_property_visible(self, name, visible): self._properties[name].view_item.widget.setVisible(visible) self._properties[name].label.setVisible(visible) self._properties[name].visible = visible def restore_properties(self, values): for prop in self._properties: if prop not in values: LOG.debug(f'Property {prop} not stored, using default') continue value, visible = values[prop] self.set_property(prop, value) self.set_property_visible(prop, visible) def export_properties(self): values = {} for prop in self._properties: values[prop] = [self.get_property(prop), self.is_property_visible(prop)] return values def contextMenuEvent(self, event): contextMenu = QMenu(self) actions = {} for name in list(self._properties.keys()): action = contextMenu.addAction(name) action.setCheckable(True) action.setChecked(self._properties[name].visible) actions[action] = name contextMenu.addSeparator() show_all_action = contextMenu.addAction('Show All') hide_all_action = contextMenu.addAction('Hide All') action = contextMenu.exec_(self.mapToGlobal(event.pos())) if action: if action in actions: name = actions[action] checked = action.isChecked() self.set_property_visible(name, checked) elif action == show_all_action: for name in self._properties.keys(): self.set_property_visible(name, True) elif action == hide_all_action: for name in self._properties.keys(): self.set_property_visible(name, False) class MultiPropertyView(QWidget): def __init__(self, groups, parent=None): super().__init__(parent=parent) self._group_box_layout = QVBoxLayout() main_layout = QVBoxLayout() widget = QWidget() widget.setLayout(self._group_box_layout) scroll = QScrollArea() scroll.setWidget(widget) scroll.setWidgetResizable(True) self.setLayout(main_layout) main_layout.addWidget(scroll) self._groups = {} for (model, visible) in groups.items(): if isinstance(model, PropertyModel): model_widget = QGroupBox(model.caption) layout = QVBoxLayout() model_widget.setLayout(layout) layout.addWidget(model.embedded_widget()) else: model_widget = QLabel(model.caption, parent=self) record = MultiPropertyViewRecord(model, model_widget, visible) self._groups[model.caption] = record self._group_box_layout.addWidget(model_widget) self.set_group_visible(model.caption, visible) def __getitem__(self, key): return self._groups[key].model def __contains__(self, key): return key in self._groups def __iter__(self): return iter(self._groups) def export_groups(self): values = {} for name in self._groups: state = self._groups[name].model.save() values[name] = {'model': state, 'visible': self._groups[name].visible} return values def restore_groups(self, values): for name in values: self[name].restore(values[name]['model']) self.set_group_visible(name, values[name]['visible']) def set_group_visible(self, name, visible): self._groups[name].widget.setVisible(visible) self._groups[name].visible = visible def is_group_visible(self, name): return self._groups[name].visible def contextMenuEvent(self, event): contextMenu = QMenu(self) actions = {} for name in list(self._groups.keys()): action = contextMenu.addAction(name) action.setCheckable(True) action.setChecked(self._groups[name].visible) actions[action] = name contextMenu.addSeparator() show_all_action = contextMenu.addAction('Show All') hide_all_action = contextMenu.addAction('Hide All') action = contextMenu.exec_(self.mapToGlobal(event.pos())) if action: if action in actions: name = actions[action] checked = action.isChecked() self.set_group_visible(name, checked) elif action == show_all_action: for name in self._groups.keys(): self.set_group_visible(name, True) elif action == hide_all_action: for name in self._groups.keys(): self.set_group_visible(name, False) class UfoModel(NodeDataModel): """The root parent of all other models in tofu flow.""" data_type = UFO_DATA_TYPE item_focus_in = pyqtSignal(QObject, str, str, NodeDataModel) def __init__(self, style=None, parent=None): super().__init__(style=style, parent=parent) # This is the caption model wants to have when it's instantiated, however, it might # get a different caption from the scene because the captions must be unique within self.base_caption = self.caption self.skip = False def restore(self, state, restore_caption=False): if restore_caption: self.caption = state.get('caption', self.caption) def save(self): return {'caption': self.caption} def double_clicked(self, parent): ... def __repr__(self): return f'UfoModel({self.caption})' def __str__(self): return repr(self) class PropertyModel(UfoModel): property_changed = pyqtSignal(UfoModel, str, object) def __init__(self, style=None, parent=None, scrollable=True): """*properties* is a dictionary of name: ViewItem items.""" super().__init__(style=style, parent=parent) properties = self.make_properties() if properties: self.properties = list(properties.keys()) self._view = PropertyView(properties=properties, scrollable=scrollable) self._view.property_changed.connect(self.on_property_changed) self._view.item_focus_in.connect(self.on_item_focus_in) else: self.properties = [] self._view = None def __getitem__(self, key): return self._view.get_property(key) def __setitem__(self, key, value): return self._view.set_property(key, value) def __contains__(self, key): return key in self.properties def __iter__(self): return iter(self.properties) def get_view_item(self, name): return self._view.get_record(name).view_item def on_property_changed(self, name, value): self.property_changed.emit(self, name, value) def on_item_focus_in(self, item, name): self.item_focus_in.emit(item, name, self.caption, self) def make_properties(self): """*properties* is a dictionary of name: ViewItem items.""" return {} def copy_properties(self): properties = self.make_properties() for (name, (item, active)) in properties.items(): item.set(self[name]) properties[name][-1] = self._view.is_property_visible(name) return properties def auto_fill(self): """Automatically fill properties (e.g. number of files, etc.)""" ... def resizable(self): return True def embedded_widget(self) -> QWidget: return self._view if self._view else None def restore(self, state, restore_caption=True): self._view.restore_properties(state['properties']) super().restore(state, restore_caption=restore_caption) def save(self): state = super().save() state['properties'] = self._view.export_properties() return state class UfoTaskModel(PropertyModel): caption_visible = True def __init__(self, task_name, style=None, parent=None, scrollable=True): self._task_name = task_name self.caption = ' '.join([item[0].upper() + item[1:] for item in self.name.split('_')]) self.needs_fixed_scheduler = False self.can_split_gpu_work = False super().__init__(style=style, parent=parent, scrollable=scrollable) def make_properties(self): hidden_properties = get_config_key('models', self._task_name, 'hidden-properties') range_properties = get_config_key('models', self._task_name, 'range-properties', default={}) properties = {} ufo_task = UFO_PLUGIN_MANAGER.get_task(self._task_name) for prop in ufo_task.list_properties(): if prop.name == 'num-processed': continue default_value = getattr(ufo_task.props, prop.name) if prop.value_type.name == 'gboolean': item = CheckBoxViewItem(checked=default_value, tooltip=prop.blurb) elif hasattr(prop, 'enum_class'): items = [name.value_nick for name in default_value.__enum_values__.values()] item = ComboBoxViewItem(items, default_value=default_value.value_nick, tooltip=prop.blurb) else: range_num_items, range_is_float = range_properties.get(prop.name, (None, True)) item = get_ufo_qline_edit_item(prop, default_value=default_value, range_num_items=range_num_items, range_is_float=range_is_float) visible = True if hidden_properties and prop.name in hidden_properties: visible = False properties[prop.name] = [item, visible] return properties def create_ufo_task(self, region=None): if self.expects_multiple_inputs and region is None: raise UfoModelError(f'{self.caption} expects multiple inputs ' 'but there is no node with such capability in the flow') ufo_task = UFO_PLUGIN_MANAGER.get_task(self._task_name) self._setup_ufo_task(ufo_task, region=region) return ufo_task def _setup_ufo_task(self, ufo_task, region=None): for prop in self: setattr(ufo_task.props, prop, self[prop]) def reset_batches(self): """ In case the model can process batches and has internal state depending on them, this is where it can be re-set. """ pass @property def uses_gpu(self): return UFO_PLUGIN_MANAGER.get_task(self._task_name).uses_gpu() @property def expects_multiple_inputs(self): return False def get_ufo_model_class(ufo_task_name): # Use this to determine inputs and outputs but create a new object in the constructor in order # to enable multiple instances having different parameter values _ufo_task = UFO_PLUGIN_MANAGER.get_task(ufo_task_name) ufo_task_num_inputs = _ufo_task.get_num_inputs() ufo_task_num_outputs = int(_ufo_task.get_mode() & Ufo.TaskMode.SINK == 0) class UfoAutoModel(UfoTaskModel): name = ufo_task_name.replace('-', '_') def __init__(self, style=None, parent=None, scrollable=True): self.num_ports = {PortType.input: ufo_task_num_inputs, PortType.output: ufo_task_num_outputs} self.data_type = {} self.port_caption = {} self.port_caption_visible = {} for port_type in (PortType.input, PortType.output): self.data_type[port_type] = {} self.port_caption[port_type] = {} self.port_caption_visible[port_type] = {} for i in range(self.num_ports[port_type]): port_captions = get_config_key('models', ufo_task_name, 'port-captions') if port_captions: port_caption = port_captions[port_type][str(i)] port_caption_visible = True if port_caption else False else: port_caption = '' port_caption_visible = False self.data_type[port_type][i] = UFO_DATA_TYPE self.port_caption[port_type][i] = port_caption self.port_caption_visible[port_type][i] = port_caption_visible self.ufo_task = None super().__init__(ufo_task_name, style=style, parent=parent, scrollable=scrollable) return UfoAutoModel class BaseCompositeModel(UfoModel): # Move functionality which can go here from CompositeModel here data_type = UFO_DATA_TYPE def __init__(self, models, connections, links=None, registry=None, style=None, parent=None): if registry is None: # This has to be keyword argument because of the qtpynodeeditor's node creation # mechanism, but the argument is actually required raise AttributeError('registry must be provided') super().__init__(style=style, parent=parent) # Nodes in the edit pop-up window self.window_parent = None self._property_links_model = None self._links = [] if links is None else links self._slave_property_links = [] self._window_nodes = {} self._other_scene = None self._other_view = None self.num_ports = {PortType.input: 0, PortType.output: 0} self.data_type = {PortType.input: {}, PortType.output: {}} self.port_caption = {PortType.input: {}, PortType.output: {}} self.port_caption_visible = {PortType.input: {}, PortType.output: {}} groups = {} self._registry = registry self._models = {} # Internal connections self._connections = connections # Composite port to subnode port mapping self._inside_ports = {} # Subnode port to composite port mapping self._outside_ports = {} for (name, state, visible, position) in models: # Don't use the deafault registry creation because embedded PropertyModel must have # scrollable set to False cls, orig_kwargs = registry.registered_model_creators()[name] # Don't mess with the original dictionary kwargs = {orig_key: orig_value for (orig_key, orig_value) in orig_kwargs.items()} if issubclass(cls, PropertyModel): kwargs['scrollable'] = False if 'num-inputs' in state: kwargs['num_inputs'] = state['num-inputs'] model = cls(**kwargs) model.restore(state) self._models[model] = position groups[model] = visible model.item_focus_in.connect(self.on_item_focus_in) for port_type in ['input', 'output']: for index in range(model.num_ports[port_type]): side = (model.caption, port_type, index) if not any([conn.contains(*side) for conn in connections]): i = self.num_ports[port_type] self.data_type[port_type][i] = UFO_DATA_TYPE port_caption = model.caption if model.port_caption[port_type][index]: port_caption += ':' + model.port_caption[port_type][index] self.port_caption[port_type][i] = port_caption self.port_caption_visible[port_type][i] = True self._inside_ports[(port_type, i)] = (model, port_type, index) self._outside_ports[side] = (port_type, i) self.num_ports[port_type] += 1 self._view = MultiPropertyView(groups) def __getitem__(self, key): return self._view[key] def __contains__(self, key): return key in self._view def __iter__(self): return iter(self._view) def __repr__(self): return f'Composite(caption={self.caption}, models={sorted(list(iter(self._view)))})' def __str__(self): return repr(self) def get_outside_port(self, unique_name, port_type, port_index): return self._outside_ports[(unique_name, port_type, port_index)] def get_model_and_port_index(self, port_type, port_index): model, spt, index = self._inside_ports[(port_type, port_index)] return (model, index) def embedded_widget(self) -> QWidget: return self._view if self._view else None def resizable(self): return True def on_item_focus_in(self, item, name, caption, model): self.item_focus_in.emit(item, name, self.caption + '->' + caption, model) @property def is_editing(self): """Is wubwindow open.""" return self._window_nodes != {} @property def property_links_model(self): return self._property_links_model @property_links_model.setter def property_links_model(self, plm): self._property_links_model = plm for model in self._models: if isinstance(model, BaseCompositeModel): model.property_links_model = plm def contains_path(self, path): """Is there a caption *path* inside this model.""" model = self for caption in path: if caption in model: model = model[caption] else: return False return True def get_model_from_path(self, path): """*path* is caption path (str).""" model = self for caption in path: model = model[caption] return model def is_model_inside(self, model): """Return True if *model* is inside at any level.""" paths = self.get_leaf_paths() for path in paths: for item in path: if item == model: return True return False def get_path_from_model(self, model): """*model* must be inside this composite model.""" paths = self.get_leaf_paths() for path in paths: for (i, item) in enumerate(path): if item == model: return path[:i + 1] raise KeyError(f'{model} not inside') def get_descendant_graph(self, in_subwindow=False): """ Get all descendant models recursively in case there are composite models inside this model. If *in_subwindow* is True, return models shown to the user in the subwindow, otherwise the ones created at class instantiation. For composites inside this one, if *in_subwindow* is True return the subwindow models, but if it's not being edited instead raising an exception, return the internal models. """ if in_subwindow and not self.is_editing: raise ValueError('in_subwindow True but no subwindow open') graph = nx.DiGraph() def descend(parent): if in_subwindow and parent.is_editing: models = [node.model for node in parent._window_nodes.values()] else: models = [parent[key] for key in parent] for model in models: graph.add_edge(parent, model) if isinstance(model, BaseCompositeModel): descend(model) descend(self) return graph def get_leaf_paths(self, in_subwindow=False): graph = self.get_descendant_graph(in_subwindow=in_subwindow) leaves = [node for node in graph.nodes if graph.out_degree(node) == 0] paths = [] for leaf in leaves: paths.append(list(nx.simple_paths.all_simple_paths(graph, self, leaf))[0]) return paths def restore(self, state, restore_caption=True): self._connections = [CompositeConnection(*args) for args in state['connections']] self._view.restore_groups(state['models']) super().restore(state, restore_caption=restore_caption) def restore_links(self, node): if self.property_links_model: row = self.property_links_model.rowCount() for items in self._links: # A row can be restored only if no property from the state is in the link model # yet row_ok = True for str_path in items: prop_name = str_path[-1] model = self.get_model_from_path(str_path[:-1]) if self.property_links_model.find_items([node, model, prop_name], [NODE_ROLE, MODEL_ROLE, PROPERTY_ROLE]): LOG.info(f'{str_path[-2]}->{prop_name} already in property links') row_ok = False break if row_ok: for (i, str_path) in enumerate(items): model = self.get_model_from_path(str_path[:-1]) self.property_links_model.add_item(node, model, str_path[-1], row, i) row += 1 def save(self): state = {'name': self.name, 'caption': self.caption} state['models'] = self._view.export_groups() for (model, position) in self._models.items(): state['models'][model.caption]['position'] = position # This is necessary for creating models from saved files state['models'][model.caption]['name'] = model.name state['connections'] = [conn.save() for conn in self._connections] if self.property_links_model: state['links'] = [] paths = self.get_leaf_paths() models = [path[-1] for path in paths] items = self.property_links_model.get_model_links(models) for row in items.values(): # First item in the row is this model, skip it state['links'].append([str_path[1:] for str_path in row]) return state def on_connection_created(self, connection): self._other_scene.connection_deleted.disconnect(self.on_connection_deleted) self._other_scene.delete_connection(connection) self._other_scene.connection_deleted.connect(self.on_connection_deleted) def on_connection_deleted(self, connection): self._other_scene.connection_created.disconnect(self.on_connection_created) self._other_scene.restore_connection(connection.__getstate__()) self._other_scene.connection_created.connect(self.on_connection_created) def double_clicked(self, parent): self.edit_in_window(parent=parent) def on_other_scene_double_clicked(self, node): node.model.double_clicked(self._other_view) def expand_into_graph(self, graph): """Expand to submodels in a *graph*, which is a networkx.DiGraph instance.""" name_to_model = {} for model in self._models: LOG.debug(f'Adding node {model.name}') graph.add_node(model) name_to_model[model.caption] = model for conn in self._connections: source = name_to_model[conn.from_unique_name] dest = name_to_model[conn.to_unique_name] LOG.debug(f'Adding edge {source.name}@{conn.from_port_index} -> ' f'{dest.name}@{conn.to_port_index}') graph.add_edge(source, dest, input=conn.to_port_index, output=conn.from_port_index) def _expand_into_scene(self, scene, original_nodes=None, restore_captions=False): # unique name to node instance mapping name_to_node = {} for model in self._models: if original_nodes and model.caption in original_nodes: node = scene.restore_node(original_nodes[model.caption]) else: with saved_kwargs(scene.registry, model.__getstate__()): if restore_captions: node = scene.create_node(model.__class__) else: # This is the main scene, links restoration takes place in expand_into_scene # for all nodes including composites node = scene.create_node(model.__class__, restore_links=False) if isinstance(model, PropertyModel) or isinstance(model, BaseCompositeModel): node.model.restore(model.save(), restore_caption=restore_captions) if isinstance(node.model, BaseCompositeModel): node.model.property_links_model = self.property_links_model else: node.model.restore(model.save()) name_to_node[model.caption] = node if self._models[model] is not None: node.position = (self._models[model]['x'], self._models[model]['y']) for conn in self._connections: f_node = name_to_node[conn.from_unique_name] t_node = name_to_node[conn.to_unique_name] f_port = f_node[PortType.output][conn.from_port_index] t_port = t_node[PortType.input][conn.to_port_index] scene.create_connection(f_port, t_port, check_cycles=False) return name_to_node def add_slave_links(self): self._slave_property_links = [] if not self.property_links_model: return for node in self._window_nodes.values(): if isinstance(node.model, BaseCompositeModel): paths = node.model.get_leaf_paths(in_subwindow=node.model._window_nodes != {}) else: paths = [[node.model]] # Propagate all signals from leaves to the original models for path in paths: str_path = [m.caption for m in path] new_model = path[-1] orig_model = self.get_model_from_path(str_path) # Create a link from this node's model instances to the original root # models in the link model (there can be other composites along the way to # the root root_model = self.property_links_model.get_root_model(orig_model) if root_model: prop_names = self.property_links_model.get_model_properties(root_model) for prop_name in prop_names: if (new_model, prop_name) not in self._slave_property_links: # In order to remove slaves when the subwindow is closed, register # the slaves with respect to the most nested composite registering_model = path[-2] if len(path) > 1 else self if registering_model.is_editing: registering_model._slave_property_links.append((new_model, prop_name)) registering_model.property_links_model.add_silent(new_model, prop_name, root_model, prop_name) if registering_model.window_parent: # If the registering model has a parent, register also the # models in it's internal model view new_model = registering_model[path[-1].caption] registering_model = registering_model.window_parent registering_model._slave_property_links.append((new_model, prop_name)) registering_model.property_links_model.add_silent(new_model, prop_name, root_model, prop_name) def edit_in_window(self, parent=None): self._other_scene = FlowScene(registry=self._registry) self._other_scene.node_double_clicked.connect(self.on_other_scene_double_clicked) self._window_nodes = self._expand_into_scene(self._other_scene, restore_captions=True) # Store references to parent composites for node in self._window_nodes.values(): if isinstance(node.model, BaseCompositeModel): node.model.window_parent = self # Property links have to be registered with respect to the top composite model because # it's property model's property model is registered in property links window_parent = self while window_parent.window_parent: window_parent = window_parent.window_parent window_parent.add_slave_links() # Disable manipulation because the number of ports is fixed, so we can't e.g. internally # connect two nodes and delete the newly occupied port from the composite node self._other_scene.allow_node_creation = False self._other_scene.allow_node_deletion = False # There is no allow_connection_creation/deletion, so take care of it here self._other_scene.connection_created.connect(self.on_connection_created) self._other_scene.connection_deleted.connect(self.on_connection_deleted) self._other_view = FlowView(self._other_scene, parent=parent) self._other_view.setWindowFlag(Qt.Window, True) self._other_view.closeEvent = self.view_close_event self._other_view.setWindowTitle(self.name) self._other_view.resize(900, 600) self._other_view.show() def view_close_event(self, event): for node in self._window_nodes.values(): # Clse all composite children recursively first if isinstance(node.model, BaseCompositeModel) and node.model.is_editing: node.model._other_view.close() node.model.window_parent = None for (unique_name, node) in self._window_nodes.items(): self._view[unique_name].restore(node.model.save()) if self.property_links_model: for (model, prop_name) in self._slave_property_links: self.property_links_model.remove_silent(model, prop_name) self._slave_property_links = [] self._window_nodes = {} self._other_scene = None self._other_view = None def expand_into_scene(self, scene, composite_node, original_nodes=None): """ Expand this node into *scene* and replace *composite_node*'s connections with connections going straight into its subnodes. Also create connections internal to this node and update property links. *original_nodes* is a dictionary in form {caption: node_state} which will be used for positioning of the replacing nodes (scene.restore_node instead of scene.create_node will be called). """ assert self.property_links_model is not None # Connections to external nodes connections = [] # name_to_node is in format caption: new node dictionary # Internal connections are handled in _expand_into_scene name_to_node = self._expand_into_scene(scene, original_nodes=original_nodes, restore_captions=False) for port_type in [PortType.input, PortType.output]: for index, port in composite_node[port_type].items(): if port.connections: connection = port.connections[0] outside_port = connection.valid_ports[opposite_port(port_type)] internal_model, pt, pi = self._inside_ports[(port_type, index)] connections.append((outside_port, name_to_node[internal_model.caption][pt][pi])) # Update property links for (subcaption, subnode) in name_to_node.items(): if isinstance(subnode.model, BaseCompositeModel): # Get all leaf PropertyModel instances paths = subnode.model.get_leaf_paths() else: paths = [[subnode.model]] # In case selected node is composite, replace all leaf node links for path in paths: str_path = [model.caption for model in path] # Captions might have changed if subnode captions were equal to other captions # in the scene and the composite node which is being replaced contains still the # old ones old_str_path = [subcaption] + str_path[1:] old_model = composite_node.model.get_model_from_path(old_str_path) self.property_links_model.replace_item(subnode, path[-1], old_model) subnode.graphics_object.setSelected(True) scene.remove_node(composite_node) # Create outside connections only after the composite node has been deleted to prevent # creating multiple connections per input port in the outside nodes for outside, inside in connections: scene.create_connection(outside, inside, check_cycles=False) return name_to_node, connections def get_composite_model_class(composite_name, models, connections, links=None): if not composite_name: raise UfoModelError('composite name must be specified') class CompositeModel(BaseCompositeModel): name = composite_name data_type = UFO_DATA_TYPE def __init__(self, style=None, parent=None, registry=None): super().__init__(models, connections, links=links, registry=registry, style=style, parent=parent) model = CompositeModel model.caption_visible = True model.caption = composite_name return model class UfoGeneralBackprojectModel(UfoTaskModel): name = 'general_backproject' num_ports = {PortType.input: 1, PortType.output: 1} data_type = UFO_DATA_TYPE def __init__(self, style=None, parent=None, scrollable=True): super().__init__('general-backproject', style=style, parent=parent, scrollable=scrollable) self.needs_fixed_scheduler = True self.can_split_gpu_work = True def make_properties(self): properties = super().make_properties() slice_memory_coeff = NumberQLineEditViewItem(0.01, 1., default_value=0.8, tooltip='Portion of used GPU memory') properties['slice-memory-coeff'] = [slice_memory_coeff, False] return properties def split_gpu_work(self, gpus): from tofu.genreco import make_runs, DTYPE_CL_SIZE def check_region(region): if not len(np.arange(*self[region])): raise UfoModelError(f'Invalid {region} {self[region]}') # Check if ranges are OK check_region('region') check_region('x-region') check_region('y-region') gpu_indices = range(len(gpus)) bpp = DTYPE_CL_SIZE[self['store-type']] runs = make_runs(gpus, gpu_indices, self['x-region'], self['y-region'], self['region'], bpp, slice_memory_coeff=self['slice-memory-coeff']) return runs def _setup_ufo_task(self, ufo_task, region=None): separate = ['region', 'slice-memory-coeff'] task_props = [prop for prop in self if prop not in separate] for prop in task_props: setattr(ufo_task.props, prop, self[prop]) # Set region separately in case there are multiple inputs current_region = self['region'] if region is None else region setattr(ufo_task.props, 'region', current_region) class UfoVaryingInputModel(UfoTaskModel): """Base class for models which can have varying number if inputs.""" def __init__(self, task_name, style=None, parent=None, scrollable=True, num_inputs=None, dialog_title='Number of inputs', dialog_label='Number of inputs:'): if not num_inputs: num_inputs, ok = QInputDialog.getInt(parent, dialog_title, dialog_label, value=1, min=1, max=10, step=1) if not ok: raise UfoModelError('Number of inputs must be specified') self.num_ports = {PortType.input: num_inputs, PortType.output: 1} self.data_type = {PortType.output: {0: UFO_DATA_TYPE}} self.port_caption = {PortType.output: {0: ''}} self.port_caption_visible = {PortType.output: {0: False}} self.data_type[PortType.input] = {} self.port_caption[PortType.input] = {} self.port_caption_visible[PortType.input] = {} for i in range(num_inputs): self.data_type[PortType.input][i] = UFO_DATA_TYPE self.port_caption[PortType.input][i] = '' self.port_caption_visible[PortType.input][i] = False super().__init__(task_name, style=style, parent=parent, scrollable=scrollable) def save(self): state = super().save() state['num-inputs'] = self.num_ports['input'] return state class UfoOpenCLModel(UfoVaryingInputModel): name = 'opencl' def __init__(self, style=None, parent=None, scrollable=True, num_inputs=None): super().__init__('opencl', style=style, parent=parent, scrollable=scrollable, num_inputs=num_inputs) def _setup_ufo_task(self, ufo_task, region=None): for prop in self: if prop in ['filename', 'source']: # opencl task really needs NULL value = self[prop] if self[prop] else None else: value = self[prop] setattr(ufo_task.props, prop, value) class UfoReadModel(UfoTaskModel): name = 'read' num_ports = {PortType.input: 0, PortType.output: 1} data_type = UFO_DATA_TYPE def __init__(self, style=None, parent=None, scrollable=True): super().__init__('read', style=style, parent=parent, scrollable=scrollable) def auto_fill(self): import glob import imageio if os.path.isdir(self['path']): paths = sorted(glob.glob(os.path.join(self['path'], '*'))) else: paths = [self['path']] num_images = 0 for path in paths: try: num_images += len(imageio.get_reader(path)) except: LOG.error(f"Error reading '{path}'") if not num_images: raise UfoModelError(f"No images found in {self['path']}") self['number'] = num_images def double_clicked(self, parent): current_path = self['path'] if not os.path.isdir(current_path): current_path = os.path.dirname(current_path) if not current_path: current_path = QtCore.QDir.homePath() dialog = FileDirDialog() if dialog.exec_(): self['path'] = dialog.selectedFiles()[0] def _setup_ufo_task(self, ufo_task, region=None): for prop in self: if prop != 'raw-bitdepth' or self['raw-bitdepth']: setattr(ufo_task.props, prop, self[prop]) class UfoRetrievePhaseModel(UfoVaryingInputModel): name = 'retrieve_phase' def __init__(self, style=None, parent=None, scrollable=True, num_inputs=None): super().__init__('retrieve-phase', style=style, parent=parent, scrollable=scrollable, dialog_title='Multi-distance Setup', dialog_label='Number of distances:', num_inputs=num_inputs) def make_properties(self): properties = super().make_properties() # Override distance property based on how many inputs we expect tooltip = properties['distance'][0].widget.toolTip() item = RangeQLineEditViewItem(tooltip=tooltip, default_value=[], num_items=self.num_ports['input'], is_float=True) properties['distance'] = [item, True] if self.num_ports['input'] > 1: properties['method'][0].set('ctf_multidistance') properties['method'][0].widget.setEnabled(False) properties['distance-x'][0].widget.setEnabled(False) properties['distance-y'][0].widget.setEnabled(False) return properties class UfoWriteModel(UfoTaskModel): name = 'write' num_ports = {PortType.input: 1, PortType.output: 0} data_type = UFO_DATA_TYPE def __init__(self, style=None, parent=None, scrollable=True): super().__init__('write', style=style, parent=parent, scrollable=scrollable) def double_clicked(self, parent): current_path = os.path.dirname(self['filename']) if not current_path: current_path = QtCore.QDir.homePath() file_name, _ = QFileDialog.getSaveFileName(None, "Select File Name", current_path) if file_name: self['filename'] = file_name @property def expects_multiple_inputs(self): return '{region}' in self['filename'] def _setup_ufo_task(self, ufo_task, region=None): if region is not None and not self.expects_multiple_inputs: raise UfoModelError('Write got region without enabling multiple inputs. ' 'Add {region} somewhere in the "filename" field to enable it.') super()._setup_ufo_task(ufo_task, region=region) filename = self['filename'] if region is not None and self.expects_multiple_inputs: filename = filename.format(region=region[0]) setattr(ufo_task.props, 'filename', filename) class _Batch(QObject): finished = pyqtSignal(int) def __init__(self, ufo_task, shape, batch_id): super().__init__(parent=None) self.batch_id = batch_id self.data = np.empty(shape, dtype=np.float32) ptr = self.data.__array_interface__['data'][0] ufo_task.props.pointer = ptr ufo_task.props.max_size = self.data.nbytes ufo_task.connect('processed', self._on_processed) self.num_processed = 0 def _on_processed(self, ufo_task): self.num_processed += 1 if self.num_processed == self.data.shape[0]: self.finished.emit(self.batch_id) class UfoMemoryOutModel(UfoTaskModel): name = 'memory_out' num_ports = {PortType.input: 1, PortType.output: 1} data_type = {PortType.input: {0: UFO_DATA_TYPE}, PortType.output: {0: ARRAY_DATA_TYPE}} port_caption = {PortType.input: {0: ''}, PortType.output: {0: ''}} port_caption_visible = {PortType.input: {0: False}, PortType.output: {0: False}} def __init__(self, style=None, parent=None, scrollable=True): self._lock = Lock() self.reset_batches() super().__init__('memory-out', style=style, parent=parent, scrollable=scrollable) @property def expects_multiple_inputs(self): return self['number'] == '{region}' def make_properties(self): width_item = IntQLineEditViewItem(0, 1000000, default_value=0, tooltip='Input width') height_item = IntQLineEditViewItem(0, 1000000, default_value=0, tooltip='Input height') depth_item = IntQLineEditViewItem(0, 1000000, default_value=1, tooltip='Input depth (for 2D images should be 1)') number_item = QLineEditViewItem(default_value=1, tooltip='Number of inputs') properties = {'width': [width_item, True], 'height': [height_item, True], 'depth': [depth_item, True], 'number': [number_item, True]} return properties def consume_batch(self, batch_id): def consume(current_batch): LOG.debug(f'{self.caption}: consuming {current_batch.batch_id} (caller {batch_id})') self._current_data = current_batch.data self.data_updated.emit(0) # Free memory up self._batches[self._expecting_id] = None with self._lock: if self._expecting_id == batch_id: consume(self._batches[self._expecting_id]) self._expecting_id += 1 while self._expecting_id in self._waiting_list: consume(self._batches[self._expecting_id]) del self._waiting_list[self._waiting_list.index(self._expecting_id)] self._expecting_id += 1 else: LOG.debug(f'{self.caption}: putting {batch_id} on waiting list') self._waiting_list.append(batch_id) def out_data(self, port: int) -> NodeData: LOG.debug(f'{self.caption}: out_data shape:' f'{None if self._current_data is None else self._current_data.shape}') return self._current_data def reset_batches(self): self._batches = [] self._waiting_list = [] self._expecting_id = 0 self._current_data = None def _setup_ufo_task(self, ufo_task, region=None): if region is not None and not self.expects_multiple_inputs: raise UfoModelError('Memory Out got region without enabling multiple inputs. ' 'Type {region} in the "number" field to enable it.') number = int(self['number']) if region is None else len(np.arange(*region)) shape = (number, self['height'], self['width']) with self._lock: batch = _Batch(ufo_task, shape, len(self._batches)) self._batches.append(batch) batch.finished.connect(self.consume_batch) class ImageViewerModel(UfoModel): name = 'image_viewer' caption = 'Image Viewer' num_ports = {PortType.input: 1, PortType.output: 0, } data_type = ARRAY_DATA_TYPE def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._node_data = None from tofu.flow.viewer import ImageViewer self._widget = ImageViewer() self._reset = True def embedded_widget(self): return self._widget def resizable(self): return True def double_clicked(self, parent): try: if self._widget.images is not None and not self._widget.popup_visible: import pyqtgraph self._widget.popup() except ImportError: LOG.debug('pyqtgraph not installed, not popping up') def set_in_data(self, data: NodeData, port: Port): if data is not None: if self._reset: self._widget.images = data self._reset = False else: self._widget.append(data) def reset_batches(self): self._reset = True def cleanup(self): self._widget.cleanup() def get_ufo_model_classes(names=None): all_names = set(UFO_PLUGIN_MANAGER.get_all_task_names()) # stamp causes a gobject unref warning blacklist = set(['general-backproject', 'memory-in', 'memory-out', 'opencl', 'read', 'retrieve-phase', 'stamp', 'write']) all_names = list(all_names - blacklist) return (get_ufo_model_class(name) for name in names or all_names) def get_composite_model_classes_from_json(state): """ Get composite model classes from their json representation. This is recursive in case a user creates a composite inside the scene, then adds nodes and creates another composite with the first one inside and doesn't export explicitly the first one. The order of returned classes is bottom -> up, i.e. first the classes which have striclty non-composite submodels are returned and the top level class is last. """ classes = [] def go_down(current): connections = [CompositeConnection(*args) for args in current['connections']] submodels = [] for (key, model) in current['models'].items(): if 'models' in model['model'] and 'connections' in model['model']: go_down(current['models'][key]['model']) # models are tuples (name, state, visible, position) submodels.append((model['name'], model['model'], model['visible'], model['position'])) classes.append(get_composite_model_class(current['name'], submodels, connections, links=current.get('links', None))) go_down(state) return classes def get_composite_model_classes(): import xdg.BaseDirectory composite_lists = [] paths = [pkg_resources.resource_filename(__name__, 'composites'), xdg.BaseDirectory.save_data_path('tofu', 'flows', 'composites')] for path in paths: file_names = sorted(glob.glob(os.path.join(path, '*.cm'))) for file_name in file_names: LOG.debug(f'Loading composite from {file_name}') try: with open(file_name, 'r') as f: state = json.load(f) composite_lists.append(get_composite_model_classes_from_json(state)) except Exception as e: LOG.error(e, exc_info=True) return composite_lists class UfoModelError(FlowError): pass ufo-kit-tofu-ed0e5bd/tofu/flow/propertylinksmodels.py000066400000000000000000000371131521054151500232500ustar00rootroot00000000000000import logging from PyQt5.QtCore import QDataStream, pyqtSignal from PyQt5.QtGui import QStandardItemModel, QStandardItem from tofu.flow.models import PropertyModel, BaseCompositeModel from tofu.flow.util import MODEL_ROLE, NODE_ROLE, PROPERTY_ROLE LOG = logging.getLogger(__name__) def _decode_mime_data(data): byte_array = data.data('application/x-sourcetreemodelindex') ds = QDataStream(byte_array) row = ds.readInt32() column = ds.readInt32() internal_id = ds.readUInt64() return (row, column, internal_id) def _data_from_tree_index(index): """ Traverse parents up to the root and get the root node, model and it's property from *index*, which must be a property record (leaf in the tree). """ prop_name = index.data() index = index.parent() model = index.data(role=MODEL_ROLE) while index.data(role=NODE_ROLE) is None and index.isValid(): index = index.parent() node = index.data(role=NODE_ROLE) return (node, model, prop_name) def _get_string_path(node, model, prop_name): if isinstance(node.model, BaseCompositeModel): path = node.model.get_path_from_model(model) else: path = [model] str_path = [model.caption for model in path] str_path.append(prop_name) return str_path class NodeTreeModel(QStandardItemModel): """Tree model representing nodes in the scene.""" def add_node(self, node): item = self._add_model(node.model) if item: item.setData(node, role=NODE_ROLE) def remove_node(self, node): for j in range(self.rowCount()): item = self.item(j, 0) if item and item.data(role=NODE_ROLE) == node: self.removeRow(j) break def clear(self): """In PyQt5, clear doesn't emit the rowsAboutToBeRemoved signal and this does effectively the same. """ self.removeRows(0, self.rowCount()) self.removeColumns(0, self.columnCount()) self.rowCount(), self.columnCount() def set_nodes(self, nodes): self.clear() for node in nodes: self.add_node(node) def _add_model(self, flow_model, parent=None): if not parent: parent = self.invisibleRootItem() item = None if (isinstance(flow_model, PropertyModel) or isinstance(flow_model, BaseCompositeModel)): item = QStandardItem(flow_model.caption) item.setData(flow_model, role=MODEL_ROLE) item.setEditable(False) if isinstance(flow_model, PropertyModel): for prop in sorted(flow_model): prop_item = QStandardItem(prop) prop_item.setEditable(False) item.appendRow(prop_item) else: for submodel_name in sorted(flow_model): self._add_model(flow_model[submodel_name], parent=item) if item: parent.appendRow(item) return item class PropertyLinksModel(QStandardItemModel): """Links model representing property links between nodes in the scene.""" restored = pyqtSignal() def __init__(self, node_model): super().__init__() self._silent = {} self._slaves = {} self._node_model = node_model self._node_model.rowsAboutToBeRemoved.connect(self.on_node_rows_about_to_be_removed) def __contains__(self, key): for column in range(self.columnCount()): if self.findItems(key, column=column): return True return False def clear(self): for j in range(self.rowCount()): for i in range(self.columnCount()): self.remove_item(self.indexFromItem(self.item(j, i))) super().clear() def find_items(self, data_list, roles): result = [] for j in range(self.rowCount()): for i in range(self.columnCount()): item = self.item(j, i) if item: success = True for (data, role) in zip(data_list, roles): if item.data(role=role) != data: success = False break if success: result.append(item) return result def get_model_links(self, models): """ Get links between *models*. Return dict {row index: [str_path, ...]}, where *str_path* is the path from the topmost model (in case of composites along the way) to the property name. """ items = {} for model in models: for item in self.find_items([model], [MODEL_ROLE]): str_path = item.text().split('->') if item.row() not in items: items[item.row()] = [str_path] else: items[item.row()].append(str_path) return items def get_root_model(self, model): root_model = None items = self.find_items([model], [MODEL_ROLE]) if items: root_model = items[0].data(role=MODEL_ROLE) else: for (silent_model, prop_name) in self._silent: if silent_model == model: root_model = self._silent[(silent_model, prop_name)][0] return root_model def get_model_properties(self, model): items = self.find_items([model], [MODEL_ROLE]) return [item.data(role=PROPERTY_ROLE) for item in items] def add_item(self, node, model, prop_name, row, column, insert=False): """ Add item where *node* is the root node (can be composite), *model* is the leaf model (there can be composites above if the leaf is nested) and *prop_name* is the property name. *row* and *column* determine the table cell to which to add the item or replace an old item with the new one. If *insert* is True, insert a new row at *row*. """ str_path = '->'.join(_get_string_path(node, model, prop_name)) if str_path in self: raise ValueError(f'{str_path} already inside') item = QStandardItem(str_path) item.setData(model, role=MODEL_ROLE) item.setData(prop_name, role=PROPERTY_ROLE) item.setData(node, role=NODE_ROLE) item.setEditable(False) if row == -1: row = self.rowCount() if column == -1: # +1 to find an empty cell even if the row is full for i in range(self.columnCount() + 1): if self.item(row, i) is None: column = i break LOG.debug(f'Add item {node.model.caption}({item.data(role=MODEL_ROLE)}):' f'{item.data(role=PROPERTY_ROLE)} at ({row}, {column})') if insert: self.insertRow(row, item) else: self.setItem(row, column, item) # In case the composite is being edit in a subwindow, connect the slave nodes from the # subsecene if isinstance(node.model, BaseCompositeModel): node.model.add_slave_links() model.property_changed.connect(self.on_property_changed) def remove_item(self, index): flow_model = index.data(role=MODEL_ROLE) if not flow_model: # Empty cell return property_name = index.data(role=PROPERTY_ROLE) flow_model.property_changed.disconnect(self.on_property_changed) self.setItem(index.row(), index.column(), None) # Remove all associated slaves root_key = (flow_model, property_name) if root_key in self._slaves: for slave_key in tuple(self._slaves[root_key]): self.remove_silent(*slave_key) def add_silent(self, model, property_name, root, root_property_name): key = (model, property_name) if key in self._silent: return model.property_changed.connect(self.on_property_changed) root_key = (root, root_property_name) if not self.find_items(root_key, (MODEL_ROLE, PROPERTY_ROLE)): raise ValueError(f'{model} not in property links') self._silent[key] = root_key if root_key not in self._slaves: self._slaves[root_key] = [key] else: self._slaves[root_key].append(key) LOG.debug(f'Slave {root}->{root_property_name} -> {model}->{property_name} added') def remove_silent(self, model, property_name): key = (model, property_name) if key not in self._silent: # Already removed, e.g. by deleting an item by del key while some composite windows were # still opened return model.property_changed.disconnect(self.on_property_changed) root_key = self._silent[key] index = self._slaves[root_key].index(key) del self._slaves[root_key][index] if not self._slaves[root_key]: del self._slaves[root_key] del self._silent[key] LOG.debug(f'Slave {model}->{property_name} removed') def replace_item(self, node, new_model, old_model): for j in range(self.rowCount()): for i in range(self.columnCount()): item = self.item(j, i) if item and item.data(role=MODEL_ROLE) == old_model: # Don't break, replace all properties of *old_model* prop_name = item.data(role=PROPERTY_ROLE) slaves = tuple(self._slaves.get((old_model, prop_name), [])) self.remove_item(self.indexFromItem(item)) self.add_item(node, new_model, prop_name, j, i) for (slave_model, slave_property_name) in slaves: self.add_silent(slave_model, slave_property_name, new_model, prop_name) def on_node_rows_about_to_be_removed(self, parent, first, last): for k in range(first, last + 1): node = self._node_model.item(k, 0).data(role=NODE_ROLE) for j in range(self.rowCount()): for i in range(self.columnCount()): item = self.item(j, i) if item and item.data(role=NODE_ROLE) == node: self.remove_item(self.indexFromItem(item)) self.compact() def canDropMimeData(self, data, action, row, column, parent): can_drop = False if data.hasFormat('application/x-sourcetreemodelindex'): src_row, src_column, src_internal_id = _decode_mime_data(data) src_model_index = self._node_model.createIndex(src_row, src_column, src_internal_id) # src_model_index is the property, it's parent is the model node, flow_model, property_name = _data_from_tree_index(src_model_index) str_path = '->'.join(_get_string_path(node, flow_model, property_name)) can_drop = str_path not in self if parent.isValid(): # Parent itself can be an empty cell, so use the first column which is for sure # occupied since the parent is valid (row exists and we are not between rows) first_item = self.item(parent.row(), 0) parent_model = first_item.data(role=MODEL_ROLE) parent_property_name = first_item.data(role=PROPERTY_ROLE) if not type(flow_model[property_name]) is type(parent_model[parent_property_name]): # Data can be dropped only if the types of properties match can_drop = False return can_drop def dropMimeData(self, data, action, row, column, parent): src_row, src_column, src_internal_id = _decode_mime_data(data) src_model_index = self._node_model.createIndex(src_row, src_column, src_internal_id) node, flow_model, property_name = _data_from_tree_index(src_model_index) if parent.isValid(): row = parent.row() insert = False else: insert = True # drops never replace items and column=-1 means "find an empty cell" self.add_item(node, flow_model, property_name, row, -1, insert=insert) return True def save(self): state = [] for j in range(self.rowCount()): row_state = [] for i in range(self.columnCount()): item = self.item(j, i) if not item: continue node = item.data(role=NODE_ROLE) model = item.data(role=MODEL_ROLE) prop_name = item.data(role=PROPERTY_ROLE) str_path = _get_string_path(node, model, prop_name) row_state.append([node.id, str_path]) state.append(row_state) return state def restore(self, state, nodes): self.clear() for (j, row) in enumerate(state): for (i, (node_id, path)) in enumerate(row): node = nodes[node_id] # Last path entry is the property name if isinstance(node.model, BaseCompositeModel): flow_model = node.model.get_model_from_path(path[1:-1]) else: flow_model = node.model self.add_item(node, flow_model, path[-1], j, i) self.restored.emit() def compact(self): # Shift rows to the left for j in range(self.rowCount()): filled = [] for i in range(self.columnCount()): if self.item(j, i): filled.append(self.takeItem(j, i)) for (i, item) in enumerate(filled): self.setItem(j, i, item) # Check empty rows for j in range(self.rowCount())[::-1]: is_empty = True for i in range(self.columnCount()): if self.item(j, i): is_empty = False if is_empty: self.removeRow(j) # Check empty columns for i in range(self.columnCount())[::-1]: is_empty = True for j in range(self.rowCount()): if self.item(j, i): is_empty = False if is_empty: self.removeColumn(i) def on_property_changed(self, sig_model, sig_property_name, value): LOG.debug(f'on_property_changed: {sig_model}, {sig_model.caption}, ' f'{sig_property_name}, {value}') sig_key = (sig_model, sig_property_name) if sig_key in self._silent: # pyqtSignal came from a composite subwindow, get root model from the silent slave root_key = self._silent[sig_key] root_key[0][root_key[1]] = value else: root_key = (sig_model, sig_property_name) row = -1 for j in range(self.rowCount()): for i in range(self.columnCount()): item = self.item(j, i) if (item and item.data(role=MODEL_ROLE) == root_key[0] and item.data(role=PROPERTY_ROLE) == root_key[1]): row = j break if row != -1: break for i in range(self.columnCount()): item = self.item(row, i) if item: model = item.data(role=MODEL_ROLE) property_name = item.data(role=PROPERTY_ROLE) if root_key != (model, property_name): model[property_name] = value # Notify all slaves key = (model, property_name) if key in self._slaves: for (slave_model, slave_property_name) in self._slaves[key]: if (slave_model, slave_property_name) != (sig_model, sig_property_name): slave_model[slave_property_name] = value ufo-kit-tofu-ed0e5bd/tofu/flow/propertylinkswidget.py000066400000000000000000000070231521054151500232450ustar00rootroot00000000000000from PyQt5.QtCore import QMimeData, Qt, QDataStream, QByteArray, QIODevice, QModelIndex from PyQt5.QtGui import QDrag from PyQt5.QtWidgets import QAbstractItemView, QLabel, QTableView, QTreeView, QVBoxLayout, QWidget def _encode_mime_data(index: QModelIndex): """Encode item in *index* into :class:`QMimeData`.""" mime_data = QMimeData() data = QByteArray() stream = QDataStream(data, QIODevice.WriteOnly) try: stream.writeInt32(index.row()) stream.writeInt32(index.column()) stream.writeUInt64(index.internalId()) finally: stream.device().close() mime_data.setData("application/x-sourcetreemodelindex", data) return mime_data class PropertyLinksView(QTableView): """Table view for displaying node property links.""" def keyPressEvent(self, event): if event.key() == Qt.Key_Delete: model = self.model() for index in self.selectedIndexes(): model.remove_item(index) model.compact() class NodesView(QTreeView): """Tree view displaying nodes in the scene.""" def get_drag_index(self): selected = self.selectedIndexes() if not selected: return index = selected[0] if index.child(0, 0).row() != -1: return return index def mouseMoveEvent(self, event): """All that a mouse *event* can do is start a drag and drop operation.""" index = self.get_drag_index() if not index: return drag = QDrag(self) mime_data = _encode_mime_data(index) drag.setMimeData(mime_data) drag.exec_(Qt.CopyAction) return True class PropertyLinks(QWidget): """Widget displaying nodes in the scene and their property links in one window.""" def __init__(self, node_model, table_model, parent=None): super().__init__(parent=parent, flags=Qt.Window) self.setWindowTitle('Property Links') self.resize(600, 800) self._treeview = NodesView() self._treeview.setHeaderHidden(True) self._treeview.setAlternatingRowColors(True) self._treeview.setDragEnabled(True) self._treeview.setAcceptDrops(False) self._treeview.setModel(node_model) node_model.itemChanged.connect(self.on_node_model_changed) self._table_view = PropertyLinksView() self._table_view.setDragDropOverwriteMode(False) self._table_view.setDragDropMode(QAbstractItemView.DropOnly) table_model.itemChanged.connect(self.on_table_model_changed) table_model.rowsInserted.connect(self.on_table_model_rows_inserted) table_model.restored.connect(self.on_table_model_restored) self._table_view.setModel(table_model) main_layout = QVBoxLayout() main_layout.addWidget(self._treeview) main_layout.addWidget(QLabel('Drag properties from above to the area below')) main_layout.addWidget(self._table_view) self.setLayout(main_layout) def show(self): self._table_view.resizeColumnsToContents() self._treeview.sortByColumn(0, Qt.AscendingOrder) super().show() def on_table_model_changed(self, item): self._table_view.resizeColumnToContents(item.column()) def on_table_model_rows_inserted(self, index, start, stop): self._table_view.resizeColumnToContents(0) def on_table_model_restored(self): self._table_view.resizeColumnsToContents() def on_node_model_changed(self, item): self._treeview.sortByColumn(0, Qt.AscendingOrder) ufo-kit-tofu-ed0e5bd/tofu/flow/runslider.py000066400000000000000000000160531521054151500211260ustar00rootroot00000000000000from functools import partial from PyQt5.QtCore import Qt, pyqtSignal, QTimer from PyQt5 import QtGui from PyQt5.QtWidgets import QGridLayout, QLineEdit, QWidget, QSlider from tofu.flow.models import IntQLineEditViewItem, RangeQLineEditViewItem, UfoIntValidator from tofu.flow.util import FlowError class RunSlider(QWidget): value_changed = pyqtSignal(float) def __init__(self, parent=None): super().__init__(parent=parent, flags=Qt.Window) self.setWindowFlag(Qt.WindowStaysOnTopHint) self.setMaximumHeight(20) self.setMinimumWidth(600) self.min_edit = QLineEdit() self.min_edit.setToolTip('Minimum') self.min_edit.setMaximumWidth(80) self.min_edit.editingFinished.connect(self.on_min_edit_editing_finished) self.current_edit = QLineEdit() self.current_edit.setToolTip('Current value') self.current_edit.editingFinished.connect(self.on_current_edit_editing_finished) self.max_edit = QLineEdit() self.max_edit.setToolTip('Maximum') self.max_edit.setMaximumWidth(80) self.max_edit.editingFinished.connect(self.on_max_edit_editing_finished) self.slider = QSlider(orientation=Qt.Horizontal) self.slider.setMinimum(0) self.slider.setMaximum(100) self.slider.valueChanged.connect(self.on_slider_value_changed) main_layout = QGridLayout() main_layout.addWidget(self.current_edit, 0, 0, 1, 3, Qt.AlignHCenter) main_layout.addWidget(self.min_edit, 1, 0) main_layout.addWidget(self.slider, 1, 1) main_layout.addWidget(self.max_edit, 1, 2) self.setLayout(main_layout) self.view_item = None self.real_minimum = 0 self.real_maximum = 100 self.real_span = 100 self.type = None self._last_value = None self.setEnabled(False) def _update_range(self, current=None): self.real_span = self.real_maximum - self.real_minimum if current is not None: self.slider.blockSignals(True) self.slider.setValue(int(round((current - self.real_minimum) / self.real_span * 100))) self.slider.blockSignals(False) def get_real_value(self): # First convert possible exponents to float (in case UFO has huge defaults set) return self.type(float(self.current_edit.text())) def set_widget_value(self): value = self.get_real_value() self._last_value = value if isinstance(self.view_item, RangeQLineEditViewItem): value = [value] self.view_item.set(value) # Notify linked widgets self.view_item.property_changed.emit(self.view_item) def set_current_validator(self): if self.type == int: validator = UfoIntValidator(self.real_minimum, self.real_maximum) else: validator = QtGui.QDoubleValidator(self.real_minimum, self.real_maximum, 1000) self.current_edit.setValidator(validator) def setup(self, view_item): if self.view_item == view_item: return False current = view_item.get() if isinstance(view_item, RangeQLineEditViewItem): if len(current) > 1: return False self.type = float current = current[0] d_current = 0.1 * abs(current) if current else 100 self.real_minimum = current - d_current self.real_maximum = current + d_current else: self.type = int if isinstance(view_item, IntQLineEditViewItem) else float self.real_minimum = view_item.widget.validator().bottom() self.real_maximum = view_item.widget.validator().top() self.view_item = view_item self._update_range(current=current) _set_number(self.min_edit, self.real_minimum) _set_number(self.max_edit, self.real_maximum) _set_number(self.current_edit, current) self._last_value = current self.setEnabled(True) self.set_current_validator() return True def reset(self): self.real_minimum = 0 self.real_maximum = 100 self.real_span = 100 self._last_value = None self.type = None self.min_edit.setText('') self.max_edit.setText('') self.current_edit.setText('') self.setWindowTitle('') self.view_item = None self.setEnabled(False) def on_slider_value_changed(self, value): def delayed_update(init_value): current_value = self.slider.value() if init_value == current_value: self.set_widget_value() self.value_changed.emit(real_value) if self.view_item: real_value = self.slider.value() / 100 * self.real_span + self.real_minimum self.current_edit.setText('{:g}'.format(self.type(real_value))) func = partial(delayed_update, value) QTimer.singleShot(100, func) def on_current_edit_editing_finished(self): if not self.view_item: return try: value = self.type(self.current_edit.text()) except ValueError: raise RunSliderError('Not a number') if value == self._last_value: # Nothing new, do not emit value_changed signal in case the app is closing return self.slider.blockSignals(True) self.slider.setValue(int(round((value - self.real_minimum) / self.real_span * 100))) self.slider.blockSignals(False) self.set_widget_value() self.value_changed.emit(value) def on_min_edit_editing_finished(self): if not self.view_item: return try: value = self.type(self.min_edit.text()) except ValueError: raise RunSliderError('Not a number') if value >= self.real_maximum: raise RunSliderError('Minimum must be smaller than maximum') current = self.get_real_value() self.real_minimum = value if current < self.real_minimum: current = self.real_minimum self.current_edit.setText('{:g}'.format(current)) self.set_widget_value() self.value_changed.emit(current) self._update_range(current=current) self.set_current_validator() def on_max_edit_editing_finished(self): if not self.view_item: return try: value = self.type(self.max_edit.text()) except ValueError: raise RunSliderError('Not a number') if value <= self.real_minimum: raise RunSliderError('Maximum must be greater than minimum') current = self.get_real_value() self.real_maximum = value if current > self.real_maximum: current = self.real_maximum self.current_edit.setText('{:g}'.format(current)) self.set_widget_value() self.value_changed.emit(current) self._update_range(current=current) self.set_current_validator() def _set_number(edit, number): edit.setText('{:g}'.format(number)) class RunSliderError(FlowError): pass ufo-kit-tofu-ed0e5bd/tofu/flow/scene.py000066400000000000000000000427651521054151500202250ustar00rootroot00000000000000import logging import numpy as np import networkx as nx from PyQt5.QtCore import pyqtSignal, QObject from PyQt5.QtWidgets import QInputDialog from qtpynodeeditor import FlowScene, NodeDataModel, PortType, opposite_port from tofu.flow.models import (BaseCompositeModel, ImageViewerModel, PropertyModel, UFO_DATA_TYPE, get_composite_model_class, get_composite_model_classes_from_json) from tofu.flow.util import CompositeConnection, FlowError, saved_kwargs from tofu.flow.propertylinksmodels import PropertyLinksModel, NodeTreeModel LOG = logging.getLogger(__name__) class UfoScene(FlowScene): nodes_duplicated = pyqtSignal(list, dict) # view item, its name and model name item_focus_in = pyqtSignal(QObject, str, str, NodeDataModel) def __init__(self, registry=None, style=None, parent=None, allow_node_creation=True, allow_node_deletion=True): super().__init__(registry=registry, style=style, parent=parent, allow_node_creation=allow_node_creation, allow_node_deletion=allow_node_deletion) self._composite_nodes = {} self._selected_nodes_on_disabled = [] self.node_model = NodeTreeModel() self.node_model.setColumnCount(1) self.property_links_model = PropertyLinksModel(self.node_model) self.style_collection.node.opacity = 1 self.style_collection.connection.use_data_defined_colors = True self.node_double_clicked.connect(self.on_node_double_clicked) def __getstate__(self): state = super().__getstate__() state['property-links'] = self.property_links_model.save() return state def __setstate__(self, doc): for node in doc['nodes']: model = node['model'] if 'models' in model and 'connections' in model: # First register the composite model models = get_composite_model_classes_from_json(model) for model in models: self.registry.register_model(model, category='Composite', registry=self.registry) # Restore the scene super().__setstate__(doc) # and the property link models and widgets if 'property-links' in doc: self.node_model.set_nodes(self.nodes.values()) self.property_links_model.restore(doc['property-links'], self.nodes) def create_node(self, data_model, restore_links=True): """Overrides :class:`FlowScene` in order to create a node with *data_model* with a unique caption. """ LOG.debug(f'Create node with model {data_model}') node = super().create_node(data_model) self._setup_new_node(node) if restore_links and isinstance(node.model, BaseCompositeModel): node.model.restore_links(node) return node def restore_node(self, node_json): LOG.debug(f"Restore node with model {node_json['model']['name']}") with saved_kwargs(self.registry, node_json['model']): node = super().restore_node(node_json) self._setup_new_node(node) return node def on_item_focus_in(self, view_item, prop_name, caption, model): self.item_focus_in.emit(view_item, prop_name, caption, model) def _setup_new_node(self, node): self._set_unique_caption(node) self.node_model.add_node(node) if isinstance(node.model, BaseCompositeModel): node.model.property_links_model = self.property_links_model node.model.item_focus_in.connect(self.on_item_focus_in) def _set_unique_caption(self, new_node): caption = new_node.model.caption captions = [node.model.caption for node in self.nodes.values() if node != new_node] if caption in captions: fmt = new_node.model.base_caption + ' {}' i = 2 while fmt.format(i) in captions: i += 1 caption = fmt.format(i) new_node.model.caption = caption def remove_node(self, node): if hasattr(node.model, 'cleanup'): node.model.cleanup() if (isinstance(node.model, BaseCompositeModel) and node.model.name in self._composite_nodes): del self._composite_nodes[node.model.name] self.node_model.remove_node(node) super().remove_node(node) def is_selected_one_composite(self): result = False nodes = self.selected_nodes() if len(nodes) == 1: result = isinstance(nodes[0].model, BaseCompositeModel) return result def skip_nodes(self): selected_nodes = self.selected_nodes() # First check if the selected nodes may be skipped for node in selected_nodes: if (node.model.num_ports[PortType.input] != 1 or node.model.num_ports[PortType.output] != 1): raise FlowError('Only nodes with one input and one output can be skipped') ports = list(node.state.ports) if ports[0].data_type != UFO_DATA_TYPE or ports[1].data_type != UFO_DATA_TYPE: raise FlowError('Only tasks with UFO input and output can be skipped') # And only if all is fine, then skip them for node in selected_nodes: node.model.skip = not node.model.skip opacity = 0.5 if node.model.skip else 1 node.state.input_connections[0].graphics_object.setOpacity(opacity) node.state.output_connections[0].graphics_object.setOpacity(opacity) node.graphics_object.setOpacity(opacity) def auto_fill(self): for node in self.nodes.values(): if isinstance(node.model, BaseCompositeModel): paths = node.model.get_leaf_paths() else: paths = [[node.model]] for path in paths: model = path[-1] if isinstance(model, PropertyModel): model.auto_fill() def copy_nodes(self): new_nodes = {} selected_nodes = self.selected_nodes() # Create nodes for node in selected_nodes: new_node = self.create_node(node.model) new_nodes[node] = new_node values = node.model.save() new_node.model.restore(values, restore_caption=False) # Create connections for node, new_node in new_nodes.items(): for connection in self.connections: port = connection.ports[0] in_index = port.index out_index = connection.ports[1].index if port.node == node: other_node = connection.ports[1].node if other_node in new_nodes: # Other node has been also selected self.create_connection_by_index(new_node, in_index, new_nodes[other_node], out_index, None) self.nodes_duplicated.emit(selected_nodes, new_nodes) def create_composite(self): composite_name, ok = QInputDialog.getText(None, 'Create Composite Node', 'Name:') if not ok: return if composite_name in self.registry.registered_model_creators(): raise FlowError(f'Composite node with name "{composite_name}" has already ' 'been registered') self._composite_nodes[composite_name] = {} connection_replacements = [] models = [] connections = [] selected_nodes = self.selected_nodes() for node in selected_nodes: unique_name = node.model.caption models.append((node.model.name, node.model.save(), True, node.__getstate__()['position'])) self._composite_nodes[composite_name][unique_name] = node.__getstate__() # Connections assigned_ports = [] x = [] y = [] for node in selected_nodes: x.append(node.position.x()) y.append(node.position.y()) for port_type in ['input', 'output']: for index, port in node[port_type].items(): if port.connections: # We allow only one connection conn = port.connections[0] other_port = conn.ports[0] if conn.ports[1] == port else conn.ports[1] other = conn.get_node(opposite_port(port_type)) if (other in selected_nodes and port not in assigned_ports and other_port not in assigned_ports): # Connection reaches to a node outside selection if port_type == PortType.input: to_node_name = node.model.caption to_node_index = index from_node_name = other.model.caption from_node_index = other_port.index else: to_node_name = other.model.caption to_node_index = other_port.index from_node_name = node.model.caption from_node_index = index conn = CompositeConnection(from_node_name, from_node_index, to_node_name, to_node_index) connections.append(conn) assigned_ports.append(port) if other not in selected_nodes: inside = (node.model.caption, port_type, index) connection_replacements.append((other_port, inside)) # Get links which will be internal to the newly created model node_models = [] for selected_node in self.selected_nodes(): if isinstance(selected_node.model, BaseCompositeModel): paths = selected_node.model.get_leaf_paths() else: paths = [[selected_node.model]] node_models += [path[-1] for path in paths] internal_links = list(self.property_links_model.get_model_links(node_models).values()) composite = get_composite_model_class(composite_name, models, connections, links=internal_links) self.registry.register_model(composite, category='Composite', registry=self.registry) node = self.create_node(composite, restore_links=False) for selected_node in selected_nodes: if isinstance(selected_node.model, BaseCompositeModel): # Get all leaf PropertyModel instances paths = selected_node.model.get_leaf_paths() else: paths = [[selected_node.model]] # In case selected node is composite, replace all leaf node links for path in paths: new_model = node.model.get_model_from_path([model.caption for model in path]) self.property_links_model.replace_item(node, new_model, path[-1]) self.remove_node(selected_node) for outside_port, inside in connection_replacements: port_type, index = node.model.get_outside_port(*inside) self.create_connection(outside_port, node[port_type][index], check_cycles=False) # Put the new composite node to the average of x and y position of the selected nodes node.position = (np.mean(x), np.mean(y)) node.graphics_object.setSelected(True) return node def on_node_double_clicked(self, node): views = self.views() if views: node.model.double_clicked(views[0]) def expand_composite(self, node): name = node.model.name original_nodes = self._composite_nodes.get(name, None) return node.model.expand_into_scene(self, node, original_nodes=original_nodes) def is_fully_connected(self): """Are all the ports in all nodes connected?""" def are_ports_connected(node, port_type): for port in node[port_type].values(): if not port.connections: return False return True for node in self.nodes.values(): if not are_ports_connected(node, 'input'): return False if not are_ports_connected(node, 'output'): return False return True def are_all_ufo_tasks(self, graphs=None): """If all inputs and outputs of all models in all *graphs* have `UfoBuffer` data type, return True. If *graphs* are not specified, they are created from the scene. """ if graphs is None: graphs = self.get_simple_node_graphs() for graph in graphs: for model in graph.nodes: for port_type in ['input', 'output']: for data_type in model.data_type[port_type].values(): if data_type.id != 'UfoBuffer': return False return True def get_simple_node_graphs(self): """ Get a graph from the scene without composite nodes which can be directly used byt the execution. """ def get_composite(graph): """Get first found composite model.""" for model in graph.nodes: if isinstance(model, BaseCompositeModel): return model def replace_edge(graph, composite, edges, port_type): """Replace interface edges (going in or out from the composite model).""" for edge in edges: ports = graph.edges[edge] other = edge[0] if port_type == PortType.input else edge[1] model, index = composite.get_model_and_port_index(port_type, ports[port_type]) if model not in graph: graph.add_node(model) if port_type == PortType.input: source = other dest = model input_port = index output_port = ports[PortType.output] else: source = model dest = other input_port = ports[PortType.input] output_port = index LOG.debug(f'Adding edge {source.name}@{output_port} -> {dest.name}@{input_port}') graph.add_edge(source, dest, input=input_port, output=output_port) def replace_composite(graph, composite): composite.expand_into_graph(graph) edges = graph.in_edges(composite, keys=True) replace_edge(graph, composite, edges, PortType.input) edges = graph.out_edges(composite, keys=True) replace_edge(graph, composite, edges, PortType.output) graph.remove_node(composite) # Initial graph with composite nodes. We need a multigraph because composite nodes may have # many outputs which can lead to a same destination node. graph = nx.MultiDiGraph() for node in self.nodes.values(): if not node.model.skip: graph.add_node(node.model) for conn in self.connections: p_dest, p_source = conn.ports if p_dest.node.model.skip: LOG.debug(f'Skiping connection {p_source.node.model.name} -> ' f'{p_dest.node.model.name}') continue while p_source.node.model.skip: LOG.debug(f'Skiping connection {p_source.node.model.name} -> ' f'{p_dest.node.model.name}') previous_conn = p_source.node.state.input_connections[0] previous_node = previous_conn.output_node p_source = list(previous_node.state.output_ports)[0] graph.add_edge(p_source.node.model, p_dest.node.model, input=p_dest.index, output=p_source.index) # Expand composite nodes until there are only simple ones left model = get_composite(graph) while model: LOG.debug(f'Replacing composite {model.name}') replace_composite(graph, model) model = get_composite(graph) components = nx.weakly_connected_components(graph) return [nx.subgraph(graph, component) for component in components] def set_enabled(self, enabled): selected_nodes = self.selected_nodes() self.allow_node_creation = enabled self.allow_node_deletion = enabled for node in self.nodes.values(): if not isinstance(node.model, ImageViewerModel): node.graphics_object.setEnabled(enabled) if enabled: if node in self._selected_nodes_on_disabled: node.graphics_object.setSelected(True) else: if node in selected_nodes: self._selected_nodes_on_disabled.append(node) for conn in self.connections: conn._graphics_object.setEnabled(enabled) if enabled: self._selected_nodes_on_disabled = [] ufo-kit-tofu-ed0e5bd/tofu/flow/util.py000066400000000000000000000044321521054151500200720ustar00rootroot00000000000000import contextlib import json import pkg_resources from PyQt5.QtCore import Qt from qtpynodeeditor import PortType MODEL_ROLE = Qt.UserRole + 1 PROPERTY_ROLE = MODEL_ROLE + 1 NODE_ROLE = PROPERTY_ROLE + 1 with open(pkg_resources.resource_filename(__name__, 'config.json')) as f: ENTRIES = json.load(f) def get_config_key(*keys, default=None): current = ENTRIES.get(keys[0], default) if current != default and len(keys) > 1: for key in keys[1:]: current = current.get(key, default) if current == default: break return current @contextlib.contextmanager def saved_kwargs(registry, state): """ Tell the registry to use the number of saved inputs for model creation but only for one model creation, i.e. reset the context afterward. """ if 'num-inputs' in state: kwargs = registry.registered_model_creators()[state['name']][1] kwargs['num_inputs'] = state['num-inputs'] try: yield finally: if 'num-inputs' in state: del kwargs['num_inputs'] class CompositeConnection: def __init__(self, from_unique_name, from_port_index, to_unique_name, to_port_index): if from_unique_name == to_unique_name: raise ValueError('from_unique_name and to_unique_name must be different') self.from_unique_name = from_unique_name self.from_port_index = from_port_index self.to_unique_name = to_unique_name self.to_port_index = to_port_index def contains(self, unique_name, port_type, port_index): is_from = is_to = False if port_type == PortType.output: is_from = (unique_name == self.from_unique_name and port_index == self.from_port_index) else: is_to = (unique_name == self.to_unique_name and port_index == self.to_port_index) return is_from or is_to def save(self): return [self.from_unique_name, self.from_port_index, self.to_unique_name, self.to_port_index] def __str__(self): return repr(self) def __repr__(self): fmt = 'Connection({}@{} -> {}@{})' return fmt.format(self.from_unique_name, self.from_port_index, self.to_unique_name, self.to_port_index) class FlowError(Exception): pass ufo-kit-tofu-ed0e5bd/tofu/flow/viewer.py000066400000000000000000000451001521054151500204130ustar00rootroot00000000000000import logging import numpy as np import os from PyQt5 import QtGui from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QFileDialog, QGridLayout, QLabel, QLineEdit, QMenu, QWidget, QSlider from tofu.flow.util import FlowError LOG = logging.getLogger(__name__) class ScreenImage: """On-screen image representation.""" def __init__(self, image=None): self._black_point = None self._white_point = None self.minimum = None self.maximum = None self.image = image @property def image(self): return self._image @image.setter def image(self, image): """ Keep the minimum, maximum, black and white points as they are so that images don't flicker when going through a sequence. """ self._image = image if self._image is not None: self._image = image.astype(np.float32) if self.minimum is None: self.minimum = np.nanmin(self._image) if self.maximum is None: self.maximum = np.nanmax(self._image) if self.black_point is None: self.black_point = self.minimum if self.white_point is None: self.white_point = self.maximum @property def white_point(self): return self._white_point @white_point.setter def white_point(self, value): if self.black_point is not None and value < self.black_point: raise ImageViewingError('White point cannot be smaller than black point') self._white_point = value @property def black_point(self): return self._black_point @black_point.setter def black_point(self, value): if self.white_point is not None and value > self.white_point: raise ImageViewingError('Black point cannot be greater than white point') self._black_point = value def reset(self): """Reset black and white points.""" if self._image is not None: self.minimum = np.nanmin(self._image) self.maximum = np.nanmax(self._image) self._black_point = self.minimum self._white_point = self.maximum def auto_levels(self, percentile=0.1): """ Compute cumulative histogram normalized to [0, 100] and truncate gray values which fall below *percentile* or above 100 - *percentile*. """ hist, bins = np.histogram(self._image, bins=256) cumsum = np.cumsum(hist) / float(np.sum(hist)) * 100 valid = bins[np.where((cumsum > percentile) & (cumsum < 100 - percentile))] if len(valid): self.black_point = valid[0] self.white_point = valid[-1] else: self.black_point = self.white_point = self._image[0, 0] def set_black_point_normalized(self, value): """Set black point according to *value*, where value is from interval [0, 255].""" native = self.convert_normalized_value_to_native(value) if native > self.white_point: raise ImageViewingError('Black point cannot be greater than white point') self.black_point = native def set_white_point_normalized(self, value): """Set white point according to *value*, where value is from interval [0, 255].""" native = self.convert_normalized_value_to_native(value) if native < self.black_point: raise ImageViewingError('White point cannot be smaller than white point') self.white_point = native def convert_normalized_value_to_native(self, value): """Convert *value* from interval [0, 255] to the gray value in the image.""" if value < 0 or value > 255: raise ImageViewingError('Normalized value must be in interval [0, 255]') span = self.maximum - self.minimum return value / 255 * span + self.minimum def convert_native_value_to_normalized(self, value): """Convert gray value in the image to a normalized value in interval [0, 255].""" if value < self.minimum or value > self.maximum: raise ImageViewingError(f'Value must be in interval [{self.minimum}, {self.maximum}]') span = self.maximum - self.minimum return (value - self.minimum) / span * 255 if span > 0 else 0 def get_pixmap(self, downsampling=1): """Get :class:`QPixmap` for display.""" if self.black_point is None or self.white_point is None: raise ImageViewingError('Image has not been set') image = self.image[::downsampling, ::downsampling] - self.black_point if self.white_point - self.black_point > 0: image = np.clip(image * 255 / (self.white_point - self.black_point), 0, 255) image = image.astype(np.uint8) qim = QtGui.QImage(image, image.shape[1], image.shape[0], image[0].nbytes, QtGui.QImage.Format.Format_Grayscale8) return QtGui.QPixmap.fromImage(qim) class ImageLabel(QLabel): """QLabel holding the image data.""" def __init__(self, screen_image=None, parent=None): super().__init__(parent=parent) self.screen_image = screen_image def updateImage(self): if self.screen_image and self.screen_image.image is not None: hd = self.screen_image.image.shape[1] // self.width() vd = self.screen_image.image.shape[0] // self.height() downsampling = max(min(hd, vd), 1) pixmap = self.screen_image.get_pixmap(downsampling=downsampling) self.setPixmap(pixmap.scaled(self.width(), self.height(), Qt.KeepAspectRatio)) def resizeEvent(self, event): self.updateImage() class ImageViewer(QWidget): edit_height = 16 edit_width = 100 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._images = None self._last_save_dir = '.' # Pyqtgraph popped up window self._pg_window = None self.screen_image = ScreenImage() self.new_image_auto_levels = True self.label = ImageLabel(self.screen_image) self.label.setAlignment(Qt.AlignVCenter | Qt.AlignCenter) self.slider_edit = QLineEdit() self.slider_edit.setFixedSize(self.edit_width, self.edit_height) self.slider_edit.returnPressed.connect(self.on_slider_edit_return_pressed) self.slider = QSlider(Qt.Horizontal) validator = QtGui.QIntValidator(0, self.slider.maximum()) self.slider_edit.setValidator(validator) self.slider.valueChanged.connect(self.on_slider_value_changed) self.min_slider = QSlider(Qt.Horizontal) self.max_slider = QSlider(Qt.Horizontal) self.min_slider_edit = QLineEdit() self.min_slider_edit.setFixedSize(self.edit_width, self.edit_height) self.max_slider_edit = QLineEdit() self.max_slider_edit.setFixedSize(self.edit_width, self.edit_height) self.min_slider.setMinimum(0) self.max_slider.setMinimum(0) self.min_slider.setMaximum(255) self.max_slider.setMaximum(255) self.max_slider.setValue(255) self.min_slider.valueChanged.connect(self.on_min_slider_value_changed) self.max_slider.valueChanged.connect(self.on_max_slider_value_changed) self.min_slider_edit.returnPressed.connect(self.on_min_slider_edit_return_pressed) self.max_slider_edit.returnPressed.connect(self.on_max_slider_edit_return_pressed) # Tooltips self.slider.setToolTip('Image index in sequence') self.slider_edit.setToolTip(self.slider.toolTip()) self.min_slider.setToolTip('Black point') self.min_slider_edit.setToolTip(self.min_slider.toolTip()) self.max_slider.setToolTip('White point') self.max_slider_edit.setToolTip(self.min_slider.toolTip()) mainLayout = QGridLayout() mainLayout.addWidget(self.label, 0, 0, 1, 2) mainLayout.addWidget(self.slider_edit, 1, 0) mainLayout.addWidget(self.slider, 1, 1) mainLayout.addWidget(self.min_slider_edit, 2, 0) mainLayout.addWidget(self.min_slider, 2, 1) mainLayout.addWidget(self.max_slider_edit, 3, 0) mainLayout.addWidget(self.max_slider, 3, 1) self.setLayout(mainLayout) def contextMenuEvent(self, event): contextMenu = QMenu(self) reset_action = contextMenu.addAction('Reset') auto_levels_action = contextMenu.addAction('Auto Levels') new_image_auto_levels = contextMenu.addAction('Auto Levels on New Image') new_image_auto_levels.setCheckable(True) new_image_auto_levels.setChecked(self.new_image_auto_levels) pop_action = None save_action = None try: import pyqtgraph if self._images is not None and not self.popup_visible: pop_action = contextMenu.addAction('Pop Up') except: LOG.debug('pyqtgraph not installed, pop up option disabled') try: import imageio if self._images is not None: save_action = contextMenu.addAction('Save') except: LOG.debug('imageio not installed, save option disabled') action = contextMenu.exec_(self.mapToGlobal(event.pos())) if not action: return if action == save_action: file_name, _ = QFileDialog.getSaveFileName(None, "Select File Name", self._last_save_dir, "Images (*.tif *.png *.jpg)") if file_name: if not os.path.splitext(file_name)[1]: file_name += '.tif' self._last_save_dir = os.path.dirname(file_name) if self._images.shape[0] == 1: imageio.imsave(file_name, self._images[0]) else: if os.path.splitext(file_name)[1] != '.tif': raise ImageViewingError('3D data can be stored only in tif format') # bigtiff size from tifffile imageio.volsave(file_name, self._images, bigtiff=self._images.nbytes > 2 ** 32 - 2 ** 25) elif action == reset_action: self.reset_clim() elif action == auto_levels_action: self.reset_clim(auto=True) elif action == new_image_auto_levels: self.new_image_auto_levels = action.isChecked() elif action == pop_action: self.popup() @property def images(self): return self._images @images.setter def images(self, images): was_none = self._images is None self._images = images if self._images is None: self.screen_image.image = None self.set_enabled_adjustments(False) return self.set_enabled_adjustments(True) if self._images.ndim == 2: self._images = self._images[np.newaxis, :, :] if self._images.shape[0] == 1: self.slider.hide() self.slider_edit.hide() else: self.slider.setMaximum(len(self._images) - 1) self.slider.show() self.slider_edit.show() self.slider_edit.setText('0') self.slider.blockSignals(True) self.slider.setValue(0) self.slider.blockSignals(False) if self._pg_window is not None: self._update_pg_window_images() self._update_pg_window_index() self.screen_image.image = self._images[0] if was_none or self.new_image_auto_levels: self.reset_clim(auto=True) else: self.label.updateImage() validator = self.min_slider_edit.validator() if validator is None: validator = QtGui.QDoubleValidator(self.screen_image.minimum, self.screen_image.maximum, 100) self.min_slider_edit.setValidator(validator) self.max_slider_edit.setValidator(validator) else: validator.setRange(self.screen_image.minimum, self.screen_image.maximum, 100) self.slider_edit.validator().setTop(self.slider.maximum()) if self.label.width() < 256 or self.label.height() < 256: self.label.resize(256, 256) def append(self, images): if self.images is None: self.images = images else: if images.ndim == 2: images = images[np.newaxis, :, :] if images.shape[1:] != self.images.shape[1:]: raise ImageViewingError('Appended images have different shape ' f'{images.shape[1:]} than the displayed ones ' f'{self.images.shape[1:]}') self.images = np.concatenate((self.images, images)) def set_enabled_adjustments(self, enabled): self.slider.setEnabled(enabled) self.slider_edit.setEnabled(enabled) self.min_slider.setEnabled(enabled) self.min_slider_edit.setEnabled(enabled) self.max_slider.setEnabled(enabled) self.max_slider_edit.setEnabled(enabled) def reset_clim(self, auto=False): self.screen_image.reset() if auto: self.screen_image.auto_levels() self.min_slider_edit.setText('{:g}'.format(self.screen_image.black_point)) self.max_slider_edit.setText('{:g}'.format(self.screen_image.white_point)) self.set_slider_value(self.min_slider, self.screen_image.black_point) self.set_slider_value(self.max_slider, self.screen_image.white_point) self.label.updateImage() self._update_pg_window_lut() @property def popup_visible(self): return self._pg_window and self._pg_window.isVisible() def popup(self): import pyqtgraph pyqtgraph.setConfigOptions(antialias=True, imageAxisOrder='row-major') if self._pg_window is not None: if not self._pg_window.isVisible(): self._pg_window.show() return def on_pg_window_time_changed(index, time): self._set_index(index) self.slider.blockSignals(True) self.slider_edit.setText(str(index)) self.slider.setValue(index) self.slider.blockSignals(False) def on_pg_window_levels_changed(hist_item): minimum, maximum = hist_item.getLevels() if (self.screen_image.minimum <= minimum <= self.screen_image.maximum and self.screen_image.minimum <= maximum <= self.screen_image.maximum): self.min_slider_edit.setText('{:g}'.format(minimum)) self.set_slider_value(self.min_slider, minimum) self.max_slider_edit.setText('{:g}'.format(maximum)) self.set_slider_value(self.max_slider, maximum) self.screen_image.black_point = minimum self.screen_image.white_point = maximum self.label.updateImage() def pg_mouse_moved(ev): if self._pg_window.imageItem.sceneBoundingRect().contains(ev): pos = self._pg_window.imageItem.mapFromScene(ev) x = int(pos.x() + 0.5) y = int(pos.y() + 0.5) self._pg_window.view.setTitle('x={}, y={}, I={:g}'.format(x, y, self._pg_window.imageItem.image[y, x])) else: self._pg_window.view.setTitle('') self._pg_window = pyqtgraph.ImageView(view=pyqtgraph.PlotItem()) self._pg_window.imageItem.scene().sigMouseMoved.connect(pg_mouse_moved) self._pg_window.setWindowFlag(Qt.SubWindow, True) self._update_pg_window_images() self._update_pg_window_index() self._update_pg_window_lut() self._pg_window.show() self._pg_window.sigTimeChanged.connect(on_pg_window_time_changed) self._pg_window.ui.histogram.item.sigLevelsChanged.connect(on_pg_window_levels_changed) def cleanup(self): if self._pg_window: self._pg_window.close() self._pg_window = None def _set_index(self, index): self.screen_image.image = self.images[index] self.label.updateImage() def _update_pg_window_images(self): if self.images.shape[0] == 1: im_to_set = self.images[0] else: im_to_set = self.images self._pg_window.setImage(im_to_set, autoLevels=False) def _update_pg_window_index(self): if self._images.shape[0] > 1 and self._pg_window is not None: self._pg_window.blockSignals(True) self._pg_window.setCurrentIndex(self.slider.value()) self._pg_window.blockSignals(False) def _update_pg_window_lut(self): if self._pg_window is not None: self._pg_window.ui.histogram.item.blockSignals(True) self._pg_window.setLevels(self.screen_image.black_point, self.screen_image.white_point) self._pg_window.ui.histogram.item.blockSignals(False) def on_slider_value_changed(self, value): self._set_index(value) self.slider_edit.setText(str(value)) self._update_pg_window_index() def on_slider_edit_return_pressed(self): self.slider.setValue(int(self.slider_edit.text())) def on_min_slider_edit_return_pressed(self): value = float(self.min_slider_edit.text()) if value < self.screen_image.white_point: self.screen_image.black_point = value self.set_slider_value(self.min_slider, value) self.label.updateImage() self._update_pg_window_lut() def on_max_slider_edit_return_pressed(self): value = float(self.max_slider_edit.text()) if value > self.screen_image.black_point: self.screen_image.white_point = value self.set_slider_value(self.max_slider, value) self.label.updateImage() self._update_pg_window_lut() def on_min_slider_value_changed(self, value): self.screen_image.set_black_point_normalized(value) self.min_slider_edit.setText('{:g}'.format(self.screen_image.black_point)) self.label.updateImage() self._update_pg_window_lut() def on_max_slider_value_changed(self, value): self.screen_image.set_white_point_normalized(value) self.max_slider_edit.setText('{:g}'.format(self.screen_image.white_point)) self.label.updateImage() self._update_pg_window_lut() def set_slider_value(self, slider, value): slider.blockSignals(True) slider.setValue(int(self.screen_image.convert_native_value_to_normalized(value))) slider.blockSignals(False) class ImageViewingError(FlowError): pass ufo-kit-tofu-ed0e5bd/tofu/genreco.py000066400000000000000000000774731521054151500176070ustar00rootroot00000000000000"""General projection-based reconstruction for tomographic/laminographic cone/parallel beam data sets. """ import copy import itertools import logging import os import time import numpy as np from multiprocessing.pool import ThreadPool from threading import Event, Thread from gi.repository import Ufo from .preprocess import create_preprocessing_pipeline from .util import (fbp_filtering_in_phase_retrieval, get_filtering_padding, get_reconstructed_cube_shape, get_reconstruction_regions, get_filenames, determine_shape, get_scarray_value, Vector) from .tasks import get_task, get_writer LOG = logging.getLogger(__name__) DTYPE_CL_SIZE = {'float': 4, 'double': 8, 'half': 2, 'uchar': 1, 'ushort': 2, 'uint': 4} def genreco(args): st = time.time() if is_output_single_file(args): try: import ufo.numpy except ImportError: LOG.error('You must install ufo python support (in ufo-core/python) to be able to write single-file output') return if (args.energy is not None and args.propagation_distance is not None and not (args.projection_margin or args.disable_projection_crop)): LOG.warning('Phase retrieval without --projection-margin specification or ' '--disable-projection-crop may cause convolution artifacts') _fill_missing_args(args) _convert_angles_to_rad(args) set_projection_filter_scale(args) x_region, y_region, z_region = get_reconstruction_regions(args, store=True, dtype=float) vol_shape = get_reconstructed_cube_shape(x_region, y_region, z_region) bpp = DTYPE_CL_SIZE[args.store_type] num_voxels = vol_shape[0] * vol_shape[1] * vol_shape[2] vol_nbytes = num_voxels * bpp resources = [Ufo.Resources()] gpus = np.array(resources[0].get_gpu_nodes()) gpu_indices = np.array(args.gpus or list(range(len(gpus)))) if min(gpu_indices) < 0 or max(gpu_indices) > len(gpus) - 1: raise ValueError('--gpus contains invalid indices') gpus = gpus[gpu_indices] duration = 0 for i, gpu in enumerate(gpus): print('Max mem for {}: {:.2f} GB'.format(i, gpu.get_info(0) / 2. ** 30)) runs = make_runs(gpus, gpu_indices, x_region, y_region, z_region, bpp, slices_per_device=args.slices_per_device, slice_memory_coeff=args.slice_memory_coeff, data_splitting_policy=args.data_splitting_policy, num_gpu_threads=args.num_gpu_threads) for i in range(len(runs[0]) - 1): resources.append(Ufo.Resources()) LOG.info('Number of passes: %d', len(runs)) LOG.debug('GPUs and regions:') for regions in runs: LOG.debug('%s', str(regions)) for i, regions in enumerate(runs): duration += _run(resources, args, x_region, y_region, regions, i, vol_nbytes) num_gupdates = num_voxels * args.number * 1e-9 total_duration = time.time() - st LOG.debug('UFO duration: %.2f s', duration) LOG.debug('Total duration: %.2f s', total_duration) LOG.debug('UFO performance: %.2f GUPS', num_gupdates / duration) LOG.debug('Total performance: %.2f GUPS', num_gupdates / total_duration) def make_runs(gpus, gpu_indices, x_region, y_region, z_region, bpp, slices_per_device=None, slice_memory_coeff=0.8, data_splitting_policy='one', num_gpu_threads=1): gpu_indices = np.array(gpu_indices) def _add_region(runs, gpu_index, current, to_process, z_start, z_step): current_per_thread = current // num_gpu_threads for i in range(num_gpu_threads): if i + 1 == num_gpu_threads: current_per_thread += current % num_gpu_threads z_end = z_start + current_per_thread * z_step runs[-1].append((gpu_indices[gpu_index], [z_start, z_end, z_step])) z_start = z_end return z_start, z_end, to_process - current z_start, z_stop, z_step = z_region y_start, y_stop, y_step = y_region x_start, x_stop, x_step = x_region slice_width, slice_height, num_slices = get_reconstructed_cube_shape(x_region, y_region, z_region) if slices_per_device: slices_per_device = [slices_per_device for i in range(len(gpus))] else: slices_per_device = get_num_slices_per_gpu(gpus, slice_width, slice_height, bpp, slice_memory_coeff=slice_memory_coeff) max_slices_per_pass = sum(slices_per_device) if not max_slices_per_pass: raise RuntimeError('None of the available devices has enough memory to store any slices') num_full_passes = num_slices // max_slices_per_pass LOG.debug('Number of slices: %d', num_slices) LOG.debug('Slices per device %s', slices_per_device) LOG.debug('Maximum slices on all GPUs per pass: %d', max_slices_per_pass) LOG.debug('Number of passes with full workload: %d', num_slices // max_slices_per_pass) sorted_indices = np.argsort(slices_per_device)[-np.count_nonzero(slices_per_device):] runs = [] z_start = z_region[0] to_process = num_slices # Create passes where all GPUs are fully loaded for j in range(num_full_passes): runs.append([]) for i in sorted_indices: z_start, z_end, to_process = _add_region(runs, i, slices_per_device[i], to_process, z_start, z_step) if to_process: if data_splitting_policy == 'one': # Fill the last pass by maximizing the workload per GPU runs.append([]) for i in sorted_indices[::-1]: if not to_process: break current = min(slices_per_device[i], to_process) z_start, z_end, to_process = _add_region(runs, i, current, to_process, z_start, z_step) else: # Fill the last pass by maximizing the number of GPUs which will work num_gpus = len(sorted_indices) runs.append([]) for j, i in enumerate(sorted_indices): # Current GPU will either process the maximum number of slices it can. If the number # of slices per GPU based on even division between them cannot saturate the GPU, use # this number. This way the work will be split evenly between the GPUs. current = max(min(slices_per_device[i], (to_process - 1) // (num_gpus - j) + 1), 1) z_start, z_end, to_process = _add_region(runs, i, current, to_process, z_start, z_step) if not to_process: break return runs def get_num_slices_per_gpu(gpus, width, height, bpp, slice_memory_coeff=0.8): num_slices = [] slice_size = width * height * bpp for i, gpu in enumerate(gpus): max_mem = gpu.get_info(Ufo.GpuNodeInfo.GLOBAL_MEM_SIZE) num_slices.append(int(np.floor(max_mem * slice_memory_coeff / slice_size))) return num_slices def _run(resources, args, x_region, y_region, regions, run_number, vol_nbytes): """Execute one pass on all possible GPUs with slice ranges given by *regions*. Use separate thread per GPU and optimize the read projection regions. """ executors = [] writer = None last = None if is_output_single_file(args): import tifffile bigtiff = vol_nbytes > 2 ** 32 - 2 ** 25 LOG.debug('Writing BigTiff: %s', bigtiff) dirname = os.path.dirname(args.output) if dirname and not os.path.exists(dirname): os.makedirs(dirname) writer = tifffile.TiffWriter(args.output, append=run_number != 0, bigtiff=bigtiff) for index in range(len(regions)): gpu_index, region = regions[index] region_index = run_number * len(resources) + index executors.append( Executor( resources[index], args, region, x_region, y_region, gpu_index, region_index, writer=writer ) ) if last: # Chain up waiting events of subsequent executors executors[-1].wait_event = last.finished last = executors[-1] def start_one(index): return executors[index].process() st = time.time() try: with ThreadPool(processes=len(regions)) as pool: try: pool.map(start_one, list(range(len(regions)))) except KeyboardInterrupt: LOG.info('Processing interrupted') for executor in executors: executor.abort() finally: if writer: writer.close() LOG.debug('Writer closed') return time.time() - st def setup_graph(args, graph, x_region, y_region, region, source=None, gpu=None, do_output=True, index=0, make_reader=True): backproject = get_task('general-backproject', processing_node=gpu) if do_output: if args.dry_run: sink = get_task('null', processing_node=gpu, download=True) else: sink = get_writer(args) sink.props.filename = '{}-{:>03}-%04i.tif'.format(args.output, index) width = args.width height = args.height if args.transpose_input: tmp = width width = height height = tmp if args.projection_filter != 'none' and args.projection_crop_after == 'backprojection': # Take projection padding into account if fbp_filtering_in_phase_retrieval(args): padding = args.retrieval_padded_width - width padding_from = 'phase retrieval' else: padding = get_filtering_padding(width) padding_from = 'default backproject' args.center_position_x = [pos + padding / 2 for pos in args.center_position_x] if args.z_parameter == 'center-position-x': region = [region[0] + padding / 2, region[1] + padding / 2, region[2]] LOG.debug('center-position-x after padding: %g (from %s)', args.center_position_x[0], padding_from) backproject.props.parameter = args.z_parameter if args.burst: backproject.props.burst = args.burst backproject.props.z = args.z backproject.props.region = region backproject.props.x_region = x_region backproject.props.y_region = y_region backproject.props.center_position_x = args.center_position_x backproject.props.center_position_z = args.center_position_z backproject.props.source_position_x = args.source_position_x backproject.props.source_position_y = args.source_position_y backproject.props.source_position_z = args.source_position_z backproject.props.detector_position_x = args.detector_position_x backproject.props.detector_position_y = args.detector_position_y backproject.props.detector_position_z = args.detector_position_z backproject.props.detector_angle_x = args.detector_angle_x backproject.props.detector_angle_y = args.detector_angle_y backproject.props.detector_angle_z = args.detector_angle_z backproject.props.axis_angle_x = args.axis_angle_x backproject.props.axis_angle_y = args.axis_angle_y backproject.props.axis_angle_z = args.axis_angle_z backproject.props.volume_angle_x = args.volume_angle_x backproject.props.volume_angle_y = args.volume_angle_y backproject.props.volume_angle_z = args.volume_angle_z backproject.props.num_projections = args.number backproject.props.compute_type = args.compute_type backproject.props.result_type = args.result_type backproject.props.store_type = args.store_type backproject.props.overall_angle = args.overall_angle backproject.props.addressing_mode = args.genreco_padding_mode backproject.props.gray_map_min = args.slice_gray_map[0] backproject.props.gray_map_max = args.slice_gray_map[1] source = create_preprocessing_pipeline(args, graph, source=source, processing_node=gpu, cone_beam_weight=not args.disable_cone_beam_weight, make_reader=make_reader) if source: graph.connect_nodes(source, backproject) else: source = backproject if do_output: graph.connect_nodes(backproject, sink) last = sink else: last = backproject return (source, last) def is_output_single_file(args): filename = args.output.lower() return not args.dry_run and (filename.endswith('.tif') or filename.endswith('.tiff')) def set_projection_filter_scale(args): is_parallel = np.all(np.isinf(args.source_position_y)) magnification = (args.source_position_y[0] - args.detector_position_y[0]) / \ args.source_position_y[0] args.projection_filter_scale = 1. if is_parallel: if np.any(np.array(args.axis_angle_x)): LOG.debug('Adjusting filter for parallel beam laminography') args.projection_filter_scale = 0.5 * np.cos(args.axis_angle_x[0]) else: args.projection_filter_scale = 0.5 args.projection_filter_scale /= magnification ** 2 if np.all(np.array(args.axis_angle_x) == 0): LOG.debug('Adjusting filter for cone beam tomography') args.projection_filter_scale /= magnification def _fill_missing_args(args): (width, height) = determine_shape(args, args.projections, store=False) if args.transpose_input: tmp = width width = height height = tmp args.center_position_x = (args.center_position_x or [width / 2.]) args.center_position_z = (args.center_position_z or [height / 2.]) if not args.overall_angle: args.overall_angle = 360. LOG.info('Overall angle not specified, using 360 deg') if not args.number: if len(args.axis_angle_z) > 1: LOG.debug("--number not specified, using length of --axis-angle-z: %d", len(args.axis_angle_z)) args.number = len(args.axis_angle_z) else: num_files = len(get_filenames(args.projections)) if not num_files: raise RuntimeError("No files found in `{}'".format(args.projections)) LOG.debug("--number not specified, using number of files matching " "--projections pattern: %d", num_files) args.number = num_files if args.dry_run: if not args.number: raise ValueError('--number must be specified by --dry-run') determine_shape(args, args.projections, store=True) LOG.info('Dummy data W x H x N: {} x {} x {}'.format(args.width, args.height, args.number)) return args def _convert_angles_to_rad(args): names = ['detector_angle', 'axis_angle', 'volume_angle'] coords = ['x', 'y', 'z'] angular_z_params = [x[0].replace('_', '-') + '-' + x[1] for x in itertools.product(names, coords)] args.overall_angle = np.deg2rad(args.overall_angle) if args.z_parameter in angular_z_params: LOG.debug('Converting z parameter values to radians') args.region = _convert_list_to_rad(args.region) for name in names: for coord in coords: full_name = name + '_' + coord values = getattr(args, full_name) setattr(args, full_name, _convert_list_to_rad(values)) def _convert_list_to_rad(values): return np.deg2rad(np.array(values)).tolist() def _are_values_equal(values): return np.all(np.array(values) == values[0]) class Executor(object): """Reconstructs one region. :param writer: if not None, we'll be writing to a file shared with other executors and need to use *wait_event* to make sure we write our region when the previous executors are finished. """ def __init__(self, resources, args, region, x_region, y_region, gpu_index, region_index, writer=None): self.resources = resources self.args = args self.region = region self.gpu_index = gpu_index self.x_region = x_region self.y_region = y_region self.region_index = region_index self.writer = writer self.output = Ufo.OutputTask() if self.writer else None self.scheduler = None self.wait_event = None self.finished = Event() self.abort_requested = False def process(self): self.scheduler = Ufo.FixedScheduler() if hasattr(self.scheduler.props, 'enable_tracing'): LOG.debug("Use tracing: {}".format(self.args.enable_tracing)) self.scheduler.props.enable_tracing = self.args.enable_tracing self.scheduler.set_resources(self.resources) graph = Ufo.TaskGraph() gpu = self.scheduler.get_resources().get_gpu_nodes()[self.gpu_index] geometry = CTGeometry(self.args) if (len(self.args.center_position_z) == 1 and np.modf(self.args.center_position_z[0])[0] == 0 and geometry.is_simple_parallel_tomo): LOG.info('Simple tomography with integer z center, changing to center_position_z + 0.5 ' 'to avoid interpolation') geometry.args.center_position_z = (geometry.args.center_position_z[0] + 0.5,) if not self.args.disable_projection_crop: if not self.args.dry_run and (self.args.y or self.args.height or self.args.transpose_input): LOG.debug('--y or --height or --transpose-input specified, ' 'not optimizing projection region') else: geometry.optimize_args(region=self.region) opt_args = geometry.args if self.args.dry_run: source = get_task('dummy-data', number=self.args.number, width=self.args.width, height=self.args.height) else: source = None last = setup_graph(opt_args, graph, self.x_region, self.y_region, self.region, source=source, gpu=gpu, index=self.region_index, make_reader=True, do_output=self.writer is None)[-1] if self.writer: graph.connect_nodes(last, self.output) LOG.debug('Device: %d, region: %s', self.gpu_index, self.region) thread = Thread(target=self.scheduler.run, args=(graph,)) thread.setDaemon(True) thread.start() if self.writer: self.consume() thread.join() return self.scheduler.props.time def consume(self): import ufo.numpy if self.wait_event: LOG.debug('Executor of region %s waiting for writing', self.region) self.wait_event.wait() for i in np.arange(*self.region): if self.abort_requested: LOG.debug('Abort requested in writing of region %s', self.region) return buf = self.output.get_output_buffer() self.writer.save(ufo.numpy.asarray(buf)) self.output.release_output_buffer(buf) self.finished.set() LOG.debug('Executor of region %s finished writing', self.region) def abort(self): self.abort_requested = True if self.scheduler: self.scheduler.abort() class CTGeometry(object): def __init__(self, args): self.args = copy.deepcopy(args) determine_shape(self.args, self.args.projections, store=True) get_reconstruction_regions(self.args, store=True, dtype=float) self.args.center_position_x = (self.args.center_position_x or [self.args.width / 2.]) self.args.center_position_z = (self.args.center_position_z or [self.args.height / 2.]) @property def is_parallel(self): return np.all(np.isinf(self.args.source_position_y)) @property def is_detector_rotated(self): return (np.any(self.args.detector_angle_x) or np.any(self.args.detector_angle_y) or np.any(self.args.detector_angle_z)) @property def is_axis_rotated(self): return (np.any(self.args.axis_angle_x) or np.any(self.args.axis_angle_y) or np.any(self.args.axis_angle_z)) @property def is_volume_rotated(self): return (np.any(self.args.volume_angle_x) or np.any(self.args.volume_angle_y) or np.any(self.args.volume_angle_z)) @property def is_center_position_x_constant(self): return _are_values_equal(self.args.center_position_x) @property def is_center_position_z_constant(self): return _are_values_equal(self.args.center_position_z) @property def is_center_constant(self): return self.is_center_position_x_constant and self.is_center_position_z_constant @property def is_simple_parallel_tomo(self): return (not (self.is_axis_rotated or self.is_detector_rotated or self.is_volume_rotated) and self.is_parallel and self.is_center_constant) def optimize_args(self, region=None): xmin, ymin, xmax, ymax = self.compute_height(region=region) center_position_z = np.array(self.args.center_position_z) - ymin self.args.center_position_z = center_position_z.tolist() self.args.y = int(ymin) self.args.height = int(ymax - ymin) LOG.debug('Optimization for region: %s', region or self.args.region) LOG.debug('Optimized X: %d - %d, Z: %d - %d', xmin, xmax, ymin, ymax) LOG.debug('Optimized Z: %d', self.args.y) LOG.debug('Optimized height: %d', self.args.height) LOG.debug('Optimized center_position_z: %g - %g', self.args.center_position_z[0], self.args.center_position_z[-1]) def compute_height(self, region=None): extrema = [] if not region: region = self.args.region if self.is_simple_parallel_tomo: # Simple parallel beam tomography, thus compute only the horizontal crop at rotations # which are multiples of 45 degrees LOG.debug('Computing optimal projection region from 4 angles') projs_per_45 = self.args.number / self.args.overall_angle * np.pi / 4 stop = 4 if self.args.overall_angle <= np.pi else 8 indices = projs_per_45 * np.arange(1, stop, 2) indices = np.round(indices).astype(int).tolist() else: LOG.debug('Computing optimal projection region from all angles') indices = list(range(self.args.number)) for i in indices: extrema_0 = self._compute_one_parameter(region[0], i) extrema_1 = self._compute_one_parameter(region[1], i) extrema.append(extrema_0) extrema.append(extrema_1) minima = np.min(extrema, axis=0) maxima = np.max(extrema, axis=0) if maxima[-1] == minima[2]: # Don't let height be 0 maxima[-1] += 1 result = tuple(minima[::2]) + tuple(maxima[1::2]) return result def _compute_one_parameter(self, param_value, index): source_position = np.array([get_scarray_value(self.args.source_position_x, index), get_scarray_value(self.args.source_position_y, index), get_scarray_value(self.args.source_position_z, index)]) axis = Vector(x_angle=get_scarray_value(self.args.axis_angle_x, index), y_angle=get_scarray_value(self.args.axis_angle_y, index), z_angle=get_scarray_value(self.args.axis_angle_z, index), position=[get_scarray_value(self.args.center_position_x, index), 0, get_scarray_value(self.args.center_position_z, index)]) detector = Vector(x_angle=get_scarray_value(self.args.detector_angle_x, index), y_angle=get_scarray_value(self.args.detector_angle_y, index), z_angle=get_scarray_value(self.args.detector_angle_z, index), position=[get_scarray_value(self.args.detector_position_x, index), get_scarray_value(self.args.detector_position_y, index), get_scarray_value(self.args.detector_position_z, index)]) volume_angle = Vector(x_angle=get_scarray_value(self.args.volume_angle_x, index), y_angle=get_scarray_value(self.args.volume_angle_y, index), z_angle=get_scarray_value(self.args.volume_angle_z, index)) z = self.args.z if self.args.z_parameter == 'z': z = param_value elif self.args.z_parameter == 'axis-angle-x': axis.x_angle = param_value elif self.args.z_parameter == 'axis-angle-y': axis.y_angle = param_value elif self.args.z_parameter == 'axis-angle-z': axis.z_angle = param_value elif self.args.z_parameter == 'volume-angle-x': volume_angle.x_angle = param_value elif self.args.z_parameter == 'volume-angle-y': volume_angle.y_angle = param_value elif self.args.z_parameter == 'volume-angle-z': volume_angle.z_angle = param_value elif self.args.z_parameter == 'detector-angle-x': detector.x_angle = param_value elif self.args.z_parameter == 'detector-angle-y': detector.y_angle = param_value elif self.args.z_parameter == 'detector-angle-z': detector.z_angle = param_value elif self.args.z_parameter == 'detector-position-x': detector.position[0] = param_value elif self.args.z_parameter == 'detector-position-y': detector.position[1] = param_value elif self.args.z_parameter == 'detector-position-z': detector.position[2] = param_value elif self.args.z_parameter == 'source-position-x': source_position[0] = param_value elif self.args.z_parameter == 'source-position-y': source_position[1] = param_value elif self.args.z_parameter == 'source-position-z': source_position[2] = param_value elif self.args.z_parameter == 'center-position-x': axis.position[0] = param_value elif self.args.z_parameter == 'center-position-z': axis.position[2] = param_value else: raise RuntimeError("Unknown z parameter '{}'".format(self.args.z_parameter)) points = get_extrema(self.args.x_region, self.args.y_region, z) if self.args.z_parameter != 'z': points_upper = get_extrema(self.args.x_region, self.args.y_region, z + 1) points = np.hstack((points, points_upper)) tomo_angle = float(index) / self.args.number * self.args.overall_angle xe, ye = compute_detector_pixels(points, source_position, axis, volume_angle, detector, tomo_angle) return compute_detector_region(xe, ye, (self.args.height, self.args.width), overhead=self.args.projection_margin) def project(points, source, detector_normal, detector_offset): """Project *points* onto a detector.""" x, y, z = points source_extended = np.tile(source[:, np.newaxis], [1, points.shape[1]]) detector_normal_extended = np.tile(detector_normal[:, np.newaxis], [1, points.shape[1]]) denom = np.sum((points - source_extended) * detector_normal_extended, axis=0) if np.isinf(source[1]): # Parallel beam if np.any(detector_normal != np.array([0., -1, 0])): # Detector is not perpendicular, compute translation along the beam direction, # otherwise don't compute anything because voxels are mapped directly # to detector coordinates points[1, :] = - (detector_offset + detector_normal[0] * points[0, :] + detector_normal[2] * points[2, :]) / detector_normal[1] projected = points else: # Cone beam u = -(detector_offset + np.dot(source, detector_normal)) / denom u = np.tile(u, [3, 1]) projected = source_extended + (points - source_extended) * u return projected def compute_detector_pixels(points, source_position, axis, volume_rotation, detector, tomo_angle): """*points* are a list of points along x-direcion, thus the array has height 3. *source_position* is a 3-vector, *axis*, *volume_rotation* and *detector* are util.Vector instances. """ # Rotate the axis detector_normal = np.array((0, -1, 0), dtype=float) detector_normal = rotate_z(detector.z_angle, detector_normal) detector_normal = rotate_y(detector.y_angle, detector_normal) detector_normal = rotate_x(detector.x_angle, detector_normal) # Compute d from ax + by + cz + d = 0 detector_offset = -np.dot(detector.position, detector_normal) if np.isinf(source_position[1]): # Parallel beam voxels = points else: # Apply magnification voxels = -points * source_position[1] / (detector.position[1] - source_position[1]) # Rotate the volume voxels = rotate_z(volume_rotation.z_angle, voxels) voxels = rotate_y(volume_rotation.y_angle, voxels) voxels = rotate_x(volume_rotation.x_angle, voxels) # Rotate around the axis voxels = rotate_z(tomo_angle, voxels) # Rotate the volume voxels = rotate_z(axis.z_angle, voxels) voxels = rotate_y(axis.y_angle, voxels) voxels = rotate_x(axis.x_angle, voxels) # Get the projected pixel projected = project(voxels, source_position, detector_normal, detector_offset) if np.any(detector_normal != np.array([0., -1, 0])): # Detector is not perpendicular projected -= np.array([detector.position]).T # Reverse rotation => reverse order of transformation matrices and negative angles projected = rotate_x(-detector.x_angle, projected) projected = rotate_y(-detector.y_angle, projected) projected = rotate_z(-detector.z_angle, projected) x = projected[0, :] + axis.position[0] - 0.5 y = projected[2, :] + axis.position[2] - 0.5 return x, y def compute_detector_region(x, y, shape, overhead=2): """*overhead* specifies how much margin is taken into account around the computed area.""" def _compute_outlier(extremum_func, values): if extremum_func == min: round_func = np.floor sgn = -1 else: round_func = np.ceil sgn = +1 return int(round_func(extremum_func(values)) + sgn * overhead) x_min = min(shape[1], max(0, _compute_outlier(min, x))) y_min = min(shape[0], max(0, _compute_outlier(min, y))) x_max = max(0, min(shape[1], _compute_outlier(max, x))) y_max = max(0, min(shape[0], _compute_outlier(max, y))) return (x_min, x_max, y_min, y_max) def get_extrema(x_region, y_region, z): def get_extrema(region): return (region[0], region[1]) product = itertools.product(get_extrema(x_region), get_extrema(y_region), [z]) return np.array(list(product), dtype=float).T.copy() def rotate_x(angle, point): cos = np.cos(angle) sin = np.sin(angle) matrix = np.identity(3) matrix[1, 1] = cos matrix[1, 2] = -sin matrix[2, 1] = sin matrix[2, 2] = cos return np.dot(matrix, point) def rotate_y(angle, point): cos = np.cos(angle) sin = np.sin(angle) matrix = np.identity(3) matrix[0, 0] = cos matrix[0, 2] = sin matrix[2, 0] = -sin matrix[2, 2] = cos return np.dot(matrix, point) def rotate_z(angle, point): cos = np.cos(angle) sin = np.sin(angle) matrix = np.identity(3) matrix[0, 0] = cos matrix[0, 1] = -sin matrix[1, 0] = sin matrix[1, 1] = cos return np.dot(matrix, point) ufo-kit-tofu-ed0e5bd/tofu/gui.py000066400000000000000000000553611521054151500167410ustar00rootroot00000000000000import sys import os import logging import numpy as np import tifffile import pkg_resources from argparse import ArgumentParser from contextlib import contextmanager from tofu import reco, config, util, __version__ try: import tofu.vis.qt from PyQt5 import QtGui, QtCore, uic, QtWidgets except ImportError: raise ImportError("Cannot import modules for GUI, please install tofu with [gui] extras.") LOG = logging.getLogger(__name__) def set_last_dir(path, line_edit, last_dir): if os.path.exists(str(path)): line_edit.clear() line_edit.setText(path) last_dir = str(line_edit.text()) return last_dir def get_filtered_filenames(path, exts=['.tif', '.edf']): result = [] try: for ext in exts: result += [os.path.join(path, f) for f in os.listdir(path) if f.endswith(ext)] except OSError: return [] return sorted(result) @contextmanager def spinning_cursor(): QtWidgets.QApplication.setOverrideCursor(QtWidgets.QCursor(QtCore.Qt.WaitCursor)) yield QtWidgets.QApplication.restoreOverrideCursor() class CallableHandler(logging.Handler): def __init__(self, func): logging.Handler.__init__(self) self.func = func def emit(self, record): self.func(self.format(record)) class ApplicationWindow(QtWidgets.QMainWindow): def __init__(self, app, params): QtWidgets.QMainWindow.__init__(self) self.params = params self.app = app ui_file = pkg_resources.resource_filename(__name__, 'gui.ui') self.ui = uic.loadUi(ui_file, self) self.ui.show() self.ui.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.ui.tab_widget.setCurrentIndex(0) self.ui.slice_dock.setVisible(False) self.ui.volume_dock.setVisible(False) self.ui.axis_view_widget.setVisible(False) self.slice_viewer = None self.volume_viewer = None self.overlap_viewer = tofu.vis.qt.OverlapViewer() self.get_values_from_params() try: import pyqtgraph.opengl as gl except ImportError: LOG.info("OpenGL not available, volume viewer disabled") self.ui.show_volume_button.setEnabled(False) log_handler = CallableHandler(self.on_log_record) log_handler.setLevel(logging.DEBUG) log_handler.setFormatter(logging.Formatter('%(name)s: %(message)s')) root_logger = logging.getLogger('') root_logger.setLevel(logging.DEBUG) root_logger.handlers = [log_handler] self.ui.input_path_button.setToolTip('Path to projections or sinograms') self.ui.proj_button.setToolTip('Denote if path contains projections') self.ui.y_step.setToolTip(self.get_help('reading', 'y-step')) self.ui.method_box.setToolTip(self.get_help('tomographic-reconstruction', 'method')) self.ui.axis_spin.setToolTip(self.get_help('tomographic-reconstruction', 'axis')) self.ui.angle_step.setToolTip(self.get_help('reconstruction', 'angle')) self.ui.angle_offset.setToolTip(self.get_help('tomographic-reconstruction', 'offset')) self.ui.oversampling.setToolTip(self.get_help('dfi', 'oversampling')) self.ui.iterations_sart.setToolTip(self.get_help('ir', 'num-iterations')) self.ui.relaxation.setToolTip(self.get_help('sart', 'relaxation-factor')) self.ui.output_path_button.setToolTip(self.get_help('general', 'output')) self.ui.ffc_box.setToolTip(self.get_help('gui', 'ffc-correction')) self.ui.interpolate_button.setToolTip('Interpolate between two sets of flat fields') self.ui.darks_path_button.setToolTip(self.get_help('flat-correction', 'darks')) self.ui.flats_path_button.setToolTip(self.get_help('flat-correction', 'flats')) self.ui.flats2_path_button.setToolTip(self.get_help('flat-correction', 'flats2')) self.ui.path_button_0.setToolTip(self.get_help('gui', 'deg0')) self.ui.path_button_180.setToolTip(self.get_help('gui', 'deg180')) self.ui.input_path_button.clicked.connect(self.on_input_path_clicked) self.ui.sino_button.clicked.connect(self.on_sino_button_clicked) self.ui.proj_button.clicked.connect(self.on_proj_button_clicked) self.ui.region_box.clicked.connect(self.on_region_box_clicked) self.ui.method_box.currentIndexChanged.connect(self.change_method) self.ui.axis_spin.valueChanged.connect(self.change_axis_spin) self.ui.angle_step.valueChanged.connect(self.change_angle_step) self.ui.output_path_button.clicked.connect(self.on_output_path_clicked) self.ui.ffc_box.clicked.connect(self.on_ffc_box_clicked) self.ui.interpolate_button.clicked.connect(self.on_interpolate_button_clicked) self.ui.darks_path_button.clicked.connect(self.on_darks_path_clicked) self.ui.flats_path_button.clicked.connect(self.on_flats_path_clicked) self.ui.flats2_path_button.clicked.connect(self.on_flats2_path_clicked) self.ui.ffc_options.currentIndexChanged.connect(self.change_ffc_options) self.ui.reco_button.clicked.connect(self.on_reconstruct) self.ui.path_button_0.clicked.connect(self.on_path_0_clicked) self.ui.path_button_180.clicked.connect(self.on_path_180_clicked) self.ui.show_slices_button.clicked.connect(self.on_show_slices_clicked) self.ui.show_volume_button.clicked.connect(self.on_show_volume_clicked) self.ui.run_button.clicked.connect(self.on_compute_center) self.ui.save_action.triggered.connect(self.on_save_as) self.ui.clear_action.triggered.connect(self.on_clear) self.ui.clear_output_dir_action.triggered.connect(self.on_clear_output_dir_clicked) self.ui.open_action.triggered.connect(self.on_open_from) self.ui.close_action.triggered.connect(self.close) self.ui.about_action.triggered.connect(self.on_about) self.ui.extrema_checkbox.clicked.connect(self.on_remove_extrema_clicked) self.ui.overlap_opt.currentIndexChanged.connect(self.on_overlap_opt_changed) self.ui.input_path_line.textChanged.connect(self.on_input_path_changed) self.ui.y_step.valueChanged.connect(lambda value: self.change_value('y_step', value)) self.ui.angle_offset.valueChanged.connect(lambda value: self.change_value('offset', value)) self.ui.oversampling.valueChanged.connect(lambda value: self.change_value('oversampling', value)) self.ui.iterations_sart.valueChanged.connect(lambda value: self.change_value('num_iterations', value)) self.ui.relaxation.valueChanged.connect(lambda value: self.change_value('relaxation_factor', value)) self.ui.output_path_line.textChanged.connect(lambda value: self.change_value('output', str(self.ui.output_path_line.text()))) self.ui.darks_path_line.textChanged.connect(lambda value: self.change_value('darks', str(self.ui.darks_path_line.text()))) self.ui.flats_path_line.textChanged.connect(lambda value: self.change_value('flats', str(self.ui.flats_path_line.text()))) self.ui.flats2_path_line.textChanged.connect(lambda value: self.change_value('flats2', str(self.ui.flats2_path_line.text()))) self.ui.fix_naninf_box.clicked.connect(lambda value: self.change_value('fix_nan_and_inf', self.ui.fix_naninf_box.isChecked())) self.ui.absorptivity_box.clicked.connect(lambda value: self.change_value('absorptivity', self.ui.absorptivity_box.isChecked())) self.ui.path_line_0.textChanged.connect(lambda value: self.change_value('deg0', str(self.ui.path_line_0.text()))) self.ui.path_line_180.textChanged.connect(lambda value: self.change_value('deg180', str(self.ui.path_line_180.text()))) self.ui.overlap_layout.addWidget(self.overlap_viewer) self.overlap_viewer.slider.valueChanged.connect(self.on_axis_slider_changed) def on_log_record(self, record): self.ui.text_browser.append(record) def get_values_from_params(self): self.ui.input_path_line.setText(self.params.sinograms or self.params.projections or '.') self.ui.output_path_line.setText(self.params.output or '') self.ui.darks_path_line.setText(self.params.darks or '') self.ui.flats_path_line.setText(self.params.flats or '') self.ui.flats2_path_line.setText(self.params.flats2 or '') self.ui.path_line_0.setText(self.params.deg0) self.ui.path_line_180.setText(self.params.deg180) self.ui.y_step.setValue(self.params.y_step if self.params.y_step else 1) self.ui.axis_spin.setValue(self.params.axis if self.params.axis else 0.0) self.ui.angle_step.setValue(self.params.angle if self.params.angle else 0.0) self.ui.angle_offset.setValue(self.params.offset if self.params.offset else 0.0) self.ui.oversampling.setValue(self.params.oversampling if self.params.oversampling else 0) self.ui.iterations_sart.setValue(self.params.num_iterations if self.params.num_iterations else 0) self.ui.relaxation.setValue(self.params.relaxation_factor if self.params.relaxation_factor else 0.0) if self.params.projections is not None: self.ui.proj_button.setChecked(True) self.ui.sino_button.setChecked(False) self.on_proj_button_clicked() else: self.ui.proj_button.setChecked(False) self.ui.sino_button.setChecked(True) self.on_sino_button_clicked() if self.params.method == "fbp": self.ui.method_box.setCurrentIndex(0) elif self.params.method == "dfi": self.ui.method_box.setCurrentIndex(1) elif self.params.method == "sart": self.ui.method_box.setCurrentIndex(2) self.change_method() if self.params.y_step > 1 and self.sino_button.isChecked(): self.ui.region_box.setChecked(True) else: self.ui.region_box.setChecked(False) self.ui.on_region_box_clicked() ffc_enabled = bool(self.params.flats) and bool(self.params.darks) and self.proj_button.isChecked() self.ui.ffc_box.setChecked(ffc_enabled) self.ui.preprocessing_container.setVisible(ffc_enabled) self.ui.interpolate_button.setChecked(bool(self.params.flats2) and ffc_enabled) self.ui.fix_naninf_box.setChecked(self.params.fix_nan_and_inf) self.ui.absorptivity_box.setChecked(self.params.absorptivity) if self.params.reduction_mode.lower() == "average": self.ui.ffc_options.setCurrentIndex(0) else: self.ui.ffc_options.setCurrentIndex(1) def change_method(self): self.params.method = str(self.ui.method_box.currentText()).lower() is_dfi = self.params.method == 'dfi' is_sart = self.params.method == 'sart' for w in (self.ui.oversampling_label, self.ui.oversampling): w.setVisible(is_dfi) for w in (self.ui.relaxation, self.ui.relaxation_label, self.ui.iterations_sart, self.ui.iterations_sart_label): w.setVisible(is_sart) def get_help(self, section, name): help = config.SECTIONS[section][name]['help'] return help def change_value(self, name, value): setattr(self.params, name, value) def on_sino_button_clicked(self): self.on_input_path_changed() self.ui.ffc_box.setEnabled(False) self.ui.preprocessing_container.setVisible(False) def on_proj_button_clicked(self): self.on_input_path_changed() self.ui.ffc_box.setEnabled(True) self.ui.preprocessing_container.setVisible(self.ffc_box.isChecked()) self.ui.region_box.setEnabled(False) self.ui.region_box.setChecked(False) self.on_region_box_clicked() def on_region_box_clicked(self): self.ui.y_step.setEnabled(self.ui.region_box.isChecked()) if self.ui.region_box.isChecked(): self.params.y_step = self.ui.y_step.value() else: self.params.y_step = 1 def on_input_path_changed(self): if self.ui.sino_button.isChecked(): self.params.sinograms = str(self.ui.input_path_line.text()) self.params.projections = None else: self.params.sinograms = None self.params.projections = str(self.ui.input_path_line.text()) def on_input_path_clicked(self, checked): directory = self.params.projections or self.params.sinograms path = self.get_path(directory, self.params.last_dir) self.params.last_dir = set_last_dir(path, self.ui.input_path_line, self.params.last_dir) def change_axis_spin(self): if self.ui.axis_spin.value() == 0: self.params.axis = None else: self.params.axis = self.ui.axis_spin.value() def change_angle_step(self): if self.ui.angle_step.value() == 0: self.params.angle = None else: self.params.angle = self.ui.angle_step.value() def on_output_path_clicked(self, checked): path = self.get_path(self.params.output, self.params.last_dir) self.params.last_dir = set_last_dir(path, self.ui.output_path_line, self.params.last_dir) def on_clear_output_dir_clicked(self): with spinning_cursor(): output_absfiles = get_filtered_filenames(str(self.ui.output_path_line.text())) for f in output_absfiles: os.remove(f) def on_ffc_box_clicked(self): checked = self.ui.ffc_box.isChecked() self.ui.preprocessing_container.setVisible(checked) self.params.ffc_correction = checked def on_interpolate_button_clicked(self): checked = self.ui.interpolate_button.isChecked() self.ui.flats2_path_line.setEnabled(checked) self.ui.flats2_path_button.setEnabled(checked) def change_ffc_options(self): self.params.reduction_mode = str(self.ui.ffc_options.currentText()).lower() def on_darks_path_clicked(self, checked): path = self.get_path(self.params.darks, self.params.last_dir) self.params.last_dir = set_last_dir(path, self.ui.darks_path_line, self.params.last_dir) def on_flats_path_clicked(self, checked): path = self.get_path(self.params.flats, self.params.last_dir) self.params.last_dir = set_last_dir(path, self.ui.flats_path_line, self.params.last_dir) def on_flats2_path_clicked(self, checked): path = self.get_path(self.params.flats2, self.params.last_dir) self.params.last_dir = set_last_dir(path, self.ui.flats2_path_line, self.params.last_dir) def get_path(self, directory, last_dir): return QtWidgets.QFileDialog.getExistingDirectory(self, '.', last_dir or directory) def get_filename(self, directory, last_dir): # Thanks to Lisa D. for pointing out that a tuple is returned in PyQT5 filename, _ = QtWidgets.QFileDialog.getOpenFileName(self, '.', last_dir or directory) return filename def on_path_0_clicked(self, checked): path = self.get_filename(self.params.deg0, self.params.last_dir) self.params.last_dir = set_last_dir(path, self.ui.path_line_0, self.params.last_dir) def on_path_180_clicked(self, checked): path = self.get_filename(self.params.deg180, self.params.last_dir) self.params.last_dir = set_last_dir(path, self.ui.path_line_180, self.params.last_dir) def on_open_from(self): config_file, _ = QtWidgets.QFileDialog.getOpenFileName(self, 'Open ...', self.params.last_dir) parser = ArgumentParser() params = config.Params(sections=config.TOMO_PARAMS + ('gui',)) parser = params.add_arguments(parser) self.params = parser.parse_known_args(config.config_to_list(config_name=config_file))[0] self.get_values_from_params() def on_about(self): message = "GUI is part of ufo-reconstruct {}.".format(__version__) QtWidgets.QMessageBox.about(self, "About ufo-reconstruct", message) def on_save_as(self): if os.path.exists(self.params.last_dir): config_file = str(self.params.last_dir + "/reco.conf") else: config_file = str(os.getenv('HOME') + "reco.conf") save_config = QtWidgets.QFileDialog.getSaveFileName(self, 'Save as ...', config_file) if save_config: sections = config.TOMO_PARAMS + ('gui',) config.write(save_config, args=self.params, sections=sections) def on_clear(self): self.ui.axis_view_widget.setVisible(False) self.ui.input_path_line.setText('.') self.ui.output_path_line.setText('.') self.ui.darks_path_line.setText('.') self.ui.flats_path_line.setText('.') self.ui.flats2_path_line.setText('.') self.ui.path_line_0.setText('.') self.ui.path_line_180.setText('.') self.ui.fix_naninf_box.setChecked(True) self.ui.absorptivity_box.setChecked(True) self.ui.sino_button.setChecked(True) self.ui.proj_button.setChecked(False) self.ui.region_box.setChecked(False) self.ui.ffc_box.setChecked(False) self.ui.interpolate_button.setChecked(False) self.ui.y_step.setValue(1) self.ui.axis_spin.setValue(0) self.ui.angle_step.setValue(0) self.ui.angle_offset.setValue(0) self.ui.oversampling.setValue(0) self.ui.ffc_options.setCurrentIndex(0) self.ui.text_browser.clear() self.ui.method_box.setCurrentIndex(0) self.params.enable_cropping = False self.params.reduction_mode = "average" self.params.fix_nan_and_inf = True self.params.absorptivity = True self.params.show_2d = False self.params.show_3d = False self.params.angle = None self.params.axis = None self.on_region_box_clicked() self.on_ffc_box_clicked() self.on_interpolate_button_clicked() def on_reconstruct(self): with spinning_cursor(): self.ui.centralWidget.setEnabled(False) self.repaint() self.app.processEvents() input_images = get_filtered_filenames(str(self.ui.input_path_line.text())) if not input_images: self.gui_warn("No data found in {}".format(str(self.ui.input_path_line.text()))) self.ui.centralWidget.setEnabled(True) return shape = util.get_image_shape(input_images[0]) self.params.width = shape[-1] self.params.height = shape[-2] self.params.ffc_correction = self.params.ffc_correction and self.ui.proj_button.isChecked() if not (self.params.output.endswith('.tif') or self.params.output.endswith('.tiff')): self.params.output = os.path.join(self.params.output, 'slice-%05i.tif') if self.params.y_step > 1: self.params.angle *= self.params.y_step if self.params.ffc_correction: flats_files = get_filtered_filenames(str(self.ui.flats_path_line.text())) self.params.num_flats = len(flats_files) else: self.params.num_flats = 0 self.params.darks = None self.params.flats = None self.params.flats2 = self.ui.flats2_path_line.text() if self.ui.interpolate_button.isChecked() else '' self.params.oversampling = self.ui.oversampling.value() if self.params.method == 'dfi' else None if self.params.method == 'sart': self.params.max_iterations = self.ui.iterations_sart.value() self.params.relaxation_factor = self.ui.relaxation.value() if self.params.angle is None: self.gui_warn("Missing argument for Angle step (rad)") else: try: reco.tomo(self.params) except Exception as e: self.gui_warn(str(e)) self.ui.centralWidget.setEnabled(True) self.params.angle = self.ui.angle_step.value() def on_show_slices_clicked(self): path = str(self.ui.output_path_line.text()) filenames = get_filtered_filenames(path) if not self.slice_viewer: self.slice_viewer = tofu.vis.qt.ImageViewer(filenames) self.slice_dock.setWidget(self.slice_viewer) self.ui.slice_dock.setVisible(True) else: self.slice_viewer.load_files(filenames) def on_show_volume_clicked(self): if not self.volume_viewer: step = int(self.ui.reduction_box.currentText()) self.volume_viewer = tofu.vis.qt.VolumeViewer(parent=self, step=step) self.volume_dock.setWidget(self.volume_viewer) self.ui.volume_dock.setVisible(True) path = str(self.ui.output_path_line.text()) filenames = get_filtered_filenames(path) self.volume_viewer.load_files(filenames) def on_compute_center(self): first_name = str(self.ui.path_line_0.text()) second_name = str(self.ui.path_line_180.text()) with tifffile.TiffFile(first_name) as tif: first = tif.pages[0].asarray().astype(float) with tifffile.TiffFile(second_name) as tif: second = tif.pages[-1].asarray().astype(float) if self.params.ffc_correction: # FIXME: we should of course use the pipelines we have ... flat_files = get_filtered_filenames(str(self.ui.flats_path_line.text())) dark_files = get_filtered_filenames(str(self.ui.darks_path_line.text())) flats = np.array([tifffile.TiffFile(x).asarray().astype(float) for x in flat_files]) darks = np.array([tifffile.TiffFile(x).asarray().astype(float) for x in dark_files]) dark = np.mean(darks, axis=0) flat = np.mean(flats, axis=0) - dark first = (first - dark) / flat second = (second - dark) / flat self.axis = reco.compute_rotation_axis(first, second) self.height, self.width = first.shape w2 = self.width / 2.0 position = w2 + (w2 - self.axis) * 2.0 self.overlap_viewer.set_images(first, second) self.overlap_viewer.set_position(position) self.ui.img_size.setText('width = {} | height = {}'.format(self.width, self.height)) def on_remove_extrema_clicked(self, val): self.ui.overlap_viewer.remove_extrema = val def on_overlap_opt_changed(self, index): self.ui.overlap_viewer.subtract = index == 0 self.ui.overlap_viewer.update_image() def on_axis_slider_changed(self): val = self.overlap_viewer.slider.value() w2 = self.width / 2.0 self.axis = w2 + (w2 - val) / 2 self.ui.axis_num.setText('{} px'.format(self.axis)) self.ui.axis_spin.setValue(self.axis) def gui_warn(self, message): QtWidgets.QMessageBox.warning(self, "Warning", message) def main(params): app = QtWidgets.QApplication(sys.argv) ApplicationWindow(app, params) sys.exit(app.exec_()) ufo-kit-tofu-ed0e5bd/tofu/gui.ui000066400000000000000000001377761521054151500167410ustar00rootroot00000000000000 mainWindow 0 0 1018 1081 0 0 Tomoviewer true 0 0 541 761 0 0 530 0 PreferDefault true Qt::TabFocus Qt::LeftToRight 1 true 0 0 0 0 Reconstruction Input 0 0 Projections true 0 0 Sinograms true true false 0 0 1 500 0 0 false false Region (y-step): Qt::Horizontal 40 20 0 0 Do flat-field correction 0 0 Path: 0 0 0 0 Browse … 0 0 Flat-field correction 0 0 Average Median Options Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter Use absorptivity Remove NaN and Inf 0 0 Interpolate Qt::Horizontal 40 20 Method: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter true Darks: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter true true Browse … true 0 Flats: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter Last flats: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter true true Browse … Browse … Reconstruction 6 0 0 Angle step (rad): Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 50 false Method: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 50 false FBP DFI SART 0 0 10 Qt::Horizontal 40 20 0 0 Angle offset (rad): Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 Reconstruct 0 0 Axis (pixel): Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 8192.000000000000000 0 0 10 0 0 Max iterations: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 0 Relaxation factor: 0.000000000000000 Qt::Horizontal 40 20 true 99 0 true 0 0 Oversampling: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter Output 0 0 Path: 0 0 0 0 Browse ... Reduction: 1 1 2 4 8 Qt::Horizontal 40 20 Show Volume Show Slices 0 0 Log 0 0 QFrame::Sunken 0 0 0 Center of rotation 0 0 Input Options: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter Method: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 Browse ... 0 0 180° projection: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 Browse ... 0 0 In case of multi-page input, last image in the file is used Remove extrema 0 0 In case of multi-page input, first image in the file is used 0 0 0° projection: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 Subtraction overlap Addition overlap 0 0 Run Output 0 0 Center: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 Size: Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 0 0 0 0 75 true 0 0 0 0 0 0 0 0 0 1018 22 0 0 0 0 File 0 0 Edit Help QDockWidget::DockWidgetFloatable|QDockWidget::DockWidgetMovable 2 0 0 QDockWidget::DockWidgetFloatable|QDockWidget::DockWidgetMovable 2 Open Save as... Open ... Qt::WindowShortcut Save as ... Quit Ctrl+Q Clear Remove old slices Remove old slices in output directory About ufo-kit-tofu-ed0e5bd/tofu/inpaint.py000066400000000000000000000236721521054151500176170ustar00rootroot00000000000000import gi import logging import numpy as np try: gi.require_version('Ufo', '0.0') except ValueError: gi.require_version('Ufo', '1.0') from gi.repository import Ufo from tofu.tasks import get_memory_in, get_task, get_writer from tofu.util import ( determine_shape, make_subargs, run_scheduler, set_node_props, setup_read_task, setup_padding, ) LOG = logging.getLogger(__name__) SELECT_SRC = """ kernel void select_simple (global float *image, global float *mask, global float *output) { const size_t idx = get_global_id (1) * get_global_size (0) + get_global_id (0); output[idx] = mask[idx] > 0.0f ? 0.0f : image[idx]; } kernel void select_guidance (global float *image, global float *mask, global float *guidance, global float *output) { const size_t idx = get_global_id (1) * get_global_size (0) + get_global_id (0); output[idx] = mask[idx] > 0.0f ? guidance[idx] : image[idx]; } """ ADD_CONSTANT_SRC = """ kernel void add_constant (global float *image, global float *value, global float *output) { const size_t idx = get_global_id (1) * get_global_size (0) + get_global_id (0); output[idx] = image[idx] + value[0]; } """ def _make_discrete_inverse_laplace(width, height): """Make discrete Laplace deconvolution kernel special for this use case, where we do not care about the (0, 0) frequency becuase the kernel is going to be applied on Laplace-filtered data, which has zero mean. """ f = np.fft.fftfreq(width) g = np.fft.fftfreq(height) f, g = np.meshgrid(f, g) # From discrete Laplace and time shift: F[f''(x, y)] = -4 F[f(x, y)] # + F[f(x + 1, y)] + F[f(x - 1, y)] + F[f(x, y + 1)] + F[f(x, y - 1)] = the result below when we # use the time shift property of the Fourier transform. kernel = 2 * (np.cos(2 * np.pi * f) + np.cos(2 * np.pi * g) - 2) # Make this invertible by simply setting the (0, 0) frequency to 1 instead of making sure that # after the inversion it is 0. We can afford this becuase we know the input to filtering will be # Laplace-filtered -> zero mean -> (0, 0) frequency = 0. kernel[0, 0] = 1 return (1 / kernel).astype(np.float32) def prepare_border_smoothing(padded_width, padded_height): """ The use case here is mainly the removal of the cross at (0, 0) in the power spectrum by masking out the borders of the image, i.e. the gradients are forced to go to zeros at the borders and thus removing the sharp transitions when we consider the periodicity assumed by the DFT. *padded_width* and *padded_height* are the width and height of the FFT-padding, not the original image shape. One should use `mirrored_repeat' padding mode on the input images to get the FFT-padded image, compute the mask here and use it for inpainting. """ mask = np.ones((padded_width, padded_height), dtype=np.float32) mask[1:-1, 1:-1] = 0 mem_in_task = get_memory_in(mask) return mem_in_task def _get_gradient_task(finite_difference_type, direction): return get_task( "gradient", finite_difference_type=finite_difference_type, direction=direction, addressing_mode="repeat", ) def create_inpaint_pipeline( args, graph, processing_node=None ): """ Create tasks needed for inpainting and connect them. The pipeline has three inputs and one output, which is the inpainted image. Based on :cite:`MOREL2012342`. """ determine_shape(args, path=args.projections, store=True, do_raise=True) if not args.inpaint_padded_width: args.inpaint_padded_width = args.width if not args.inpaint_padded_height: args.inpaint_padded_height = args.height do_pad = args.inpaint_padded_width != args.width and args.inpaint_padded_height != args.height use_guidance = not args.harmonize_borders and args.guidance_image LOG.debug("inpaint padding on: %s", do_pad) LOG.debug("inpaint using guidance image: %s", use_guidance) copy_projections = Ufo.CopyTask() copy_mask = Ufo.CopyTask() copy_guidance = Ufo.CopyTask() if use_guidance else None if do_pad: # Padding pad_projections = get_task("pad") pad_mask = get_task("pad") pad_guidance = get_task("pad") for pad_task in (pad_projections, pad_mask, pad_guidance): setup_padding( pad_task, args.width, args.height, args.inpaint_padding_mode, pad_width=args.inpaint_padded_width - args.width, pad_height=args.inpaint_padded_height - args.height, centered=False ) graph.connect_nodes(pad_projections, copy_projections) graph.connect_nodes(pad_mask, copy_mask) if use_guidance: graph.connect_nodes(pad_guidance, copy_guidance) else: pad_guidance = None inputs = (pad_projections, pad_mask, pad_guidance) else: inputs = (copy_projections, copy_mask, copy_guidance) # First gradient is forward and the second backward -> we get exactly the discrete Laplace after # the two passes. gx = _get_gradient_task("forward", "horizontal") gy = _get_gradient_task("forward", "vertical") ggx = _get_gradient_task("backward", "horizontal") ggy = _get_gradient_task("backward", "vertical") fft_task = get_task("fft", dimensions=2) ifft_task = get_task("ifft", dimensions=2) add_ggx_ggy = get_task("opencl", kernel="add", dimensions=2) mul_task = get_task("opencl", kernel="multiply", halve_width=False, dimensions=2) select_kernel = "select_guidance" if use_guidance else "select_simple" select_gx = get_task("opencl", source=SELECT_SRC, kernel=select_kernel, dimensions=2) select_gy = get_task("opencl", source=SELECT_SRC, kernel=select_kernel, dimensions=2) # We are computing discrete gradients -> Laplace must also be discrete lap_kernel = _make_discrete_inverse_laplace( args.inpaint_padded_width, args.inpaint_padded_height ) # Multiply interleaved complex array -> a * z = a * Re[z] + j * a * Im[z] mem_in_task = get_memory_in(lap_kernel + 1j * lap_kernel) if args.preserve_mean: mean_task = get_task("measure", axis=-1, metric="mean") add_constant = get_task( "opencl", source=ADD_CONSTANT_SRC, kernel="add_constant", dimensions=2 ) # First derivative graph.connect_nodes(copy_projections, gx) graph.connect_nodes(copy_projections, gy) # Select guidance or zeros where mask >= 0 graph.connect_nodes_full(gx, select_gx, 0) graph.connect_nodes_full(gy, select_gy, 0) graph.connect_nodes_full(copy_mask, select_gx, 1) graph.connect_nodes_full(copy_mask, select_gy, 1) if use_guidance: guidance_gx = _get_gradient_task("forward", "horizontal") guidance_gy = _get_gradient_task("forward", "vertical") graph.connect_nodes(copy_guidance, guidance_gx) graph.connect_nodes(copy_guidance, guidance_gy) graph.connect_nodes_full(guidance_gx, select_gx, 2) graph.connect_nodes_full(guidance_gy, select_gy, 2) # Second derivative graph.connect_nodes(select_gx, ggx) graph.connect_nodes(select_gy, ggy) # Sum -> Laplacian graph.connect_nodes_full(ggx, add_ggx_ggy, 0) graph.connect_nodes_full(ggy, add_ggx_ggy, 1) # Deconvolve with Laplacian graph.connect_nodes(add_ggx_ggy, fft_task) graph.connect_nodes_full(fft_task, mul_task, 0) graph.connect_nodes_full(mem_in_task, mul_task, 1) graph.connect_nodes(mul_task, ifft_task) if args.preserve_mean: # Get the mean back to the one of the input image graph.connect_nodes(copy_projections, mean_task) graph.connect_nodes_full(ifft_task, add_constant, 0) graph.connect_nodes_full(mean_task, add_constant, 1) last = add_constant else: last = ifft_task outputs = (last,) return (inputs, outputs) def run(args): """Usage with tofu: create readers, the pipeline and run it.""" if args.harmonize_borders: if args.mask_image: LOG.warning( "--mask-image has no effect when --harmonize-borders is specified" ) if args.guidance_image: LOG.warning( "--guidance-image has no effect when --harmonize-borders is specified" ) if args.inpaint_padding_mode != "mirrored_repeat": LOG.warning( "Padding mode should be `mirrored_repeat' for smooth transitions between " "true image borders and padded borders" ) elif not args.mask_image: raise ValueError("One of --mask-image or --harmonize-borders must be specified") # Reading reader = get_task("read") roi_args = make_subargs(args, ['y', 'height', 'y_step']) set_node_props(reader, args) setup_read_task(reader, args.projections, args) out_task = get_writer(args) graph = Ufo.TaskGraph() ((input_projections, input_mask, input_guidance), (last,)) = create_inpaint_pipeline( args, graph, ) if args.harmonize_borders: mask_reader = prepare_border_smoothing( args.inpaint_padded_width, args.inpaint_padded_height ) else: mask_reader = get_task("read") set_node_props(mask_reader, roi_args) setup_read_task(mask_reader, args.mask_image, args) graph.connect_nodes(reader, input_projections) graph.connect_nodes(mask_reader, input_mask) if not args.harmonize_borders and args.guidance_image: guidance_reader = get_task("read") set_node_props(guidance_reader, roi_args) setup_read_task(guidance_reader, args.guidance_image, args) graph.connect_nodes(guidance_reader, input_guidance) graph.connect_nodes(last, out_task) # CopyTask works only with FixedScheduler sched = Ufo.FixedScheduler() run_scheduler(sched, graph) ufo-kit-tofu-ed0e5bd/tofu/lamino.py000066400000000000000000000212451521054151500174260ustar00rootroot00000000000000"""Laminographic reconstruction.""" import logging import numpy as np from multiprocessing import Queue, Process from tofu.preprocess import create_preprocessing_pipeline from tofu.util import (get_filtering_padding, determine_shape, get_filenames, get_reconstruction_regions, get_reconstructed_cube_shape) from tofu.tasks import get_task, get_writer LOG = logging.getLogger(__name__) def lamino(params): """Laminographic reconstruction utilizing all GPUs.""" LOG.info('Z parameter: {}'.format(params.z_parameter)) prepare_angular_arguments(params) params.projection_filter_scale = np.sin(np.deg2rad(params.lamino_angle)) # For now we need to make a workaround for the memory leak, which means we need to execute # the passes in separate processes to clean up the low level code. For that we also need to # call the region-splitting in a separate function. # TODO: Simplify after the memory leak fix! queue = Queue() proc = Process(target=_create_runs, args=(params, queue,)) proc.start() proc.join() x_region, y_region, regions, num_gpus = queue.get() for i in range(0, len(regions), num_gpus): z_subregion = regions[i:min(i + num_gpus, len(regions))] LOG.info('Computing slices {}..{}'.format(z_subregion[0][0], z_subregion[-1][1])) proc = Process(target=_run, args=(params, x_region, y_region, z_subregion, i // num_gpus)) proc.start() proc.join() def prepare_angular_arguments(params): if not params.overall_angle: params.overall_angle = 360. LOG.info('Overall angle not specified, using 360 deg') if not params.angle: if params.dry_run: if not params.number: raise ValueError('--number must be specified by --dry-run') num_files = params.number else: num_files = len(get_filenames(params.projections)) if not num_files: raise RuntimeError("No files found in `{}'".format(params.projections)) params.angle = params.overall_angle / num_files * params.step LOG.info('Angle not specified, calculating from ' + '{} projections and step {}: {} deg'.format(num_files, params.step, params.angle)) determine_shape(params, params.projections, store=True) if not params.number: params.number = int(np.round(np.abs(params.overall_angle / params.angle))) if params.dry_run: LOG.info('Dummy data W x H x N: {} x {} x {}'.format(params.width, params.height, params.number)) def _create_runs(params, queue): """Workaround function to get the number of gpus and compute regions. gi.repository must always be called in a separate process, otherwise the resources return None gpus. """ #TODO: remove the whole function after memory leak fix! from gi.repository import Ufo scheduler = Ufo.FixedScheduler() gpus = scheduler.get_resources().get_gpu_nodes() num_gpus = len(gpus) x_region, y_region, regions = _split_regions(params, gpus) LOG.info('Using {} GPUs in {} passes'.format(min(len(regions), num_gpus), len(regions))) queue.put((x_region, y_region, regions, num_gpus)) def _run(params, x_region, y_region, regions, index): """Execute one pass on all possible GPUs with slice ranges given by *regions*.""" from gi.repository import Ufo pm = Ufo.PluginManager() graph = Ufo.TaskGraph() scheduler = Ufo.FixedScheduler() gpus = scheduler.get_resources().get_gpu_nodes() num_gpus = len(gpus) broadcast = Ufo.CopyTask() source = _setup_source(params, pm, graph) graph.connect_nodes(source, broadcast) for i, region in enumerate(regions): subindex = index * num_gpus + i _setup_graph(pm, graph, subindex, x_region, y_region, region, params, broadcast, gpu=gpus[i]) scheduler.run(graph) duration = scheduler.props.time LOG.info('Execution time: {} s'.format(duration)) return duration def _setup_source(params, pm, graph): from tofu.preprocess import create_flat_correct_pipeline from tofu.util import set_node_props, setup_read_task if params.dry_run: source = pm.get_task('dummy-data') source.props.number = params.number source.props.width = params.width source.props.height = params.height elif params.darks and params.flats: source = create_flat_correct_pipeline(params, graph) else: source = pm.get_task('read') set_node_props(source, params) setup_read_task(source, params.projections, params) return source def _setup_graph(pm, graph, index, x_region, y_region, region, params, source, gpu=None): backproject = get_task('lamino-backproject', processing_node=gpu) slicer = get_task('slice', processing_node=gpu) writer = get_writer(params) if not params.dry_run: writer.props.filename = '{}-{:>03}-%04i.tif'.format(params.output, index) # parameters backproject.props.num_projections = params.number backproject.props.overall_angle = np.deg2rad(params.overall_angle) backproject.props.lamino_angle = np.deg2rad(params.lamino_angle) backproject.props.roll_angle = np.deg2rad(params.roll_angle) backproject.props.x_region = x_region backproject.props.y_region = y_region backproject.props.z = params.z backproject.props.addressing_mode = params.lamino_padding_mode backproject.props.parameter = params.z_parameter if params.projection_crop_after == 'backprojection': padding = get_filtering_padding(params.width) else: padding = 0 if params.z_parameter in ['lamino-angle', 'roll-angle']: region = [np.deg2rad(reg) for reg in region] if params.z_parameter == 'x-center': # Take projection padding into account region = [region[0] + padding / 2, region[1] + padding / 2, region[2]] backproject.props.region = region backproject.props.center = (params.axis[0] + padding / 2, params.axis[1]) LOG.debug('x center after padding: %g', backproject.props.center[0]) graph.connect_nodes(backproject, slicer) graph.connect_nodes(slicer, writer) if params.only_bp: first = backproject graph.connect_nodes(source, backproject) else: first = create_preprocessing_pipeline(params, graph, source=source, processing_node=gpu) graph.connect_nodes(first, backproject) return first def _split_regions(params, gpus): """Split processing between *gpus* by specifying the number of slices processed per GPU.""" x_region, y_region, z_region = get_reconstruction_regions(params) z_start, z_stop, z_step = z_region y_start, y_stop, y_step = y_region x_start, x_stop, x_step = x_region slice_width, slice_height, num_slices = get_reconstructed_cube_shape(x_region, y_region, z_region) if params.slices_per_device: num_slices_per_gpu = params.slices_per_device else: num_slices_per_gpu = _compute_num_slices(gpus, slice_width, slice_height) if num_slices_per_gpu > num_slices: num_slices_per_gpu = num_slices LOG.info('Using {} slices per GPU'.format(num_slices_per_gpu)) z_starts = np.arange(z_start, z_stop, z_step * num_slices_per_gpu) regions = [] for start in z_starts: regions.append((start, min(z_stop, start + z_step * num_slices_per_gpu), z_step)) return x_region, y_region, regions def _compute_num_slices(gpus, width, height): """Determine number of slices which can be calculated per-device based on *gpus*, slice *width* and *height*. """ from gi.repository import Ufo # Make sure the double buffering works with room for intermediate steps # TODO: compute this precisely safety_coeff = 3. # Use the weakest one, if heterogenous systems emerge, measure the performance and # reconsider memories = [gpu.get_info(Ufo.GpuNodeInfo.GLOBAL_MEM_SIZE) for gpu in gpus] i = np.argmin(memories) max_allocatable = gpus[i].get_info(Ufo.GpuNodeInfo.MAX_MEM_ALLOC_SIZE) if max_allocatable * safety_coeff <= memories[i]: # Don't waste resources max_memory = max_allocatable else: max_memory = memories[i] / safety_coeff if max_memory > 2 ** 32: # Current NVIDIA implementation allows only 4 GB max_memory = 2 ** 32 max_memory /= safety_coeff num_slices = int(np.floor(max_memory / (width * height * 4))) LOG.info('GPU memory used per GPU: {:.2f} GB'.format(max_memory / 2. ** 30)) return num_slices ufo-kit-tofu-ed0e5bd/tofu/preprocess.py000066400000000000000000000435271521054151500203430ustar00rootroot00000000000000"""Flat field correction.""" import sys import logging from gi.repository import Ufo from tofu.util import (fbp_filtering_in_phase_retrieval, get_filenames, set_node_props, make_subargs, determine_shape, setup_read_task, setup_padding, next_power_of_two, run_scheduler) from tofu.tasks import get_task, get_writer LOG = logging.getLogger(__name__) def create_flat_correct_pipeline(args, graph, processing_node=None): """ Create flat field correction pipeline. All the settings are provided in *args*. *graph* is used for making the connections. Returns the flat field correction task which can be used for further pipelining. """ pm = Ufo.PluginManager() if args.projections is None or not args.flats or not args.darks: raise RuntimeError("You must specify --projections, --flats and --darks.") reader = get_task('read') dark_reader = get_task('read') flat_before_reader = get_task('read') ffc = get_task('flat-field-correct', processing_node=processing_node, dark_scale=args.dark_scale, flat_scale=args.flat_scale, absorption_correct=args.absorptivity, fix_nan_and_inf=args.fix_nan_and_inf) mode = args.reduction_mode.lower() roi_args = make_subargs(args, ['y', 'height', 'y_step']) set_node_props(reader, args) set_node_props(dark_reader, roi_args) set_node_props(flat_before_reader, roi_args) for r, path in ((reader, args.projections), (dark_reader, args.darks), (flat_before_reader, args.flats)): setup_read_task(r, path, args) LOG.debug("Doing flat field correction using reduction mode `{}'".format(mode)) if args.flats2: flat_after_reader = get_task('read') setup_read_task(flat_after_reader, args.flats2, args) set_node_props(flat_after_reader, roi_args) num_files = len(get_filenames(args.projections)) can_read = len(list(range(args.start, num_files, args.step))) number = args.number if args.number else num_files num_read = min(can_read, number) flat_interpolate = get_task('interpolate', processing_node=processing_node, number=num_read) if args.resize: LOG.debug("Resize input data by factor of {}".format(args.resize)) proj_bin = get_task('bin', processing_node=processing_node, size=args.resize) dark_bin = get_task('bin', processing_node=processing_node, size=args.resize) flat_bin = get_task('bin', processing_node=processing_node, size=args.resize) graph.connect_nodes(reader, proj_bin) graph.connect_nodes(dark_reader, dark_bin) graph.connect_nodes(flat_before_reader, flat_bin) reader, dark_reader, flat_before_reader = proj_bin, dark_bin, flat_bin if args.flats2: flat_bin = get_task('bin', processing_node=processing_node, size=args.resize) graph.connect_nodes(flat_after_reader, flat_bin) flat_after_reader = flat_bin if mode == 'median': dark_stack = get_task('stack', processing_node=processing_node, number=len(get_filenames(args.darks))) dark_reduced = get_task('flatten', processing_node=processing_node, mode='median') flat_before_stack = get_task('stack', processing_node=processing_node, number=len(get_filenames(args.flats))) flat_before_reduced = get_task('flatten', processing_node=processing_node, mode='median') graph.connect_nodes(dark_reader, dark_stack) graph.connect_nodes(dark_stack, dark_reduced) graph.connect_nodes(flat_before_reader, flat_before_stack) graph.connect_nodes(flat_before_stack, flat_before_reduced) if args.flats2: flat_after_stack = get_task('stack', processing_node=processing_node, number=len(get_filenames(args.flats2))) flat_after_reduced = get_task('flatten', processing_node=processing_node, mode='median') graph.connect_nodes(flat_after_reader, flat_after_stack) graph.connect_nodes(flat_after_stack, flat_after_reduced) elif mode == 'average': dark_reduced = get_task('average', processing_node=processing_node) flat_before_reduced = get_task('average', processing_node=processing_node) graph.connect_nodes(dark_reader, dark_reduced) graph.connect_nodes(flat_before_reader, flat_before_reduced) if args.flats2: flat_after_reduced = get_task('average', processing_node=processing_node) graph.connect_nodes(flat_after_reader, flat_after_reduced) else: raise ValueError('Invalid reduction mode') graph.connect_nodes_full(reader, ffc, 0) graph.connect_nodes_full(dark_reduced, ffc, 1) if args.flats2: graph.connect_nodes_full(flat_before_reduced, flat_interpolate, 0) graph.connect_nodes_full(flat_after_reduced, flat_interpolate, 1) graph.connect_nodes_full(flat_interpolate, ffc, 2) else: graph.connect_nodes_full(flat_before_reduced, ffc, 2) return ffc def create_phase_retrieval_pipeline(args, graph, processing_node=None): LOG.debug('Creating phase retrieval pipeline') pm = Ufo.PluginManager() # Retrieve phase phase_retrieve = get_task('retrieve-phase', processing_node=processing_node) pad_phase_retrieve = get_task('pad', processing_node=processing_node) crop_phase_retrieve = get_task('crop', processing_node=processing_node) fft_phase_retrieve = get_task('fft', processing_node=processing_node) ifft_phase_retrieve = get_task('ifft', processing_node=processing_node) calculate = get_task('calculate', processing_node=processing_node) width = args.width height = args.height default_padded_width = next_power_of_two(width + 64) default_padded_height = next_power_of_two(height + 64) if not args.retrieval_padded_width: args.retrieval_padded_width = default_padded_width if not args.retrieval_padded_height: args.retrieval_padded_height = default_padded_height fmt = 'Phase retrieval padding: {}x{} -> {}x{}' LOG.debug(fmt.format(width, height, args.retrieval_padded_width, args.retrieval_padded_height)) x = (args.retrieval_padded_width - width) // 2 y = (args.retrieval_padded_height - height) // 2 pad_phase_retrieve.props.x = x pad_phase_retrieve.props.y = y pad_phase_retrieve.props.width = args.retrieval_padded_width pad_phase_retrieve.props.height = args.retrieval_padded_height pad_phase_retrieve.props.addressing_mode = args.retrieval_padding_mode crop_phase_retrieve.props.y = y crop_phase_retrieve.props.height = height if ( args.projection_crop_after == 'filter' or not fbp_filtering_in_phase_retrieval(args) ): crop_phase_retrieve.props.x = x crop_phase_retrieve.props.width = width phase_retrieve.props.method = args.retrieval_method phase_retrieve.props.energy = args.energy if len(args.propagation_distance) == 1: phase_retrieve.props.distance = [args.propagation_distance[0]] else: phase_retrieve.props.distance_x = args.propagation_distance[0] phase_retrieve.props.distance_y = args.propagation_distance[1] phase_retrieve.props.pixel_size = args.pixel_size phase_retrieve.props.regularization_rate = args.regularization_rate phase_retrieve.props.thresholding_rate = args.thresholding_rate phase_retrieve.props.frequency_cutoff = args.frequency_cutoff fft_phase_retrieve.props.dimensions = 2 ifft_phase_retrieve.props.dimensions = 2 if args.delta is not None: import numpy as np lam = 6.62606896e-34 * 299792458 / (args.energy * 1.60217733e-16) thickness_conversion = -lam / (2 * np.pi * args.delta) else: thickness_conversion = 1 if fbp_filtering_in_phase_retrieval(args): LOG.debug('Fusing phase retrieval and FBP filtering') fltr = get_task('filter', processing_node=processing_node) fltr.props.filter = args.projection_filter fltr.props.scale = args.projection_filter_scale fltr.props.cutoff = args.projection_filter_cutoff graph.connect_nodes(phase_retrieve, fltr) graph.connect_nodes(fltr, ifft_phase_retrieve) else: graph.connect_nodes(phase_retrieve, ifft_phase_retrieve) if args.retrieval_method == 'tie' and args.tie_approximate_logarithm: # a = 2 / 10^R, b = -10^R / 2, c = thickness_conversion # t = Taylor series point, T = t * (1 - ln(t)) # a * b = -1 (from above) # ----------------------------------------------------- # We will use the Taylor expansion to the 1st order of the logarithm which TIE needs: # -ln (a * retrieved) * b * c ~ b * c / t * [T - a * retrieved] # T - a * retrieved = T - a * F^-1{F(I) * kernel} = T - F^-1{aF(I) * kernel} # for u, v = 0: aF(I) * kernel = F(I)(0, 0) because kernel(0, 0) = 1 / a, thus: # T - F^-1{aF(I) * kernel} = -F^-1{aF(I - T) * kernel}, because for u, v = 0 we have: # -aF(I - T) * kernel = - F(I - T) = T - F(I)(0, 0) (and the rest of the frequencies is # unaffected by "T". # further: -aF(I - T) * kernel = F[a(T - I)] * kernel # bring in b, c and t and we have F[abc/t(T - I)] * kernel, with ab=-1 we end up with: # F[c/t(I - T)] * kernel, so the approximation of the logarithm does not need any change in # the TIE kernel itself, we may just transform the input image and use the rest of the # pipeline as usual. if args.delta is None: # Do not multiply by one expression = "v - 1" else: expression = "{} * (v - {})".format( # c / t thickness_conversion / args.tie_approximate_point, # t * (1 - ln(t)) args.tie_approximate_point * (1 - np.log(args.tie_approximate_point)) ) calculate.props.expression = expression LOG.debug("Phase contrast conversion expression (log approximation): `%s'", expression) graph.connect_nodes(calculate, pad_phase_retrieve) first = calculate last = crop_phase_retrieve else: if args.retrieval_method == 'tie': expression = '(isinf (v) || isnan (v) || (v <= 0)) ? 0.0f : ' if args.tie_approximate_logarithm: # ln(x) ~ x - 1 at a=1 expression += '(1.0f - {} * v) * {}' else: expression += '-log ({} * v) * {}' # first term: 2 for 0.5 factor in ufo-filters and alpha = 10^-R, so divide by 10^R # second term: The following converts the TIE result to the actual phase, which when # multiplied by the thickness_conversion gives the projected thickness thickness_conversion *= -10 ** args.regularization_rate / 2 expression = expression.format(2 / 10 ** args.regularization_rate, thickness_conversion) else: expression = '(isinf (v) || isnan (v)) ? 0.0f : v * {}'.format(thickness_conversion) LOG.debug("Phase contrast conversion expression: `%s'", expression) calculate.props.expression = expression graph.connect_nodes(crop_phase_retrieve, calculate) first = pad_phase_retrieve last = calculate graph.connect_nodes(pad_phase_retrieve, fft_phase_retrieve) graph.connect_nodes(fft_phase_retrieve, phase_retrieve) graph.connect_nodes(ifft_phase_retrieve, crop_phase_retrieve) return (first, last) def run_flat_correct(args): graph = Ufo.TaskGraph() sched = Ufo.Scheduler() pm = Ufo.PluginManager() out_task = get_writer(args) flat_task = create_flat_correct_pipeline(args, graph) graph.connect_nodes(flat_task, out_task) run_scheduler(sched, graph) def create_sinogram_pipeline(args, graph): """Create sinogram generating pipeline based on arguments from *args*.""" pm = Ufo.PluginManager() sinos = pm.get_task('transpose-projections') if args.number: region = (args.start, args.start + args.number, args.step) num_projections = len(list(range(*region))) else: num_projections = len(get_filenames(args.projections)) sinos.props.number = num_projections if args.darks and args.flats: start = create_flat_correct_pipeline(args, graph) else: start = get_task('read') start.props.path = args.projections set_node_props(start, args) graph.connect_nodes(start, sinos) return sinos def run_sinogram_generation(args): """Make the sinograms with arguments provided by *args*.""" if not args.height: args.height = determine_shape(args, args.projections)[1] - args.y step = args.y_step * args.pass_size if args.pass_size else args.height starts = list(range(args.y, args.y + args.height, step)) + [args.y + args.height] def generate_partial(append=False): graph = Ufo.TaskGraph() sched = Ufo.Scheduler() args.output_append = append writer = get_writer(args) sinos = create_sinogram_pipeline(args, graph) graph.connect_nodes(sinos, writer) return run_scheduler(sched, graph) for i in range(len(starts) - 1): args.y = starts[i] args.height = starts[i + 1] - starts[i] if not generate_partial(append=i != 0): # We were interrupted break def create_projection_filtering_pipeline(args, graph, processing_node=None): pm = Ufo.PluginManager() pad = get_task('pad', processing_node=processing_node) fft = get_task('fft', processing_node=processing_node) ifft = get_task('ifft', processing_node=processing_node) fltr = get_task('filter', processing_node=processing_node) if args.projection_crop_after == 'filter': crop = get_task('crop', processing_node=processing_node) else: crop = None padding_width = setup_padding(pad, args.width, args.height, args.projection_padding_mode, crop=crop)[0] fft.props.dimensions = 1 ifft.props.dimensions = 1 fltr.props.filter = args.projection_filter fltr.props.scale = args.projection_filter_scale fltr.props.cutoff = args.projection_filter_cutoff graph.connect_nodes(pad, fft) graph.connect_nodes(fft, fltr) graph.connect_nodes(fltr, ifft) if crop: graph.connect_nodes(ifft, crop) last = crop else: last = ifft return (pad, last) def create_preprocessing_pipeline(args, graph, source=None, processing_node=None, cone_beam_weight=True, make_reader=True): """If *make_reader* is True, create a read task if *source* is None and no dark and flat fields are given. """ import numpy as np if not (args.width and args.height): width, height = determine_shape(args, args.projections) if not width: raise RuntimeError("Could not determine width from the input") if not args.width: args.width = width if not args.height: args.height = height - args.y LOG.debug('Image width x height: %d x %d', args.width, args.height) current = None if source: current = source elif args.darks and args.flats: current = create_flat_correct_pipeline(args, graph, processing_node=processing_node) else: if make_reader: current = get_task('read') set_node_props(current, args) if not args.projections: raise RuntimeError('--projections not set') setup_read_task(current, args.projections, args) if args.absorptivity: absorptivity = get_task('calculate', processing_node=processing_node) absorptivity.props.expression = 'v <= 0 ? 0.0f : -log(v)' if current: graph.connect_nodes(current, absorptivity) current = absorptivity if args.transpose_input: transpose = get_task('transpose') if current: graph.connect_nodes(current, transpose) current = transpose tmp = args.width args.width = args.height args.height = tmp if cone_beam_weight and not np.all(np.isinf(args.source_position_y)): # Cone beam projection weight LOG.debug('Enabling cone beam weighting') weight = get_task('cone-beam-projection-weight', processing_node=processing_node) weight.props.source_distance = (-np.array(args.source_position_y)).tolist() weight.props.detector_distance = args.detector_position_y weight.props.center_position_x = args.center_position_x or [args.width / 2. + (args.width % 2) * 0.5] weight.props.center_position_z = args.center_position_z or [args.height / 2. + (args.height % 2) * 0.5] weight.props.axis_angle_x = args.axis_angle_x if current: graph.connect_nodes(current, weight) current = weight if args.energy is not None and args.propagation_distance is not None: pr_first, pr_last = create_phase_retrieval_pipeline(args, graph, processing_node=processing_node) if current: graph.connect_nodes(current, pr_first) current = pr_last if args.projection_filter != 'none' and not fbp_filtering_in_phase_retrieval(args): pf_first, pf_last = create_projection_filtering_pipeline(args, graph, processing_node=processing_node) if current: graph.connect_nodes(current, pf_first) current = pf_last return current def run_preprocessing(args): graph = Ufo.TaskGraph() sched = Ufo.Scheduler() pm = Ufo.PluginManager() out_task = get_writer(args) current = create_preprocessing_pipeline(args, graph) graph.connect_nodes(current, out_task) run_scheduler(sched, graph) ufo-kit-tofu-ed0e5bd/tofu/reco.py000066400000000000000000000267451521054151500171110ustar00rootroot00000000000000import os import logging import glob import tempfile import sys import numpy as np from gi.repository import Ufo from tofu.preprocess import create_flat_correct_pipeline from tofu.util import (set_node_props, setup_read_task, get_filenames, read_image, determine_shape, setup_padding, run_scheduler) from tofu.tasks import get_task, get_writer LOG = logging.getLogger(__name__) pm = Ufo.PluginManager() def get_dummy_reader(params): if params.width is None and params.height is None: raise RuntimeError("You have to specify --width and --height when generating data.") width, height = params.width, params.height reader = get_task('dummy-data', width=width, height=height, number=params.number or 1) return reader, width, height def get_file_reader(params): reader = pm.get_task('read') set_node_props(reader, params) return reader def get_projection_reader(params): reader = get_file_reader(params) setup_read_task(reader, params.projections, params) width, height = determine_shape(params, params.projections) return reader, width, height def get_sinogram_reader(params): reader = get_file_reader(params) setup_read_task(reader, params.sinograms, params) width, height = determine_shape(params, path=params.sinograms) return reader, width, height def tomo(params): # Create reader and writer if params.projections and params.sinograms: raise RuntimeError("Cannot specify both --projections and --sinograms.") if params.projections is None and params.sinograms is None: reader, width, height = get_dummy_reader(params) else: if params.projections: reader, width, height = get_projection_reader(params) else: reader, width, height = get_sinogram_reader(params) axis = params.axis or width / 2.0 if params.projections and params.resize: width /= params.resize height /= params.resize axis /= params.resize LOG.debug("Input dimensions: {}x{} pixels".format(width, height)) writer = get_writer(params) # Setup graph depending on the chosen method and input data g = Ufo.TaskGraph() if params.projections is not None: if params.number: count = len(list(range(params.start, params.start + params.number, params.step))) else: count = len(get_filenames(params.projections)) LOG.debug("Number of projections: {}".format(count)) sino_output = get_task('transpose-projections', number=count) if params.darks and params.flats: g.connect_nodes(create_flat_correct_pipeline(params, g), sino_output) else: g.connect_nodes(reader, sino_output) if height: # Sinogram height is the one needed for further padding height = count else: sino_output = reader if params.method == 'fbp': fft = get_task('fft', dimensions=1) ifft = get_task('ifft', dimensions=1) fltr = get_task('filter', filter=params.projection_filter, cutoff=params.projection_filter_cutoff) bp = get_task('backproject', axis_pos=axis) last_node = bp if params.angle: bp.props.angle_step = params.angle if params.offset: bp.props.angle_offset = params.offset if width and height: # Pad the image with its extent to prevent reconstuction ring pad = get_task('pad') crop = get_task('crop') if params.projection_crop_after == 'filter': crop_after_filter = crop else: crop_after_filter = None padding_width = setup_padding(pad, width, height, params.projection_padding_mode, crop=crop_after_filter)[0] LOG.debug("Padding input to: {}x{} pixels".format(pad.props.width, pad.props.height)) g.connect_nodes(sino_output, pad) g.connect_nodes(pad, fft) g.connect_nodes(fft, fltr) g.connect_nodes(fltr, ifft) if crop_after_filter: g.connect_nodes(ifft, crop) g.connect_nodes(crop, bp) else: bp.props.axis_pos = axis + padding_width / 2 bp.props.roi_x = padding_width // 2 bp.props.roi_y = padding_width // 2 bp.props.roi_width = width bp.props.roi_height = width g.connect_nodes(ifft, bp) last_node = bp else: if params.crop_width: ifft.props.crop_width = int(params.crop_width) LOG.debug("Cropping to {} pixels".format(ifft.props.crop_width)) g.connect_nodes(sino_output, fft) g.connect_nodes(fft, fltr) g.connect_nodes(fltr, ifft) g.connect_nodes(ifft, bp) g.connect_nodes(last_node, writer) if params.method in ('sart', 'sirt', 'sbtv', 'asdpocs'): projector = pm.get_task_from_package('ir', 'parallel-projector') projector.set_properties(model='joseph', is_forward=False) projector.set_properties(axis_position=axis) projector.set_properties(step=params.angle if params.angle else np.pi / 180.0) method = pm.get_task_from_package('ir', params.method) method.set_properties(projector=projector, num_iterations=params.num_iterations) if params.method in ('sart', 'sirt'): method.set_properties(relaxation_factor=params.relaxation_factor) if params.method == 'asdpocs': minimizer = pm.get_task_from_package('ir', 'sirt') method.set_properties(df_minimizer=minimizer) if params.method == 'sbtv': # FIXME: the lambda keyword is preventing from the following # assignment ... # method.props.lambda = params.lambda method.set_properties(mu=params.mu) g.connect_nodes(sino_output, method) g.connect_nodes(method, writer) if params.method == 'dfi': oversampling = params.oversampling or 1 pad = get_task('zeropad', center_of_rotation=axis, oversampling=oversampling) fft = get_task('fft', dimensions=1, auto_zeropadding=0) dfi = get_task('dfi-sinc') ifft = get_task('ifft', dimensions=2) swap_forward = get_task('swap-quadrants') swap_backward = get_task('swap-quadrants') if params.angle: dfi.props.angle_step = params.angle g.connect_nodes(sino_output, pad) g.connect_nodes(pad, fft) g.connect_nodes(fft, dfi) g.connect_nodes(dfi, swap_forward) g.connect_nodes(swap_forward, ifft) g.connect_nodes(ifft, swap_backward) if width: crop = get_task('crop') crop.set_properties(from_center=True, width=width, height=width) g.connect_nodes(swap_backward, crop) g.connect_nodes(crop, writer) else: g.connect_nodes(swap_backward, writer) scheduler = Ufo.Scheduler() if hasattr(scheduler.props, 'enable_tracing'): LOG.debug("Use tracing: {}".format(params.enable_tracing)) scheduler.props.enable_tracing = params.enable_tracing if not run_scheduler(scheduler, g): return duration = scheduler.props.time LOG.info("Execution time: {} s".format(duration)) return duration def estimate_center(params): if params.estimate_method == 'reconstruction': axis = estimate_center_by_reconstruction(params) else: axis = estimate_center_by_correlation(params) return axis def estimate_center_by_reconstruction(params): if params.projections is not None: raise RuntimeError("Cannot estimate axis from projections") sinos = sorted(glob.glob(os.path.join(params.sinograms, '*.tif'))) if not sinos: raise RuntimeError("No sinograms found in {}".format(params.sinograms)) # Use a sinogram that probably has some interesting data filename = sinos[len(sinos) // 2] sinogram = read_image(filename) initial_width = sinogram.shape[1] m0 = np.mean(np.sum(sinogram, axis=1)) center = initial_width / 2.0 width = initial_width / 2.0 new_center = center tmp_dir = tempfile.mkdtemp() tmp_output = os.path.join(tmp_dir, 'slice-0.tif') params.sinograms = filename params.output = os.path.join(tmp_dir, 'slice-%i.tif') def heaviside(A): return (A >= 0.0) * 1.0 def get_score(guess, m0): # Run reconstruction with new guess params.axis = guess tomo(params) # Analyse reconstructed slice result = read_image(tmp_output) Q_IA = float(np.sum(np.abs(result)) / m0) Q_IN = float(-np.sum(result * heaviside(-result)) / m0) LOG.info("Q_IA={}, Q_IN={}".format(Q_IA, Q_IN)) return Q_IA def best_center(center, width): trials = [center + (width / 4.0) * x for x in range(-2, 3)] scores = [(guess, get_score(guess, m0)) for guess in trials] LOG.info(scores) best = sorted(scores, cmp=lambda x, y: cmp(x[1], y[1])) return best[0][0] for i in range(params.num_iterations): LOG.info("Estimate iteration: {}".format(i)) new_center = best_center(new_center, width) LOG.info("Currently best center: {}".format(new_center)) width /= 2.0 try: os.remove(tmp_output) os.removedirs(tmp_dir) except OSError: LOG.info("Could not remove {} or {}".format(tmp_output, tmp_dir)) return new_center def estimate_center_by_correlation(params): """Use correlation to estimate center of rotation for tomography.""" def flat_correct(flat, radio): nonzero = np.where(radio != 0) result = np.zeros_like(radio) result[nonzero] = flat[nonzero] / radio[nonzero] # log(1) = 0 result[result <= 0] = 1 return np.log(result) first = read_image(get_filenames(params.projections)[0]).astype(float) last_index = params.start + params.number if params.number else -1 last = read_image(get_filenames(params.projections)[last_index]).astype(float) if params.darks and params.flats: dark = read_image(get_filenames(params.darks)[0]).astype(float) flat = read_image(get_filenames(params.flats)[0]) - dark first = flat_correct(flat, first - dark) last = flat_correct(flat, last - dark) height = params.height if params.height else -1 y_region = slice(params.y, min(params.y + height, first.shape[0]), params.y_step) first = first[y_region, :] last = last[y_region, :] return compute_rotation_axis(first, last) def compute_rotation_axis(first_projection, last_projection): """ Compute the tomographic rotation axis based on cross-correlation technique. *first_projection* is the projection at 0 deg, *last_projection* is the projection at 180 deg. """ from scipy.signal import fftconvolve width = first_projection.shape[1] first_projection = first_projection - first_projection.mean() last_projection = last_projection - last_projection.mean() # The rotation by 180 deg flips the image horizontally, in order # to do cross-correlation by convolution we must also flip it # vertically, so the image is transposed and we can apply convolution # which will act as cross-correlation convolved = fftconvolve(first_projection, last_projection[::-1, :], mode='same') center = np.unravel_index(convolved.argmax(), convolved.shape)[1] return (width / 2.0 + center) / 2 ufo-kit-tofu-ed0e5bd/tofu/tasks.py000066400000000000000000000063051521054151500172740ustar00rootroot00000000000000import logging from gi.repository import Ufo LOG = logging.getLogger(__name__) PLUGIN_MANAGER = Ufo.PluginManager() def get_task(name, processing_node=None, **kwargs): task = PLUGIN_MANAGER.get_task(name) task.set_properties(**kwargs) if processing_node and task.uses_gpu(): LOG.debug("Assigning task '%s' to node %d", name, processing_node.get_index()) task.set_proc_node(processing_node) return task def get_writer(params): if 'dry_run' in params and params.dry_run: LOG.debug("Discarding data output") return get_task('null', download=True) outname = params.output LOG.debug("Writing output to {}".format(outname)) writer = get_task('write', filename=outname, rescale=params.output_rescale) writer.props.append = params.output_append if params.output_bitdepth != 32: writer.props.bits = params.output_bitdepth if params.output_minimum is not None: writer.props.minimum = params.output_minimum if params.output_maximum is not None: writer.props.maximum = params.output_maximum if params.output_minimum is not None or params.output_maximum is not None: LOG.info('--output-minimum or --output-maximum specified, turning --output-rescale on') writer.props.rescale = True if hasattr (writer.props, 'bytes_per_file'): writer.props.bytes_per_file = params.output_bytes_per_file if hasattr(writer.props, 'tiff_bigtiff'): writer.props.tiff_bigtiff = params.output_bytes_per_file > 2 ** 32 - 2 ** 25 return writer def get_memory_in(array): import numpy as np if array.ndim != 2: raise ValueError("Only 2D images are supported") if array.dtype != np.float32 and array.dtype != np.complex64: raise ValueError("Only images with float32 or complex64 data type are supported") is_complex = array.dtype == np.complex64 in_task = get_task('memory-in') in_task.props.complex_layout = is_complex in_task.props.pointer = array.__array_interface__['data'][0] in_task.props.width = 2 * array.shape[1] if is_complex else array.shape[1] in_task.props.height = array.shape[0] in_task.props.number = 1 in_task.props.bitdepth = 32 # We need to extend the survival of *array* beyond this function to the point when the graph is # executed, otherwise it will be destroyed and UFO will try to get data from freed memory. Thus, # attach it to the task which actually needs it, because when that one is garbage collected then # the array may be as well. in_task.np_array = array return in_task def get_memory_out(width, height): import numpy as np array = np.empty((height, width), dtype=np.float32) out_task = get_task('memory-out') out_task.props.pointer = array.__array_interface__['data'][0] out_task.props.max_size = array.nbytes # We need to extend the survival of *array* beyond this function to the point when the graph is # executed, otherwise it will be destroyed and UFO will try to get data from freed memory. Thus, # attach it to the task which actually needs it, because when that one is garbage collected then # the array may be as well. out_task.np_array = array return out_task ufo-kit-tofu-ed0e5bd/tofu/tests/000077500000000000000000000000001521054151500167335ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/tests/__init__.py000066400000000000000000000000001521054151500210320ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/tests/composites/000077500000000000000000000000001521054151500211205ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/tests/composites/cmp.cm000066400000000000000000000056661521054151500222350ustar00rootroot00000000000000{ "name": "cmp", "caption": "cmp", "models": { "Null": { "model": { "caption": "Null", "properties": { "download": [ false, true ], "finish": [ false, true ], "durations": [ false, true ] } }, "visible": true, "position": { "x": 756.0, "y": 272.0 }, "name": "null" }, "Read": { "model": { "caption": "Read", "properties": { "path": [ ".", true ], "start": [ 0, false ], "number": [ 4294967295, true ], "step": [ 1, false ], "y": [ 0, false ], "height": [ 0, false ], "y-step": [ 1, false ], "convert": [ true, false ], "raw-width": [ 0, false ], "raw-height": [ 0, false ], "raw-bitdepth": [ 0, false ], "raw-pre-offset": [ 0, false ], "raw-post-offset": [ 0, false ], "type": [ "unspecified", false ], "retries": [ 0, false ], "retry-timeout": [ 1, false ] } }, "visible": true, "position": { "x": 266.0, "y": 242.0 }, "name": "read" } }, "connections": [ [ "Read", 0, "Null", 0 ] ], "links": [] }ufo-kit-tofu-ed0e5bd/tofu/tests/composites/cmp_2.cm000066400000000000000000000034751521054151500224520ustar00rootroot00000000000000{ "name": "cmp_2", "caption": "cmp_2", "models": { "Dummy Data": { "model": { "caption": "Dummy Data", "properties": { "width": [ 1, true ], "height": [ 1, true ], "depth": [ 1, true ], "number": [ 1, true ], "init": [ 0.0, true ], "metadata": [ false, true ] } }, "visible": true, "position": { "x": 359.0, "y": 357.0 }, "name": "dummy_data" }, "Null": { "model": { "caption": "Null", "properties": { "download": [ false, true ], "finish": [ false, true ], "durations": [ false, true ] } }, "visible": true, "position": { "x": 859.0, "y": 459.0 }, "name": "null" } }, "connections": [ [ "Dummy Data", 0, "Null", 0 ] ], "links": [] }ufo-kit-tofu-ed0e5bd/tofu/tests/conftest.py000066400000000000000000000030441521054151500211330ustar00rootroot00000000000000import pytest from PyQt5.QtWidgets import QInputDialog from tofu.flow.main import get_filled_registry from tofu.flow.scene import UfoScene from tofu.flow.propertylinksmodels import PropertyLinksModel, NodeTreeModel @pytest.fixture(scope='function') def nodes(monkeypatch): reg = get_filled_registry() scene = UfoScene(reg) nodes = {} # Composite node for name in ['read', 'pad']: model_cls = reg.create(name) node = scene.create_node(model_cls) node.graphics_object.setSelected(True) monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm', True)) nodes['cpm'] = scene.create_composite() nodes['cpm'].graphics_object.setSelected(False) # Simple nodes for i in range(5): name = f'read_{i}' if i else 'read' model_cls = reg.create('read') nodes[name] = scene.create_node(model_cls) model_cls = reg.create('image_viewer') nodes['image_viewer'] = scene.create_node(model_cls) model_cls = reg.create('average') nodes['average'] = scene.create_node(model_cls) return nodes @pytest.fixture(scope='function') def scene(): reg = get_filled_registry() return UfoScene(reg) @pytest.fixture(scope='function') def scene_with_composite(nodes): return UfoScene(nodes['cpm'].model._registry) @pytest.fixture(scope='function') def node_model(): model = NodeTreeModel() model.setColumnCount(1) return model @pytest.fixture(scope='function') def link_model(node_model): model = PropertyLinksModel(node_model) return model ufo-kit-tofu-ed0e5bd/tofu/tests/flow_util.py000066400000000000000000000016661521054151500213220ustar00rootroot00000000000000def populate_link_model(link_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] composite = nodes['cpm'] records = [[read, read.model, 'number'], [read_2, read_2.model, 'height'], [composite, composite.model['Read'], 'y']] for (i, (node, model, prop)) in enumerate(records): link_model.add_item(node, model, prop, 0, i) return records def get_index_from_treemodel(node_model, row, prop_name): item = node_model.item(row, 0) i = 0 prop_item = item.child(i) while prop_item.text() != prop_name: i += 1 prop_item = item.child(i) return node_model.indexFromItem(prop_item) def add_nodes_to_scene(scene, model_names=None): if not model_names: model_names = ['read'] nodes = [] for name in model_names: model_cls = scene.registry.create(name) nodes.append(scene.create_node(model_cls)) return nodes ufo-kit-tofu-ed0e5bd/tofu/tests/test_flow_execution.py000066400000000000000000000114371521054151500234040ustar00rootroot00000000000000import pytest from tofu.flow.execution import get_gpu_splitting_models, UfoExecutor from tofu.flow.main import get_filled_registry from tofu.flow.scene import UfoScene @pytest.fixture(scope='function') def scene(): reg = get_filled_registry() scene = UfoScene(reg) for name in ['dummy_data', 'pad', 'null']: # Set nodes as scene attributes for convenience setattr(scene, name, scene.create_node(reg.create(name))) scene.create_connection(scene.dummy_data['output'][0], scene.pad['input'][0]) scene.create_connection(scene.pad['output'][0], scene.null['input'][0]) return scene @pytest.fixture(scope='function') def executor(): return UfoExecutor() class TestUfoExecutor: def test_init(self, executor): ... def test_reset(self, executor): assert not executor._aborted assert executor._schedulers == [] assert executor.num_generated == 0 def test_abort(self, executor): self.called = False def slot(): self.called = True executor.execution_finished.connect(slot) executor.abort() assert self.called def test_on_processed(self, executor): self.num_generated = 0 def slot(): self.num_generated += 1 executor.processed_signal.connect(slot) executor.on_processed(None) executor.on_processed(None) assert self.num_generated == executor.num_generated == 2 def test_setup_ufo_graph(self, qtbot, scene, executor): graph = scene.get_simple_node_graphs()[0] gpus = executor._resources.get_gpu_nodes() assert gpus executor.setup_ufo_graph(graph, gpu=gpus[0], region=None, signalling_model=scene.dummy_data.model) def test_run_ufo_graph(self, qtbot, scene, executor): graph = scene.get_simple_node_graphs()[0] gpus = executor._resources.get_gpu_nodes() assert gpus ufo_graph = executor.setup_ufo_graph(graph, gpu=gpus[0], region=None, signalling_model=scene.dummy_data.model) # Run with default scheduler executor._run_ufo_graph(ufo_graph, False) # Run with fixed scheduler executor._run_ufo_graph(ufo_graph, True) # def test_check_graph(self, qtbot, scene, executor): # # TODO: implement this when memory-in is implemented and there is something to test def test_run(self, qtbot, scene, executor): def on_num_inputs_changed(number): self.num_inputs = number def on_processed(number): self.num_processed = number def on_execution_started(): self.started = True def on_execution_finished(): self.finished = True def on_exception_occured(): self.exception = True scene.dummy_data.model['number'] = 10 graph = scene.get_simple_node_graphs()[0] self.num_inputs = 0 self.num_processed = 0 self.started = False self.finished = False self.exception = None executor.number_of_inputs_changed.connect(on_num_inputs_changed) executor.processed_signal.connect(on_processed) executor.execution_started.connect(on_execution_started) executor.execution_finished.connect(on_execution_finished) executor.exception_occured.connect(on_exception_occured) with qtbot.waitSignal(signal=executor.execution_finished, timeout=100000): executor.run(graph) assert self.num_inputs == scene.dummy_data.model['number'] assert self.num_processed == scene.dummy_data.model['number'] assert self.started assert self.finished assert self.exception is None scene.remove_node(scene.dummy_data) # Create a reader and point it to a nonexistent path so that it raises an exception and # check that this exception has been processed byt the executor setattr(scene, 'read', scene.create_node(scene.registry.create('read'))) scene.create_connection(scene.read['output'][0], scene.pad['input'][0]) # Make sure the path is nonsense scene.read.model['path'] = '/dfasf/fsdafsdaf/asd/asf' scene.read.model['number'] = 10 graph = scene.get_simple_node_graphs()[0] executor.swallow_run_exceptions = True with qtbot.waitSignal(signal=executor.execution_finished): executor.run(graph) assert self.exception def test_get_gpu_splitting_models(qtbot, scene, executor): graph = scene.get_simple_node_graphs()[0] assert len(get_gpu_splitting_models(graph)) == 0 scene.clear_scene() scene.create_node(scene.registry.create('general_backproject')) graph = scene.get_simple_node_graphs()[0] assert len(get_gpu_splitting_models(graph)) == 1 ufo-kit-tofu-ed0e5bd/tofu/tests/test_flow_main.py000066400000000000000000000513111521054151500223200ustar00rootroot00000000000000import glob import os import pathlib import pkg_resources import pytest import sys import xdg.BaseDirectory from PyQt5.QtWidgets import QFileDialog, QInputDialog, QMessageBox from tofu.flow.execution import UfoExecutor from tofu.flow.main import ApplicationWindow, get_filled_registry, GlobalExceptionHandler from tofu.flow.scene import UfoScene from tofu.flow.util import FlowError from tofu.tests.flow_util import add_nodes_to_scene @pytest.fixture(scope='function') def app_window(qtbot, scene): window = ApplicationWindow(scene) qtbot.addWidget(window) return window class TestApplicationWindow: def test_init(self, qtbot, app_window): assert app_window.ufo_scene assert app_window.executor def test_on_save(self, monkeypatch, app_window): def getSaveFileNameDefault(inst, header, path, fltr): return (os.path.join(path, 'flow.flow'), True) def getSaveFileName(inst, header, path, fltr): return (os.path.join('foo', 'bar', 'flow.flow'), True) # Don't actually write to disk monkeypatch.setattr(UfoScene, "save", lambda *args: None) # Default directory monkeypatch.setattr(QFileDialog, "getSaveFileName", getSaveFileNameDefault) app_window.on_save() directory = xdg.BaseDirectory.save_data_path('tofu', 'flows') assert os.path.exists(directory) assert app_window.last_dirs['scene'] == directory # When user picks a different directory it must be remembered monkeypatch.setattr(QFileDialog, "getSaveFileName", getSaveFileName) app_window.on_save() assert app_window.last_dirs['scene'] == os.path.join('foo', 'bar') # And used the next time monkeypatch.setattr(QFileDialog, "getSaveFileName", getSaveFileNameDefault) app_window.on_save() assert app_window.last_dirs['scene'] == os.path.join('foo', 'bar') def test_on_open(self, monkeypatch, app_window): def getOpenFileNameDefault(inst, header, path, fltr): return (os.path.join(path, 'flow.flow'), True) def getOpenFileName(inst, header, path, fltr): return (os.path.join('foo', 'bar', 'flow.flow'), True) # Don't actually read from disk monkeypatch.setattr(UfoScene, "load", lambda *args: None) # Default directory monkeypatch.setattr(QFileDialog, "getOpenFileName", getOpenFileNameDefault) app_window.on_open() directory = xdg.BaseDirectory.save_data_path('tofu', 'flows') if not os.path.exists(directory): directory = pathlib.Path.home() assert app_window.last_dirs['scene'] == directory # When user picks a different directory it must be remembered monkeypatch.setattr(QFileDialog, "getOpenFileName", getOpenFileName) app_window.on_open() assert app_window.last_dirs['scene'] == os.path.join('foo', 'bar') # And used the next time monkeypatch.setattr(QFileDialog, "getOpenFileName", getOpenFileNameDefault) app_window.on_open() assert app_window.last_dirs['scene'] == os.path.join('foo', 'bar') def test_on_exception_occured(self, qtbot, monkeypatch, app_window): def exec_(inst): self.message_shown = True self.message_shown = False monkeypatch.setattr(QMessageBox, "exec_", exec_) app_window.on_exception_occured('foo') assert self.message_shown def test_on_number_of_inputs_changed(self, qtbot, app_window): app_window.on_number_of_inputs_changed(123) assert app_window.progress_bar.maximum() == 123 def test_on_processed(self, qtbot, app_window): app_window.on_number_of_inputs_changed(100) app_window.on_processed(10) assert app_window.progress_bar.value() == 11 def test_on_nodes_duplicated(self, qtbot, app_window): node = add_nodes_to_scene(app_window.ufo_scene)[0] node.graphics_object.setSelected(True) app_window.ufo_scene.copy_nodes() nodes = list(app_window.ufo_scene.nodes.values()) assert nodes[0].graphics_object.pos().y() != nodes[1].graphics_object.pos().y() def test_on_selection_menu_about_to_show(self, qtbot, monkeypatch, app_window): # Nothing selected app_window.on_selection_menu_about_to_show() assert not app_window.edit_composite_action.isEnabled() assert not app_window.expand_composite_action.isEnabled() assert not app_window.export_composite_action.isEnabled() # Only non-composite nodes nodes = add_nodes_to_scene(app_window.ufo_scene, model_names=['read', 'average', 'null']) app_window.on_selection_menu_about_to_show() assert not app_window.edit_composite_action.isEnabled() assert not app_window.expand_composite_action.isEnabled() assert not app_window.export_composite_action.isEnabled() # One composite monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm', True)) for i in range(2): nodes[i].graphics_object.setSelected(True) app_window.ufo_scene.create_composite() app_window.on_selection_menu_about_to_show() assert app_window.edit_composite_action.isEnabled() assert app_window.expand_composite_action.isEnabled() assert app_window.export_composite_action.isEnabled() # More composites monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm_2', True)) app_window.ufo_scene.clearSelection() nodes[-1].graphics_object.setSelected(True) app_window.ufo_scene.create_composite() for node in app_window.ufo_scene.nodes.values(): node.graphics_object.setSelected(True) app_window.on_selection_menu_about_to_show() assert not app_window.edit_composite_action.isEnabled() assert app_window.expand_composite_action.isEnabled() assert not app_window.export_composite_action.isEnabled() def test_skip_action(self, qtbot, app_window): # No nodes selected, menu item must be disabled app_window.on_selection_menu_about_to_show() assert not app_window.skip_action.isEnabled() # Add some nodes, conect them and disable one nodes = add_nodes_to_scene(app_window.ufo_scene, model_names=['read', 'average', 'null']) app_window.ufo_scene.create_connection(nodes[0]['output'][0], nodes[1]['input'][0]) app_window.ufo_scene.create_connection(nodes[1]['output'][0], nodes[2]['input'][0]) average = nodes[1] average.graphics_object.setSelected(True) app_window.on_selection_menu_about_to_show() # Nodes selected, menu item must be enabled assert app_window.skip_action.isEnabled() def test_on_edit_composite(self, qtbot, scene_with_composite, app_window): app_window.ufo_scene = scene_with_composite node = add_nodes_to_scene(app_window.ufo_scene, model_names=['cpm'])[0] node.graphics_object.setSelected(True) app_window.on_edit_composite() qtbot.addWidget(node.model._other_view) assert node.model.is_editing def test_on_create_composite(self, qtbot, monkeypatch, scene_with_composite, app_window): monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm', True)) nodes = add_nodes_to_scene(app_window.ufo_scene, model_names=['read', 'pad']) # Link a model to the slider model = nodes[0].model view_item = model._view._properties['number'].view_item app_window.on_item_focus_in(view_item, 'number', 'Read', model) # Create a composite for node in app_window.ufo_scene.nodes.values(): node.graphics_object.setSelected(True) app_window.on_create_composite() composite = list(app_window.ufo_scene.nodes.values())[0].model slider_model, prop_name = app_window.run_slider_key assert slider_model == composite.get_model_from_path(['Read']) assert prop_name == 'number' def test_on_item_focus_in(self, qtbot, app_window, scene_with_composite): read, pad = add_nodes_to_scene(app_window.ufo_scene, model_names=['read', 'pad']) # Simple node model = read.model view_item = model._view._properties['number'].view_item app_window.on_item_focus_in(view_item, 'number', model.caption, model) slider_model, prop_name = app_window.run_slider_key assert slider_model == model assert prop_name == 'number' app_window.fix_run_slider.setChecked(False) model = pad.model view_item = model._view._properties['y'].view_item app_window.on_item_focus_in(view_item, 'y', model.caption, model) slider_model, prop_name = app_window.run_slider_key assert slider_model == model assert prop_name == 'y' # Focus gets another widget, but the run slider must be linked to the one focused before the # fix option is checked app_window.fix_run_slider.setChecked(True) model = read.model view_item = model._view._properties['number'].view_item app_window.on_item_focus_in(view_item, 'number', model.caption, model) slider_model, prop_name = app_window.run_slider_key assert slider_model == pad.model assert prop_name == 'y' def test_on_node_deleted(self, qtbot, monkeypatch, app_window, scene_with_composite): app_window.ufo_scene = scene_with_composite cpm, cpm_2, read = add_nodes_to_scene(app_window.ufo_scene, model_names=['cpm', 'cpm', 'read']) # Simple node model = read.model view_item = model._view._properties['number'].view_item app_window.on_item_focus_in(view_item, 'number', model.caption, model) # remove in the scene doesn't seem to emit the signal, so use the window app_window.on_node_deleted(read) slider_model, prop_name = app_window.run_slider_key assert slider_model is None assert prop_name is None # Composite node model = cpm.model.get_model_from_path(['Read']) view_item = model._view._properties['number'].view_item app_window.on_item_focus_in(view_item, 'number', 'cpm->Read', model) # remove in the scene doesn't seem to emit the signal, so use the window app_window.on_node_deleted(cpm) slider_model, prop_name = app_window.run_slider_key assert slider_model is None assert prop_name is None # Nested composite node cpm_2.graphics_object.setSelected(True) monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('parent', True)) app_window.on_create_composite() node = app_window.ufo_scene.selected_nodes()[0] model = node.model.get_model_from_path(['cpm 2', 'Read']) view_item = model._view._properties['number'].view_item app_window.on_item_focus_in(view_item, 'number', 'parent->cpm 2->Read', model) # remove in the scene doesn't seem to emit the signal, so use the window app_window.on_node_deleted(node) slider_model, prop_name = app_window.run_slider_key assert slider_model is None assert prop_name is None def test_on_expand_composite(self, qtbot, scene_with_composite, app_window): app_window.ufo_scene = scene_with_composite nodes = add_nodes_to_scene(app_window.ufo_scene, model_names=['cpm', 'cpm']) for node in nodes: node.graphics_object.setSelected(True) app_window.on_expand_composite() captions = {node.model.caption for node in app_window.ufo_scene.nodes.values()} assert captions == {'Read 2', 'Pad 2', 'Read', 'Pad'} # Run slider # Create yet another composite and select a reader inside node = add_nodes_to_scene(app_window.ufo_scene, model_names=['cpm'])[0] model = node.model.get_model_from_path(['Read']) view_item = model._view._properties['number'].view_item app_window.on_item_focus_in(view_item, 'number', 'cpm->Read', model) node.graphics_object.setSelected(True) app_window.on_expand_composite() # After expansion, the reader's index will be 3 slider_model, prop_name = app_window.run_slider_key assert slider_model.caption == 'Read 3' assert prop_name == 'number' def test_on_import_composites(self, qtbot, monkeypatch, app_window): tests_directory = pkg_resources.resource_filename(__name__, 'composites') def getOpenFileNamesDefault(inst, header, path, fltr): # Let's pretend there are files file_names = [os.path.join(path, 'foo.cm')] return (file_names, True) def getOpenFileNames(inst, header, path, fltr): file_names = sorted(glob.glob(os.path.join(tests_directory, '*.cm'))) return (file_names, True) def exec_(inst): self.message_shown = True monkeypatch.setattr(QMessageBox, "exec_", exec_) # Nothing opened, nothing happens monkeypatch.setattr(QFileDialog, "getOpenFileNames", lambda *args: ([], True)) app_window.on_import_composites() # Default directory monkeypatch.setattr(QFileDialog, "getOpenFileNames", getOpenFileNamesDefault) directory = xdg.BaseDirectory.save_data_path('tofu', 'flows', 'composites') if not os.path.exists(directory): directory = pathlib.Path.home() try: app_window.on_import_composites() except FileNotFoundError: # We don't care if there are files, just the last_dirs setting is important pass assert app_window.last_dirs['composite'] == directory # It's possible to open more than one at a time monkeypatch.setattr(QFileDialog, "getOpenFileNames", getOpenFileNames) app_window.on_import_composites() assert 'cmp' in app_window.ufo_scene.registry.registered_model_creators() assert 'cmp_2' in app_window.ufo_scene.registry.registered_model_creators() # When user picks a different directory it must be remembered assert app_window.last_dirs['composite'] == tests_directory # And used the next time self.message_shown = False app_window.on_import_composites() assert app_window.last_dirs['composite'] == tests_directory # Message about overwriting models must be shown assert self.message_shown def test_on_export_composite(self, qtbot, monkeypatch, scene_with_composite, app_window): tests_directory = pkg_resources.resource_filename(__name__, 'composites') def getSaveFileNameDefault(inst, header, path, fltr): return (os.path.join(path, self.file_name), True) def getSaveFileName(inst, header, path, fltr): return (os.path.join(tests_directory, self.file_name), True) def export_composite(inst, node, file_name): self.final_file_name = file_name # Nothing selected, must silently pass app_window.on_export_composite() # Make a composite node app_window.ufo_scene = scene_with_composite node = add_nodes_to_scene(app_window.ufo_scene, model_names=['cpm'])[0] node.graphics_object.setSelected(True) monkeypatch.setattr(ApplicationWindow, "export_composite", export_composite) # Default directory monkeypatch.setattr(QFileDialog, "getSaveFileName", getSaveFileNameDefault) self.file_name = 'composite' directory = xdg.BaseDirectory.save_data_path('tofu', 'flows', 'composites') app_window.on_export_composite() assert self.final_file_name.endswith('.cm') and not self.final_file_name.endswith('.cm.cm') assert os.path.exists(directory) assert app_window.last_dirs['composite'] == directory # When user picks a different directory it must be remembered monkeypatch.setattr(QFileDialog, "getSaveFileName", getSaveFileName) app_window.on_export_composite() assert self.final_file_name.endswith('.cm') and not self.final_file_name.endswith('.cm.cm') assert app_window.last_dirs['composite'] == tests_directory # And used the next time monkeypatch.setattr(QFileDialog, "getSaveFileName", getSaveFileNameDefault) app_window.on_export_composite() assert app_window.last_dirs['composite'] == tests_directory # .cm must not be added if it's present in the file name self.file_name = 'composite.cm' app_window.on_export_composite() assert self.final_file_name.endswith('.cm') and not self.final_file_name.endswith('.cm.cm') def test_on_reset_view(self, qtbot, app_window): app_window.flow_view.scale_up() app_window.on_reset_view() assert app_window.flow_view.transform().m11() == pytest.approx(1) assert app_window.flow_view.transform().m22() == pytest.approx(1) def test_on_property_links_action(self, qtbot, app_window): qtbot.addWidget(app_window.property_links_widget) app_window.property_links_widget.show() assert app_window.property_links_widget.isVisible() def test_on_run(self, qtbot, monkeypatch, app_window): def executor_run(inst, graph): self.ran = True monkeypatch.setattr(UfoExecutor, "run", executor_run) nodes = add_nodes_to_scene(app_window.ufo_scene, model_names=['read', 'read', 'flat_field_correct', 'null']) i_0, i_1, ffc, null = nodes # No connections -> many graphs with pytest.raises(FlowError): app_window.on_run() assert app_window.run_action.isEnabled() app_window.ufo_scene.create_connection(i_0['output'][0], ffc['input'][0]) app_window.ufo_scene.create_connection(i_1['output'][0], ffc['input'][1]) app_window.ufo_scene.create_connection(ffc['output'][0], null['input'][0]) # One ffc input is not connected with pytest.raises(FlowError): app_window.on_run() assert app_window.run_action.isEnabled() # All connections present -> must run i_2 = add_nodes_to_scene(app_window.ufo_scene, model_names=['read'])[0] app_window.ufo_scene.create_connection(i_2['output'][0], ffc['input'][2]) self.ran = False app_window.on_run() assert self.ran assert not app_window.run_action.isEnabled() def test_on_save_json(self, qtbot, monkeypatch, app_window): import gi try: gi.require_version('Ufo', '0.0') except ValueError: gi.require_version('Ufo', '1.0') from gi.repository import Ufo # Don't pop up file dialog def getSaveFileName(inst, header, path, fltr): return (os.path.join(path, 'flow.json'), True) monkeypatch.setattr(QFileDialog, "getSaveFileName", getSaveFileName) # Don't actually write to disk monkeypatch.setattr(Ufo.TaskGraph, "save_to_json", lambda *args: None) # Empty scene with pytest.raises(FlowError): app_window.on_save_json() # Wrong data types app_window.ufo_scene.clear_scene() read, mem_out, viewer = add_nodes_to_scene(app_window.ufo_scene, model_names=['read', 'memory_out', 'image_viewer']) app_window.ufo_scene.create_connection(read['output'][0], mem_out['input'][0]) app_window.ufo_scene.create_connection(mem_out['output'][0], viewer['input'][0]) with pytest.raises(FlowError): app_window.on_save_json() # Not connected app_window.ufo_scene.clear_scene() read, null = add_nodes_to_scene(app_window.ufo_scene, model_names=['read', 'null']) with pytest.raises(FlowError): app_window.on_save_json() # This must pass app_window.ufo_scene.create_connection(read['output'][0], null['input'][0]) app_window.on_save_json() def test_on_execution_finished(self, qtbot, app_window): app_window.run_action.setEnabled(False) app_window.progress_bar.setMaximum(100) app_window.progress_bar.setValue(50) app_window.on_execution_finished() assert app_window.progress_bar.value() == -1 assert app_window.run_action.isEnabled() def test_global_exception_handler(qtbot): handler = GlobalExceptionHandler() def slot(text): handler.called_signal = True handler.exception_occured.connect(slot) handler.called_signal = False try: raise FlowError('foo') except: # Call the hook explicitly, sys.excinfo = ... doesn't seem to have effect handler.excepthook(*sys.exc_info()) assert handler.called_signal def test_get_filled_registry(): registry = get_filled_registry() assert 'read' in registry.registered_model_creators() ufo-kit-tofu-ed0e5bd/tofu/tests/test_flow_models.py000066400000000000000000001643221521054151500226660ustar00rootroot00000000000000import pytest import numpy as np from PyQt5.QtCore import Qt from PyQt5.QtGui import QValidator from PyQt5.QtWidgets import QFileDialog, QInputDialog, QLineEdit from tofu.flow.main import get_filled_registry from tofu.flow.models import (CheckBoxViewItem, ComboBoxViewItem, get_composite_model_class, get_composite_model_classes, get_composite_model_classes_from_json, get_ufo_model_class, get_ufo_model_classes, ImageViewerModel, IntQLineEditViewItem, MultiPropertyView, NumberQLineEditViewItem, PropertyModel, PropertyView, QLineEditViewItem, RangeQLineEditViewItem, UfoGeneralBackprojectModel, UfoIntValidator, UfoMemoryOutModel, UfoModelError, UfoRangeValidator, UfoReadModel, UfoRetrievePhaseModel, UfoModel, UfoTaskModel, UfoVaryingInputModel, UfoWriteModel, ViewItem) from tofu.flow.scene import UfoScene from tofu.flow.util import CompositeConnection, MODEL_ROLE, PROPERTY_ROLE from tofu.tests.flow_util import populate_link_model def check_property_changed_emit(qtbot, view_item, expected, gui_func, gui_args, gui_kwargs=None, show=False): if gui_kwargs is None: gui_kwargs = {} def on_changed(vit): vit.change_called = True view_item.change_called = False view_item.property_changed.connect(on_changed) qtbot.addWidget(view_item.widget) if show: # without show the mouse click for QCheckBox doesn't happen, bug in pytest-qt? view_item.widget.show() # Store old value for later check of programmatic change old_value = view_item.get() # Simulate user interaction gui_func(*gui_args, **gui_kwargs) # Value must have been set assert view_item.get() == expected # Signal must have been emitted assert view_item.change_called view_item.change_called = False view_item.set(old_value) # Signal must be emitted only on user interacion, not programmatic access assert not view_item.change_called def make_properties(): return { 'int': [IntQLineEditViewItem(0, 100, default_value=10), True], 'float': [NumberQLineEditViewItem(0, 100, default_value=0), True], 'string': [QLineEditViewItem(default_value='foo'), True], 'range': [RangeQLineEditViewItem(default_value=[1, 2, 3], num_items=3, is_float=True), True], 'choices': [ComboBoxViewItem(['a', 'b', 'c']), True], 'check': [CheckBoxViewItem(checked=True), True] } class DummyPropertyModel(PropertyModel): def make_properties(self): return make_properties() @pytest.fixture(scope='function') def property_view(): return PropertyView(properties=make_properties(), scrollable=False) @pytest.fixture(scope='function') def multi_property_view(nodes): groups = {nodes['cpm'].model: True, nodes['read'].model: False} return MultiPropertyView(groups=groups) def make_composite_model_class(nodes, name='foobar'): # We want to connect cpm:Pad to average, thus we need to get the outside port of the cpm # composite which corresponds to the pad model pad_index = nodes['cpm'].model.get_outside_port('Pad', 'output', 0)[1] connections = [CompositeConnection('cpm', pad_index, 'Average', 0)] state = [('cpm', nodes['cpm'].model.save(), True, None), ('average', nodes['average'].model.save(), True, None)] return get_composite_model_class(name, state, connections) def create_scene(qtbot, registry): scene = UfoScene(registry=registry) if scene.views(): for view in scene.views(): qtbot.addWidget(view) return scene def make_composite_node_in_scene(qtbot, nodes): model_cls = make_composite_model_class(nodes) registry = get_filled_registry() # Register both composites so that we can create them registry.register_model(nodes['cpm'].model.__class__, category='Composite', registry=registry) registry.register_model(model_cls, category='Composite', registry=registry) scene = create_scene(qtbot, registry) node = scene.create_node(model_cls) return (scene, node) @pytest.fixture(scope='function') def composite_model(nodes): # Make sure 'cpm', which is inside this composite model, has been registered registry = nodes['cpm'].model._registry model_cls = make_composite_model_class(nodes) registry.register_model(model_cls, category='Composite', registry=registry) return model_cls(registry=registry) @pytest.fixture(scope='function') def general_backproject(qtbot): model = UfoGeneralBackprojectModel() qtbot.addWidget(model.embedded_widget()) return model @pytest.fixture(scope='function') def read_model(qtbot): model = UfoReadModel() qtbot.addWidget(model.embedded_widget()) return model @pytest.fixture(scope='function') def write_model(qtbot): model = UfoWriteModel() qtbot.addWidget(model.embedded_widget()) return model @pytest.fixture(scope='function') def memory_out_model(qtbot): model = UfoMemoryOutModel() model['width'] = 100 model['height'] = 100 qtbot.addWidget(model.embedded_widget()) return model @pytest.fixture(scope='function') def image_viewer_model(qtbot): model = ImageViewerModel() qtbot.addWidget(model.embedded_widget()) return model def test_ufo_int_validator(): validator = UfoIntValidator(-10, 10) def check(input_str, expected): assert validator.validate(input_str, -1)[0] == expected check('0', QValidator.Acceptable) check('1', QValidator.Acceptable) check('-1', QValidator.Acceptable) check('101', QValidator.Intermediate) check('-101', QValidator.Intermediate) check('-', QValidator.Intermediate) check('1.', QValidator.Invalid) check('1.0', QValidator.Invalid) check('asdf', QValidator.Invalid) validator = UfoIntValidator(3, 10) check('1', QValidator.Intermediate) def test_ufo_range_validator(): def check(validator, input_str, expected): assert validator.validate(input_str, len(input_str))[0] == expected # Integer validator = UfoRangeValidator(num_items=3, is_float=False) check(validator, ',,', QValidator.Intermediate) check(validator, ' ,,', QValidator.Intermediate) check(validator, '1,1,', QValidator.Intermediate) check(validator, ',1,', QValidator.Intermediate) check(validator, '1,-2,3', QValidator.Acceptable) check(validator, '1,1.0,1', QValidator.Invalid) check(validator, '-1,s,-1', QValidator.Invalid) check(validator, '1,1,1,1', QValidator.Invalid) check(validator, '1,1,1,', QValidator.Invalid) # Float validator = UfoRangeValidator(num_items=3, is_float=True) check(validator, ',,', QValidator.Intermediate) check(validator, ' ,,', QValidator.Intermediate) check(validator, '.,,', QValidator.Intermediate) check(validator, '.e,,', QValidator.Intermediate) check(validator, '.e-,,', QValidator.Intermediate) check(validator, '.e+,,', QValidator.Intermediate) check(validator, '1.0e,,', QValidator.Intermediate) check(validator, '1.0e+,,', QValidator.Intermediate) check(validator, '1.0e-,,', QValidator.Intermediate) check(validator, '1e,,', QValidator.Intermediate) check(validator, '1e+,,', QValidator.Intermediate) check(validator, '1e-,,', QValidator.Intermediate) check(validator, '.1e,,', QValidator.Intermediate) check(validator, '.1e+,,', QValidator.Intermediate) check(validator, '.1e-,,', QValidator.Intermediate) check(validator, '1,1,1', QValidator.Acceptable) check(validator, '-1,1,1', QValidator.Acceptable) check(validator, '1.,1.,1', QValidator.Acceptable) check(validator, '-1.,1.,1', QValidator.Acceptable) check(validator, '1.0e1,1.0,1', QValidator.Acceptable) check(validator, '1.0e+1,1.0,1', QValidator.Acceptable) check(validator, '1.0e-1,1.0,1', QValidator.Acceptable) check(validator, '.1,1.0,1', QValidator.Acceptable) check(validator, '.1e-1,1.0,1', QValidator.Acceptable) check(validator, '.1e+1,1.0,1', QValidator.Acceptable) check(validator, '.1e1,1.0,1', QValidator.Acceptable) check(validator, 'e,,', QValidator.Invalid) check(validator, 'e.,,', QValidator.Invalid) check(validator, '+e,,', QValidator.Invalid) check(validator, '-e,,', QValidator.Invalid) check(validator, '+e.,,', QValidator.Invalid) check(validator, '-e.,,', QValidator.Invalid) check(validator, '1+,,', QValidator.Invalid) check(validator, '1-,,', QValidator.Invalid) check(validator, 'gfd,1,3', QValidator.Invalid) def test_view_item_init(qtbot): def get(inst): return inst.widget.text() def set(inst, value): inst.widget.setText(value) def on_changed(vit): vit.change_called = True ViewItem.get = get ViewItem.set = set edit = QLineEdit() qtbot.addWidget(edit) vit = ViewItem(edit, default_value='foo', tooltip='tooltip') edit.textEdited.connect(vit.on_changed) assert vit.widget.toolTip() == 'tooltip' assert vit.widget.text() == 'foo' check_property_changed_emit(qtbot, vit, 'fooa', qtbot.keyClick, (edit, 'a')) def test_check_box_view_item(qtbot): assert CheckBoxViewItem(checked=True).get() vit = CheckBoxViewItem(checked=False, tooltip='tooltip') assert vit.widget.toolTip() == 'tooltip' assert not vit.get() check_property_changed_emit(qtbot, vit, True, qtbot.mouseClick, (vit.widget, Qt.LeftButton), show=True) def test_combo_box_view_item(qtbot): items = ['a', 'b', 'c'] vit = ComboBoxViewItem(items, default_value='b', tooltip='tooltip') assert vit.widget.toolTip() == 'tooltip' assert vit.get() == 'b' check_property_changed_emit(qtbot, vit, 'c', qtbot.keyClick, (vit.widget, 'c')) def test_qline_edit_view_item(qtbot): vit = QLineEditViewItem(default_value='foo', tooltip='tooltip') assert vit.widget.toolTip() == 'tooltip' assert vit.get() == 'foo' check_property_changed_emit(qtbot, vit, 'fooc', qtbot.keyClick, (vit.widget, 'c')) def test_number_qline_edit_view_item(qtbot): with pytest.raises(ValueError): NumberQLineEditViewItem(-100, 100, default_value=1000) with pytest.raises(ValueError): NumberQLineEditViewItem(-100, 100, default_value=-1000) vit = NumberQLineEditViewItem(-100., 100., default_value=0., tooltip='tooltip') assert vit.widget.toolTip().startswith('tooltip') assert vit.get() == 0 # is 0.0, after key click "1" will be 0.01 check_property_changed_emit(qtbot, vit, 0.01, qtbot.keyClick, (vit.widget, '1')) def test_int_qline_edit_view_item(qtbot): with pytest.raises(ValueError): IntQLineEditViewItem(-100, 100, default_value=1000) with pytest.raises(ValueError): IntQLineEditViewItem(-100, 100, default_value=-1000) vit = IntQLineEditViewItem(-100, 100, default_value=0, tooltip='tooltip') assert vit.widget.toolTip().startswith('tooltip') assert vit.get() == 0 # is 0, after key click "1" will be 01, thus 1 check_property_changed_emit(qtbot, vit, 1, qtbot.keyClick, (vit.widget, '1')) def test_range_edit_view_item(qtbot): vit = RangeQLineEditViewItem(default_value=[1.0, 2.0, 3.0], tooltip='tooltip') assert vit.widget.toolTip().startswith('tooltip') assert vit.get() == [1.0, 2.0, 3.0] # Last is 3.0, after key click "1" will be 3.01 check_property_changed_emit(qtbot, vit, [1.0, 2.0, 3.01], qtbot.keyClick, (vit.widget, '1')) class TestPropertyView: def test_init(self, qtbot, property_view): assert len(property_view.property_names) > 0 # Defaults must pass PropertyView() def test_get_property(self, qtbot, property_view): assert property_view.get_property('int') == 10 def test_set_property(self, qtbot, property_view): property_view.set_property('int', 50) assert property_view.get_property('int') == 50 def test_on_property_changed(self, qtbot, property_view): widget = property_view._properties['int'].view_item.widget qtbot.addWidget(widget) qtbot.keyClick(widget, '0') assert property_view.get_property('int') == 100 def test_is_property_visible(self, qtbot, property_view): assert property_view.is_property_visible('int') def test_set_property_visible(self, qtbot, property_view): visible = not property_view.is_property_visible('int') property_view.set_property_visible('int', visible) assert property_view.is_property_visible('int') == visible def test_restore_properties(self, qtbot, property_view): props = property_view.export_properties() property_view.set_property('int', props['int'][0] + 1) property_view.restore_properties(props) assert property_view.get_property('int') == props['int'][0] def test_export_properties(self, qtbot, property_view): props = property_view.export_properties() assert 'int' in props assert props['int'][0] == property_view.get_property('int') assert props['int'][1] == property_view.is_property_visible('int') class TestMultiPropertyView: def test_init(self, qtbot, multi_property_view): assert len(list(iter(multi_property_view))) == 2 def test_getitem(self, qtbot, multi_property_view, nodes): assert multi_property_view['cpm'] == nodes['cpm'].model def test_contains(self, qtbot, multi_property_view): assert 'cpm' in multi_property_view assert 'foo' not in multi_property_view def test_iter(self, qtbot, multi_property_view): assert set(list(iter(multi_property_view))) == set(['cpm', 'Read']) def test_export_groups(self, qtbot, multi_property_view): state = multi_property_view.export_groups() multi_property_view.set_group_visible('Read', False) assert state['Read']['model']['caption'] == 'Read' assert not state['Read']['visible'] def test_restore_groups(self, qtbot, multi_property_view, nodes): multi_property_view['Read']['number'] = 100 state = multi_property_view.export_groups() multi_property_view['Read']['number'] = 1000 multi_property_view.restore_groups(state) assert multi_property_view['Read']['number'] def test_set_group_visible(self, qtbot, multi_property_view): visible = not multi_property_view.is_group_visible('cpm') multi_property_view.set_group_visible('cpm', visible) assert multi_property_view.is_group_visible('cpm') == visible def test_is_group_visible(self, qtbot, multi_property_view): assert multi_property_view.is_group_visible('cpm') assert not multi_property_view.is_group_visible('Read') class TestUfoModel: def test_init(self): model = UfoModel() assert model.caption == model.base_caption def test_restore(self): model = UfoModel() state = {'caption': 'foo'} old_caption = model.caption model.restore(state, restore_caption=False) assert model.caption == old_caption model.restore(state, restore_caption=True) assert model.caption == 'foo' # 'caption' not in state, the old one must be preserved model = UfoModel() old_caption = model.caption model.restore({}, restore_caption=True) assert model.caption == old_caption def save(self): model = UfoModel() model.caption = 'foo' assert model.save()['caption'] == 'foo' class TestPropertyModel: def test_init(self, qtbot): PropertyModel() model = DummyPropertyModel() # make_properties must be called assert set(model.properties) == set(make_properties().keys()) def test_getitem(self, qtbot): model = DummyPropertyModel() model['int'] with pytest.raises(KeyError): model['foo'] def test_setitem(self, qtbot): model = DummyPropertyModel() model['int'] = 132 assert model['int'] == 132 def test_contains(self, qtbot): model = DummyPropertyModel() assert 'int' in model assert 'foo' not in model def test_iter(self, qtbot): model = DummyPropertyModel() assert set(iter(model)) == set(make_properties().keys()) def test_on_property_changed(self, qtbot): def callback(model, name, value): self.called_name = name self.called_value = value model = DummyPropertyModel() model.property_changed.connect(callback) widget = model._view._properties['int'].view_item.widget qtbot.addWidget(widget) qtbot.keyClick(widget, '0') assert self.called_value == model['int'] assert self.called_name == 'int' def test_make_properties(self, qtbot): props = DummyPropertyModel().make_properties() assert props.keys() == make_properties().keys() assert PropertyModel().make_properties() == {} def test_copy_properties(self, qtbot): model = DummyPropertyModel() model['int'] = 123 visible = not model._view.is_property_visible('int') model._view.set_property_visible('int', visible) properties = model.copy_properties() # It has to be a deep copy, so changing the model properties cannot affect the copy model['int'] = 12 model._view.set_property_visible('int', not visible) assert properties['int'][0].get() == 123 assert properties['int'][1] == visible def test_embedded_widget(self, qtbot): assert PropertyModel().embedded_widget() is None assert isinstance(DummyPropertyModel().embedded_widget(), PropertyView) def test_restore(self, qtbot): model = DummyPropertyModel() state = model.save() old_value = model['int'] old_caption = model.caption visible = not model._view.is_property_visible('int') model['int'] = old_value + 1 model._view.set_property_visible('int', visible) model.caption = 'Foo' model.restore(state, restore_caption=False) assert model['int'] == old_value assert model._view.is_property_visible('int') == (not visible) assert model.caption == 'Foo' model.restore(state, restore_caption=True) assert model.caption == old_caption def test_save(self, qtbot): model = DummyPropertyModel() old_value = model['int'] visible = not model._view.is_property_visible('int') model['int'] = old_value + 1 model._view.set_property_visible('int', visible) model.caption = 'Foo' state = model.save() assert state['properties']['int'][0] == old_value + 1 assert state['properties']['int'][1] == visible assert state['caption'] == 'Foo' class TestUfoTaskModel: def test_init(self, qtbot): model = UfoTaskModel('flat-field-correct') assert model.properties # A task doesn't need any special treatment by default assert not model.expects_multiple_inputs assert not model.can_split_gpu_work assert not model.needs_fixed_scheduler def test_make_properties(self, qtbot): model = UfoTaskModel('flat-field-correct') # Config takes effect assert not model._view.is_property_visible('dark-scale') def test_create_ufo_task(self, qtbot): model = UfoTaskModel('flat-field-correct') model['dark-scale'] = 12.3 task = model.create_ufo_task() assert task.props.dark_scale == pytest.approx(12.3) def test_uses_gpu(self, qtbot): model = UfoTaskModel('flat-field-correct') assert model.uses_gpu model = UfoTaskModel('read') assert not model.uses_gpu def test_get_ufo_model_class(qtbot): # flat correction is a fairly complicated task to test task_name = 'flat-field-correct' model_cls = get_ufo_model_class(task_name) # Model class attributes assert model_cls.name == 'flat_field_correct' model = model_cls() # Model instance attributes assert model.num_ports['input'] == 3 assert model.num_ports['output'] == 1 assert model.port_caption['input'][0] == 'radios' assert model.port_caption['input'][1] == 'darks' assert model.port_caption['input'][2] == 'flats' assert model.port_caption['output'][0] == '' class TestBaseCompositeModel: def test_init(self, qtbot, monkeypatch, composite_model, scene): # cpm has 1 input and 2 outputs (read and pad are not connected) and average has 1 input and # 1 output, but cpm is connected with average, which reduces both port types by 1 assert composite_model.num_ports['input'] == 1 assert composite_model.num_ports['output'] == 2 for port_type in ['input', 'output']: for i in range(composite_model.num_ports[port_type]): submodel, j = composite_model.get_model_and_port_index(port_type, i) subcaption = submodel.port_caption[port_type][j] if subcaption: subcaption = ':' + subcaption assert (composite_model.port_caption[port_type][i] == submodel.caption + subcaption) assert composite_model._view # num-inputs must take effect monkeypatch.setattr(QInputDialog, "getInt", lambda *args, **kwargs: (2, True)) monkeypatch.setattr(QInputDialog, "getText", lambda *args, **kwargs: ('with-pr', True)) node = scene.create_node(scene.registry.create('retrieve_phase')) node.graphics_object.setSelected(True) node = scene.create_composite() assert node.model.get_model_from_path(['Retrieve Phase']).num_ports['input'] == 2 # and it must not affect default registry creators kwargs = scene.registry.registered_model_creators()['retrieve_phase'][1] assert 'num_inputs' not in kwargs def test_getitem(self, qtbot, composite_model, nodes): assert composite_model['cpm'] assert composite_model['Average'] with pytest.raises(KeyError): composite_model['foo'] def test_contains(self, qtbot, composite_model, nodes): assert 'cpm' in composite_model assert 'foo' not in composite_model def test_iter(self, qtbot, composite_model): assert set(list(iter(composite_model))) == set(['cpm', 'Average']) def test_get_descendant_graph(self, qtbot, monkeypatch, composite_model, nodes): graph = composite_model.get_descendant_graph() cpm = composite_model['cpm'] assert (composite_model, cpm) in graph.edges assert (composite_model, composite_model['Average']) in graph.edges assert (cpm, cpm['Read']) in graph.edges assert (cpm, cpm['Pad']) in graph.edges with pytest.raises(ValueError): composite_model.get_descendant_graph(in_subwindow=True) # Subwindow editing composite_model.edit_in_window() qtbot.addWidget(composite_model._other_view) graph = composite_model.get_descendant_graph(in_subwindow=True) cpm = composite_model._window_nodes['cpm'].model average = composite_model._window_nodes['Average'].model assert (composite_model, cpm) in graph.edges assert (composite_model, average) in graph.edges assert (cpm, cpm['Read']) in graph.edges assert (cpm, cpm['Pad']) in graph.edges composite_model._other_view.close() # Create outer composite with foobar inside, get_descendant_graph with in_subwindow=True # when outer is being edited and foobar not must return outer subwindow models and foobar's # internal models scene = create_scene(qtbot, composite_model._registry) inner = scene.create_node(composite_model.__class__) monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('outer', True)) inner.graphics_object.setSelected(True) outer = scene.create_composite().model outer.edit_in_window() qtbot.addWidget(outer._other_view) graph = outer.get_descendant_graph(in_subwindow=True) inner = outer._window_nodes['foobar'].model assert (outer, inner) in graph.edges assert (inner, inner['Average']) in graph.edges assert (inner['cpm'], inner['cpm']['Read']) in graph.edges outer._other_view.close() def test_contains_path(self, qtbot, composite_model, nodes): assert composite_model.contains_path(['Average']) assert composite_model.contains_path(['cpm']) assert composite_model.contains_path(['cpm', 'Read']) assert not composite_model.contains_path(['cpm', 'Read 2']) assert not composite_model.contains_path(['foo']) def test_get_model_from_path(self, qtbot, composite_model, nodes): assert composite_model.get_model_from_path(['cpm', 'Read']) with pytest.raises(KeyError): composite_model.get_model_from_path(['foo']) def test_is_model_inside(self, qtbot, composite_model, nodes): model = composite_model.get_model_from_path(['cpm']) assert composite_model.is_model_inside(model) model = composite_model.get_model_from_path(['cpm', 'Read']) assert composite_model.is_model_inside(model) assert not composite_model.is_model_inside(nodes['read_2'].model) def test_get_path_from_model(self, qtbot, composite_model, nodes): cpm = composite_model['cpm'] path = composite_model.get_path_from_model(cpm) assert path == [composite_model, cpm] path = composite_model.get_path_from_model(cpm['Read']) assert path == [composite_model, cpm, cpm['Read']] model = composite_model['cpm']['Read'] path = composite_model.get_path_from_model(model) assert path == [composite_model, cpm, model] with pytest.raises(KeyError): composite_model.get_path_from_model(nodes['read_2'].model) def test_leaf_paths(self, qtbot, composite_model, nodes): leaves = composite_model.get_leaf_paths(in_subwindow=False) cpm = composite_model['cpm'] assert len(leaves) == 3 assert [composite_model, cpm, cpm['Read']] in leaves assert [composite_model, cpm, cpm['Pad']] in leaves assert [composite_model, composite_model['Average']] in leaves def test_set_property_links_model(self, qtbot, link_model, composite_model): composite_model.property_links_model = link_model assert composite_model.property_links_model == link_model # The property links model must be set also for children assert composite_model['cpm'].property_links_model == link_model def test_get_outside_port(self, qtbot, composite_model): # There is one input corresponding to cpm's pad model cpm = composite_model.get_model_from_path(['cpm']) pad_index = cpm.get_outside_port('Pad', 'input', 0)[1] composite_model.get_outside_port('cpm', 'input', pad_index) # and two outputs: cpm's read and average read_index = cpm.get_outside_port('Read', 'output', 0)[1] composite_model.get_outside_port('cpm', 'output', read_index) composite_model.get_outside_port('Average', 'output', 0) def test_get_model_and_port_index(self, qtbot, composite_model): model, index = composite_model.get_model_and_port_index('input', 0) cpm = composite_model.get_model_from_path(['cpm']) # There is only one input: Pad. Get it's internal cpm's index and compare with what the # outer composite object gives. pad_index = cpm.get_outside_port('Pad', 'input', 0)[1] assert model == cpm assert index == pad_index # There are two output ports, one from cpm's read model and one from average average = composite_model.get_model_from_path(['Average']) # Get read index from the cpm inside the composite_model and not from the cpm in the 'nodes' # fixsture because those are not the same instance and the read output index might be # different in those two instances because the ports are dictionaries read_index = cpm.get_outside_port('Read', 'output', 0)[1] outputs = [composite_model.get_model_and_port_index('output', 0)] outputs.append(composite_model.get_model_and_port_index('output', 1)) assert (cpm, read_index) in outputs assert (average, 0) in outputs def test_embedded_widget(self, qtbot, composite_model): assert isinstance(composite_model.embedded_widget(), MultiPropertyView) def test_restore(self, qtbot, composite_model): state = composite_model.save() old_value = composite_model['cpm']['Pad']['width'] old_caption = composite_model.caption visible = not composite_model._view.is_group_visible('cpm') composite_model['cpm']['Pad']['width'] = old_value + 1 composite_model._view.set_group_visible('cpm', visible) composite_model.caption = 'Foo' composite_model.restore(state, restore_caption=False) assert composite_model['cpm']['Pad']['width'] == old_value assert composite_model._view.is_group_visible('cpm') == (not visible) assert composite_model.caption == 'Foo' composite_model.restore(state, restore_caption=True) assert composite_model.caption == old_caption conn = composite_model._connections[0] assert [[conn.from_unique_name, conn.from_port_index, conn.to_unique_name, conn.to_port_index]] == state['connections'] def test_restore_links(self, qtbot, nodes): def check_links(node, link_model): assert link_model.rowCount() == 1 assert link_model.columnCount() == 3 assert link_model.find_items((node.model['cpm']['Read'], 'number'), (MODEL_ROLE, PROPERTY_ROLE)) assert link_model.find_items((node.model['cpm']['Pad'], 'height'), (MODEL_ROLE, PROPERTY_ROLE)) assert link_model.find_items((node.model['Average'], 'number'), (MODEL_ROLE, PROPERTY_ROLE)) assert not link_model.find_items((node.model['cpm']['Read'], 'height'), (MODEL_ROLE, PROPERTY_ROLE)) scene, node = make_composite_node_in_scene(qtbot, nodes) link_model = scene.property_links_model link_model.add_item(node, node.model['cpm']['Read'], 'number', 0, 0) link_model.add_item(node, node.model['cpm']['Pad'], 'height', 0, 1) link_model.add_item(node, node.model['Average'], 'number', 0, 2) # Set links to the newly created links node.model._links = node.model.save()['links'] # Link model has to have the exact same entries as before link_model.clear() node.model.restore_links(node) check_links(node, link_model) # Second time doesn't add the same links twice node.model.restore_links(node) check_links(node, link_model) def test_save(self, qtbot, nodes): scene, node = make_composite_node_in_scene(qtbot, nodes) link_model = scene.property_links_model cpm = node.model['cpm'] link_model.add_item(node, node.model['cpm']['Read'], 'number', 0, 0) link_model.add_item(node, node.model['cpm']['Pad'], 'height', 0, 1) link_model.add_item(node, node.model['Average'], 'number', 0, 2) old_value = node.model['cpm']['Pad']['width'] visible = not node.model._view.is_group_visible('cpm') node.model['cpm']['Pad']['width'] = old_value + 1 node.model._view.set_group_visible('cpm', visible) node.model.caption = 'Foo' state = node.model.save() cpm_models_state = state['models']['cpm']['model']['models'] assert state['models']['cpm']['visible'] == visible assert cpm_models_state['Pad']['model']['properties']['width'][0] == old_value + 1 assert state['caption'] == 'Foo' cpm = node.model.get_model_from_path(['cpm']) pad_index = cpm.get_outside_port('Pad', 'output', 0)[1] assert state['connections'] == [['cpm', pad_index, 'Average', 0]] # Property links links = link_model.get_model_links([path[-1] for path in node.model.get_leaf_paths()]) links = [[str_path[1:] for str_path in row] for row in links.values()] saved = node.model.save()['links'] # One row assert len(saved) == len(links) == 1 # All linked paths must be saved for str_path in saved[0]: assert str_path in links[0] def test_on_connection_created(self, qtbot, composite_model): composite_model.edit_in_window() qtbot.addWidget(composite_model._other_view) for node in composite_model._other_scene.nodes.values(): if node.model.caption == 'cpm': read_index = node.model.get_outside_port('Read', 'output', 0)[1] pad_index = node.model.get_outside_port('Pad', 'input', 0)[1] output_port = node['output'][read_index] input_port = node['input'][pad_index] num_connections = len(composite_model._other_scene.connections) composite_model._other_scene.create_connection(output_port, input_port) # No new connections allowed assert len(composite_model._other_scene.connections) == num_connections composite_model._other_view.close() def test_on_connection_deleted(self, qtbot, composite_model): composite_model.edit_in_window() qtbot.addWidget(composite_model._other_view) num_connections = len(composite_model._other_scene.connections) composite_model._other_scene.delete_connection(composite_model._other_scene.connections[0]) # No connection deletions assert len(composite_model._other_scene.connections) == num_connections composite_model._other_view.close() def test_double_clicked(self, qtbot, composite_model): composite_model.double_clicked(None) qtbot.addWidget(composite_model._other_view) assert composite_model.is_editing and composite_model._other_view is not None def test_on_other_scene_double_clicked(self, qtbot, composite_model): composite_model.double_clicked(None) qtbot.addWidget(composite_model._other_view) for node in composite_model._other_scene.nodes.values(): if node.model.caption == 'cpm': node.model.double_clicked(composite_model._other_view) qtbot.addWidget(node.model._other_view) assert composite_model.is_editing and composite_model._other_view is not None break def test_expand_into_graph(self, qtbot, composite_model): import networkx as nx graph = nx.MultiDiGraph() composite_model.expand_into_graph(graph) src, dst, ports = list(graph.edges.data())[0] conn = composite_model._connections[0] gt = [conn.from_unique_name, conn.from_port_index, conn.to_unique_name, conn.to_port_index] conn_graph = [src.caption, ports['output'], dst.caption, ports['input']] assert conn_graph == gt def test_add_slave_links(self, qtbot, monkeypatch, nodes): def crosscheck(model, root_model, property_name, link_model): key = (model, property_name) root_key = (root_model, property_name) assert link_model._silent[key] == root_key assert key in link_model._slaves[root_key] scene, node = make_composite_node_in_scene(qtbot, nodes) link_model = scene.property_links_model link_model.add_item(node, node.model['cpm']['Read'], 'number', 0, 0) link_model.add_item(node, node.model['cpm']['Pad'], 'height', 0, 1) link_model.add_item(node, node.model['Average'], 'number', 0, 2) # Not being edited, nothing registered node.model.add_slave_links() assert link_model._silent == {} node.model.edit_in_window() qtbot.addWidget(node.model._other_view) # Standard editing setup assert hasattr(node.model, '_other_scene') assert hasattr(node.model, '_other_view') assert not node.model._other_scene.allow_node_creation assert not node.model._other_scene.allow_node_deletion # Test foobar's subwindow, registering model is cpm and its internal models must be linked crosscheck(node.model._window_nodes['cpm'].model['Read'], node.model['cpm']['Read'], 'number', link_model) crosscheck(node.model._window_nodes['cpm'].model['Pad'], node.model['cpm']['Pad'], 'height', link_model) crosscheck(node.model._window_nodes['Average'].model, node.model['Average'], 'number', link_model) # Test foobar's subwindow and cpm's subwindow, cpm and also its models in the subwindow must # be linked cpm = node.model._window_nodes['cpm'].model cpm.edit_in_window() qtbot.addWidget(cpm._other_view) assert cpm.window_parent == node.model crosscheck(cpm._window_nodes['Read'].model, node.model['cpm']['Read'], 'number', link_model) crosscheck(cpm._window_nodes['Pad'].model, node.model['cpm']['Pad'], 'height', link_model) # Add one more composite layer, outer->foobar->cpm->Model, both registering model and its # window_parent must be jinked node.model._other_view.close() assert cpm._other_view is None monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('outermost', True)) node.graphics_object.setSelected(True) outer = scene.create_composite() outer.model.edit_in_window() qtbot.addWidget(outer.model._other_view) node_sub = outer.model._window_nodes['foobar'] node_sub.model.edit_in_window() qtbot.addWidget(node_sub.model._other_view) cpm = node_sub.model._window_nodes['cpm'].model cpm.edit_in_window() qtbot.addWidget(cpm._other_view) crosscheck(node_sub.model._window_nodes['cpm'].model['Read'], outer.model['foobar']['cpm']['Read'], 'number', link_model) crosscheck(node_sub.model._window_nodes['cpm'].model['Pad'], outer.model['foobar']['cpm']['Pad'], 'height', link_model) crosscheck(node_sub.model._window_nodes['Average'].model, outer.model['foobar']['Average'], 'number', link_model) crosscheck(cpm._window_nodes['Read'].model, outer.model['foobar']['cpm']['Read'], 'number', link_model) crosscheck(cpm._window_nodes['Pad'].model, outer.model['foobar']['cpm']['Pad'], 'height', link_model) cpm._other_view.close() node_sub.model._other_view.close() outer.model._other_view.close() def test_edit_in_window(self, qtbot, nodes): composite_model = nodes['cpm'].model link_model = composite_model.property_links_model populate_link_model(link_model, nodes) composite_model.edit_in_window() qtbot.addWidget(composite_model._other_view) assert hasattr(composite_model, '_other_scene') assert hasattr(composite_model, '_other_view') assert not composite_model._other_scene.allow_node_creation assert not composite_model._other_scene.allow_node_deletion # Silent must have been added with root cpm's read model assert (list(link_model._slaves.keys())[0] == (composite_model.get_model_from_path(['Read']), 'y')) # Subcomposites must link to their parent models scene, node = make_composite_node_in_scene(qtbot, nodes) node.model.edit_in_window() qtbot.addWidget(node.model._other_view) assert node.model._window_nodes['cpm'].model.window_parent == node.model node.model._other_view.close() composite_model._other_view.close() def test_view_close_event(self, qtbot, nodes): composite_model = nodes['cpm'].model link_model = composite_model.property_links_model populate_link_model(link_model, nodes) composite_model.edit_in_window() qtbot.addWidget(composite_model._other_view) for node in composite_model._other_scene.nodes.values(): if node.model.caption == 'Read': widget = node.model.embedded_widget()._properties['y'].view_item.widget qtbot.addWidget(node.model.embedded_widget()) qtbot.keyClicks(widget, '11') else: # Pad node.model['width'] += 10 # Linked models must be updated immediately assert composite_model['Read']['y'] == 11 assert nodes['read'].model['number'] == 11 assert nodes['read_2'].model['height'] == 11 composite_model._other_view.close() # Original models in the composite must be updated after close assert composite_model['Pad']['width'] == 10 # Silent model must be removed (it was the only one, so test for {} is sufficient) assert link_model._slaves == {} assert link_model._silent == {} def test_expand_into_scene(self, qtbot, monkeypatch): def get_int(*args, **kwargs): return self.get_int_return monkeypatch.setattr(QInputDialog, "getInt", get_int) nodes = {} registry = get_filled_registry() scene = create_scene(qtbot, registry) # Composite node for name in ['read', 'pad']: model_cls = registry.create(name) node = scene.create_node(model_cls) node.graphics_object.setSelected(True) monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm', True)) nodes['cpm'] = scene.create_composite() nodes['cpm'].graphics_object.setSelected(False) model_cls = registry.create('average') nodes['average'] = scene.create_node(model_cls) self.get_int_return = (2, True) model_cls = registry.create('retrieve_phase') nodes['retrieve_phase'] = scene.create_node(model_cls) # Add null node to create an outside connection null_cls = registry.create('null') null_node = scene.create_node(null_cls) # Make a property link scene.property_links_model.add_item(nodes['cpm'], nodes['cpm'].model['Read'], 'number', 0, 0) scene.property_links_model.add_item(nodes['cpm'], nodes['cpm'].model['Pad'], 'width', 0, 1) # Export composite and reload it so that it remembers the links (important for testing of # adding property link duplicates) cpm_cls_with_links = get_composite_model_classes_from_json(nodes['cpm'].model.save())[0] registry.register_model(cpm_cls_with_links, category='Composites', registry=registry) scene.remove_node(nodes['cpm']) nodes['cpm'] = scene.create_node(registry.create('cpm')) # Outer composite node has inside: read, pad, average; pad and average are connected # read and pad are encapsulated in an internal composite cpm pad_index = nodes['cpm'].model.get_outside_port('Pad', 'output', 0)[1] scene.create_connection(nodes['cpm']['output'][pad_index], nodes['average']['input'][0]) nodes['cpm'].graphics_object.setSelected(True) nodes['average'].graphics_object.setSelected(True) nodes['retrieve_phase'].graphics_object.setSelected(True) monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('foobar', True)) scene.create_composite() composite_node = scene.selected_nodes()[0] composite_model = composite_node.model # Create outside connection from outer composite's average to null port_null = null_node['input'][0] # average_index = nodes['cpm'].model.get_outside_port('Pad', 'output', 0)[1] # Get the average index dynamically because it might be mapped to a different output port # every time (reader in cpm makes another output) average_index = composite_model.get_outside_port('Average', 'output', 0)[1] port_composite = composite_node['output'][average_index] scene.create_connection(port_composite, port_null) # Change some property to see if it persists after expansion composite_model['cpm']['Read']['number'] = 123 # Make sure the nested num-inputs takes effect, i.e. QInputDialog.getInt invocation must # fail the test self.get_int_return = (None, False) composite_model.expand_into_scene(scene, composite_node) # Nodes must be there assert (set([node.model.caption for node in scene.nodes.values()]) == set(['Null', 'Average', 'Retrieve Phase', 'cpm'])) # num-inputs took effect for node in scene.nodes.values(): if node.model.caption == 'Retrieve Phase': assert node.model.num_ports['input'] == 2 break # Changed properties must be there for node in scene.nodes.values(): if node.model.caption == 'cpm': assert node.model['Read']['number'] == 123 break # Connections must be preserved for connection in scene.connections: if connection.get_node('output').model.caption == 'cpm': # Internal composite connection Pad -> Average must be there assert connection.get_node('input').model.caption == 'Average' cpm_index = connection.get_port_index('output') cpm_model = connection.get_node('output').model assert cpm_model.get_model_and_port_index('output', cpm_index)[0].caption == 'Pad' else: # Outside connection Average -> Null must be there assert connection.get_node('input').model.caption == 'Null' # Property links must be there assert scene.property_links_model.rowCount() == 1 # Original composite node must be gone assert composite_node not in scene.nodes.values() def test_get_composite_model_class(qtbot, nodes): model_cls = make_composite_model_class(nodes) with pytest.raises(AttributeError): # Registry must be provided model_cls() # Name must be provided with pytest.raises(UfoModelError): make_composite_model_class(nodes, name='') with pytest.raises(UfoModelError): make_composite_model_class(nodes, name=None) class TestUfoGeneralBackprojectModel: def test_init(self, general_backproject): assert general_backproject.num_ports['input'] == 1 assert general_backproject.num_ports['output'] == 1 assert general_backproject.needs_fixed_scheduler is True assert general_backproject.can_split_gpu_work is True def test_make_properties(self, general_backproject): props = general_backproject.make_properties() assert 'slice-memory-coeff' in props def test_split_gpu_work(self, general_backproject): from gi.repository import Ufo resources = Ufo.Resources() gpus = resources.get_gpu_nodes() general_backproject['x-region'] = [-100., 100., 1.] general_backproject['y-region'] = [-100., 100., 1.] general_backproject['region'] = [-100., 100., 1.] if gpus: # Normal operation assert general_backproject.split_gpu_work(gpus) # Wrong input general_backproject['x-region'] = [-100., -200., 1.] with pytest.raises(UfoModelError): general_backproject.split_gpu_work(gpus) general_backproject['x-region'] = [-100., 100., 1.] general_backproject['y-region'] = [-100., -200., 1.] with pytest.raises(UfoModelError): general_backproject.split_gpu_work(gpus) general_backproject['y-region'] = [-100., 100., 1.] general_backproject['region'] = [-100., -200., 1.] with pytest.raises(UfoModelError): general_backproject.split_gpu_work(gpus) general_backproject['region'] = [-100., 100., 1.] def test_create_ufo_task(self, general_backproject): general_backproject['region'] = [-100., 100., 1.] ufo_task = general_backproject.create_ufo_task(region=None) assert ufo_task.props.region == pytest.approx(general_backproject['region']) ufo_task = general_backproject.create_ufo_task(region=[-10., 10., 1.]) assert ufo_task.props.region == pytest.approx([-10., 10., 1.]) class TestUfoReadModel: def test_init(self, read_model): assert read_model.num_ports['input'] == 0 assert read_model.num_ports['output'] == 1 def test_double_clicked(self, qtbot, monkeypatch, read_model): from tofu.flow.filedirdialog import FileDirDialog monkeypatch.setattr(FileDirDialog, "exec_", lambda *args: 1) monkeypatch.setattr(FileDirDialog, "selectedFiles", lambda *args: ['foobarbaz']) read_model.double_clicked(None) assert read_model['path'] == 'foobarbaz' class TestUfoVaryingInputModel: def test_init(self, qtbot, monkeypatch): def get_int(*args, **kwargs): self.called = True return (1, True) # No number of inputs specified, dialog needs to pop up self.called = False monkeypatch.setattr(QInputDialog, 'getInt', get_int) model = UfoVaryingInputModel('opencl', num_inputs=None) qtbot.addWidget(model.embedded_widget()) assert self.called assert model.num_ports['input'] == 1 # e.g. opencl task can have multiple inputs model = UfoVaryingInputModel('opencl', num_inputs=4) qtbot.addWidget(model.embedded_widget()) assert model.num_ports['input'] == 4 assert len(model.data_type['input']) == 4 assert len(model.port_caption['input']) == 4 assert len(model.port_caption_visible['input']) == 4 def test_save(self, qtbot): model = UfoVaryingInputModel('opencl', num_inputs=4) qtbot.addWidget(model.embedded_widget()) assert model.save()['num-inputs'] == 4 class TestUfoRetrievePhaseModel: def test_distance_input(self, qtbot): model = UfoRetrievePhaseModel(num_inputs=4) qtbot.addWidget(model.embedded_widget()) validator = model._view._properties['distance'].view_item.widget.validator() # Validator accepts only 4 values assert validator.validate('1,2,3,4', 0)[0] == QValidator.Acceptable assert validator.validate('1,2,3', 0)[0] == QValidator.Intermediate assert validator.validate('1,2,3,4,5', 0)[0] == QValidator.Invalid def test_multidistance_fixed_method(self, qtbot): def check(num_inputs): model = UfoRetrievePhaseModel(num_inputs=num_inputs) qtbot.addWidget(model.embedded_widget()) enabled = num_inputs == 1 assert model._view._properties['method'].view_item.widget.isEnabled() == enabled if not enabled: assert model['method'] == 'ctf_multidistance' assert model._view._properties['distance-x'].view_item.widget.isEnabled() == enabled assert model._view._properties['distance-y'].view_item.widget.isEnabled() == enabled check(1) check(2) class TestUfoWriteModel: def test_init(self, write_model): assert write_model.num_ports['input'] == 1 assert write_model.num_ports['output'] == 0 def test_double_clicked(self, monkeypatch, write_model): monkeypatch.setattr(QFileDialog, "getSaveFileName", lambda *args: ('foobarbaz', None)) write_model.double_clicked(None) assert write_model['filename'] == 'foobarbaz' def test_expects_multiple_inputs(self, write_model): write_model['filename'] = 'foo{region}bar' assert write_model.expects_multiple_inputs write_model['filename'] = 'foobar' assert not write_model.expects_multiple_inputs def test_setup_ufo_task(self, write_model): write_model['filename'] = '{region}' # Must pass ufo_task = write_model.create_ufo_task(region=[0, 1, 1]) # Must fail with pytest.raises(UfoModelError): write_model.create_ufo_task(region=None) assert ufo_task.props.filename == '0' write_model['filename'] = 'foo.tif' # Must pass ufo_task = write_model.create_ufo_task(region=None) # Must fail with pytest.raises(UfoModelError): write_model.create_ufo_task(region=[0, 1, 1]) assert ufo_task.props.filename == 'foo.tif' class TestUfoMemoryOutModel: def test_init(self, memory_out_model): assert memory_out_model.num_ports['input'] == 1 assert memory_out_model.num_ports['output'] == 1 def test_expects_multiple_inputs(self, memory_out_model): memory_out_model['number'] = '{region}' assert memory_out_model.expects_multiple_inputs memory_out_model['number'] = '1' assert not memory_out_model.expects_multiple_inputs def test_make_properties(self, memory_out_model): prop_names = {'width', 'height', 'depth', 'number'} assert prop_names == memory_out_model.make_properties().keys() def test_out_data(self, monkeypatch, memory_out_model): def slot(port_index): self.num_called += 1 self.data = memory_out_model.out_data(port_index) self.num_called = 0 memory_out_model['number'] = 10 shape = (int(memory_out_model['number']), memory_out_model['height'], memory_out_model['width']) memory_out_model.create_ufo_task() batch = memory_out_model._batches[0] memory_out_model.data_updated.connect(slot) batch.data[:] = 3 assert len(memory_out_model._batches) == 1 assert batch.data.shape == shape for i in range(shape[0]): batch._on_processed(None) # Called once per 3D array assert self.num_called == 1 # out_data has been set to the batch ouput np.testing.assert_almost_equal(self.data, 3) # Original data must have been freed assert memory_out_model._batches == [None] memory_out_model.reset_batches() # Multiple inputs def slot(port_index): # Append the first item in the current result self.called.append(memory_out_model.out_data(port_index)[0, 0, 0]) self.called = [] memory_out_model.data_updated.connect(slot) memory_out_model['number'] = '{region}' # Two parallel batches of four regions each for j in range(2): for i in range(4): memory_out_model.create_ufo_task(region=[0, 10, 1]) # Set batch data to its linearized index to make checking easy memory_out_model._batches[4 * j + i].data[:] = 4 * j + i # Out of order processing for batch_id in np.array([2, 0, 1, 3], dtype=int) + (4 * j): for e in range(10): memory_out_model._batches[batch_id]._on_processed(None) # All regions in the current paralell batch must have been processed assert memory_out_model._waiting_list == [] # Result must be in order np.testing.assert_almost_equal(self.called, np.arange(8)) # Original data must have been freed assert memory_out_model._batches == [None] * 8 def test_reset_batches(self, memory_out_model): memory_out_model.reset_batches() assert memory_out_model._batches == [] assert memory_out_model._waiting_list == [] assert memory_out_model._expecting_id == 0 assert memory_out_model._current_data is None def test_setup_ufo_task(self, memory_out_model): memory_out_model['number'] = '{region}' # Must pass memory_out_model.create_ufo_task(region=[0, 100, 1]) memory_out_model.create_ufo_task(region=[100, 200, 1]) assert len(memory_out_model._batches) == 2 # Must fail with pytest.raises(UfoModelError): memory_out_model.create_ufo_task(region=None) memory_out_model.reset_batches() memory_out_model['number'] = '100' # Must pass memory_out_model.create_ufo_task(region=None) # Must fail with pytest.raises(UfoModelError): memory_out_model.create_ufo_task(region=[0, 100, 1]) assert len(memory_out_model._batches) == 1 class TestImageViewerModel: def test_init(self, image_viewer_model): assert image_viewer_model.num_ports['input'] == 1 assert image_viewer_model.num_ports['output'] == 0 def test_double_clicked(self, qtbot, image_viewer_model): image_viewer_model.double_clicked(None) # No images, no pop up assert image_viewer_model._widget._pg_window is None image_viewer_model._widget.images = np.arange(1000).reshape(10, 10, 10) image_viewer_model.double_clicked(None) assert image_viewer_model._widget._pg_window.isVisible() qtbot.addWidget(image_viewer_model._widget._pg_window) # User closes, must re-open image_viewer_model._widget._pg_window.close() image_viewer_model.double_clicked(None) assert image_viewer_model._widget._pg_window.isVisible() def test_set_in_data(self, image_viewer_model): images = np.arange(1000).reshape(10, 10, 10) image_viewer_model.set_in_data(images, None) assert image_viewer_model._widget.images.shape == images.shape image_viewer_model.set_in_data(images, None) assert image_viewer_model._widget.images.shape == (20,) + images.shape[1:] # Images cannot be appended after reset is called, they must be set image_viewer_model.reset_batches() image_viewer_model.set_in_data(images, None) assert image_viewer_model._widget.images.shape == images.shape def test_reset_batches(self, image_viewer_model): image_viewer_model.reset_batches() assert image_viewer_model._reset def test_get_ufo_model_classes(): # All classes = list(get_ufo_model_classes()) assert classes # Blacklist assert 'read' not in [cls.name for cls in classes] # Selection assert len(list(get_ufo_model_classes(names=['pad']))) == 1 def test_get_composite_model_classes_from_json(qtbot, composite_model): classes = get_composite_model_classes_from_json(composite_model.save()) # First must be the bottom class, top class comes last assert [cls.name for cls in classes] == ['cpm', 'foobar'] def test_get_composite_model_classes(): # Just make sure this runs and the result is not empty assert get_composite_model_classes() ufo-kit-tofu-ed0e5bd/tofu/tests/test_flow_propertylinksmodels.py000066400000000000000000000472631521054151500255400ustar00rootroot00000000000000import pytest from qtpy.QtCore import QByteArray, QMimeData, QModelIndex from tofu.flow.propertylinksmodels import _get_string_path from tofu.flow.propertylinkswidget import _encode_mime_data from tofu.flow.util import MODEL_ROLE, NODE_ROLE, PROPERTY_ROLE from tofu.tests.flow_util import get_index_from_treemodel, populate_link_model def setup_silent(link_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] composite = nodes['cpm'] orig_key = (read.model, 'number') link_model.add_item(read, read.model, 'number', -1, -1) link_model.add_silent(composite.model['Read'], 'number', orig_key[0], orig_key[1]) link_model.add_silent(read_2.model, 'height', orig_key[0], orig_key[1]) # Put to 0 to make sure we are not lucky when checking if the links work composite.model['Read']['number'] = 0 read_2.model['height'] = 0 return orig_key class TestNodeTreeModel: def test_add_node(self, qtbot, node_model, nodes): # Unsupported model type not added node_model.add_node(nodes['image_viewer']) assert node_model.rowCount() == 0 # Supported model type (composite is handled in test_add_node) node_model.add_node(nodes['read']) assert node_model.rowCount() == 1 # Composite node_model.add_node(nodes['cpm']) item = node_model.findItems('cpm')[0] # Model contains composite node assert item.data(role=NODE_ROLE) == nodes['cpm'] # and it's children assert item.child(0).data(role=MODEL_ROLE) == nodes['cpm'].model['Pad'] assert item.child(1).data(role=MODEL_ROLE) == nodes['cpm'].model['Read'] # and their properties assert item.child(0).child(0).text() == sorted(nodes['cpm'].model['Pad'])[0] def test_remove_node(self, qtbot, node_model, nodes): node_model.add_node(nodes['cpm']) assert node_model.rowCount() == 1 node_model.remove_node(nodes['cpm']) assert node_model.rowCount() == 0 def test_set_nodes(self, qtbot, node_model, nodes): names = ['cpm', 'read'] subset = [nodes[key] for key in names] node_model.set_nodes(subset) for (i, key) in enumerate(names): assert node_model.item(i).data(role=NODE_ROLE) == nodes[key] def test_clear(self, qtbot, node_model, nodes): node_model.set_nodes(nodes.values()) assert node_model.rowCount() > 0 assert node_model.columnCount() > 0 node_model.clear() assert node_model.rowCount() == 0 assert node_model.columnCount() == 0 class TestPropertyLinksModel: def test_add_item(self, qtbot, link_model, nodes): read = nodes['read'] composite = nodes['cpm'] composite.model.property_links_model = link_model # Put to 0 to make sure we are not lucky below when checking if the links work composite.model['Read']['number'] = 0 # Items must be added link_model.add_item(read, read.model, 'number', -1, -1) item = link_model.item(0, 0) assert item.data(role=NODE_ROLE) == read assert item.data(role=MODEL_ROLE) == read.model assert item.data(role=PROPERTY_ROLE) == 'number' link_model.add_item(composite, composite.model['Read'], 'number', 0, -1) item = link_model.item(0, 1) assert item.data(role=NODE_ROLE) == composite assert item.data(role=MODEL_ROLE) == composite.model['Read'] assert item.data(role=PROPERTY_ROLE) == 'number' # Can't add one item twice with pytest.raises(ValueError): link_model.add_item(read, read.model, 'number', -1, -1) # Properties must be linked read.model['number'] = 100 read.model.property_changed.emit(read.model, 'number', read.model['number']) assert composite.model['Read']['number'] == read.model['number'] # When composite is being added, make sure the slave links are set up link_model.remove_item(link_model.find_items([composite], [NODE_ROLE])[0]) composite.model.edit_in_window() qtbot.addWidget(composite.model._other_view) link_model.add_item(composite, composite.model['Read'], 'number', 0, -1) key = (composite.model._window_nodes['Read'].model, 'number') root_key = (composite.model['Read'], 'number') assert link_model._slaves[root_key] == [key] assert link_model._silent[key] == root_key def test_remove_item(self, qtbot, link_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] composite = nodes['cpm'] link_model.add_item(read, read.model, 'number', -1, -1) link_model.add_item(read_2, read_2.model, 'number', 0, -1) link_model.add_silent(composite.model['Read'], 'number', read.model, 'number') # Properties must be connected at first read.model['number'] = 100 read.model.property_changed.emit(read.model, 'number', read.model['number']) assert read_2.model['number'] == read.model['number'] link_model.remove_item(link_model.indexFromItem(link_model.item(0, 0))) assert link_model.item(0, 0) is None assert link_model._silent == {} assert link_model._slaves == {} # Properties must be disconnected after removal read.model['number'] = 0 read.model.property_changed.emit(read.model, 'number', read.model['number']) # read_2 still at the old 100 assert read_2.model['number'] == 100 def test_contains(self, qtbot, link_model, nodes): composite = nodes['cpm'] link_model.add_item(composite, composite.model['Read'], 'number', 0, -1) assert link_model.item(0, 0).text() in link_model assert 'foo' not in link_model def test_clear(self, qtbot, link_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] composite = nodes['cpm'] link_model.add_item(read, read.model, 'number', -1, -1) link_model.add_item(read_2, read_2.model, 'number', 0, -1) link_model.add_silent(composite.model['Read'], 'number', read.model, 'number') link_model.clear() assert link_model.rowCount() == 0 assert link_model.columnCount() == 0 assert link_model._silent == {} assert link_model._slaves == {} def test_find_items(self, qtbot, link_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] # Empty model assert link_model.find_items([read.model], [MODEL_ROLE]) == [] link_model.add_item(read, read.model, 'number', -1, -1) # Not inside assert link_model.find_items([read_2.model], [MODEL_ROLE]) == [] # Inside assert (link_model.find_items([read.model], [MODEL_ROLE])[0].data(role=MODEL_ROLE) == read.model) # Model not inside, property not inside assert link_model.find_items((read_2.model, 'height'), (MODEL_ROLE, PROPERTY_ROLE)) == [] # Model inside, property not inside assert link_model.find_items((read.model, 'height'), (MODEL_ROLE, PROPERTY_ROLE)) == [] # Model not inside, property inside assert link_model.find_items((read_2.model, 'number'), (MODEL_ROLE, PROPERTY_ROLE)) == [] # Model inside, property inside item = link_model.find_items((read.model, 'number'), (MODEL_ROLE, PROPERTY_ROLE))[0] assert item.data(role=MODEL_ROLE) == read.model assert item.data(role=PROPERTY_ROLE) == 'number' def test_get_model_links(sef, qtbot, link_model, nodes): populate_link_model(link_model, nodes) assert link_model.get_model_links(nodes['read_3'].model) == {} links = link_model.get_model_links([nodes['read'].model, nodes['read_2'].model, nodes['cpm'].model['Read']]) links = list(links.values()) # Just one row assert len(links) == 1 # Three items in that row assert len(links[0]) == 3 assert [nodes['read'].model.caption, 'number'] in links[0] assert [nodes['read_2'].model.caption, 'height'] in links[0] path = nodes['cpm'].model.get_path_from_model(nodes['cpm'].model['Read']) str_path = [model.caption for model in path] + ['y'] assert str_path in links[0] def test_get_root_model(self, qtbot, link_model, nodes): read = nodes['read'] composite = nodes['cpm'] link_model.add_item(read, read.model, 'number', -1, -1) # Not inside assert link_model.get_root_model(nodes['read_2'].model) is None # Directly inside assert link_model.get_root_model(read.model) == read.model # Indirectly inside via silent link_model.add_silent(composite.model['Read'], 'number', read.model, 'number') assert link_model.get_root_model(composite.model['Read']) == read.model def test_get_model_properties(self, qtbot, link_model, nodes): read = nodes['read'] link_model.add_item(read, read.model, 'number', -1, -1) link_model.add_item(read, read.model, 'height', -1, -1) # Empty assert link_model.get_model_properties(nodes['read_2'].model) == [] # Multiple assert set(link_model.get_model_properties(read.model)) == set(['number', 'height']) def test_add_silent(self, qtbot, link_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] composite = nodes['cpm'] orig_key = setup_silent(link_model, nodes) # orig model not inside with pytest.raises(ValueError): link_model.add_silent(composite.model['Read'], 'height', nodes['read_3'].model, 'number') # source property not inside with pytest.raises(ValueError): link_model.add_silent(composite.model['Read'], 'height', read.model, 'height') # Links inside assert len(link_model._slaves[orig_key]) == 2 key = (composite.model['Read'], 'number') assert link_model._silent[key] == orig_key assert key in link_model._slaves[orig_key] key = (read_2.model, 'height') assert link_model._silent[key] == orig_key assert key in link_model._slaves[orig_key] # Properties conected read.model['number'] = 100 read.model.property_changed.emit(read.model, 'number', read.model['number']) assert composite.model['Read']['number'] == read.model['number'] assert read_2.model['height'] == read.model['number'] def test_remove_silent(self, qtbot, link_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] composite = nodes['cpm'] orig_key = setup_silent(link_model, nodes) key = (composite.model['Read'], 'number') link_model.remove_silent(*key) assert key not in link_model._silent # Silent link disconected read.model['number'] = 100 read.model.property_changed.emit(read.model, 'number', read.model['number']) assert composite.model['Read']['number'] == 0 assert read_2.model['height'] == read.model['number'] # No more slaves, remove the original key as well key = (nodes['read_2'].model, 'height') link_model.remove_silent(*key) assert orig_key not in link_model._slaves def test_replace_item(self, qtbot, link_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] composite = nodes['cpm'] orig_key = setup_silent(link_model, nodes) replacer = nodes['read_3'] item = link_model.find_items(orig_key, (MODEL_ROLE, PROPERTY_ROLE))[0] (row, column) = item.row(), item.column() link_model.replace_item(replacer, replacer.model, orig_key[0]) new_item = link_model.item(row, column) assert new_item.data(role=MODEL_ROLE) == replacer.model # Silent links re-connected # This must have no effect on silent models read.model['number'] = 100 read.model.property_changed.emit(read.model, 'number', read.model['number']) assert composite.model['Read']['number'] == 0 assert read_2.model['height'] == 0 # This must change silent models' properties replacer.model['number'] = 100 replacer.model.property_changed.emit(replacer.model, 'number', replacer.model['number']) assert composite.model['Read']['number'] == replacer.model['number'] assert read_2.model['height'] == replacer.model['number'] def test_on_node_rows_about_to_be_removed(self, qtbot, link_model, node_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] read_3 = nodes['read_3'] node_model.add_node(read) node_model.add_node(read_2) node_model.add_node(read_3) link_model.add_item(read, read.model, 'number', -1, -1) link_model.add_item(read_2, read_2.model, 'number', 0, -1) link_model.add_item(read_3, read_3.model, 'number', -1, -1) # Remove one node_model.removeRow(0) assert link_model.find_items([read.model], [MODEL_ROLE]) == [] # Remove all node_model.clear() assert link_model.find_items([read_2.model], [MODEL_ROLE]) == [] assert link_model.find_items([read_3.model], [MODEL_ROLE]) == [] def test_canDropMimeData(self, qtbot, link_model, node_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] node_model.add_node(read) node_model.add_node(read_2) # Incompatible QMimeData data = QMimeData() data.setData('application/x-foobar', QByteArray()) assert not link_model.canDropMimeData(data, None, -1, -1, QModelIndex()) # No parent index = get_index_from_treemodel(node_model, 0, 'number') data = _encode_mime_data(index) assert link_model.canDropMimeData(data, None, -1, -1, QModelIndex()) link_model.add_item(read, read.model, 'number', -1, -1) assert not link_model.canDropMimeData(data, None, -1, -1, QModelIndex()) # On parent # Compatible property type index = get_index_from_treemodel(node_model, 1, 'number') data = _encode_mime_data(index) parent = link_model.indexFromItem(link_model.item(0, 0)) assert link_model.canDropMimeData(data, None, 0, 0, parent) # Incompatible property type index = get_index_from_treemodel(node_model, 1, 'path') data = _encode_mime_data(index) parent = link_model.indexFromItem(link_model.item(0, 0)) assert not link_model.canDropMimeData(data, None, 0, 0, parent) def test_dropMimeData(self, qtbot, link_model, node_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] node_model.add_node(read) node_model.add_node(read_2) # No parent index = get_index_from_treemodel(node_model, 0, 'number') data = _encode_mime_data(index) link_model.dropMimeData(data, None, -1, -1, QModelIndex()) item = link_model.item(0, 0) assert item.data(role=NODE_ROLE) == read assert item.data(role=MODEL_ROLE) == read.model assert item.data(role=PROPERTY_ROLE) == 'number' # On parent index = get_index_from_treemodel(node_model, 1, 'number') data = _encode_mime_data(index) parent = link_model.indexFromItem(link_model.item(0, 0)) link_model.dropMimeData(data, None, -1, -1, parent) item = link_model.item(0, 1) assert item.data(role=NODE_ROLE) == read_2 assert item.data(role=MODEL_ROLE) == read_2.model assert item.data(role=PROPERTY_ROLE) == 'number' def test_save(self, qtbot, link_model, nodes): records = populate_link_model(link_model, nodes) for (i, (node_id, str_path)) in enumerate(link_model.save()[0]): assert node_id == records[i][0].id path = _get_string_path(records[i][0], records[i][1], records[i][2]) assert str_path == path def test_restore(self, qtbot, link_model, nodes): records = populate_link_model(link_model, nodes) state = link_model.save() link_model.clear() # Add new item read_3 = nodes['read_3'] link_model.add_item(read_3, read_3.model, 'number', -1, -1) link_model.restore(state, {node.id: node for node in nodes.values()}) assert link_model.columnCount() == 3 for column in range(link_model.columnCount()): item = link_model.item(0, column) assert item.data(role=NODE_ROLE) == records[column][0] assert item.data(role=MODEL_ROLE) == records[column][1] assert item.data(role=PROPERTY_ROLE) == records[column][2] # Restore must clear whatever is inside assert link_model.find_items([read_3.model], [MODEL_ROLE]) == [] def test_compact(self, qtbot, link_model, nodes): read = nodes['read'] read_2 = nodes['read_2'] read_3 = nodes['read_3'] read_4 = nodes['read_4'] def populate(): link_model.add_item(read, read.model, 'number', 0, 0) link_model.add_item(read_2, read_2.model, 'number', 0, 1) link_model.add_item(read_3, read_3.model, 'number', 1, 0) link_model.add_item(read_4, read_4.model, 'number', 1, 1) def check(row_count, column_count): assert link_model.rowCount() == row_count assert link_model.columnCount() == column_count populate() link_model.remove_item(link_model.indexFromItem(link_model.item(0, 1))) link_model.compact() check(2, 2) link_model.clear() # Shift item to the left to an unused cell populate() link_model.remove_item(link_model.indexFromItem(link_model.item(0, 0))) link_model.compact() assert link_model.item(0, 0).data(role=NODE_ROLE) == read_2 check(2, 2) # Nothing in the row, remove it link_model.remove_item(link_model.indexFromItem(link_model.item(0, 0))) link_model.compact() check(1, 2) # Remove column 0 and shift 1st column to the left link_model.clear() populate() link_model.remove_item(link_model.indexFromItem(link_model.item(0, 0))) link_model.remove_item(link_model.indexFromItem(link_model.item(1, 0))) link_model.compact() assert link_model.item(0, 0).data(role=NODE_ROLE) == read_2 assert link_model.item(1, 0).data(role=NODE_ROLE) == read_4 check(2, 1) def test_on_property_changed(self, qtbot, link_model, nodes): composite = nodes['cpm'] read = nodes['read'] read_2 = nodes['read_2'] read_3 = nodes['read_3'] read_4 = nodes['read_4'] # Read 2->height and cpm->Read->number are silent dependend on Read->number setup_silent(link_model, nodes) # Put every linked property to 0 to make sure we are not lucky when checking if the links # work read_3.model['number'] = 0 read_4.model['number'] = 0 composite.model['Pad']['width'] = 0 link_model.add_item(read_3, read_3.model, 'number', 0, 1) link_model.add_item(read_4, read_4.model, 'number', 1, 0) link_model.add_item(composite, composite.model['Pad'], 'width', 1, 1) read.model['number'] = 100 read.model.property_changed.emit(read.model, 'number', read.model['number']) # Row 0 # Direct link assert read_3.model['number'] == read.model['number'] # Silent links assert read_2.model['height'] == read.model['number'] assert composite.model['Read']['number'] == read.model['number'] # Row 1 read_4.model['number'] = 100 read_4.model.property_changed.emit(read_4.model, 'number', read_4.model['number']) assert composite.model['Pad']['width'] == read_4.model['number'] ufo-kit-tofu-ed0e5bd/tofu/tests/test_flow_propertylinkswidget.py000066400000000000000000000032251521054151500255260ustar00rootroot00000000000000import pytest from PyQt5.QtCore import Qt, QItemSelectionModel from tofu.flow.propertylinkswidget import NodesView, PropertyLinks, PropertyLinksView from tofu.tests.flow_util import get_index_from_treemodel, populate_link_model @pytest.fixture(scope='function') def node_view(node_model): view = NodesView() view.setHeaderHidden(True) view.setAlternatingRowColors(True) view.setDragEnabled(True) view.setAcceptDrops(False) view.setModel(node_model) return view @pytest.fixture(scope='function') def link_view(): return PropertyLinksView() @pytest.fixture(scope='function') def link_widget(node_model): return PropertyLinks() def test_property_links_view_delete_key(qtbot, link_model, link_view, nodes): qtbot.addWidget(link_view) link_view.setModel(link_model) populate_link_model(link_model, nodes) link_view.selectColumn(0) qtbot.keyPress(link_view, Qt.Key_Delete) assert link_model.columnCount() == 2 def test_node_view_get_drag_index(qtbot, node_view, nodes): node_model = node_view.model() read = nodes['read'] node_model.add_node(read) sm = node_view.selectionModel() # Nothing selected assert node_view.get_drag_index() is None # Node selection must yield nothing index = node_model.indexFromItem(node_model.item(0, 0)) sm.select(index, QItemSelectionModel.Select) assert node_view.get_drag_index() is None sm.clear() # Property selection must yield an index which can be dragged index = get_index_from_treemodel(node_model, 0, 'number') sm.select(index, QItemSelectionModel.Select) assert node_view.get_drag_index() is not None sm.clear() ufo-kit-tofu-ed0e5bd/tofu/tests/test_flow_runslider.py000066400000000000000000000147101521054151500234050ustar00rootroot00000000000000import pytest from tofu.flow.runslider import RunSlider, RunSliderError @pytest.fixture(scope='function') def runslider(qtbot, scene): slider = RunSlider() node = scene.create_node(scene.registry.create('filter')) slider.setup(node.model._view._properties['cutoff'].view_item) qtbot.addWidget(slider) return slider class TestRunSlider: def test_setup(self, qtbot, runslider): assert not runslider.setup(runslider.view_item) bottom = runslider.view_item.widget.validator().bottom() top = runslider.view_item.widget.validator().top() assert runslider.type == float assert runslider.real_minimum == bottom assert runslider.real_maximum == top assert float(runslider.min_edit.text()) == bottom assert float(runslider.max_edit.text()) == top assert float(runslider.current_edit.text()) == runslider.view_item.get() assert runslider.slider.value() / 100 + runslider.real_minimum == runslider.view_item.get() assert runslider.isEnabled() def test_reset(self, qtbot, runslider): runslider.reset() assert runslider.view_item is None assert runslider.type is None assert runslider.real_minimum == 0 assert runslider.real_maximum == 100 assert runslider.real_span == 100 assert runslider.min_edit.text() == '' assert runslider.max_edit.text() == '' assert runslider.current_edit.text() == '' assert not runslider.isEnabled() def test_empty(self, qtbot, runslider): runslider.reset() runslider.on_min_edit_editing_finished() runslider.on_max_edit_editing_finished() runslider.on_current_edit_editing_finished() def test_min_edit_changed(self, qtbot, runslider): top = runslider.view_item.widget.validator().top() with pytest.raises(RunSliderError): runslider.min_edit.setText('asdf') runslider.on_min_edit_editing_finished() with pytest.raises(RunSliderError): runslider.min_edit.setText(str(top + 1)) runslider.on_min_edit_editing_finished() # Current value lower than new minimum, must be updated value = runslider.get_real_value() + 0.1 runslider.min_edit.setText(str(value)) runslider.on_min_edit_editing_finished() assert value == runslider.get_real_value() def test_max_edit_changed(self, qtbot, runslider): bottom = runslider.view_item.widget.validator().bottom() with pytest.raises(RunSliderError): runslider.max_edit.setText('asdf') runslider.on_max_edit_editing_finished() with pytest.raises(RunSliderError): runslider.max_edit.setText(str(bottom - 1)) runslider.on_max_edit_editing_finished() # Current value greater than new maximum, must be updated value = runslider.get_real_value() - 0.1 runslider.max_edit.setText(str(value)) runslider.on_max_edit_editing_finished() assert value == runslider.get_real_value() def test_current_edit_changed(self, qtbot, runslider): self.value_changed_value = None def on_value_changed(value): self.value_changed_value = value # Nothing changed, no update triggered runslider.on_current_edit_editing_finished() assert self.value_changed_value is None runslider.value_changed.connect(on_value_changed) current = runslider.get_real_value() + 0.1 runslider.current_edit.setText(str(current)) runslider.on_current_edit_editing_finished() assert runslider.get_real_value() == current runslider.current_edit.setText('asf') with pytest.raises(RunSliderError): runslider.on_current_edit_editing_finished() def test_int(self, qtbot, runslider, scene): node = scene.create_node(scene.registry.create('read')) runslider.setup(node.model._view._properties['y'].view_item) assert runslider.type == int runslider.min_edit.setText('1') runslider.on_min_edit_editing_finished() runslider.max_edit.setText('10') runslider.on_max_edit_editing_finished() runslider.slider.setValue(50) assert type(runslider.get_real_value()) == int assert runslider.get_real_value() == 5 runslider.current_edit.setText('7') runslider.on_current_edit_editing_finished() assert type(runslider.get_real_value()) == int assert runslider.get_real_value() == 7 # Maximum smaller than current -> update current runslider.max_edit.setText('5') runslider.on_max_edit_editing_finished() assert type(runslider.get_real_value()) == int assert runslider.get_real_value() == 5 # Minimum greater than current -> update current runslider.max_edit.setText('10') runslider.on_max_edit_editing_finished() runslider.min_edit.setText('8') runslider.on_min_edit_editing_finished() assert type(runslider.get_real_value()) == int assert runslider.get_real_value() == 8 def test_range(self, qtbot, scene): runslider = RunSlider() qtbot.addWidget(runslider) node = scene.create_node(scene.registry.create('general_backproject')) node.model['center-position-x'] = [1, 2, 3] assert not runslider.setup(node.model._view._properties['center-position-x'].view_item) assert runslider.view_item is None node.model['center-position-x'] = [1] assert runslider.setup(node.model._view._properties['center-position-x'].view_item) assert runslider.view_item == node.model._view._properties['center-position-x'].view_item assert type(runslider.get_real_value()) == float assert runslider.get_real_value() == 1 runslider.current_edit.setText('1.1') runslider.on_current_edit_editing_finished() assert node.model['center-position-x'] == [runslider.get_real_value()] def test_links(self, qtbot, link_model, nodes): runslider = RunSlider() qtbot.addWidget(runslider) read = nodes['read'] read_2 = nodes['read_2'] runslider.setup(read.model._view._properties['number'].view_item) link_model.add_item(read, read.model, 'number', -1, -1) link_model.add_item(read_2, read_2.model, 'number', 0, -1) runslider.current_edit.setText('123') runslider.on_current_edit_editing_finished() assert read_2.model['number'] == 123 ufo-kit-tofu-ed0e5bd/tofu/tests/test_flow_scene.py000066400000000000000000000420371521054151500224760ustar00rootroot00000000000000import pytest from PyQt5.QtCore import QModelIndex from PyQt5.QtWidgets import QInputDialog from qtpynodeeditor import FlowView from tofu.flow.models import BaseCompositeModel, UfoModelError, UfoReadModel from tofu.flow.util import FlowError, MODEL_ROLE, NODE_ROLE, PROPERTY_ROLE from tofu.tests.flow_util import add_nodes_to_scene class TestScene: def test_create_node(self, qtbot, scene): def check_node(node, gt_caption): # Node must be in the scene assert node in scene.nodes.values() # Caption must be unique assert node.model.caption == gt_caption # Node must be in the nodes model item = scene.node_model.findItems(node.model.caption)[0] assert item.data(role=MODEL_ROLE) == node.model nodes = add_nodes_to_scene(scene, model_names=['read', 'read']) for (node, gt_caption) in zip(nodes, ['Read', 'Read 2']): check_node(node, gt_caption) # Property links must be set up by composites def check_link(model, prop_name): assert scene.property_links_model.find_items((model, prop_name), (MODEL_ROLE, PROPERTY_ROLE)) scene.clear_scene() node = add_nodes_to_scene(scene, model_names=['CFlatFieldCorrect'])[0] for link in node.model._links: model_name, prop_name = link[0] other_name, other_prop_name = link[1] model = node.model[model_name] other = node.model[other_name] model[prop_name] = 0 qtbot.addWidget(model.embedded_widget()) qtbot.addWidget(other.embedded_widget()) qtbot.keyClick(model._view._properties[prop_name].view_item.widget, '1') # Other model's property has to be updated if the property links have been set up # correctly assert node.model[other_name][other_prop_name] == node.model[model_name][prop_name] def test_setstate(self, qtbot, scene): # Make sure there are some links by adding FFC nodes = add_nodes_to_scene(scene, model_names=['CFlatFieldCorrect', 'average']) # Create a connection scene.create_connection(nodes[0]['output'][0], nodes[1]['input'][0]) state = scene.__getstate__() scene.clear_scene() scene.__setstate__(state) assert scene.__getstate__() == state def test_getstate(self, qtbot, scene): # Make sure there are some links by adding FFC nodes = add_nodes_to_scene(scene, model_names=['CFlatFieldCorrect', 'average']) # Create a connection scene.create_connection(nodes[0]['output'][0], nodes[1]['input'][0]) state = scene.__getstate__() # Nodes ids = [record['id'] for record in state['nodes']] assert nodes[0].id in ids assert nodes[1].id in ids # Connections assert len(state['connections']) == 1 conn = state['connections'][0] assert conn['in_id'] == nodes[1].id assert conn['out_id'] == nodes[0].id # Property links assert state['property-links'] == scene.property_links_model.save() def test_restore_node(self, qtbot, monkeypatch, scene): add_nodes_to_scene(scene) old_node = list(scene.nodes.values())[0] state = old_node.__getstate__() scene.remove_node(old_node) new_node = scene.restore_node(state) # Don't test the nodes themselves because the models won't match assert old_node.id == new_node.id # num-inputs monkeypatch.setattr(QInputDialog, 'getInt', lambda *args, **kwargs: (2, True)) node = add_nodes_to_scene(scene, model_names=['retrieve_phase'])[0] state = node.__getstate__() scene.remove_node(node) new_node = scene.restore_node(state) assert new_node.model.num_ports['input'] == 2 def test_remove_node(self, monkeypatch, qtbot, scene, nodes): def cleanup(): self.cleanup_called = True node = add_nodes_to_scene(scene)[0] self.cleanup_called = False node.model.cleanup = cleanup scene.property_links_model.add_item(node, node.model, node.model.properties[0], 0, 0, QModelIndex()) scene.remove_node(list(scene.nodes.values())[0]) # Scene, node model and property links model must be empty assert len(scene.nodes) == 0 assert scene.node_model.rowCount() == 0 assert scene.property_links_model.rowCount() == 0 assert self.cleanup_called # Composite removal monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm', True)) nodes = add_nodes_to_scene(scene, model_names=['pad', 'crop']) for node in nodes: node.graphics_object.setSelected(True) node = scene.create_composite() state = node.__getstate__() scene.remove_node(node) # _composite_nodes must be updated assert scene._composite_nodes == {} # Simulate non-interactive composite creation, i.e. not combining existing nodes into a # composite node. When removing such node, _composite_nodes must not raise a KeyError node = scene.restore_node(state) scene.remove_node(node) def test_is_selected_one_composite(self, qtbot, scene, monkeypatch): # Circumvent the input dialog monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm', True)) nodes = add_nodes_to_scene(scene, model_names=['read', 'read']) for node in nodes: node.graphics_object.setSelected(True) # Simple nodes assert not scene.is_selected_one_composite() node = scene.create_composite() # Composite assert scene.is_selected_one_composite() node.graphics_object.setSelected(False) # Nothing selected assert not scene.is_selected_one_composite() # Composite and other selected add_nodes_to_scene(scene, ['null']) for node in scene.nodes.values(): node.graphics_object.setSelected(True) assert not scene.is_selected_one_composite() def test_skip_nodes(self, qtbot, scene): nodes = add_nodes_to_scene(scene, model_names=['read', 'pad', 'crop', 'null']) read, pad, crop, null = nodes scene.create_connection(read['output'][0], pad['input'][0]) scene.create_connection(pad['output'][0], crop['input'][0]) scene.create_connection(crop['output'][0], null['input'][0]) read.graphics_object.setSelected(True) # Only fully connected nodes can be disabled with pytest.raises(FlowError): scene.skip_nodes() read.graphics_object.setSelected(False) null.graphics_object.setSelected(True) with pytest.raises(FlowError): scene.skip_nodes() null.graphics_object.setSelected(False) pad.graphics_object.setSelected(True) scene.skip_nodes() assert pad.model.skip scene.skip_nodes() assert not pad.model.skip # Deprecation warning coming from imageio @pytest.mark.filterwarnings('ignore::DeprecationWarning') def test_auto_fill(self, qtbot, scene): add_nodes_to_scene(scene) with pytest.raises(UfoModelError): scene.auto_fill() def test_copy_node(self, qtbot, scene): nodes = add_nodes_to_scene(scene, model_names=['read', 'null']) scene.create_connection(nodes[0]['output'][0], nodes[1]['input'][0]) for node in nodes: node.graphics_object.setSelected(True) scene.copy_nodes() assert len(scene.nodes) == 4 # Choose the newly created connections if scene.connections[0].valid_ports['input'].node in nodes: ports = scene.connections[1].valid_ports else: ports = scene.connections[0].valid_ports # The fact that the connections are there means the nodes are there as well, so we don't # need to test that assert ports['input'].node.model.name == 'null' assert ports['output'].node.model.name == 'read' def test_create_composite(self, monkeypatch, qtbot, scene): monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm', True)) plm = scene.property_links_model nodes = add_nodes_to_scene(scene, model_names=['read', 'read']) plm.add_item(nodes[0], nodes[0].model, nodes[0].model.properties[0], -1, -1, QModelIndex()) for (i, node) in enumerate(nodes): node.graphics_object.setSelected(True) node = scene.create_composite() assert node.model._links == [[[nodes[0].model.caption, nodes[0].model.properties[0]]]] with pytest.raises(FlowError): # Can't create a composite with the same name scene.create_composite() assert len(scene.nodes) == 1 assert list(scene.nodes.values())[0] == node assert isinstance(node.model, BaseCompositeModel) assert nodes[0] not in scene.nodes.values() assert nodes[1] not in scene.nodes.values() # Property links model assert plm.item(0, 0).data(role=NODE_ROLE) == node # Simulate non-interactive composite creation, i.e. not combining existing nodes into a # composite node. In this case it can't be possible to create a new composite node with the # same name as has already been registered. state = node.__getstate__() scene.remove_node(node) node = scene.restore_node(state) node.graphics_object.setSelected(True) with pytest.raises(FlowError): scene.create_composite() # Add outer composite with a composite and another simple model inside and set the property # links between the inner composite and inner simple. They must be present in the newly # craeted outer composite. average = add_nodes_to_scene(scene, model_names=['average'])[0] average.graphics_object.setSelected(True) monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('outer', True)) plm.add_item(node, node.model['Read'], 'number', 0, 0) plm.add_item(node, node.model['Read 2'], 'number', 0, 1) plm.add_item(average, average.model, 'number', 0, 2) outer = scene.create_composite() assert len(outer.model._links) == 1 row = outer.model._links[0] assert [node.model.caption, node.model['Read'].caption, 'number'] in row assert [node.model.caption, node.model['Read 2'].caption, 'number'] in row assert [average.model.caption, 'number'] in row assert [node.model.caption, node.model['Read'].caption, 'height'] not in row def test_on_node_double_clicked(self, qtbot, scene, monkeypatch): def double_clicked(*args): self.did_click = True self.did_click = False monkeypatch.setattr(UfoReadModel, "double_clicked", double_clicked) node = add_nodes_to_scene(scene)[0] # We need a view for double clicks _ = FlowView(scene) scene.on_node_double_clicked(node) assert self.did_click def test_expand_composite(self, qtbot, scene, monkeypatch): monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm', True)) plm = scene.property_links_model nodes = add_nodes_to_scene(scene, model_names=['read', 'null']) name_to_caption = {'read': 'Read', 'null': 'Null'} for node in nodes: node.graphics_object.setSelected(True) node = scene.create_composite() path = node.model.get_leaf_paths()[0] plm.add_item(node, path[-1], path[-1].properties[0], -1, -1, QModelIndex()) scene.expand_composite(node) assert plm.item(0, 0).data(role=MODEL_ROLE).name == path[-1].name assert plm.item(0, 0).data(role=NODE_ROLE) in [node for node in scene.selected_nodes()] # Captions are the same for node in scene.nodes.values(): assert node.model.caption == name_to_caption[node.model.name] # New caption if there is a node with the original one # Selection stays, just re-use the expanded nodes monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm_2', True)) node = scene.create_composite() other_read_node = add_nodes_to_scene(scene, model_names=['read'])[0] scene.expand_composite(node) for node in scene.nodes.values(): if node.model.name == 'read': if node == other_read_node: assert node.model.caption == 'Read' else: assert node.model.caption == 'Read 2' def test_is_fully_connected(self, qtbot, scene): nodes = add_nodes_to_scene(scene, model_names=['read', 'pad', 'crop', 'null']) read, pad, crop, null = nodes scene.create_connection(read['output'][0], pad['input'][0]) scene.create_connection(pad['output'][0], crop['input'][0]) scene.create_connection(crop['output'][0], null['input'][0]) assert scene.is_fully_connected() scene.remove_node(read) assert not scene.is_fully_connected() def test_are_all_ufo_tasks(self, qtbot, scene): add_nodes_to_scene(scene, model_names=['read', 'pad', 'crop', 'null']) assert scene.are_all_ufo_tasks() scene.create_node(scene.registry.create('memory_out')) assert not scene.are_all_ufo_tasks() def test_get_simple_node_graphs(self, qtbot, scene, monkeypatch): def connect(read, pad, crop, null): scene.create_connection(read['output'][0], pad['input'][0]) scene.create_connection(pad['output'][0], crop['input'][0]) scene.create_connection(crop['output'][0], null['input'][0]) monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm', True)) nodes = add_nodes_to_scene(scene, model_names=2 * ['read', 'pad', 'crop', 'null']) read, pad, crop, null = nodes[:4] read_2, pad_2, crop_2, null_2 = nodes[4:] connect(read, pad, crop, null) connect(read_2, pad_2, crop_2, null_2) connections = [('Read', 'Pad'), ('Pad', 'Crop'), ('Crop', 'Null'), ('Read 2', 'Pad 2'), ('Pad 2', 'Crop 2'), ('Crop 2', 'Null 2')] graphs = scene.get_simple_node_graphs() assert len(graphs) == 2 num_visited = 0 for graph in graphs: for (src, dst, index) in graph.edges: assert (src.caption, dst.caption) in connections num_visited += 1 assert num_visited == len(connections) # Create first composite for node in nodes: if node.model.name in ['pad', 'crop']: node.graphics_object.setSelected(True) scene.create_composite() # Create a second composite which will cause the scene to have multiple edges between two # nodes (the first composite's outputs and second's inputs) scene.clearSelection() monkeypatch.setattr(QInputDialog, "getText", lambda *args: ('cpm_2', True)) null.graphics_object.setSelected(True) null_2.graphics_object.setSelected(True) scene.create_composite() # Composite must not affect simple graphs, especially the multiple edges cannot be present # anymore graphs = scene.get_simple_node_graphs() assert len(graphs) == 2 num_visited = 0 for graph in graphs: for (src, dst, index) in graph.edges: assert (src.caption, dst.caption) in connections num_visited += 1 assert num_visited == len(connections) add_nodes_to_scene(scene) assert len(scene.get_simple_node_graphs()) == 3 # Test disabling nodes scene.clear_scene() nodes = add_nodes_to_scene(scene, model_names=['read', 'pad', 'crop', 'null']) read, pad, crop, null = nodes connect(read, pad, crop, null) # Disable padding, the generated flow must be read -> crop -> null pad.graphics_object.setSelected(True) scene.skip_nodes() graph = scene.get_simple_node_graphs()[0] assert len(graph.edges) == 2 edges = list(graph.edges) src, dst = edges[0][:-1] assert dst == crop.model src, dst = edges[1][:-1] assert src == crop.model assert dst == null.model def test_set_enabled(self, qtbot, scene): def check(enabled): assert scene.allow_node_creation == enabled assert scene.allow_node_deletion == enabled for node in scene.nodes.values(): assert node._graphics_obj.isEnabled() == enabled for conn in scene.connections: assert conn._graphics_object.isEnabled() == enabled nodes = add_nodes_to_scene(scene, model_names=['CFlatFieldCorrect', 'average']) nodes[0].graphics_object.setSelected(True) # Create a connection scene.create_connection(nodes[0]['output'][0], nodes[1]['input'][0]) scene.set_enabled(False) check(False) scene.set_enabled(True) check(True) assert nodes[0].graphics_object.isSelected() ufo-kit-tofu-ed0e5bd/tofu/tests/test_flow_util.py000066400000000000000000000036671521054151500223640ustar00rootroot00000000000000import pytest from PyQt5.QtWidgets import QInputDialog from tofu.flow.util import CompositeConnection, get_config_key, saved_kwargs from tofu.flow.main import get_filled_registry def test_get_config_key(): # Existing key assert 'z' in get_config_key('models', 'general-backproject', 'hidden-properties') # Non-existent key assert get_config_key('foobarbaz') is None assert get_config_key('foobarbaz', default=1) == 1 def test_saved_kwargs(qtbot, monkeypatch, scene): registry = get_filled_registry() name = 'retrieve_phase' # No num-inputs info monkeypatch.setattr(QInputDialog, 'getInt', lambda *args, **kwargs: (2, True)) state = {'name': name} model = registry.create(name) assert model.num_ports['input'] == 2 # num-inputs specified state = {'name': name, 'num-inputs': 3} with saved_kwargs(registry, state): model = registry.create(name) assert model.num_ports['input'] == 3 assert 'num_inputs' not in registry.registered_model_creators()[state['name']][1] class TestCompositeConnection: def test_init(self): # Identical source and tartet -> exception with pytest.raises(ValueError): CompositeConnection('a', 0, 'a', 0) # OK, must pass CompositeConnection('a', 0, 'b', 0) def test_contains(self): conn = CompositeConnection('a', 0, 'b', 0) assert conn.contains('a', 'output', 0) assert not conn.contains('a', 'output', 1) assert not conn.contains('a', 'input', 0) assert not conn.contains('a', 'input', 1) assert conn.contains('b', 'input', 0) assert not conn.contains('b', 'input', 1) assert not conn.contains('b', 'output', 0) assert not conn.contains('b', 'output', 1) assert not conn.contains('foo', 'input', 14) def test_save(self): conn = CompositeConnection('a', 0, 'b', 0) assert conn.save() == ['a', 0, 'b', 0] ufo-kit-tofu-ed0e5bd/tofu/tests/test_flow_viewer.py000066400000000000000000000272341521054151500227040ustar00rootroot00000000000000import pytest import numpy as np from PyQt5.QtGui import QValidator from tofu.flow.viewer import ImageLabel, ImageViewingError, ScreenImage, ImageViewer @pytest.fixture(scope='function') def screen_image(): image = np.arange(256, dtype=np.float32).reshape(16, 16) return ScreenImage(image=image) @pytest.fixture(scope='function') def viewer(qtbot): viewer = ImageViewer() viewer.images = np.ones((10, 16, 16)) viewer.popup() qtbot.addWidget(viewer._pg_window) return viewer class TestScreenImage: def test_image_setter(self): screen_image = ScreenImage() assert screen_image.image is None screen_image.image = np.random.normal(size=(8, 8)) assert screen_image.minimum is not None assert screen_image.maximum is not None assert screen_image.black_point is not None assert screen_image.white_point is not None def test_black_point_setter(self, screen_image): screen_image.black_point = 100 assert screen_image.black_point == 100 screen_image.white_point = 150 # Black point cannot be greater than white point with pytest.raises(ImageViewingError): screen_image.black_point = 200 def test_white_point_setter(self, screen_image): screen_image.white_point = 100 assert screen_image.white_point == 100 screen_image.black_point = 50 # White point cannot be smaller than black point with pytest.raises(ImageViewingError): screen_image.white_point = 0 def test_reset(self, screen_image): screen_image.reset() # We can test with ==, the data types are the same so the extrema must be exactly the same assert screen_image.minimum == 0 assert screen_image.maximum == 255 assert screen_image.black_point == 0 assert screen_image.white_point == 255 # Going out of the original gray value range must not cause exception on reset screen_image.black_point = -100 screen_image.white_point = -50 screen_image.reset() def test_auto_levels(self, screen_image): screen_image.auto_levels() # nonsense values must pass as well screen_image.auto_levels(percentile=200.0) screen_image.auto_levels(percentile=-200.0) def test_set_black_point_normalized(self, screen_image): screen_image.set_black_point_normalized(100) assert screen_image.black_point == 100 screen_image.set_white_point_normalized(150) # Black point cannot be greater than white point with pytest.raises(ImageViewingError): screen_image.set_black_point_normalized(200) def test_set_white_point_normalized(self, screen_image): screen_image.set_white_point_normalized(100) assert screen_image.white_point == 100 screen_image.set_black_point_normalized(50) # White point cannot be smaller than black point with pytest.raises(ImageViewingError): screen_image.set_white_point_normalized(0) def test_convert_normalized_value_to_native(self, screen_image): assert screen_image.convert_normalized_value_to_native(128) == 128. with pytest.raises(ImageViewingError): screen_image.convert_normalized_value_to_native(-500) with pytest.raises(ImageViewingError): screen_image.convert_normalized_value_to_native(500) def test_convert_native_value_to_normalized(self, screen_image): assert screen_image.convert_native_value_to_normalized(128) == 128. with pytest.raises(ImageViewingError): screen_image.convert_native_value_to_normalized(-500) with pytest.raises(ImageViewingError): screen_image.convert_native_value_to_normalized(500) # One gray value must not cause division by zero erro screen_image.image = np.ones((4, 4)) screen_image.reset() screen_image.convert_native_value_to_normalized(1) def test_get_pixmap(self, qtbot, screen_image): # Empty image must raise an exception with pytest.raises(ImageViewingError): ScreenImage().get_pixmap() # Downsampling pixmap = screen_image.get_pixmap() assert (pixmap.height(), pixmap.width()) == screen_image.image.shape pixmap = screen_image.get_pixmap(downsampling=2) assert (pixmap.height(), pixmap.width()) == tuple(dim // 2 for dim in screen_image.image.shape) # One gray value must not cause division by zero erro screen_image.image = np.ones((4, 4)) screen_image.reset() screen_image.get_pixmap() class TestImageLabel: def test_updateImage(self, qtbot, screen_image): label = ImageLabel() # Empty image must pass label.updateImage() label.screen_image = screen_image label.updateImage() assert label.pixmap() is not None def test_resizeEvent(self, qtbot, screen_image): label = ImageLabel(screen_image) label.updateImage() old_size = label.pixmap().size() # ensure the label will get the resize event label.show() label.resize(8, 8) new_size = label.pixmap().size() assert new_size != old_size class TestImageViewer: def test_images_setter(self, qtbot): viewer = ImageViewer() viewer.images = np.zeros((16, 16)) assert viewer.images.ndim == 3 assert viewer.slider.isHidden() assert float(viewer.min_slider_edit.text()) == 0 assert float(viewer.max_slider_edit.text()) == 0 viewer.images = np.ones((5, 16, 16)) assert viewer.images.ndim == 3 assert not viewer.slider.isHidden() assert viewer.slider.minimum() == 0 assert viewer.slider.maximum() == viewer.images.shape[0] - 1 assert float(viewer.min_slider_edit.text()) == 1 assert float(viewer.max_slider_edit.text()) == 1 # Test viewer and popup window equality viewer.popup() qtbot.addWidget(viewer._pg_window) np.testing.assert_almost_equal(viewer.images, 1) np.testing.assert_almost_equal(viewer._pg_window.image, 1) # 3D viewer.images = np.ones((5, 16, 16)) * 5 np.testing.assert_almost_equal(viewer.images, 5) np.testing.assert_almost_equal(viewer._pg_window.image, 5) # 2D viewer.images = np.ones((16, 16)) * 3 np.testing.assert_almost_equal(viewer.images, 3) np.testing.assert_almost_equal(viewer._pg_window.image, 3) # validators viewer.images = 10 + np.arange(200 * 8 ** 2).reshape(200, 8, 8) validator = viewer.slider_edit.validator() assert validator.validate('199', 0)[0] == QValidator.Acceptable assert validator.validate('2000', 0)[0] == QValidator.Invalid assert viewer.min_slider_edit.validator().bottom() == viewer.images[0].min() assert viewer.min_slider_edit.validator().top() == viewer.images[0].max() viewer._pg_window.close() def test_append(self, qtbot): viewer = ImageViewer() # Append to empty viewer.append(np.zeros((4, 4))) assert viewer.images.ndim == 3 assert viewer.images.shape == (1, 4, 4) # Append 2D viewer.append(np.zeros((4, 4))) assert viewer.images.shape == (2, 4, 4) # Append 3D viewer.append(np.zeros((3, 4, 4))) assert viewer.images.shape == (5, 4, 4) # Append wrong shape with pytest.raises(ImageViewingError): viewer.append(np.zeros((3, 2, 2))) def test_set_enabled_adjustments(self, qtbot): viewer = ImageViewer() def assert_all(value): viewer.set_enabled_adjustments(value) assert viewer.slider.isEnabled() == value assert viewer.slider_edit.isEnabled() == value assert viewer.min_slider.isEnabled() == value assert viewer.min_slider_edit.isEnabled() == value assert viewer.max_slider.isEnabled() == value assert viewer.max_slider_edit.isEnabled() == value assert_all(True) assert_all(False) def test_reset_clim(self, viewer): image = np.arange(16 ** 2).reshape(16, 16) viewer.images = image viewer.append(image * 2) viewer.slider.setValue(1) viewer.reset_clim(auto=False) si = viewer.screen_image min_converted = si.convert_native_value_to_normalized(si.black_point) max_converted = si.convert_native_value_to_normalized(si.white_point) assert viewer.screen_image.maximum == pytest.approx(510) assert viewer.min_slider.value() == pytest.approx(min_converted) assert viewer.max_slider.value() == pytest.approx(max_converted) assert float(viewer.min_slider_edit.text()) == pytest.approx(si.black_point) assert float(viewer.max_slider_edit.text()) == pytest.approx(si.white_point) # Pop up window must be updated assert viewer._pg_window.getLevels() == pytest.approx((si.black_point, si.white_point)) viewer._pg_window.close() def test_on_slider_value_changed(self, viewer): viewer.slider.setValue(5) assert viewer._pg_window.currentIndex == 5 assert viewer.slider_edit.text() == '5' viewer._pg_window.close() def test_on_slider_edit_return_pressed(self, viewer): viewer.slider_edit.setText('5') viewer.slider_edit.returnPressed.emit() assert viewer.slider.value() == 5 assert viewer._pg_window.currentIndex == 5 viewer._pg_window.close() def test_on_min_slider_edit_return_pressed(self, viewer): viewer.images = np.arange(256).reshape(16, 16) viewer.min_slider_edit.setText('100') viewer.min_slider_edit.returnPressed.emit() assert viewer.screen_image.black_point == pytest.approx(100) assert viewer.min_slider.value() == pytest.approx(100) assert viewer._pg_window.getLevels()[0] == pytest.approx(100) viewer._pg_window.close() def test_on_max_slider_edit_return_pressed(self, viewer): viewer.images = np.arange(256).reshape(16, 16) viewer.max_slider_edit.setText('100') viewer.max_slider_edit.returnPressed.emit() assert viewer.screen_image.white_point == pytest.approx(100) assert viewer.max_slider.value() == pytest.approx(100) assert viewer._pg_window.getLevels()[1] == pytest.approx(100) viewer._pg_window.close() def test_on_min_slider_value_changed(self, viewer): viewer.images = np.arange(256).reshape(16, 16) viewer.min_slider.valueChanged.emit(100) assert viewer.screen_image.black_point == pytest.approx(100) assert float(viewer.min_slider_edit.text()) == pytest.approx(100) assert viewer._pg_window.getLevels()[0] == pytest.approx(100) viewer._pg_window.close() def test_on_max_slider_value_changed(self, viewer): viewer.images = np.arange(256).reshape(16, 16) viewer.max_slider.valueChanged.emit(100) assert viewer.screen_image.white_point == pytest.approx(100) assert float(viewer.max_slider_edit.text()) == pytest.approx(100) assert viewer._pg_window.getLevels()[1] == pytest.approx(100) viewer._pg_window.close() def test_popup(self, qtbot, viewer): # Close and another popup call must show the widget viewer._pg_window.close() viewer.popup() assert viewer._pg_window.isVisible() # 2D must work other = ImageViewer() other.images = np.ones((4, 4)) other.popup() qtbot.addWidget(other._pg_window) assert other._pg_window is not None viewer._pg_window.close() other._pg_window.close() ufo-kit-tofu-ed0e5bd/tofu/util.py000066400000000000000000000407221521054151500171250ustar00rootroot00000000000000"""Various utility functions.""" import argparse import gi import glob import logging import math import os from collections import OrderedDict try: gi.require_version('Ufo', '0.0') from gi.repository import Ufo except ModuleNotFoundError as e: print(str(e)) except ValueError as e: try: gi.require_version('Ufo', '1.0') from gi.repository import Ufo except ValueError as e: print(str(e)) LOG = logging.getLogger(__name__) RESOURCES = None def range_list(value): """ Split *value* separated by ':' into int triple, filling missing values with 1s. """ def check(region): if region[0] >= region[1]: raise argparse.ArgumentTypeError("{} must be less than {}".format(region[0], region[1])) lst = [int(x) for x in value.split(':')] if len(lst) == 1: frm = lst[0] return (frm, frm + 1, 1) if len(lst) == 2: check(lst) return (lst[0], lst[1], 1) if len(lst) == 3: check(lst) return (lst[0], lst[1], lst[2]) raise argparse.ArgumentTypeError("Cannot parse {}".format(value)) def make_subargs(args, subargs): """Return an argparse.Namespace consisting of arguments from *args* which are listed in the *subargs* list.""" namespace = argparse.Namespace() for subarg in subargs: setattr(namespace, subarg, getattr(args, subarg)) return namespace def set_node_props(node, args): """Set up *node*'s properties to *args* which is a dictionary of values.""" for name in dir(node.props): if not name.startswith('_') and hasattr(args, name): value = getattr(args, name) if value is not None: LOG.debug("Setting {}:{} to {}".format(node.get_plugin_name(), name, value)) node.set_property(name, getattr(args, name)) def get_filenames(path): """ Get all filenams from *path*, which could be a directory or a pattern for matching files in a directory. """ if not path: return [] return sorted(glob.glob(os.path.join(path, '*') if os.path.isdir(path) else path)) def setup_read_task(task, path, args): """Set up *task* and take care of handling file types correctly.""" task.props.path = path fnames = get_filenames(path) if fnames and fnames[0].endswith('.raw'): if not args.width or not args.height: raise RuntimeError("Raw files require --width, --height and --bitdepth arguments.") task.props.raw_width = args.width task.props.raw_height = args.height task.props.raw_bitdepth = args.bitdepth def restrict_value(limits, dtype=float): """Convert value to *dtype* and make sure it is within *limits* (included) specified as tuple (min, max). If one of the tuple values is None it is ignored.""" def check(value=None, clamp=False): if value is None: return limits result = dtype(value) if limits[0] is not None and result < limits[0]: if clamp: result = dtype(limits[0]) else: raise argparse.ArgumentTypeError('Value cannot be less than {}'.format(limits[0])) if limits[1] is not None and result > limits[1]: if clamp: result = dtype(limits[1]) else: raise argparse.ArgumentTypeError('Value cannot be greater than {}'.format(limits[1])) return result check.dtype = dtype check.limits = limits return check def convert_filesize(value): multiplier = 1 conv = OrderedDict((('k', 2 ** 10), ('m', 2 ** 20), ('g', 2 ** 30), ('t', 2 ** 40))) if not value[-1].isdigit(): if value[-1] not in list(conv.keys()): raise argparse.ArgumentTypeError('--output-bytes-per-file must either be a ' + 'number or end with {} '.format(list(conv.keys())) + 'to indicate kilo, mega, giga or terabytes') multiplier = conv[value[-1]] value = value[:-1] value = int(float(value) * multiplier) if value < 0: raise argparse.ArgumentTypeError('--output-bytes-per-file cannot be less than zero') return value def tupleize(num_items=None, conv=float, dtype=tuple): """Convert comma-separated string values to a *num-items*-tuple of values converted with *conv*. """ def split_values(value=None): """Convert comma-separated string *value* to a tuple of numbers.""" if not value: # empty value or string return dtype([]) if type(value) is float or type(value) is int: return dtype([value]) try: result = dtype([conv(x) for x in value.split(',')]) except: raise argparse.ArgumentTypeError('Expect comma-separated tuple') if num_items and len(result) != num_items: raise argparse.ArgumentTypeError('Expected {} items'.format(num_items)) return result split_values.dtype = conv return split_values def next_power_of_two(number): """Compute the next power of two of the *number*.""" return 2 ** int(math.ceil(math.log(number, 2))) def read_image(filename, allow_multi=False): """Read image from file *filename*. In case of tif files, *filename* can be a regular expression matching more files. If *allow_multi* is True and there are more images in the *filename*, return them all, not only the first one. """ if os.path.isdir(filename): format_check = glob.glob(os.path.join(filename, "*"))[0] else: format_check = filename if format_check.lower().endswith('.tif') or format_check.lower().endswith('.tiff'): reader = TiffSequenceReader(filename) images = [reader.read(i) for i in range(reader.num_images)] return images if allow_multi else images[0] elif '.edf' in format_check.lower(): import fabio edf = fabio.edfimage.edfimage() edf.read(filename) return edf.data else: raise ValueError('Unsupported image format') def write_image(filename, image): import tifffile directory = os.path.dirname(filename) if directory: os.makedirs(directory, exist_ok=True) tifffile.imwrite(filename, image) def get_image_shape(filename): """Determine image shape (numpy order) from file *filename*.""" if filename.lower().endswith('.tif') or filename.lower().endswith('.tiff'): from tifffile import TiffFile with TiffFile(filename) as tif: page = tif.pages[0] shape = (page.imagelength, page.imagewidth) if len(tif.pages) > 1: shape = (len(tif.pages),) + shape else: # fabio doesn't seem to be able to read the shape without reading the data shape = read_image(filename).shape return shape def get_first_filename(path, valid_exts: list[str] = None): """Returns the first valid image filename in *path*. If *valid_exts* is set, only return files with the extension matching *valid_exts*.""" if not path: raise RuntimeError("Path to sinograms or projections not set.") filenames = get_filenames(path) if valid_exts is not None: filenames = [f for f in filenames if os.path.splitext(f)[1] in valid_exts] if not filenames: raise RuntimeError("No files found in `{}'".format(path)) return filenames[0] def determine_shape(args, path=None, store=False, do_raise=False): """Determine input shape from *args* which means either width and height are specified in args or try to read the *path* and determine the shape from it. The default path is args.projections, which is the typical place to find the input. If *store* is True, assign the determined values if they aren't already present in *args*. Return a tuple (width, height). If *do_raise* is True, raise an exception if shape cannot be determined. """ width = args.width height = args.height if not (width and height): filename = get_first_filename(path or args.projections) try: shape = get_image_shape(filename) # Now set the width and height if not specified width = width or shape[-1] height = height or shape[-2] except Exception as exc: LOG.info("Couldn't determine image dimensions from '{}'".format(filename)) if do_raise: raise exc if store: if not args.width: args.width = width if not args.height: args.height = height - args.y return (width, height) def get_filtering_padding(width): """Get the number of horizontal padded pixels in order to avoid convolution artifacts.""" return next_power_of_two(2 * width) - width def setup_padding(pad, width, height, mode, crop=None, pad_width=None, pad_height=0, centered=True): if pad_width is not None and pad_width < 0: raise ValueError("pad_width must be >= 0") if pad_height < 0: raise ValueError("pad_height must be >= 0") if pad_width is None: # Default is horizontal padding only pad_width = get_filtering_padding(width) pad.props.width = width + pad_width pad.props.height = height + pad_height pad.props.x = pad_width // 2 if centered else 0 pad.props.y = pad_height // 2 if centered else 0 pad.props.addressing_mode = mode LOG.debug( "Padding (x=0, y=0, w=%d, h=%d) -> (x=%d, y=%d, w=%d, h=%d) with mode `%s'", width, height, pad.props.x, pad.props.y, pad.props.width, pad.props.height, mode, ) if crop: # crop to original width after filtering crop.props.width = width crop.props.height = height crop.props.x = pad_width // 2 if centered else 0 crop.props.y = pad_height // 2 if centered else 0 return (pad_width, pad_height) def make_region(n, dtype=int): """Make region in such a way that in case of odd *n* it is centered around 0. Use *dtype* as data type. """ return (-dtype(n / 2), dtype(n / 2 + n % 2), dtype(1)) def get_reconstructed_cube_shape(x_region, y_region, z_region): """Get the shape of the reconstructed cube as (slice width, slice height, num slices).""" import numpy as np z_start, z_stop, z_step = z_region y_start, y_stop, y_step = y_region x_start, x_stop, x_step = x_region num_slices = len(np.arange(z_start, z_stop, z_step)) slice_height = len(np.arange(y_start, y_stop, y_step)) slice_width = len(np.arange(x_start, x_stop, x_step)) return slice_width, slice_height, num_slices def get_reconstruction_regions(params, store=False, dtype=int): """Compute reconstruction regions along all three axes, use *dtype* for as data type for x and y regions, z region is always float. """ width, height = determine_shape(params) if getattr(params, 'transpose_input', False): # In case down the pipeline there is a transpose task tmp = width width = height height = tmp if params.x_region[1] == -1: x_region = make_region(width, dtype=dtype) else: x_region = params.x_region if params.y_region[1] == -1: y_region = make_region(width, dtype=dtype) else: y_region = params.y_region if params.region[1] == -1: region = make_region(height, dtype=float) else: region = params.region LOG.info('X region: {}'.format(x_region)) LOG.info('Y region: {}'.format(y_region)) LOG.info('Parameter region: {}'.format(region)) if store: params.x_region = x_region params.y_region = y_region params.region = region return x_region, y_region, region def get_scarray_value(scarray, index): if len(scarray) == 1: return scarray[0] return scarray[index] def run_scheduler(scheduler, graph): from threading import Thread # Reuse resources until https://github.com/ufo-kit/ufo-core/issues/191 is solved. global RESOURCES if not RESOURCES: RESOURCES = Ufo.Resources() scheduler.set_resources(RESOURCES) thread = Thread(target=scheduler.run, args=(graph,)) thread.setDaemon(True) thread.start() try: thread.join() return True except KeyboardInterrupt: LOG.info('Processing interrupted') scheduler.abort() return False def fbp_filtering_in_phase_retrieval(args): if args.energy is None or args.propagation_distance is None: # No phase retrieval at all return False return ( args.projection_filter != 'none' and ( args.retrieval_method != 'tie' or args.tie_approximate_logarithm ) ) class Vector(object): """A vector based on axis-angle representation.""" def __init__(self, x_angle=0, y_angle=0, z_angle=0, position=None): import numpy as np self.position = np.array(position, dtype=float) if position is not None else None self.x_angle = x_angle self.y_angle = y_angle self.z_angle = z_angle def __repr__(self): return 'Vector(position={}, angles=({}, {}, {}))'.format(self.position, self.x_angle, self.y_angle, self.z_angle) def __str__(self): return repr(self) class FileSequenceReader: """Image sequence reader optimized for reading consecutive images. One multi-page image file is not closed after an image is read so that it does not have to be re-opened for reading the next image. The :func:`.close` function must be called explicitly in order to close the last opened image. """ def __init__(self, file_prefix, ext=''): if os.path.isdir(file_prefix): file_prefix = os.path.join(file_prefix, '*' + ext) self._filenames = sorted(glob.glob(file_prefix)) if not self._filenames: raise SequenceReaderError("No files matching `{}' found".format(file_prefix)) self._lengths = {} self._file = None self._filename = None def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): self.close() @property def num_images(self): num = 0 for filename in self._filenames: num += self._get_num_images_in_file(filename) return num def read(self, index): if index < 0: # Enables negative indexing index += self.num_images file_index = 0 while index >= 0: if file_index >= len(self._filenames): raise SequenceReaderError('image index greater than sequence length') index -= self._get_num_images_in_file(self._filenames[file_index]) file_index += 1 file_index -= 1 index += self._lengths[self._filenames[file_index]] self._open(self._filenames[file_index]) return self._read_real(index) def _open(self, filename): if self._filename != filename: if self._filename: self.close() self._file = self._open_real(filename) self._filename = filename def close(self): if self._filename: self._close_real() self._file = None self._filename = None def _get_num_images_in_file(self, filename): if filename not in self._lengths: self._open(filename) self._lengths[filename] = self._get_num_images_in_file_real() return self._lengths[filename] def _open_real(self, filename): """Returns an open file.""" raise NotImplementedError def _close_real(self, filename): raise NotImplementedError def _get_num_images_in_file_real(self): raise NotImplementedError def _read_real(self, index): raise NotImplementedError class TiffSequenceReader(FileSequenceReader): def __init__(self, file_prefix, ext='.tif'): super(TiffSequenceReader, self).__init__(file_prefix, ext=ext) def _open_real(self, filename): import tifffile return tifffile.TiffFile(filename) def _close_real(self): self._file.close() def _get_num_images_in_file_real(self): return len(self._file.pages) def _read_real(self, index): return self._file.pages[index].asarray() class SequenceReaderError(Exception): pass ufo-kit-tofu-ed0e5bd/tofu/vis/000077500000000000000000000000001521054151500163725ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/vis/__init__.py000066400000000000000000000000001521054151500204710ustar00rootroot00000000000000ufo-kit-tofu-ed0e5bd/tofu/vis/qt.py000066400000000000000000000133721521054151500173760ustar00rootroot00000000000000import pyqtgraph as pg try: import pyqtgraph.opengl as gl except ImportError: pass import logging import numpy as np import tifffile from PyQt5 import QtCore, QtWidgets LOG = logging.getLogger(__name__) def read_tiff(filename): tiff = tifffile.TiffFile(filename) array = tiff.asarray() return array.T def remove_extrema(data): upper = np.percentile(data, 99) lower = np.percentile(data, 1) data[data > upper] = upper data[data < lower] = lower return data def create_volume(data): gradient = (data - np.roll(data, 1))**2 cmin = gradient.min() div = gradient.max() - cmin gradient = (gradient - cmin) / div * 255 volume = np.empty(data.shape + (4, ), dtype=np.ubyte) volume[..., 0] = data volume[..., 1] = data volume[..., 2] = data volume[..., 3] = gradient return volume class ImageViewer(QtWidgets.QWidget): """ Present a sequence of files that can be browsed with a slider. To get the currently selected position connect to the *slider* attribute's valueChanged signal. """ def __init__(self, filenames, parent=None): super(ImageViewer, self).__init__(parent) image_view = pg.ImageView() image_view.getView().setAspectLocked(True) self.image_item = image_view.getImageItem() self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.slider.valueChanged.connect(self.update_image) self.main_layout = QtWidgets.QVBoxLayout(self) self.main_layout.addWidget(image_view) self.main_layout.addWidget(self.slider) self.setLayout(self.main_layout) self.load_files(filenames) def load_files(self, filenames): """Load *filenames* for display.""" self.filenames = filenames self.slider.setRange(0, len(self.filenames) - 1) self.slider.setSliderPosition(0) self.update_image() def update_image(self): """Update the currently display image.""" if self.filenames: pos = self.slider.value() image = read_tiff(self.filenames[pos]) self.image_item.setImage(image) class ImageWindow(object): """ Stand-alone window to display image sequences. """ global_app = None def __init__(self, filenames): self.global_app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) self.viewer = ImageViewer(filenames) self.viewer.show() class OverlapViewer(QtWidgets.QWidget): """ Presents two images by subtracting the flipped second from the first. To get the current deviation connect to the *slider* attribute's valueChanged signal. """ def __init__(self, parent=None, remove_extrema=False): super(OverlapViewer, self).__init__() image_view = pg.ImageView() image_view.getView().setAspectLocked(True) self.image_item = image_view.getImageItem() self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self.slider.setRange(0, 0) self.slider.valueChanged.connect(self.update_image) self.main_layout = QtWidgets.QVBoxLayout() self.main_layout.addWidget(image_view) self.main_layout.addWidget(self.slider) self.setLayout(self.main_layout) self.first, self.second = (None, None) self.remove_extrema = remove_extrema self.subtract = True def set_images(self, first, second): """Set *first* and *second* image.""" self.first, self.second = first.T, np.flipud(second.T) if self.remove_extrema: self.first = remove_extrema(self.first) self.second = remove_extrema(self.second) if self.first.shape != self.second.shape: LOG.warn("Shape {} of {} is different to {} of {}". format(self.first.shape, self.first, self.second.shape, self.second)) width = self.first.shape[0] self.slider.setRange(-width / 2, int(1.5 * width)) self.slider.setSliderPosition(self.first.shape[0] / 2) self.update_image() def set_position(self, position): self.slider.setValue(int(position)) self.update_image() def update_image(self): """Update the current subtraction.""" if self.first is None or self.second is None: LOG.warn("No images set yet") else: pos = self.slider.value() moved = np.roll(self.second, self.second.shape[0] // 2 - pos, axis=0) if self.subtract: self.image_item.setImage(moved - self.first) else: self.image_item.setImage(moved + self.first) class VolumeViewer(QtWidgets.QWidget): def __init__(self, step=1, density=1, parent=None): super(VolumeViewer, self).__init__(parent) self.volume_view = gl.GLViewWidget() self.main_layout = QtWidgets.QVBoxLayout() self.main_layout.addWidget(self.volume_view) self.setLayout(self.main_layout) self.step = step self.density = density def load_files(self, filenames): """Load *filenames* for display.""" filenames = filenames[::self.step] num = len(filenames) first = read_tiff(filenames[0])[::self.step, ::self.step] width, height = first.shape data = np.empty((width, height, num), dtype=np.float32) data[:,:,0] = first for i, filename in enumerate(filenames[1:]): data[:, :, i + 1] = read_tiff(filename)[::self.step, ::self.step] volume = create_volume(data) dx, dy, dz, _ = volume.shape volume_item = gl.GLVolumeItem(volume, sliceDensity=self.density) volume_item.translate(-dx / 2, -dy / 2, -dz / 2) volume_item.scale(0.05, 0.05, 0.05, local=False) self.volume_view.addItem(volume_item) ufo-kit-tofu-ed0e5bd/tox.ini000066400000000000000000000000771521054151500161330ustar00rootroot00000000000000[flake8] ignore = E402, E721, E722, W503 max-line-length = 100