pax_global_header00006660000000000000000000000064144356241030014514gustar00rootroot0000000000000052 comment=e38185943ef5c7dfd4eb4a70e5d49a485285378c CodraFT-2.2.1/000077500000000000000000000000001443562410300130005ustar00rootroot00000000000000CodraFT-2.2.1/.coveragerc000066400000000000000000000001561443562410300151230ustar00rootroot00000000000000[run] parallel = True omit = */codraft/utils/tests.py */codraft/tests/* */guidata/* */guiqwt/*CodraFT-2.2.1/.env000066400000000000000000000000141443562410300135640ustar00rootroot00000000000000PYTHONPATH=.CodraFT-2.2.1/.github/000077500000000000000000000000001443562410300143405ustar00rootroot00000000000000CodraFT-2.2.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001443562410300165235ustar00rootroot00000000000000CodraFT-2.2.1/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000013331443562410300212150ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Installation information** - CodraFT installation type ["Python package" or "stand-alone Windows version"] - Copy/paste here the contents of "About CodraFT installation..." window (Menu "?") **Additional context** Add any other context about the problem here. CodraFT-2.2.1/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011341443562410300222470ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. CodraFT-2.2.1/.gitignore000066400000000000000000000016421443562410300147730ustar00rootroot00000000000000winpython.env .spyderproject doc.zip Thumbs.db doctmp/ .vs/ *.pyproj *.sln releases/ *.chm .doctrees/ doc/install_requires.txt # Created by https://www.gitignore.io/api/python ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ _build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest # *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ /.spyproject CodraFT-2.2.1/.isort.cfg000066400000000000000000000000441443562410300146750ustar00rootroot00000000000000[settings] known_first_party=codraftCodraFT-2.2.1/.pylintrc000066400000000000000000000007221443562410300146460ustar00rootroot00000000000000[FORMAT] # Essential to be able to compare code side-by-side (`black` default setting) # and best compromise to minimize file size max-line-length=88 [TYPECHECK] ignored-modules=qtpy.QtWidgets,qtpy.QtCore,qtpy.QtGui,cv2 [MESSAGES CONTROL] disable=wrong-import-order [DESIGN] max-args=8 # default: 5 max-attributes=12 # default: 7 max-branches=17 # default: 12 max-locals=20 # default: 15 min-public-methods=0 # default: 2 max-public-methods=25 # default: 20CodraFT-2.2.1/.vscode/000077500000000000000000000000001443562410300143415ustar00rootroot00000000000000CodraFT-2.2.1/.vscode/launch.json000066400000000000000000000065401443562410300165130ustar00rootroot00000000000000{ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Run CodraFT", "type": "python", "request": "launch", "program": "${workspaceFolder}/codraft/app.py", "console": "integratedTerminal", "envFile": "${workspaceFolder}/dev.env", "python": "${config:python.defaultInterpreterPath}", "justMyCode": true, "env": { // "DEBUG": "1", "LANG": "en", "QT_COLOR_MODE": "light", } }, { "name": "Run Test Launcher", "type": "python", "request": "launch", "program": "${workspaceFolder}/codraft/tests/__init__.py", "console": "integratedTerminal", "envFile": "${workspaceFolder}/dev.env", "python": "${config:python.defaultInterpreterPath}", "justMyCode": true, "env": { // "DEBUG": "1", "QT_COLOR_MODE": "light", } }, { "name": "Run current file", "type": "python", "request": "launch", "program": "${file}", "console": "integratedTerminal", "envFile": "${workspaceFolder}/dev.env", "python": "${config:python.defaultInterpreterPath}", "justMyCode": false, "args": [ // "--h5browser", // "${workspaceFolder}/codraft/data/tests/format_v1.7.h5", // "C:/Dev/Projets/CodraFT_Test_CEA/codraft_test_cea/data/lmj_cbfx.h5,/A/valeur" // "${workspaceFolder}/codraft/data/tests/format_v1.7.h5,/CodraFT_Ima/i002: i002+i004", // "--mode", // "unattended", // "--verbose", // "quiet", // "screenshot", // "--delay", // "1" ], "env": { // "DEBUG": "1", // "TEST_SEGFAULT_ERROR": "1", "LANG": "en", "QT_COLOR_MODE": "light", } }, { "name": "Profile current file", "type": "python", "request": "launch", "module": "cProfile", "console": "integratedTerminal", "envFile": "${workspaceFolder}/dev.env", "python": "${config:python.defaultInterpreterPath}", "args": [ "-o", "${file}.prof", "${file}" ], }, { "name": "Run H5browser", "type": "python", "request": "launch", "program": "${workspaceFolder}/codraft_test_cea/tests/h5browser1_test.py", "console": "integratedTerminal", "envFile": "${workspaceFolder}/dev.env", "python": "${config:python.defaultInterpreterPath}", "justMyCode": true, "env": { "DEBUG": "1", // "QT_COLOR_MODE": "light", }, "args": [ // "--mode", // "unattended", ], }, ] }CodraFT-2.2.1/.vscode/settings.json000066400000000000000000000016721443562410300171020ustar00rootroot00000000000000{ "[bat]": { "files.encoding": "cp850", }, "editor.rulers": [ 88 ], "files.exclude": { "**/__pycache__": true, "**/*.pyc": true, "**/*.pyo": true }, "files.trimFinalNewlines": true, "files.trimTrailingWhitespace": true, "python.defaultInterpreterPath": "${env:PYTHON_CODRAFT_DEV}", "editor.formatOnSave": true, "python.sortImports.args": [ "--profile", "black" ], "[python]": { "editor.codeActionsOnSave": { "source.organizeImports": true } }, "python.linting.pycodestyleArgs": [ "--max-line-length=88", "--ignore=E203,W503" ], "python.linting.pycodestyleEnabled": true, "python.formatting.provider": "black", "esbonio.server.enabled": true, "restructuredtext.linter.doc8.extraArgs": [ "--ignore=D004" ], "esbonio.sphinx.confDir": "${workspaceFolder}\\doc" }CodraFT-2.2.1/.vscode/tasks.json000066400000000000000000000230621443562410300163640ustar00rootroot00000000000000{ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ { "label": "gettext - Scan", "type": "shell", "command": "cmd", "args": [ "/c", "gettext_scan.bat", ], "options": { "cwd": "scripts", "env": { "UNATTENDED": "1", "PYTHON": "${env:PYTHON_CODRAFT_DEV}", } }, "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": false } }, { "label": "gettext - Compile", "type": "shell", "command": "cmd", "args": [ "/c", "gettext.bat", "compile", ], "options": { "cwd": "scripts", "env": { "UNATTENDED": "1", "PYTHON": "${env:PYTHON_CODRAFT_DEV}", } }, "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": false } }, { "label": "Run Pylint", "type": "shell", "command": "cmd", "args": [ "/c", "run_pylint.bat", "--disable=fixme", "codraft", ], "options": { "cwd": "scripts", "env": { "UNATTENDED": "1", "PYTHON": "${env:PYTHON_CODRAFT_DEV}", } }, "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "dedicated", "showReuseMessage": true, "clear": true } }, { "label": "Run Coverage", "type": "shell", "command": "cmd", "args": [ "/c", "run_coverage.bat", // "--contains", // "scenario", ], "options": { "cwd": "scripts", "env": { "UNATTENDED": "1", "PYTHON": "${env:PYTHON_CODRAFT_DEV}", } }, "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "dedicated", "showReuseMessage": true, "clear": true } }, { "label": "Upgrade environment", "type": "shell", "command": "cmd", "args": [ "/c", "upgrade_env.bat" ], "options": { "cwd": "scripts", "env": { "UNATTENDED": "1", "PYTHON": "${env:PYTHON_CODRAFT_DEV}", } }, "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": false } }, { "label": "Clean Up", "type": "shell", "command": "cmd", "args": [ "/c", "clean_up.bat" ], "options": { "cwd": "scripts", }, "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": false } }, { "label": "Create executable", "type": "shell", "command": "cmd", "options": { "cwd": "scripts", "env": { "PYTHON": "${env:PYTHON_CODRAFT_DEV}", "UNATTENDED": "1", } }, "args": [ "/c", "build_exe.bat" ], "problemMatcher": [], "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": true } }, { "label": "Create installer", "type": "shell", "command": "cmd", "options": { "cwd": "scripts", "env": { "PYTHON": "${env:PYTHON_CODRAFT_DEV}", "UNATTENDED": "1", "NSIS_COPYRIGHT_INFO": "Copyright (c) CEA-CODRA 2019-2022", "NSIS_HELP_LINK": "https://codraft.readthedocs.io/en/latest/", "NSIS_URLUPDATEINFO": "https://github.com/CODRA-Ingenierie-Informatique/CodraFT/releases", "NSIS_URLINFOABOUT": "https://github.com/CODRA-Ingenierie-Informatique/CodraFT", } }, "args": [ "/c", "build_installer.bat" ], "problemMatcher": [], "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": true } }, { "label": "Build documentation", "type": "shell", "command": "cmd", "options": { "cwd": "scripts", "env": { "PYTHON": "${env:PYTHON_CODRAFT_DEV}", "PYTHONPATH": "${env:PYTHONPATH_CODRAFT}", "QT_COLOR_MODE": "light", "UNATTENDED": "1", } }, "args": [ "/c", "build_doc.bat" ], "problemMatcher": [], "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": true } }, { "label": "Build Python packages", "type": "shell", "command": "cmd", "options": { "cwd": "scripts", "env": { "PYTHON": "${env:PYTHON_CODRAFT_DEV}", "UNATTENDED": "1", } }, "args": [ "/c", "build_dist.bat" ], "problemMatcher": [], "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": true }, "dependsOrder": "sequence", "dependsOn": [ "Build documentation", ] }, { "label": "New release", "type": "shell", "command": "cmd", "args": [ "/c", "release.bat" ], "options": { "cwd": "scripts", "env": { "PYTHON": "${env:PYTHON_CODRAFT_DEV}", "UNATTENDED": "1", } }, "problemMatcher": [], "group": { "kind": "build", "isDefault": true }, "presentation": { "echo": true, "reveal": "always", "focus": false, "panel": "shared", "showReuseMessage": true, "clear": true }, "dependsOrder": "sequence", "dependsOn": [ "Clean Up", "Build Python packages", "Create executable", "Create installer", ] }, ] }CodraFT-2.2.1/CHANGELOG.md000066400000000000000000000534351443562410300146230ustar00rootroot00000000000000# CodraFT Releases # See CodraFT [roadmap page](https://codraft.readthedocs.io/en/latest/roadmap.html) for future and past milestones. ## Version 2.2.1 ## Bug fixes: * Fixed 1D FFT (added optionnal frequency shift) * Fixed ROI/pixel alignment issue ## Version 2.2.0 ## New features: * Images: added support for XYZ image files * All shapes: removed shape drag symbols, so that background image is no longer masked by small-sized shapes * At startup, restoring last current panel (image or signal panel) * Plot cleanup and shape management: greatly optimized performance * After removing object(s) (signal/image), the previous object in the list is selected * Added default image visualization settings in .INI configuration file * Using guiqwt v4.3.2: fixed pixel position (first pixel is centered at (0,0) coords) ## Version 2.1.4 ## Bug fixes: * HDF5 import/browser features: added support for non-ASCII dataset names * ANDOR SIF files: * Fixed compatibility issues for various SIF files * Fixed unicode error * Image Contour detection: * Fixed level default value for 8-bit data * Added missing "level" parameter * Dev/VSCode: simplified `launch.json` and fixed environment variable substitution issue Other changes: * Alpha/beta release: fixed installer, added warning ## Version 2.1.3 ## Bug fixes: * Panel's object list `select_rows` method: fixed plot refresh behavior in case of multiple selection (refresh widget only once) * LMJ-formatted HDF5 file: now reading invalid compound datasets * [Issue #16](https://github.com/CODRA-Ingenierie-Informatique/CodraFT/issues/16) - Embedding CodraFT: "add_object" method call with invalid data should lead to app crash * Panel's `add_object` method (public API): check data type before adding object to panel - this prevents CodraFT from crashing when trying to plot invalid data type afterwards * Now handling exceptions in `add_object` and `insert_object` methods * Multigaussian curve fitting: fixed default fit parameters * Improved I/O application test with respect to unsupported filetypes Other changes: * Images: added support for `numpy.int32` datatype * Added unit tests for all curve fitting dialogs ## Version 2.1.2 ## Bug fixes: * [Pull Request #2](https://github.com/CODRA-Ingenierie-Informatique/CodraFT/pull/2) - Load / Save conventional CSVs, by [@aanastasiou](https://github.com/aanastasiou) * [Issue #3](https://github.com/CODRA-Ingenierie-Informatique/CodraFT/issues/3) - Wrong units/titles are displayed * [Issue #6](https://github.com/CODRA-Ingenierie-Informatique/CodraFT/issues/6) - 2D peak detection: GUI freezes when creating ROIs * [Issue #4](https://github.com/CODRA-Ingenierie-Informatique/CodraFT/issues/4) - Processing multiple images/signals: avoid unnecessary time-consuming plot updates * [Issue #7](https://github.com/CODRA-Ingenierie-Informatique/CodraFT/issues/7) - Image/Circular ROI: IndexError when circle exceeds the image size * [Issue #5](https://github.com/CODRA-Ingenierie-Informatique/CodraFT/issues/5) - ROI dialog box: unable to remove all ROIs and validate * [Issue #8](https://github.com/CODRA-Ingenierie-Informatique/CodraFT/issues/8) - HDF5 import: unable to easily distinguish datasets with the same name but different path * Average operation now merges ROI data (i.e. same behavior as sum) * Fixed multiple regressions with ROI management (adding, removing ROI, ...) Other changes: * Optimized load time (especially for images): avoid unnecessary refresh when adding objects * Added "Remove regions of interest" entry to "Computing" menu (and context menu) * Signal/image list: added tooltip showing a summary of metadata values (e.g. when importing data from HDF5, this shows HDF5 filename and HDF5 dataset path) - Issue #8 * Dependencies hash check: feature is now OS-dependent (+ more explicit messages) * Slightly improved test coverage ## Version 2.1.1 ## Changes: * Image Regions Of Interest (ROI): * ROIs are now shown as masks (areas outside ROIs are shaded) * Added support for circular ROIs * ROIs now take into account pixel size (dx, dy) as well as origin (x0, y0) * Signal and Image ROIs: * New default extract mode: creating as many signals/images as ROIs (each ROI is extracted into a single signal/image) * The old extract mode (single signal/image output) is still available and may be enabled using the new checkbox added in ROI extraction dialog box * Image visualization: * Added "Show contrast panel" option in toolbar and view menu * By default, contrast panel is now visible * When multiple images are selected, the first image LUT range is applied to all * "View in a new window": now opens non-modal dialogs, thus allowing to visualize multiple signals or images in separate windows * Added demo mode (from command line, simply run: codraft-demo) * Command line option --h5 is now a positionnal argument (h5) * Added command line option -b (or --h5browser) to browse a HDF5 file at startup * Added command line option --version to show CodraFT version Bug fixes: * Image computations now takes into account origin (x0, y0), pixel size (dx, dy) as well as regions of interest (related features: centroid, enclosing circle, 2D peak detection and contour detection) * Image ROI definition dialog: maximum rows and columns were erroneously truncated * Centralized argument parsing in CodraFT exec env object, thus avoiding conflicts ## Version 2.0.3 ## Bug fixes: * Fixed pen.setWidth TypeError on Linux Other changes: * Added an option to ignore dependency check warning at startup * Installation configuration viewer: added info on dependency check result * Ignore when unable to save h5 in ima/sig test scenarios ## Version 2.0.2 ## The following major changes were introduced with CodraFT V2: * Fully automated high-level processing features for internal testing purpose, as well as embedding CodraFT in a third-party software * Extensive test suite (unit tests and application tests) with 90% feature coverage * Segmentation fault and Python exception logging * Customizable annotations for both signals and images ### Release key features ### * New data visualization and processing features: | Signal | Image | Feature | |:------:|:-----:|--------------------------------------------------------| | | • | Automatic 2D-peak detection | | | • | Automatic contour extraction (circle/ellipse fit) | | • | • | Multiple Regions of Interest (ROIs) | | | • | User-defined annotations (labels and geometric shapes) | | • | • | "Statistics" computing feature | * Automation of high-level processing features: added fully automated high-level test scenarios, and enhanced public API for embedding CodraFT into a third-party application * Test Driven Development with high quality standards (pylint score >= 9.8/10, test coverage >= 90%) ### Detailed feature list ### New data visualization and processing features: * Image: * New automatic image contour detection feature returning fitted circle/ellipse * New automatic 2D peak detection feature (optionally create ROIs) * "View in a new window": added customizable "Annotations" support for both signal and image panels - supports user-defined annotations (points, segments, circles, ellipses, labels,...) which are serialized in image metadata * Added "Show graphical object titles" option in "View" menu to show or hide the title (or subtitle) of ROIs or any other graphical object * Added support for **multiple** Regions of Interest (ROI): * All "Computing" menu features apply to multiple ROIs * Computation result arrays now contains ROI index (first column) and one row per ROI * ROI are merged when summing objects (signals or images) * ROI can be removed, modified or added at any time * Added option "Show graphical object titles" ("View" menu) to show or hide ROI titles or any other geometrical shapes title (or subtitle) * New computing "Statistics" feature showing a table with statistics on image/signal and eventually regions of interest (min, max, mean, standard deviation, sum, ...) New general purpose features: * Memory management: * New available memory indicator on main window status bar * New warning dialog box when trying to open/create data if available memory is below the "available_memory_threshold" defined in CodraFT configuration file (default: 500MB) * Error handling: * New integrated log file viewer * New warning dialog box at startup suggesting to view log files when logs were generated during last session * Logging segmentation faults in ".CodraFT_faulthandler.log" * Logging Python exceptions in ".CodraFT_traceback.log" * Signal/Image metadata: * New copy/paste feature: update object metadata from another one * New import/export feature: import-export object metadata (JSON text file) using the new "Import metadata into" / "Export metadata from" entries in "File" menu * HDF5 browser feature: complete redesign (better compatibility, evolutive design, ...) * Added support for multiple HDF5 files opening at once * Added `.CodraFT.ini` configuration file (user home directory): * New configuration file entry: current working directory * New configuration file entry: current main window size and position * New configuration file entry: embedded Python console enable state * New configuration file entry: available memory alarm threshold New test-related features: * Added non-interactive tests, opening the way for unit tests with better coverage * Added "unattended" and "screenshot" execution modes respectively for testing and documentation purpose * Added automated high-level test scenarios (signal and image processing) * Tests are now splitted in two categories: unit tests (`*_unit.py`) and application tests (`*_app.py`). * Added Coverage.py support * Added "all_tests.py" to run all tests in unattended mode New dependencies: * [scikit-image](https://pypi.org/project/scikit-image/) * [psutil](https://pypi.org/project/psutil/) Other changes (on existing features): * Image and Signal: * Object properties panel: added data type information (feature refactored upstream to guidata) * New random signal/image: added support for both Normal and Uniform distributions * Operations "sum" and "average" now merge metadata results * Computed titles "s/i000" are now renamed after inserting/removing an object * Computing results (geometrical shapes: segment, circle, ellipse): numerical results are now automatically added to metadata (respectively: length, center and radius, center, a and b) * Image: * Added support for image origin and pixel size * Flat field correction: added threshold parameter * "New image" now creates an image with the same data type as selected image * "New image" now supports uint16 data type * Signal: * Peak detection: added minimal distance parameter * Fit dialog / plot: do auto scale at startup * Peak detection dialog: preselect horizontal cursor at startup * `codraft.core.gui` code refactoring: added subpackage `core.gui.processor` * Added "Browse HDF5" action to main window ("Open HDF5" now imports all data) Bug fixes: * HDF5 file import: converted `bytes` metadata to `str` * Added h5py to requirements (setup.py) * Plot: reintroduced pure white background in light mode (white background was removed unintentionally when introducing dark mode) * Image: * "Clean-up data view" feature was accidently removing grid * Fixed hard crash when trying to visualize images with NaNs (use case: result of any filter on `uint8` image) * Fixed hard crash when using image Z-axis log scale on some images * Fixed DICOM support * Fixed hard crash in "to_codraft" (cross section item with empty data) * Fixed image visualization parameters update from metadata * MinEnclosingCircle: fixed sqrt(2) error * Signal: * "Clean-up data view" feature was accidently removing legend box and grid * Fixed integral (missing initial point) * Fixed plotting support for complex data * Fixed signal visualization parameters update from metadata ## Version 1.7.2 ## Bug fixes: * Fixed unit test "app1_test.py" (create a single QApp) * Fixed progress bar cancel issues (when passing HDF5 files to `app.run` function) * Fixed random hard crash when opening curve fitting dialog * Fixed curve fitting dialog parenting * ROI metadata is now removed (because potentially invalid) after performing a computation that changes X-axis or Y-axis data (e.g. ROI extraction, image flip, image rotation, etc.) * Fixed image creation features (broken since major refactoring) Other changes: * Removed deprecated Qt `exec_` calls (replaced by `exec`) * Added more infos on uninstaller registry keys * Added documentation on key features ## Version 1.7.1 ## Added first page of documentation (there is a beginning to everything...). Bug fixes: * Cross section tool was working only on first image in item list * Separate view was broken since major refactoring ## Version 1.7.0 ## New features: * Python 3.8 is now the reference Python release * Dropped Python 2 and PyQt 4 support * Major code cleaning and refactoring * Reorganized the whole code base * Added more unit tests * Added GUI-based test launcher * Added isort/black code formatting * Switched from cx_Freeze to pyinstaller for generating the stand-alone version * Improved pylint score up to 9.90/10 with strict quality criteria ## Version 1.6.0 ## New features: * Added dependencies check on startup: warn the user if at least one dependency has been altered (i.e. the application has not been qualified in this context) * Added py3compat (since QtPy is dropping Python 3 support) ## Version 1.5.0 ## New features: * Sum, average, difference, multiplication: re-converting data to initial type. * Now supporting PySide2/PyQt4/PyQt5 compatibility thanks to guidata >= v1.7.9 (using QtPy). * Now supporting Python 3.9 and NumPy 1.20. Bug fixes: * Fixed cross section retrieval feature: in stand-alone mode, a new CodraFT window was created (that is not the expected behavior). * Fixed crash when enabling cross sections on main window (needs PythonQwt 0.9.2). * Fixed ValueError when generating a 2D-gaussian image with floats. * HDF5 file import feature: * Fixed unit processing (parsing) with Python 3. * Fixed critical bug when clicking on "Check all". ## Version 1.4.4 ## New experimental features: * Experimental support for PySide2/PyQt4/PyQt5 thanks to guidata >= v1.7.9 (using QtPy). * Experimental support for Python 3.9 and NumPy 1.20. New minor features: * ZAxisLogTool: update automatically Z-axis scale (+ showing real value) * Added contrast test (following issues with "eliminate_outliers") ## Version 1.4.3 ## New minor features: * New test script for global application test (test_app.py). * Improved CodraFT launcher (app.py). ## Version 1.4.2 ## New minor features: * LMJ-formatted HDF5 file import: tree widget item's tooltip now shows item data "description". Bug fixes: * Fixed runtime warnings when computing centroid coordinates on an image ROI filled with zeros. * LMJ-formatted HDF5 file support: fixed truncated units. ## Version 1.4.1 ## Bug fixes: * Fixed LMJ-formatted HDF5 files: strings are encoded in "latin-1" which is not the expected behavior ("utf-8" is the expected encoding for ensuring better compatibility). ## Version 1.4.0 ## New features: * LMJ-formatted HDF5 file import: added support for axis units and labels. * New curve style behavior (more readable): unselecting items by default, circling over curve colors when selecting multiple curve items. Bug fixes: * Fixed LMJ-formatted HDF5 file support in CodraFT data import feature. ## Version 1.3.1 ## Bug fixes: * Improved support for LMJ-formatted HDF5 files. * Z-axis logscale feature: freeing memory when mode is off. * CodraFTMainWindow.get_instance: create instance if it doesn't already exist. * to_codraft: show CodraFT main window on top, if not already visible. * Patch/guiqwt.histogram: removing histogram curve (if necessary) when image item has been removed. ## Version 1.3.0 ## New features: * Image computations: added "Smallest enclosing circle center" computation. * Added support for FXD image file type. Bug fixes: * Fixed image levels "Log scale" feature for Python 3 compatibility. ## Version 1.2.2 ## New features: * Added "Delete all" entry to "Edit" menu: this removes all objects (signals or images) from current view. * Added an option "hide_on_close" to CodraFTMainWindow class constructor (default value is False): when set to True, CodraFT main window will simply hide when "Close" button is clicked, which is the expected behavior when embedding CodraFT in another application. Bug fixes: * The memory leak fix in app.py was accidentally commented before commit. ## Version 1.2.1 ## Bug fixes: * When quitting CodraFT, objects were not deleted: this was causing a memory leak when embedding CodraFT in another Qt window. * When canceling HDF5 import dialog box after selecting at least one signal or image, the progress bar was shown even if no data was being imported. * When closing HDF5 import dialog box, preview signal/image widgets were not deleted, hence causing another memory leak. ## Version 1.2.0 ## New features: * Added support for uint32 images (converting to int32 data) * Added "Z-axis logarithmic scale" feature for image items (check out the new entries in standard image toolbar and context menu) * Added "HDF5 I/O Toolbar" to avoid a frequently reported user confusion between HDF5 I/O icons and Signal/Image specific I/O icons (i.e. open and save actions) * Cross-section panels are now configured to show only cross-section curves associated to the currently selected image (instead of showing all curves, including those associated to hidden images) * Image subtraction: now handling integer underflow Bug fixes: * When "Clean up data view" option was enabled, image histogram was not updated properly when changing image selection (histogram was the sum of all images histograms). * Changed default image levels histogram "eliminate outliers" value: .1% instead of 2% to avoid display bug for noise background images for example (i.e. images with high contrast and very narrow histogram levels) ## Version 1.1.2 ## Bug fixes: * When the X/Y Cross Section widget is embedded into a main window other than CodraFT's, clicking on the "Process signal" button will send the signal to CodraFT's signal panel for further processing, as expected. ## Version 1.1.1 ## Bug fixes: * Fixed a bug leading to "None" titles when importing signals/images from HDF5 files created outside CodraFT. ## Version 1.1.0 ## New features: * Added new icons. * Images: * Added support for SPIRICON image files (single-frame support only). Bug fixes: * Fixed a critical bug when opening HDF5 file (bug from "guidata" package). Now guidata is patched inside CodraFT to take into account the unusual/risky PyQt patch from Taurus package (PyQt API is set to 2 for QString objects and instead of raising an ImportError when importing QString from PyQt4.QtCore, QString still exists and is replaced by "str"...). * Images: * Centroid feature: coordinates were mixed up in CodraFT application. * Signals: * Curve fitting (gaussian and lorentzian): fixed amplitude initial value for automatic fitting feature * FWHM and FW1/e²: fixed amplitude computation for input fit parameters and output results ## Version 1.0.0 ## Copyright © 2018 Codra, Pierre Raybaut, licensed under the terms of the CECILL License v2.1. First release of `CodraFT`. New features: * Added support for both Python 3 and Python 2.7, and both PyQt5 and PyQt4. * Added HDF5 file reading support, using a new HDF5 browser with embedded curve and image preview. * Signal and Image: * Added menu "Computing" for computing scalar values from signals/images. * Added "ROI definition" for "Computing" features * Added absolute value operation. * Added 10 base logarithm operation. * Added moving average/median filtering feature. * Images: * Added support for Andor SIF image files (support multiple frames). * Added centroid computing feature. * Added support for images containing NaN values. * Signals: * Added FWHM computing feature (based on curve fitting) * Added Full Width at 1/e² computing feature (based on gaussian fitting) * Added derivative and integral computation features. * Added "lorentzian" and "Voigt" to "new signals" available. * Added curve fitting feature supporting various models (polynomial, gaussian, lorentzian, Voigt and multi-gaussian). Computed fitting parameters are stored in signal's metadata (a new dictionnary item for the Signal objects) * Edit menu: added a new "View in a new window" action * Added standard keyboard shortcuts (new, open, copy, etc.) * "New image": added new 2D-gaussian creation feature * Added a GUI-based ROI extraction feature for both signal and image views * Added a pop-up dialog when double-clicking on a signal/image to allow visualizing things on a possibly large window * Added a peak detection feature * Added centroid coordinates in image statistics tool * Added support for curve/image titles, axis labels and axis units (those can be modified through the editable form within the "Properties" groupbox) * Added support for cross section extraction from the image widget to the signal tab ; the extracted curve's title shows the associated coordinates * Added deployment script for building self-consistent executable distribution using the cx_Freeze tool * Improved curve visual: background is now flat and white Bug fixes: * Console dockwidget is now created after the `View` menu so that it appears in it, as expected. It is now hidden by default. * Improved curve visual when selected: instead of adding big black squares along a selected curve, the curve line is simply broader when selected. CodraFT-2.2.1/CodraFT.spec000066400000000000000000000036721443562410300151460ustar00rootroot00000000000000# -*- mode: python ; coding: utf-8 -*- block_cipher = None import sys sitepackages = os.path.join(sys.prefix, 'Lib', 'site-packages') guidata_images = os.path.join(sitepackages, 'guidata', 'images') guidata_locale = os.path.join(sitepackages, 'guidata', 'locale', 'fr', 'LC_MESSAGES') guiqwt_images = os.path.join(sitepackages, 'guiqwt', 'images') guiqwt_locale = os.path.join(sitepackages, 'guiqwt', 'locale', 'fr', 'LC_MESSAGES') a = Analysis(['codraft\\start.pyw'], pathex=[], binaries=[], datas=[ (guidata_images, 'guidata\\images'), (guidata_locale, 'guidata\\locale\\fr\\LC_MESSAGES'), (guiqwt_images, 'guiqwt\\images'), (guiqwt_locale, 'guiqwt\\locale\\fr\\LC_MESSAGES'), ('codraft\\data', 'codraft\\data'), ('codraft\\locale\\fr\\LC_MESSAGES\\codraft.mo', 'codraft\\locale\\fr\\LC_MESSAGES'), ], hiddenimports=[], hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE(pyz, a.scripts, [], exclude_binaries=True, name='CodraFT', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, console=False, disable_windowed_traceback=False, target_arch=None, codesign_identity=None, entitlements_file=None , icon='resources\\codraft.ico') coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='CodraFT') CodraFT-2.2.1/LICENSE000066400000000000000000000034351443562410300140120ustar00rootroot00000000000000The Licensee is authorized to use the Software according to one of the following compatible agreements: * BSD 3-Clause License Agreement (see below) * CeCILL-B License Agreement (see Licence_CeCILL-B_V1-en.txt) ---------------------------------------------------------------------------------------- BSD 3-Clause License Copyright (c) 2022, CEA-CODRA All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. CodraFT-2.2.1/Licence_CeCILL-B_V1-en.txt000066400000000000000000000516241443562410300173130ustar00rootroot00000000000000 CeCILL-B FREE SOFTWARE LICENSE AGREEMENT Notice This Agreement is a Free Software license agreement that is the result of discussions between its authors in order to ensure compliance with the two main principles guiding its drafting: * firstly, compliance with the principles governing the distribution of Free Software: access to source code, broad rights granted to users, * secondly, the election of a governing law, French law, with which it is conformant, both as regards the law of torts and intellectual property law, and the protection that it offers to both authors and holders of the economic rights over software. The authors of the CeCILL-B (for Ce[a] C[nrs] I[nria] L[ogiciel] L[ibre]) license are: Commissariat à l'Energie Atomique - CEA, a public scientific, technical and industrial research establishment, having its principal place of business at 25 rue Leblanc, immeuble Le Ponant D, 75015 Paris, France. Centre National de la Recherche Scientifique - CNRS, a public scientific and technological establishment, having its principal place of business at 3 rue Michel-Ange, 75794 Paris cedex 16, France. Institut National de Recherche en Informatique et en Automatique - INRIA, a public scientific and technological establishment, having its principal place of business at Domaine de Voluceau, Rocquencourt, BP 105, 78153 Le Chesnay cedex, France. Preamble This Agreement is an open source software license intended to give users significant freedom to modify and redistribute the software licensed hereunder. The exercising of this freedom is conditional upon a strong obligation of giving credits for everybody that distributes a software incorporating a software ruled by the current license so as all contributions to be properly identified and acknowledged. In consideration of access to the source code and the rights to copy, modify and redistribute granted by the license, users are provided only with a limited warranty and the software's author, the holder of the economic rights, and the successive licensors only have limited liability. In this respect, the risks associated with loading, using, modifying and/or developing or reproducing the software by the user are brought to the user's attention, given its Free Software status, which may make it complicated to use, with the result that its use is reserved for developers and experienced professionals having in-depth computer knowledge. Users are therefore encouraged to load and test the suitability of the software as regards their requirements in conditions enabling the security of their systems and/or data to be ensured and, more generally, to use and operate it in the same conditions of security. This Agreement may be freely reproduced and published, provided it is not altered, and that no provisions are either added or removed herefrom. This Agreement may apply to any or all software for which the holder of the economic rights decides to submit the use thereof to its provisions. Article 1 - DEFINITIONS For the purpose of this Agreement, when the following expressions commence with a capital letter, they shall have the following meaning: Agreement: means this license agreement, and its possible subsequent versions and annexes. Software: means the software in its Object Code and/or Source Code form and, where applicable, its documentation, "as is" when the Licensee accepts the Agreement. Initial Software: means the Software in its Source Code and possibly its Object Code form and, where applicable, its documentation, "as is" when it is first distributed under the terms and conditions of the Agreement. Modified Software: means the Software modified by at least one Contribution. Source Code: means all the Software's instructions and program lines to which access is required so as to modify the Software. Object Code: means the binary files originating from the compilation of the Source Code. Holder: means the holder(s) of the economic rights over the Initial Software. Licensee: means the Software user(s) having accepted the Agreement. Contributor: means a Licensee having made at least one Contribution. Licensor: means the Holder, or any other individual or legal entity, who distributes the Software under the Agreement. Contribution: means any or all modifications, corrections, translations, adaptations and/or new functions integrated into the Software by any or all Contributors, as well as any or all Internal Modules. Module: means a set of sources files including their documentation that enables supplementary functions or services in addition to those offered by the Software. External Module: means any or all Modules, not derived from the Software, so that this Module and the Software run in separate address spaces, with one calling the other when they are run. Internal Module: means any or all Module, connected to the Software so that they both execute in the same address space. Parties: mean both the Licensee and the Licensor. These expressions may be used both in singular and plural form. Article 2 - PURPOSE The purpose of the Agreement is the grant by the Licensor to the Licensee of a non-exclusive, transferable and worldwide license for the Software as set forth in Article 5 hereinafter for the whole term of the protection granted by the rights over said Software. Article 3 - ACCEPTANCE 3.1 The Licensee shall be deemed as having accepted the terms and conditions of this Agreement upon the occurrence of the first of the following events: * (i) loading the Software by any or all means, notably, by downloading from a remote server, or by loading from a physical medium; * (ii) the first time the Licensee exercises any of the rights granted hereunder. 3.2 One copy of the Agreement, containing a notice relating to the characteristics of the Software, to the limited warranty, and to the fact that its use is restricted to experienced users has been provided to the Licensee prior to its acceptance as set forth in Article 3.1 hereinabove, and the Licensee hereby acknowledges that it has read and understood it. Article 4 - EFFECTIVE DATE AND TERM 4.1 EFFECTIVE DATE The Agreement shall become effective on the date when it is accepted by the Licensee as set forth in Article 3.1. 4.2 TERM The Agreement shall remain in force for the entire legal term of protection of the economic rights over the Software. Article 5 - SCOPE OF RIGHTS GRANTED The Licensor hereby grants to the Licensee, who accepts, the following rights over the Software for any or all use, and for the term of the Agreement, on the basis of the terms and conditions set forth hereinafter. Besides, if the Licensor owns or comes to own one or more patents protecting all or part of the functions of the Software or of its components, the Licensor undertakes not to enforce the rights granted by these patents against successive Licensees using, exploiting or modifying the Software. If these patents are transferred, the Licensor undertakes to have the transferees subscribe to the obligations set forth in this paragraph. 5.1 RIGHT OF USE The Licensee is authorized to use the Software, without any limitation as to its fields of application, with it being hereinafter specified that this comprises: 1. permanent or temporary reproduction of all or part of the Software by any or all means and in any or all form. 2. loading, displaying, running, or storing the Software on any or all medium. 3. entitlement to observe, study or test its operation so as to determine the ideas and principles behind any or all constituent elements of said Software. This shall apply when the Licensee carries out any or all loading, displaying, running, transmission or storage operation as regards the Software, that it is entitled to carry out hereunder. 5.2 ENTITLEMENT TO MAKE CONTRIBUTIONS The right to make Contributions includes the right to translate, adapt, arrange, or make any or all modifications to the Software, and the right to reproduce the resulting software. The Licensee is authorized to make any or all Contributions to the Software provided that it includes an explicit notice that it is the author of said Contribution and indicates the date of the creation thereof. 5.3 RIGHT OF DISTRIBUTION In particular, the right of distribution includes the right to publish, transmit and communicate the Software to the general public on any or all medium, and by any or all means, and the right to market, either in consideration of a fee, or free of charge, one or more copies of the Software by any means. The Licensee is further authorized to distribute copies of the modified or unmodified Software to third parties according to the terms and conditions set forth hereinafter. 5.3.1 DISTRIBUTION OF SOFTWARE WITHOUT MODIFICATION The Licensee is authorized to distribute true copies of the Software in Source Code or Object Code form, provided that said distribution complies with all the provisions of the Agreement and is accompanied by: 1. a copy of the Agreement, 2. a notice relating to the limitation of both the Licensor's warranty and liability as set forth in Articles 8 and 9, and that, in the event that only the Object Code of the Software is redistributed, the Licensee allows effective access to the full Source Code of the Software at a minimum during the entire period of its distribution of the Software, it being understood that the additional cost of acquiring the Source Code shall not exceed the cost of transferring the data. 5.3.2 DISTRIBUTION OF MODIFIED SOFTWARE If the Licensee makes any Contribution to the Software, the resulting Modified Software may be distributed under a license agreement other than this Agreement subject to compliance with the provisions of Article 5.3.4. 5.3.3 DISTRIBUTION OF EXTERNAL MODULES When the Licensee has developed an External Module, the terms and conditions of this Agreement do not apply to said External Module, that may be distributed under a separate license agreement. 5.3.4 CREDITS Any Licensee who may distribute a Modified Software hereby expressly agrees to: 1. indicate in the related documentation that it is based on the Software licensed hereunder, and reproduce the intellectual property notice for the Software, 2. ensure that written indications of the Software intended use, intellectual property notice and license hereunder are included in easily accessible format from the Modified Software interface, 3. mention, on a freely accessible website describing the Modified Software, at least throughout the distribution term thereof, that it is based on the Software licensed hereunder, and reproduce the Software intellectual property notice, 4. where it is distributed to a third party that may distribute a Modified Software without having to make its source code available, make its best efforts to ensure that said third party agrees to comply with the obligations set forth in this Article . If the Software, whether or not modified, is distributed with an External Module designed for use in connection with the Software, the Licensee shall submit said External Module to the foregoing obligations. 5.3.5 COMPATIBILITY WITH THE CeCILL AND CeCILL-C LICENSES Where a Modified Software contains a Contribution subject to the CeCILL license, the provisions set forth in Article 5.3.4 shall be optional. A Modified Software may be distributed under the CeCILL-C license. In such a case the provisions set forth in Article 5.3.4 shall be optional. Article 6 - INTELLECTUAL PROPERTY 6.1 OVER THE INITIAL SOFTWARE The Holder owns the economic rights over the Initial Software. Any or all use of the Initial Software is subject to compliance with the terms and conditions under which the Holder has elected to distribute its work and no one shall be entitled to modify the terms and conditions for the distribution of said Initial Software. The Holder undertakes that the Initial Software will remain ruled at least by this Agreement, for the duration set forth in Article 4.2. 6.2 OVER THE CONTRIBUTIONS The Licensee who develops a Contribution is the owner of the intellectual property rights over this Contribution as defined by applicable law. 6.3 OVER THE EXTERNAL MODULES The Licensee who develops an External Module is the owner of the intellectual property rights over this External Module as defined by applicable law and is free to choose the type of agreement that shall govern its distribution. 6.4 JOINT PROVISIONS The Licensee expressly undertakes: 1. not to remove, or modify, in any manner, the intellectual property notices attached to the Software; 2. to reproduce said notices, in an identical manner, in the copies of the Software modified or not. The Licensee undertakes not to directly or indirectly infringe the intellectual property rights of the Holder and/or Contributors on the Software and to take, where applicable, vis-à-vis its staff, any and all measures required to ensure respect of said intellectual property rights of the Holder and/or Contributors. Article 7 - RELATED SERVICES 7.1 Under no circumstances shall the Agreement oblige the Licensor to provide technical assistance or maintenance services for the Software. However, the Licensor is entitled to offer this type of services. The terms and conditions of such technical assistance, and/or such maintenance, shall be set forth in a separate instrument. Only the Licensor offering said maintenance and/or technical assistance services shall incur liability therefor. 7.2 Similarly, any Licensor is entitled to offer to its licensees, under its sole responsibility, a warranty, that shall only be binding upon itself, for the redistribution of the Software and/or the Modified Software, under terms and conditions that it is free to decide. Said warranty, and the financial terms and conditions of its application, shall be subject of a separate instrument executed between the Licensor and the Licensee. Article 8 - LIABILITY 8.1 Subject to the provisions of Article 8.2, the Licensee shall be entitled to claim compensation for any direct loss it may have suffered from the Software as a result of a fault on the part of the relevant Licensor, subject to providing evidence thereof. 8.2 The Licensor's liability is limited to the commitments made under this Agreement and shall not be incurred as a result of in particular: (i) loss due the Licensee's total or partial failure to fulfill its obligations, (ii) direct or consequential loss that is suffered by the Licensee due to the use or performance of the Software, and (iii) more generally, any consequential loss. In particular the Parties expressly agree that any or all pecuniary or business loss (i.e. loss of data, loss of profits, operating loss, loss of customers or orders, opportunity cost, any disturbance to business activities) or any or all legal proceedings instituted against the Licensee by a third party, shall constitute consequential loss and shall not provide entitlement to any or all compensation from the Licensor. Article 9 - WARRANTY 9.1 The Licensee acknowledges that the scientific and technical state-of-the-art when the Software was distributed did not enable all possible uses to be tested and verified, nor for the presence of possible defects to be detected. In this respect, the Licensee's attention has been drawn to the risks associated with loading, using, modifying and/or developing and reproducing the Software which are reserved for experienced users. The Licensee shall be responsible for verifying, by any or all means, the suitability of the product for its requirements, its good working order, and for ensuring that it shall not cause damage to either persons or properties. 9.2 The Licensor hereby represents, in good faith, that it is entitled to grant all the rights over the Software (including in particular the rights set forth in Article 5). 9.3 The Licensee acknowledges that the Software is supplied "as is" by the Licensor without any other express or tacit warranty, other than that provided for in Article 9.2 and, in particular, without any warranty as to its commercial value, its secured, safe, innovative or relevant nature. Specifically, the Licensor does not warrant that the Software is free from any error, that it will operate without interruption, that it will be compatible with the Licensee's own equipment and software configuration, nor that it will meet the Licensee's requirements. 9.4 The Licensor does not either expressly or tacitly warrant that the Software does not infringe any third party intellectual property right relating to a patent, software or any other property right. Therefore, the Licensor disclaims any and all liability towards the Licensee arising out of any or all proceedings for infringement that may be instituted in respect of the use, modification and redistribution of the Software. Nevertheless, should such proceedings be instituted against the Licensee, the Licensor shall provide it with technical and legal assistance for its defense. Such technical and legal assistance shall be decided on a case-by-case basis between the relevant Licensor and the Licensee pursuant to a memorandum of understanding. The Licensor disclaims any and all liability as regards the Licensee's use of the name of the Software. No warranty is given as regards the existence of prior rights over the name of the Software or as regards the existence of a trademark. Article 10 - TERMINATION 10.1 In the event of a breach by the Licensee of its obligations hereunder, the Licensor may automatically terminate this Agreement thirty (30) days after notice has been sent to the Licensee and has remained ineffective. 10.2 A Licensee whose Agreement is terminated shall no longer be authorized to use, modify or distribute the Software. However, any licenses that it may have granted prior to termination of the Agreement shall remain valid subject to their having been granted in compliance with the terms and conditions hereof. Article 11 - MISCELLANEOUS 11.1 EXCUSABLE EVENTS Neither Party shall be liable for any or all delay, or failure to perform the Agreement, that may be attributable to an event of force majeure, an act of God or an outside cause, such as defective functioning or interruptions of the electricity or telecommunications networks, network paralysis following a virus attack, intervention by government authorities, natural disasters, water damage, earthquakes, fire, explosions, strikes and labor unrest, war, etc. 11.2 Any failure by either Party, on one or more occasions, to invoke one or more of the provisions hereof, shall under no circumstances be interpreted as being a waiver by the interested Party of its right to invoke said provision(s) subsequently. 11.3 The Agreement cancels and replaces any or all previous agreements, whether written or oral, between the Parties and having the same purpose, and constitutes the entirety of the agreement between said Parties concerning said purpose. No supplement or modification to the terms and conditions hereof shall be effective as between the Parties unless it is made in writing and signed by their duly authorized representatives. 11.4 In the event that one or more of the provisions hereof were to conflict with a current or future applicable act or legislative text, said act or legislative text shall prevail, and the Parties shall make the necessary amendments so as to comply with said act or legislative text. All other provisions shall remain effective. Similarly, invalidity of a provision of the Agreement, for any reason whatsoever, shall not cause the Agreement as a whole to be invalid. 11.5 LANGUAGE The Agreement is drafted in both French and English and both versions are deemed authentic. Article 12 - NEW VERSIONS OF THE AGREEMENT 12.1 Any person is authorized to duplicate and distribute copies of this Agreement. 12.2 So as to ensure coherence, the wording of this Agreement is protected and may only be modified by the authors of the License, who reserve the right to periodically publish updates or new versions of the Agreement, each with a separate number. These subsequent versions may address new issues encountered by Free Software. 12.3 Any Software distributed under a given version of the Agreement may only be subsequently distributed under the same version of the Agreement or a subsequent version. Article 13 - GOVERNING LAW AND JURISDICTION 13.1 The Agreement is governed by French law. The Parties agree to endeavor to seek an amicable solution to any disagreements or disputes that may arise during the performance of the Agreement. 13.2 Failing an amicable solution within two (2) months as from their occurrence, and unless emergency proceedings are necessary, the disagreements or disputes shall be referred to the Paris Courts having jurisdiction, by the more diligent Party. Version 1.0 dated 2006-09-05. CodraFT-2.2.1/MANIFEST.in000066400000000000000000000004231443562410300145350ustar00rootroot00000000000000recursive-include codraft *.png *.svg *.pot *.po *.mo *.dcm *.ico *.h5 *.chm *.txt *.sig *.csv *.json *.npy *.fxd *.scor-data recursive-include doc *.py *.rst *.png *.pot *.po recursive-include nsis *.nsi *.nsh *.bmp *.ico include MANIFEST.in include Licence*.* include *.md CodraFT-2.2.1/README.md000066400000000000000000000141761443562410300142700ustar00rootroot00000000000000![CodraFT - CODRA's Filtering Tool](./doc/images/codraft_banner.png) [![license](https://img.shields.io/pypi/l/codraft.svg)](./LICENSE) [![pypi version](https://img.shields.io/pypi/v/codraft.svg)](https://pypi.org/project/codraft/) [![PyPI status](https://img.shields.io/pypi/status/codraft.svg)](https://github.com/CODRA-Ingenierie-Informatique/CodraFT) [![PyPI pyversions](https://img.shields.io/pypi/pyversions/codraft.svg)](https://pypi.python.org/pypi/codraft/) CodraFT is [CODRA](https://codra.net/)'s Filtering Tool. ![CodraFT - CODRA's Filtering Tool](./doc/images/dark_light_modes.png) ---- ## Important note **CodraFT will soon be replaced by DataLab.** ![DataLab - The open-source data processing and visualization platform](./doc/images/DataLab-banner.png) DataLab is a platform for data processing and visualization, with a focus on extensibility, automation and reproducibility, thanks to its macro-command system, a high-level Python API and a plugin system. See [roadmap](https://codraft.readthedocs.io/en/latest/roadmap.html) section in documentation for more details. ## Overview CodraFT is a generic signal and image processing software based on Python scientific libraries (such as NumPy, SciPy or scikit-image) and Qt graphical user interfaces (thanks to [guidata](https://pypi.python.org/pypi/guidata) and [guiqwt](https://pypi.python.org/pypi/guiqwt) libraries). CodraFT stands for "CODRA Filtering Tool". CodraFT is available as a **stand-alone** application (see for example our all-in-one Windows installer) or as an **addon to your Python-Qt application** thanks to advanced automation and embedding features. See [home page](https://codra-ingenierie-informatique.github.io/CodraFT/) and [documentation](https://codraft.readthedocs.io/en/latest/) for more details on the library and [changelog](CHANGELOG.md) for recent history of changes. ### New in CodraFT 2.0 * New data processing and visualization features (see details in [changelog](CHANGELOG.md)) * Fully automated high-level processing features for internal testing purpose, as well as embedding CodraFT in a third-party software * Extensive test suite (unit tests and application tests) with 90% feature coverage ### Credits Copyrights and licensing: * Copyright © 2018-2022 [CEA](http://www.cea.fr)-[CODRA](https://codra.net/), Pierre Raybaut * Licensed under the terms of the BSD 3-Clause or the CeCILL-B License. See ``Licence_CeCILL_V2.1-en.txt``. ---- ## Key features ### Data visualization | Signal | Image | Feature | |:------:|:------:|--------------------------------| | • | • | Screenshots (save, copy) | | • | Z-axis | Lin/log scales | | • | • | Data table editing | | • | • | Statistics on user-defined ROI | | • | • | Markers | | | • | Aspect ratio (1:1, custom) | | | • | 50+ available colormaps | | | • | X/Y raw/averaged profiles | | • | • | User-defined annotations | ![1D-Peak detection](./doc/images/peak_detection.png) ![2D-Peak detection](./doc/images/2dpeak_detection.png) ### Data processing | Signal | Image | Feature | |:------:|:-----:|----------------------------------------------------| | • | • | Multiple ROI support | | • | • | Sum, average, difference, product, ... | | • | • | ROI extraction, Swap X/Y axes | | • | | Semi-automatic multi-peak detection | | | • | Rotation (flip, rotate), resize, ... | | | • | Flat-field correction | | • | | Normalize, derivative, integral | | • | • | Linear calibration | | | • | Thresholding, clipping | | • | • | Gaussian filter, Wiener filter | | • | • | Moving average, moving median | | • | • | FFT, inverse FFT | | • | | Interactive fit: Gauss, Lorenzt, Voigt, polynomial | | • | | Interactive multigaussian fit | | • | • | Computing on custom ROI | | • | | FWHM, FW @ 1/e² | | | • | Centroid (robust method w/r noise) | | | • | Minimum enclosing circle center | | | • | Automatic 2D-peak detection | | | • | Automatic contour extraction (circle/ellipse fit) | ![Contour detection](./doc/images/contour_detection.png) ![Multi-gaussian fit](./doc/images/multi_gaussian_fit.png) ---- ## Installation ### From the installer CodraFT is available as a stand-alone application, which does not require any Python distribution to be installed. Just run the installer and you're good to go! The installer package is available in the [Releases](https://github.com/CODRA-Ingenierie-Informatique/CodraFT/releases) section. ### From the source package ```bash python setup.py install ``` ---- ## Dependencies ### Requirements * Python 3.7+ (reference is Python 3.8) * [PyQt5](https://pypi.python.org/pypi/PyQt5) (Python Qt bindings) * [QtPy](https://pypi.org/project/QtPy/) (abstraction layer for Python-Qt binding libraries) * [guidata](https://pypi.python.org/pypi/guidata) (set of tools for automatic GUI generation) * [guiqwt](https://pypi.python.org/pypi/guiqwt) (set of tools for curve and image plotting based on guidata) * [h5py](https://pypi.org/project/h5py/) (interface to the HDF5 data format) * [NumPy](https://pypi.org/project/numpy/) (operations on multidimensional arrays) * [SciPy](https://pypi.org/project/scipy/) (algorithms for scientific computing) * [scikit-image](https://pypi.org/project/scikit-image/) (algorithms for image processing) * [psutil](https://pypi.org/project/psutil/) (process and system monitoring) CodraFT-2.2.1/codraft/000077500000000000000000000000001443562410300144225ustar00rootroot00000000000000CodraFT-2.2.1/codraft/__init__.py000066400000000000000000000021451443562410300165350ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT ======= CodraFT is a generic signal and image processing software based on Python scientific libraries (such as NumPy, SciPy or scikit-image) and Qt graphical user interfaces (thanks to `guidata`_ and `guiqwt`_ libraries). .. _guidata: https://pypi.python.org/pypi/guidata .. _guiqwt: https://pypi.python.org/pypi/guiqwt """ import os __version__ = "2.2.1" __docurl__ = "https://codraft.readthedocs.io/en/latest/" __homeurl__ = "https://codra-ingenierie-informatique.github.io/CodraFT/" __supporturl__ = ( "https://github.com/CODRA-Ingenierie-Informatique/CodraFT/issues/new/choose" ) os.environ["CODRAFT_VERSION"] = __version__ try: import codraft.core.io # analysis:ignore import codraft.patch # analysis:ignore except ImportError: if not os.environ.get("CODRAFT_DOC"): raise # Dear (Debian, RPM, ...) package makers, please feel free to customize the # following path to module's data (images) and translations: DATAPATH = LOCALEPATH = "" CodraFT-2.2.1/codraft/app.py000066400000000000000000000042111443562410300155520ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT launcher module """ from guidata.configtools import get_image_file_path from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from codraft.config import Conf from codraft.core.gui.main import CodraFTMainWindow from codraft.env import execenv from codraft.utils.qthelpers import qt_app_context def create( splash: bool = True, console: bool = None, objects=None, h5files=None, size=None ) -> CodraFTMainWindow: """Create CodraFT application and return mainwindow instance""" if splash: # Showing splash screen pixmap = QG.QPixmap(get_image_file_path("codraft_titleicon.png")) splashscreen = QW.QSplashScreen(pixmap, QC.Qt.WindowStaysOnTopHint) splashscreen.show() window = CodraFTMainWindow(console=console) if size is not None: width, height = size window.resize(width, height) if splash: splashscreen.finish(window) if Conf.main.window_maximized.get(None): window.showMaximized() else: window.showNormal() if h5files is not None: window.open_h5_files(h5files, import_all=True) if objects is not None: for obj in objects: window.add_object(obj) if execenv.h5browser_file is not None: window.import_h5_file(execenv.h5browser_file) return window def run(console=None, objects=None, h5files=None, size=None): """Run the CodraFT application Note: this function is an entry point in `setup.py` and therefore may not be moved without modifying the package setup script.""" if execenv.h5files: h5files = ([] if h5files is None else h5files) + execenv.h5files with qt_app_context(exec_loop=True): window = create( splash=True, console=console, objects=objects, h5files=h5files, size=size ) QW.QApplication.processEvents() window.check_stable_release() window.check_dependencies() window.check_for_previous_crash() if __name__ == "__main__": run() CodraFT-2.2.1/codraft/config.py000066400000000000000000000144411443562410300162450ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ codraft.config -------------- The `config` module handles `codraft` configuration (options, images and icons). """ import os import os.path as osp from guidata import configtools from guiqwt.config import CONF as GUIQWT_CONF from codraft.utils import conf, tests _ = configtools.get_translation("codraft") CONF_VERSION = "1.0.0" APP_NAME = "CodraFT" APP_DESC = _( """CodraFT (Codra Filtering Tool) is a generic signal and image processing software based on Python and Qt""" ) APP_PATH = osp.dirname(__file__) DEBUG = len(os.environ.get("DEBUG", "")) > 0 if DEBUG: print("*** DEBUG mode *** [Reset configuration file, do not redirect std I/O]") TEST_SEGFAULT_ERROR = len(os.environ.get("TEST_SEGFAULT_ERROR", "")) > 0 if TEST_SEGFAULT_ERROR: print('*** TEST_SEGFAULT_ERROR mode *** [Enabling test action in "?" menu]') DATETIME_FORMAT = "%d/%m/%Y - %H:%M:%S" configtools.add_image_module_path("codraft", osp.join("data", "logo")) configtools.add_image_module_path("codraft", osp.join("data", "icons")) class MainSection(conf.Section, metaclass=conf.SectionMeta): """Class defining the main configuration section structure. Each class attribute is an option (metaclass is automatically affecting option names in .INI file based on class attribute names).""" traceback_log_path = conf.ConfigPathOption() traceback_log_available = conf.Option() faulthandler_enabled = conf.Option() faulthandler_log_path = conf.ConfigPathOption() faulthandler_log_available = conf.Option() window_maximized = conf.Option() window_position = conf.Option() window_size = conf.Option() base_dir = conf.WorkingDirOption() available_memory_threshold = conf.Option() ignore_dependency_check = conf.Option() current_tab = conf.Option() class ConsoleSection(conf.Section, metaclass=conf.SectionMeta): """Classs defining the console configuration section structure. Each class attribute is an option (metaclass is automatically affecting option names in .INI file based on class attribute names).""" enable = conf.Option() max_line_count = conf.Option() class IOSection(conf.Section, metaclass=conf.SectionMeta): """Class defining the I/O configuration section structure. Each class attribute is an option (metaclass is automatically affecting option names in .INI file based on class attribute names).""" h5_fname_in_title = conf.Option() h5_fullpath_in_title = conf.Option() class ProcSection(conf.Section, metaclass=conf.SectionMeta): """Class defining the Processing configuration section structure. Each class attribute is an option (metaclass is automatically affecting option names in .INI file based on class attribute names).""" extract_roi_singleobj = conf.Option() class ViewSection(conf.Section, metaclass=conf.SectionMeta): """Class defining the view configuration section structure. Each class attribute is an option (metaclass is automatically affecting option names in .INI file based on class attribute names).""" # String formatting for shape legends sig_format = conf.Option() ima_format = conf.Option() show_label = conf.Option() show_contrast = conf.Option() # If True, images are shown with the same LUT range as the first selected image ima_ref_lut_range = conf.Option() # Default visualization settings at item creation # (e.g. see `ImageParam.make_item` in codraft/core/model/image.py) ima_eliminate_outliers = conf.Option() # Default visualization settings, persisted in object metadata # (e.g. see `create_image` in codraft/core/model/image.py) ima_def_colormap = conf.Option() ima_def_interpolation = conf.Option() # Usage (example): Conf.console.enable.get(True) class Conf(conf.Configuration, metaclass=conf.ConfMeta): """Class defining CodraFT configuration structure. Each class attribute is a section (metaclass is automatically affecting section names in .INI file based on class attribute names).""" main = MainSection() console = ConsoleSection() view = ViewSection() proc = ProcSection() io = IOSection() def get_old_log_fname(fname): """Return old log fname from current log fname""" return osp.splitext(fname)[0] + ".1.log" def initialize(): """Initialize application configuration""" Conf.initialize(APP_NAME, CONF_VERSION, load=not DEBUG) Conf.main.traceback_log_path.set(f".{APP_NAME}_traceback.log") Conf.main.faulthandler_log_path.set(f".{APP_NAME}_faulthandler.log") def reset(): """Reset application configuration""" Conf.reset() initialize() initialize() tests.add_test_module_path("codraft", osp.join("data", "tests")) GUIQWT_DEFAULTS = { "plot": { # "antialiasing": False, # "title/font/size": 12, # "title/font/bold": False, # "marker/curve/text/font/size": 8, # "marker/curve/text/font/family": "default", # "marker/curve/text/font/bold": False, # "marker/curve/text/font/italic": False, "marker/curve/text/textcolor": "black", # "marker/curve/text/background_color": "#ffffff", # "marker/curve/text/background_alpha": 0.8, # "marker/cross/text/font/family": "default", # "marker/cross/text/font/size": 8, # "marker/cross/text/font/bold": False, # "marker/cross/text/font/italic": False, "marker/cross/text/textcolor": "black", # "marker/cross/text/background_color": "#ffffff", "marker/cross/text/background_alpha": 0.7, # "marker/cross/line/style": "DashLine", # "marker/cross/line/color": "yellow", # "marker/cross/line/width": 1, # "marker/cursor/text/font/size": 8, # "marker/cursor/text/font/family": "default", # "marker/cursor/text/font/bold": False, # "marker/cursor/text/font/italic": False, # "marker/cursor/text/textcolor": "#ff9393", # "marker/cursor/text/background_color": "#ffffff", # "marker/cursor/text/background_alpha": 0.8, "shape/drag/symbol/marker": "NoSymbol", "shape/mask/symbol/size": 5, "shape/mask/sel_symbol/size": 8, }, } GUIQWT_CONF.update_defaults(GUIQWT_DEFAULTS) CodraFT-2.2.1/codraft/core/000077500000000000000000000000001443562410300153525ustar00rootroot00000000000000CodraFT-2.2.1/codraft/core/__init__.py000066400000000000000000000000021443562410300174530ustar00rootroot00000000000000# CodraFT-2.2.1/codraft/core/computation/000077500000000000000000000000001443562410300177145ustar00rootroot00000000000000CodraFT-2.2.1/codraft/core/computation/__init__.py000066400000000000000000000000021443562410300220150ustar00rootroot00000000000000# CodraFT-2.2.1/codraft/core/computation/fit.py000066400000000000000000000064221443562410300210540ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Computation / Curve fitting module """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import abc import numpy as np import scipy.special as sps # ----- Fitting models --------------------------------------------------------- class FitModel(abc.ABC): """Curve fitting model base class""" @classmethod @abc.abstractmethod def func(cls, x, amp, sigma, x0, y0): """Return fitting function""" @classmethod def get_amp_from_amplitude( cls, amplitude, sigma ): # pylint: disable=unused-argument """Return amp from function amplitude and sigma""" return amplitude @classmethod def amplitude(cls, amp, sigma): """Return function amplitude""" return cls.func(0, amp, sigma, 0, 0) @classmethod @abc.abstractmethod def fwhm(cls, amp, sigma): """Return function FWHM""" @classmethod def half_max_segment(cls, amp, sigma, x0, y0): """Return segment coordinates for y=half-maximum intersection""" hwhm = 0.5 * cls.fwhm(amp, sigma) yhm = 0.5 * cls.amplitude(amp, sigma) + y0 return x0 - hwhm, yhm, x0 + hwhm, yhm class GaussianModel(FitModel): """1-dimensional Gaussian fit model""" @classmethod def func(cls, x, amp, sigma, x0, y0): """Return fitting function""" return ( amp / (sigma * np.sqrt(2 * np.pi)) * np.exp(-0.5 * ((x - x0) / sigma) ** 2) + y0 ) @classmethod def get_amp_from_amplitude(cls, amplitude, sigma): """Return amp from function amplitude and sigma""" return amplitude * (sigma * np.sqrt(2 * np.pi)) @classmethod def amplitude(cls, amp, sigma): """Return function amplitude""" return amp / (sigma * np.sqrt(2 * np.pi)) @classmethod def fwhm(cls, amp, sigma): """Return function FWHM""" return 2 * sigma * np.sqrt(2 * np.log(2)) class LorentzianModel(FitModel): """1-dimensional Lorentzian fit model""" @classmethod def func(cls, x, amp, sigma, x0, y0): """Return fitting function""" return (amp / (sigma * np.pi)) / (1 + ((x - x0) / sigma) ** 2) + y0 @classmethod def get_amp_from_amplitude(cls, amplitude, sigma): """Return amp from function amplitude and sigma""" return amplitude * (sigma * np.pi) @classmethod def amplitude(cls, amp, sigma): """Return function amplitude""" return amp / (sigma * np.pi) @classmethod def fwhm(cls, amp, sigma): """Return function FWHM""" return 2 * sigma class VoigtModel(FitModel): """1-dimensional Voigt fit model""" @classmethod def func(cls, x, amp, sigma, x0, y0): """Return fitting function""" # pylint: disable=no-member z = (x - x0 + 1j * sigma) / (sigma * np.sqrt(2.0)) return y0 + amp * sps.wofz(z).real / (sigma * np.sqrt(2 * np.pi)) @classmethod def fwhm(cls, amp, sigma): """Return function FWHM""" wg = GaussianModel.fwhm(amp, sigma) wl = LorentzianModel.fwhm(amp, sigma) return 0.5346 * wl + np.sqrt(0.2166 * wl**2 + wg**2) CodraFT-2.2.1/codraft/core/computation/image.py000066400000000000000000000143301443562410300213510ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Computation / Image module """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import numpy as np import scipy.ndimage as spi import scipy.ndimage.filters as spf import scipy.spatial as spt from numpy import ma from skimage import measure def scale_data_to_min_max(data: np.ndarray, zmin, zmax): """Scale array `data` to fit [zmin, zmax] dynamic range""" dmin = data.min() dmax = data.max() fdata = np.array(data, dtype=float) fdata -= dmin fdata *= float(zmax - zmin) / (dmax - dmin) fdata += float(zmin) return np.array(fdata, data.dtype) def flatfield(rawdata: np.ndarray, flatdata: np.ndarray, threshold: float = None): """Compute flat-field correction""" dtemp = np.array(rawdata, dtype=np.float64, copy=True) * flatdata.mean() dunif = np.array(flatdata, dtype=np.float64, copy=True) dunif[dunif == 0] = 1.0 dcorr_all = np.array(dtemp / dunif, dtype=rawdata.dtype) dcorr = np.array(rawdata, copy=True) dcorr[rawdata > threshold] = dcorr_all[rawdata > threshold] return dcorr def get_centroid_fourier(data: np.ndarray): """Return image centroid using Fourier algorithm""" # Fourier transform method as discussed by Weisshaar et al. # (http://www.mnd-umwelttechnik.fh-wiesbaden.de/pig/weisshaar_u5.pdf) rows, cols = data.shape if rows == 1 or cols == 1: return 0, 0 i = np.arange(0, rows).reshape(1, rows) sin_a = np.sin((i - 1) * 2 * np.pi / (rows - 1)).T cos_a = np.cos((i - 1) * 2 * np.pi / (rows - 1)).T j = np.arange(0, cols).reshape(cols, 1) sin_b = np.sin((j - 1) * 2 * np.pi / (cols - 1)).T cos_b = np.cos((j - 1) * 2 * np.pi / (cols - 1)).T a = (cos_a * data).sum() b = (sin_a * data).sum() c = (data * cos_b).sum() d = (data * sin_b).sum() rphi = (0 if b > 0 else 2 * np.pi) if a > 0 else np.pi cphi = (0 if d > 0 else 2 * np.pi) if c > 0 else np.pi if a * c == 0.0: return 0, 0 row = (np.arctan(b / a) + rphi) * (rows - 1) / (2 * np.pi) + 1 col = (np.arctan(d / c) + cphi) * (cols - 1) / (2 * np.pi) + 1 try: row = int(row) except ma.MaskError: row = np.nan try: col = int(col) except ma.MaskError: col = np.nan return row, col def get_absolute_level(data: np.ndarray, level: float): """Return absolute level""" if not isinstance(level, float) or level < 0.0 or level > 1.0: raise ValueError("Level must be a float between 0. and 1.") return (float(np.nanmin(data)) + float(np.nanmax(data))) * level def get_enclosing_circle(data: np.ndarray, level: float = 0.5): """Return (x, y, radius) for the circle contour enclosing image values above threshold relative level (.5 means FWHM) Raise ValueError if no contour was found""" data_th = data.copy() data_th[data <= get_absolute_level(data, level)] = 0.0 contours = measure.find_contours(data_th) model = measure.CircleModel() result = None max_radius = 1.0 for contour in contours: if model.estimate(contour): yc, xc, radius = model.params if radius > max_radius: result = (int(xc), int(yc), radius) max_radius = radius if result is None: raise ValueError("No contour was found") return result def distance_matrix(coords: list) -> np.ndarray: """Return distance matrix from coords""" return np.triu(spt.distance.cdist(coords, coords, "euclidean")) def get_2d_peaks_coords( data: np.ndarray, size: int = None, level: float = 0.5 ) -> np.ndarray: """Detect peaks in image data, return coordinates. If neighborhoods size is None, default value is the highest value between 50 pixels and the 1/40th of the smallest image dimension. Detection threshold level is relative to difference between data maximum and minimum values. """ if size is None: size = max(min(data.shape) // 40, 50) data_max = spf.maximum_filter(data, size) data_min = spf.minimum_filter(data, size) data_diff = data_max - data_min diff = (data_max - data_min) > get_absolute_level(data_diff, level) maxima = data == data_max maxima[diff == 0] = 0 labeled, _num_objects = spi.label(maxima) slices = spi.find_objects(labeled) coords = [] for dy, dx in slices: x_center = int(0.5 * (dx.start + dx.stop - 1)) y_center = int(0.5 * (dy.start + dy.stop - 1)) coords.append((x_center, y_center)) if len(coords) > 1: # Eventually removing duplicates dist = distance_matrix(coords) for index in reversed(np.unique(np.where((dist < size) & (dist > 0))[1])): coords.pop(index) return np.array(coords) def get_contour_shapes( data: np.ndarray, shape: str = "ellipse", level: float = 0.5 ) -> np.ndarray: """Find iso-valued contours in a 2D array, above relative level (.5 means FWHM), then fit contours with shape ('ellipse' or 'circle') Return NumPy array containing coordinates of shapes.""" # pylint: disable=too-many-locals contours = measure.find_contours(data, level=get_absolute_level(data, level)) coords = [] for contour in contours: if shape == "circle": model = measure.CircleModel() if model.estimate(contour): yc, xc, r = model.params if r <= 1.0: continue coords.append([xc - r, yc, xc + r, yc]) elif shape == "ellipse": model = measure.EllipseModel() if model.estimate(contour): yc, xc, b, a, theta = model.params if a <= 1.0 or b <= 1.0: continue dxa, dya = a * np.cos(theta), a * np.sin(theta) dxb, dyb = b * np.sin(theta), b * np.cos(theta) x1, y1, x2, y2 = xc - dxa, yc - dya, xc + dxa, yc + dya x3, y3, x4, y4 = xc - dxb, yc - dyb, xc + dxb, yc + dyb coords.append([x1, y1, x2, y2, x3, y3, x4, y4]) else: raise NotImplementedError(f"Invalid contour model {model}") return np.array(coords) CodraFT-2.2.1/codraft/core/computation/signal.py000066400000000000000000000145371443562410300215550ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Computation / Signal module """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import numpy as np # ----- Filtering functions ---------------------------------------------------- def moving_average(y, n): """Compute moving average""" y_padded = np.pad(y, (n // 2, n - 1 - n // 2), mode="edge") return np.convolve(y_padded, np.ones((n,)) / n, mode="valid") # ----- Misc. functions -------------------------------------------------------- def derivative(x, y): """Compute numerical derivative""" dy = np.zeros_like(y) dy[0:-1] = np.diff(y) / np.diff(x) dy[-1] = (y[-1] - y[-2]) / (x[-1] - x[-2]) return dy def normalize(yin, parameter="maximum"): """ Normalize input array *yin* with respect to parameter *parameter* Support values for *parameter*: 'maximum' (default), 'amplitude', 'sum', 'energy' """ axis = len(yin.shape) - 1 if parameter == "maximum": maximum = np.max(yin, axis) if axis == 1: maximum = maximum.reshape((len(maximum), 1)) maxarray = np.tile(maximum, yin.shape[axis]).reshape(yin.shape) return yin / maxarray if parameter == "amplitude": ytemp = np.array(yin, copy=True) minimum = np.min(yin, axis) if axis == 1: minimum = minimum.reshape((len(minimum), 1)) ytemp -= minimum return normalize(ytemp, parameter="maximum") if parameter == "sum": return yin / yin.sum() if parameter == "energy": return yin / (yin * yin.conjugate()).sum() raise RuntimeError(f"Unsupported parameter {parameter}") def xy_fft(x, y, shift=True): """Compute FFT on X,Y data. Args: x (np.ndarray): X data y (np.ndarray): Y data shift (bool, optional): Shift the zero frequency to the center of the spectrum. Defaults to True. Returns: tuple[np.ndarray, np.ndarray]: X,Y data """ y1 = np.fft.fft(y) x1 = np.fft.fftfreq(x.shape[-1], d=x[1] - x[0]) if shift: x1 = np.fft.fftshift(x1) y1 = np.fft.fftshift(y1) return x1, y1 def xy_ifft(x, y, shift=True): """Compute iFFT on X,Y data. Args: x (np.ndarray): X data y (np.ndarray): Y data shift (bool, optional): Shift the zero frequency to the center of the spectrum. Defaults to True. Returns: tuple[np.ndarray, np.ndarray]: X,Y data """ x1 = np.fft.fftfreq(x.shape[-1], d=x[1] - x[0]) if shift: x1 = np.fft.ifftshift(x1) y = np.fft.ifftshift(y) y1 = np.fft.ifft(y) return x1, y1.real # ----- Peak detection functions ----------------------------------------------- def peak_indexes(y, thres=0.3, min_dist=1, thres_abs=False): # Copyright (c) 2014 Lucas Hermann Negri # Unmodified code snippet from PeakUtils 1.3.0 """Peak detection routine. Finds the numeric index of the peaks in *y* by taking its first order difference. By using *thres* and *min_dist* parameters, it is possible to reduce the number of detected peaks. *y* must be signed. Parameters ---------- y : ndarray (signed) 1D amplitude data to search for peaks. thres : float between [0., 1.] Normalized threshold. Only the peaks with amplitude higher than the threshold will be detected. min_dist : int Minimum distance between each detected peak. The peak with the highest amplitude is preferred to satisfy this constraint. thres_abs: boolean If True, the thres value will be interpreted as an absolute value, instead of a normalized threshold. Returns ------- ndarray Array containing the numeric indexes of the peaks that were detected """ if isinstance(y, np.ndarray) and np.issubdtype(y.dtype, np.unsignedinteger): raise ValueError("y must be signed") if not thres_abs: thres = thres * (np.max(y) - np.min(y)) + np.min(y) min_dist = int(min_dist) # compute first order difference dy = np.diff(y) # propagate left and right values successively to fill all plateau pixels # (0-value) (zeros,) = np.where(dy == 0) # check if the signal is totally flat if len(zeros) == len(y) - 1: return np.array([]) if len(zeros): # compute first order difference of zero indexes zeros_diff = np.diff(zeros) # check when zeros are not chained together (zeros_diff_not_one,) = np.add(np.where(zeros_diff != 1), 1) # make an array of the chained zero indexes zero_plateaus = np.split(zeros, zeros_diff_not_one) # fix if leftmost value in dy is zero if zero_plateaus[0][0] == 0: dy[zero_plateaus[0]] = dy[zero_plateaus[0][-1] + 1] zero_plateaus.pop(0) # fix if rightmost value of dy is zero if len(zero_plateaus) > 0 and zero_plateaus[-1][-1] == len(dy) - 1: dy[zero_plateaus[-1]] = dy[zero_plateaus[-1][0] - 1] zero_plateaus.pop(-1) # for each chain of zero indexes for plateau in zero_plateaus: median = np.median(plateau) # set leftmost values to leftmost non zero values dy[plateau[plateau < median]] = dy[plateau[0] - 1] # set rightmost and middle values to rightmost non zero values dy[plateau[plateau >= median]] = dy[plateau[-1] + 1] # find the peaks by using the first order difference peaks = np.where( (np.hstack([dy, 0.0]) < 0.0) & (np.hstack([0.0, dy]) > 0.0) & (np.greater(y, thres)) )[0] # handle multiple peaks, respecting the minimum distance if peaks.size > 1 and min_dist > 1: highest = peaks[np.argsort(y[peaks])][::-1] rem = np.ones(y.size, dtype=bool) rem[peaks] = False for peak in highest: if not rem[peak]: sl = slice(max(0, peak - min_dist), peak + min_dist + 1) rem[sl] = True rem[peak] = False peaks = np.arange(y.size)[~rem] return peaks def xpeak(x, y): """Return default peak X-position (assuming a single peak)""" peaks = peak_indexes(y) if peaks.size == 1: return x[peaks[0]] return np.average(x, weights=y) CodraFT-2.2.1/codraft/core/gui/000077500000000000000000000000001443562410300161365ustar00rootroot00000000000000CodraFT-2.2.1/codraft/core/gui/__init__.py000066400000000000000000000012111443562410300202420ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT core.gui module This module handles all GUI features which are specific to CodraFT: * core.gui.main: handles CodraFT main window which relies on signal and image panels * core.gui.panel: handles CodraFT signal and image panels, relying on: * core.gui.actionhandler * core.gui.objectlist * core.gui.plotitemlist * core.gui.roieditor * core.gui.processor * core.gui.docks: handles CodraFT dockwidgets * core.gui.h5io: handles HDF5 browser widget and related features """ CodraFT-2.2.1/codraft/core/gui/actionhandler.py000066400000000000000000000432761443562410300213370ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Action handler module This module handles all application actions (menus, toolbars, context menu). These actions point to CodraFT panels, processors, objectlist, ... """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import abc import enum from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action from qtpy import QtGui as QG from qtpy import QtWidgets as QW from codraft.config import _ from codraft.widgets import fitdialog class ActionCategory(enum.Enum): """Action categories""" FILE = enum.auto() EDIT = enum.auto() VIEW = enum.auto() OPERATION = enum.auto() PROCESSING = enum.auto() COMPUTING = enum.auto() class BaseActionHandler(metaclass=abc.ABCMeta): """Object handling panel GUI interactions: actions, menus, ...""" OBJECT_STR = "" # e.g. "signal" def __init__(self, panel, objlist, itmlist, processor, toolbar): self.panel = panel self.objlist = objlist self.itmlist = itmlist self.processor = processor self.feature_actions = {} self.operation_end_actions = None self.delete_roi_action = None # Object selection dependent actions self.actlist_1more = [] self.actlist_2more = [] self.actlist_1 = [] self.actlist_2 = [] self.actlist_cmenu = [] # Context menu if self.__class__ is not BaseActionHandler: self.create_all_actions(toolbar) def get_context_menu_actions(self): """Return context menu action list""" return self.actlist_cmenu def selection_rows_changed(self): """Number of selected rows has changed""" selrows = self.objlist.get_selected_rows() nbrows = len(selrows) for act in self.actlist_1more: act.setEnabled(nbrows >= 1) for act in self.actlist_2more: act.setEnabled(nbrows >= 2) for act in self.actlist_1: act.setEnabled(nbrows == 1) for act in self.actlist_2: act.setEnabled(nbrows == 2) self.delete_roi_action.setEnabled(False) for row in selrows: obj = self.objlist[row] if obj.roi is not None: self.delete_roi_action.setEnabled(True) break def create_all_actions(self, toolbar): """Setup actions, menus, toolbar""" featact = self.feature_actions featact[ActionCategory.FILE] = file_act = self.create_file_actions() featact[ActionCategory.EDIT] = edit_act = self.create_edit_actions() featact[ActionCategory.VIEW] = view_act = self.create_view_actions() featact[ActionCategory.OPERATION] = self.create_operation_actions() featact[ActionCategory.PROCESSING] = self.create_processing_actions() featact[ActionCategory.COMPUTING] = self.create_computing_actions() add_actions(toolbar, file_act + [None] + edit_act + [None] + view_act) def cra( self, title, triggered=None, toggled=None, shortcut=None, icon=None, tip=None ): """Create action convenience method""" return create_action(self.panel, title, triggered, toggled, shortcut, icon, tip) def create_file_actions(self): """Create file actions""" new_act = self.cra( _("New %s...") % self.OBJECT_STR, icon=get_icon(f"new_{self.OBJECT_STR}.svg"), tip=_("Create new %s") % self.OBJECT_STR, triggered=self.panel.new_object, shortcut=QG.QKeySequence(QG.QKeySequence.New), ) open_act = self.cra( _("Open %s...") % self.OBJECT_STR, icon=get_icon("libre-gui-import.svg"), tip=_("Open %s") % self.OBJECT_STR, triggered=self.panel.open_objects, shortcut=QG.QKeySequence(QG.QKeySequence.Open), ) save_act = self.cra( _("Save %s...") % self.OBJECT_STR, icon=get_icon("libre-gui-export.svg"), tip=_("Save selected %s") % self.OBJECT_STR, triggered=self.panel.save_objects, shortcut=QG.QKeySequence(QG.QKeySequence.Save), ) importmd_act = self.cra( _("Import metadata into %s...") % self.OBJECT_STR, icon=get_icon("metadata_import.svg"), tip=_("Import metadata into %s") % self.OBJECT_STR, triggered=self.panel.import_metadata_from_file, ) exportmd_act = self.cra( _("Export metadata from %s...") % self.OBJECT_STR, icon=get_icon("metadata_export.svg"), tip=_("Export selected %s metadata") % self.OBJECT_STR, triggered=self.panel.export_metadata_from_file, ) self.actlist_1more += [save_act] self.actlist_cmenu += [save_act] self.actlist_1 += [importmd_act, exportmd_act] return [new_act, open_act, save_act, None, importmd_act, exportmd_act] def create_edit_actions(self): """Create edit actions""" dup_action = self.cra( _("Duplicate"), icon=get_icon("libre-gui-copy.svg"), triggered=self.panel.duplicate_object, shortcut=QG.QKeySequence(QG.QKeySequence.Copy), ) cpymeta_action = self.cra( _("Copy metadata"), icon=get_icon("metadata_copy.svg"), triggered=self.panel.copy_metadata, ) pstmeta_action = self.cra( _("Paste metadata"), icon=get_icon("metadata_paste.svg"), triggered=self.panel.paste_metadata, ) cleanup_action = self.cra( _("Clean up data view"), icon=get_icon("libre-tools-vacuum-cleaner.svg"), tip=_("Clean up data view before updating plotting panels"), toggled=self.itmlist.toggle_cleanup_dataview, ) cleanup_action.setChecked(True) delm_action = self.cra( _("Delete object metadata"), icon=get_icon("metadata_delete.svg"), tip=_("Delete all that is contained in object metadata"), triggered=self.panel.delete_metadata, ) delall_action = self.cra( _("Delete all"), shortcut="Shift+Ctrl+Suppr", icon=get_icon("delete_all.svg"), triggered=self.panel.delete_all_objects, ) del_action = self.cra( _("Remove"), icon=get_icon("delete.svg"), triggered=self.panel.remove_object, shortcut=QG.QKeySequence(QG.QKeySequence.Delete), ) self.actlist_1more += [ dup_action, del_action, delm_action, pstmeta_action, delall_action, ] self.actlist_cmenu += [dup_action, del_action] self.actlist_1 += [cpymeta_action] return [ dup_action, del_action, delall_action, None, cpymeta_action, pstmeta_action, delm_action, ] def create_view_actions(self): """Create view actions""" view_action = self.cra( _("View in a new window"), icon=get_icon("libre-gui-binoculars.svg"), triggered=self.panel.open_separate_view, ) showlabel_action = self.cra( _("Show graphical object titles"), icon=get_icon("show_titles.svg"), tip=_("Show or hide ROI and other graphical object titles or subtitles"), toggled=self.panel.toggle_show_titles, ) showlabel_action.setChecked(False) self.actlist_1more += [view_action] self.actlist_cmenu = [view_action, None] + self.actlist_cmenu return [view_action, showlabel_action] def create_operation_actions(self): """Create operation actions""" proc = self.processor sum_action = self.cra(_("Sum"), proc.compute_sum) average_action = self.cra(_("Average"), proc.compute_average) diff_action = self.cra(_("Difference"), lambda: proc.compute_difference(False)) qdiff_action = self.cra( _("Quadratic difference"), lambda: proc.compute_difference(True) ) prod_action = self.cra(_("Product"), proc.compute_product) div_action = self.cra(_("Division"), proc.compute_division) roi_action = self.cra( _("ROI extraction"), proc.extract_roi, icon=get_icon(f"{self.OBJECT_STR}_roi.svg"), ) swapaxes_action = self.cra(_("Swap X/Y axes"), proc.swap_axes) abs_action = self.cra(_("Absolute value"), proc.compute_abs) log_action = self.cra("Log10(y)", proc.compute_log10) self.actlist_1more += [roi_action, swapaxes_action, abs_action, log_action] self.actlist_2more += [sum_action, average_action, prod_action] self.actlist_2 += [diff_action, qdiff_action, div_action] self.operation_end_actions = [roi_action, swapaxes_action] return [ sum_action, average_action, diff_action, qdiff_action, prod_action, div_action, None, abs_action, log_action, ] def create_processing_actions(self): """Create processing actions""" proc = self.processor threshold_action = self.cra(_("Thresholding"), proc.compute_threshold) clip_action = self.cra(_("Clipping"), proc.compute_clip) lincal_action = self.cra(_("Linear calibration"), proc.calibrate) gauss_action = self.cra(_("Gaussian filter"), proc.compute_gaussian) movavg_action = self.cra(_("Moving average"), proc.compute_moving_average) movmed_action = self.cra(_("Moving median"), proc.compute_moving_median) wiener_action = self.cra(_("Wiener filter"), proc.compute_wiener) fft_action = self.cra(_("FFT"), proc.compute_fft) ifft_action = self.cra(_("Inverse FFT"), proc.compute_ifft) for act in (fft_action, ifft_action): act.setToolTip(_("Warning: only real part is plotted")) actions = [ threshold_action, clip_action, lincal_action, gauss_action, movavg_action, movmed_action, wiener_action, fft_action, ifft_action, ] self.actlist_1more += actions return actions @abc.abstractmethod def create_computing_actions(self): """Create computing actions""" proc = self.processor defineroi_action = self.cra( _("Edit regions of interest..."), triggered=proc.edit_regions_of_interest, icon=get_icon("roi.svg"), ) self.delete_roi_action = self.cra( _("Remove regions of interest"), triggered=proc.delete_regions_of_interest, icon=get_icon("roi_delete.svg"), ) stats_action = self.cra( _("Statistics") + "...", triggered=proc.compute_stats, icon=get_icon("stats.svg"), ) self.actlist_1 += [defineroi_action, self.delete_roi_action, stats_action] self.actlist_cmenu += [ None, defineroi_action, self.delete_roi_action, None, stats_action, ] return [defineroi_action, self.delete_roi_action, None, stats_action] class SignalActionHandler(BaseActionHandler): """Object handling signal panel GUI interactions: actions, menus, ...""" OBJECT_STR = _("signal") def create_operation_actions(self): """Create operation actions""" base_actions = super().create_operation_actions() proc = self.processor peakdetect_action = self.cra( _("Peak detection"), proc.detect_peaks, icon=get_icon("peak_detect.svg"), ) self.actlist_1more += [peakdetect_action] roi_actions = self.operation_end_actions return base_actions + [None, peakdetect_action, None] + roi_actions def create_processing_actions(self): """Create processing actions""" base_actions = super().create_processing_actions() proc = self.processor normalize_action = self.cra(_("Normalize"), proc.normalize) deriv_action = self.cra(_("Derivative"), proc.compute_derivative) integ_action = self.cra(_("Integral"), proc.compute_integral) polyfit_action = self.cra(_("Polynomial fit"), proc.compute_polyfit) mgfit_action = self.cra(_("Multi-Gaussian fit"), proc.compute_multigaussianfit) def cra_fit(title, fitdlgfunc): """Create curve fitting action""" return self.cra(title, lambda: proc.compute_fit(title, fitdlgfunc)) gaussfit_action = cra_fit(_("Gaussian fit"), fitdialog.gaussianfit) lorentzfit_action = cra_fit(_("Lorentzian fit"), fitdialog.lorentzianfit) voigtfit_action = cra_fit(_("Voigt fit"), fitdialog.voigtfit) actions1 = [normalize_action, deriv_action, integ_action] actions2 = [ gaussfit_action, lorentzfit_action, voigtfit_action, polyfit_action, mgfit_action, ] self.actlist_1more += actions1 + actions2 return actions1 + [None] + base_actions + [None] + actions2 def create_computing_actions(self): """Create computing actions""" base_actions = super().create_computing_actions() proc = self.processor fwhm_action = self.cra( _("Full width at half-maximum"), triggered=proc.compute_fwhm, tip=_("Compute Full Width at Half-Maximum (FWHM)"), ) fw1e2_action = self.cra( _("Full width at") + " 1/e²", triggered=proc.compute_fw1e2, tip=_("Compute Full Width at Maximum") + "/e²", ) self.actlist_1more += [fwhm_action, fw1e2_action] return base_actions + [fwhm_action, fw1e2_action] class ImageActionHandler(BaseActionHandler): """Object handling image panel GUI interactions: actions, menus, ...""" OBJECT_STR = _("image") def create_view_actions(self): """Create view actions""" base_actions = super().create_view_actions() showcontrast_action = self.cra( _("Show contrast panel"), icon=get_icon("contrast.png"), tip=_("Show or hide contrast adjustment panel"), toggled=self.panel.toggle_show_contrast, ) showcontrast_action.setChecked(True) self.actlist_1more += [showcontrast_action] return base_actions + [showcontrast_action] def create_operation_actions(self): """Create operation actions""" base_actions = super().create_operation_actions() proc = self.processor rotate_menu = QW.QMenu(_("Rotation"), self.panel) hflip_act = self.cra( _("Flip horizontally"), triggered=proc.flip_horizontally, icon=get_icon("flip_horizontally.svg"), ) vflip_act = self.cra( _("Flip vertically"), triggered=proc.flip_vertically, icon=get_icon("flip_vertically.svg"), ) rot90_act = self.cra( _("Rotate %s right") % "90°", # pylint: disable=consider-using-f-string triggered=proc.rotate_270, icon=get_icon("rotate_right.svg"), ) rot270_act = self.cra( _("Rotate %s left") % "90°", # pylint: disable=consider-using-f-string triggered=proc.rotate_90, icon=get_icon("rotate_left.svg"), ) rotate_act = self.cra( _("Rotate arbitrarily..."), triggered=proc.rotate_arbitrarily ) resize_act = self.cra(_("Resize"), triggered=proc.resize_image) logp1_act = self.cra("Log10(z+n)", triggered=proc.compute_logp1) flatfield_act = self.cra( _("Flat-field correction"), triggered=proc.flat_field_correction ) self.actlist_2 += [flatfield_act] self.actlist_1more += [ resize_act, hflip_act, vflip_act, logp1_act, rot90_act, rot270_act, rotate_act, ] self.actlist_cmenu += [None, hflip_act, vflip_act, rot90_act, rot270_act] add_actions( rotate_menu, [hflip_act, vflip_act, rot90_act, rot270_act, rotate_act] ) roi_actions = self.operation_end_actions actions = [ logp1_act, flatfield_act, None, rotate_menu, None, resize_act, ] return base_actions + actions + roi_actions def create_computing_actions(self): """Create computing actions""" base_actions = super().create_computing_actions() proc = self.processor # TODO: [P3] Add "Create ROI grid..." action to create a regular grid or ROIs cent_act = self.cra( _("Centroid"), proc.compute_centroid, tip=_("Compute image centroid") ) encl_act = self.cra( _("Minimum enclosing circle center"), proc.compute_enclosing_circle, tip=_("Compute smallest enclosing circle center"), ) peak_act = self.cra( _("2D peak detection"), proc.compute_peak_detection, tip=_("Compute automatic 2D peak detection"), ) contour_act = self.cra( _("Contour detection"), proc.compute_contour_shape, tip=_("Compute contour shape fit"), ) self.actlist_1more += [cent_act, encl_act, peak_act, contour_act] return base_actions + [cent_act, encl_act, peak_act, contour_act] CodraFT-2.2.1/codraft/core/gui/docks.py000066400000000000000000000037611443562410300176220ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Dockable widgets """ from guidata.qthelpers import is_dark_mode from guidata.qtwidgets import DockableWidget, DockableWidgetMixin from guiqwt.plot import ImageWidget from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW class DockablePlotWidget(DockableWidget): """Docked plotting widget""" LOCATION = QC.Qt.RightDockWidgetArea def __init__(self, parent, plotwidgetclass, toolbar): super().__init__(parent) self.toolbar = toolbar layout = QW.QVBoxLayout() self.plotwidget = plotwidgetclass() layout.addWidget(self.plotwidget) self.setLayout(layout) self.setup() def get_plot(self): """Return plot instance""" return self.plotwidget.plot def setup(self): """Setup plotting widget""" title = self.toolbar.windowTitle() pwidget = self.plotwidget pwidget.add_toolbar(self.toolbar, title) if isinstance(self.plotwidget, ImageWidget): pwidget.register_all_image_tools() else: pwidget.register_all_curve_tools() # Customizing widget appearances plot = pwidget.get_plot() if not is_dark_mode(): for widget in (pwidget, plot, self): widget.setBackgroundRole(QG.QPalette.Window) widget.setAutoFillBackground(True) widget.setPalette(QG.QPalette(QC.Qt.white)) canvas = plot.canvas() canvas.setFrameStyle(canvas.Plain | canvas.NoFrame) # ------DockableWidget API def visibility_changed(self, enable): """DockWidget visibility has changed""" DockableWidget.visibility_changed(self, enable) self.toolbar.setVisible(enable) class DockableTabWidget(QW.QTabWidget, DockableWidgetMixin): """Docked tab widget""" LOCATION = QC.Qt.LeftDockWidgetArea CodraFT-2.2.1/codraft/core/gui/h5io.py000066400000000000000000000110511443562410300173520ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT HDF5 open/save module """ import os.path as osp from qtpy import QtWidgets as QW from codraft.config import _ from codraft.core.io.base import NativeH5Reader, NativeH5Writer from codraft.core.io.h5 import H5Importer from codraft.core.model.signal import SignalParam from codraft.utils.qthelpers import create_progress_bar, qt_try_loadsave_file from codraft.widgets.h5browser import H5BrowserDialog class H5InputOutput: """Object handling HDF5 file open/save into/from CodraFT data model/main window""" def __init__(self, mainwindow): self.mainwindow = mainwindow self.h5browser = None self.uint32_wng = None self.progressbar = None self.lmj_metadata = None @staticmethod def __progbartitle(fname): """Return progress bar title""" return _("Loading data from %s...") % osp.basename(fname) def save_file(self, filename): """Save all signals and images from CodraFT model into a HDF5 file""" writer = NativeH5Writer(filename) for panel in self.mainwindow.panels: panel.serialize_to_hdf5(writer) writer.close() def open_file(self, filename, import_all, reset_all): """Open HDF5 file""" progress = None try: reader = NativeH5Reader(filename) if reset_all: self.mainwindow.reset_all() with create_progress_bar( self.mainwindow, self.__progbartitle(filename), 2 ) as progress: for idx, panel in enumerate(self.mainwindow.panels): progress.setValue(idx) QW.QApplication.processEvents() panel.deserialize_from_hdf5(reader) if progress.wasCanceled(): break reader.close() except KeyError: if progress is not None: # KeyError was encoutered when deserializing datasets (CodraFT data # model is not compatible with this version) progress.close() self.import_file(filename, import_all, reset_all) def __add_object_from_node(self, node): """Add CodraFT object from h5 node""" obj = node.get_object() self.uint32_wng = self.uint32_wng or node.uint32_wng if isinstance(obj, SignalParam): self.mainwindow.signalpanel.add_object(obj) else: self.mainwindow.imagepanel.add_object(obj) def __eventually_show_warnings(self): """Eventually show warnings after everything is imported""" if self.uint32_wng: QW.QMessageBox.warning( self.mainwindow, _("Warning"), _("Clipping uint32 data to int32.") ) def import_file(self, filename, import_all, reset_all): """Import HDF5 file""" if self.h5browser is None: self.h5browser = H5BrowserDialog(self.mainwindow) with qt_try_loadsave_file(self.mainwindow, filename, "load"): self.h5browser.setup(filename) if not import_all and not self.h5browser.exec(): self.h5browser.cleanup() return if import_all: nodes = self.h5browser.get_all_nodes() else: nodes = self.h5browser.get_nodes() if nodes is None: self.h5browser.cleanup() return if reset_all: self.mainwindow.reset_all() with create_progress_bar( self.mainwindow, self.__progbartitle(filename), len(nodes) ) as progress: self.uint32_wng = False for idx, node in enumerate(nodes): progress.setValue(idx) QW.QApplication.processEvents() if progress.wasCanceled(): break self.__add_object_from_node(node) self.h5browser.cleanup() self.__eventually_show_warnings() def import_dataset_from_file(self, filename, dsetname): """Import dataset from HDF5 file""" h5importer = H5Importer(filename) try: node = h5importer.get(dsetname) self.uint32_wng = False self.__add_object_from_node(node) self.__eventually_show_warnings() except KeyError as exc: raise KeyError(f"Dataset not found: {dsetname}") from exc h5importer.close() CodraFT-2.2.1/codraft/core/gui/main.py000066400000000000000000000762161443562410300174500ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT main window """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import locale import os import os.path as osp import platform import sys import time import webbrowser from typing import List import numpy as np import scipy.ndimage as spi import scipy.signal as sps from guidata import __version__ as guidata_ver from guidata.configtools import get_icon, get_module_data_path, get_module_path from guidata.qthelpers import add_actions, create_action, win32_fix_title_bar_background from guidata.widgets.console import DockableConsole from guiqwt import __version__ as guiqwt_ver from guiqwt.builder import make from guiqwt.plot import CurveWidget, ImageWidget from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from qtpy.compat import getopenfilenames, getsavefilename from qwt import __version__ as qwt_ver from codraft import __docurl__, __homeurl__, __supporturl__, __version__, env from codraft.config import APP_DESC, APP_NAME, TEST_SEGFAULT_ERROR, Conf, _ from codraft.core.gui.actionhandler import ActionCategory from codraft.core.gui.docks import DockablePlotWidget, DockableTabWidget from codraft.core.gui.h5io import H5InputOutput from codraft.core.gui.panel import ImagePanel, SignalPanel from codraft.core.model.image import ImageParam from codraft.core.model.signal import SignalParam from codraft.env import execenv from codraft.utils import dephash from codraft.utils import qthelpers as qth from codraft.widgets.instconfviewer import exec_codraft_installconfig_dialog from codraft.widgets.logviewer import exec_codraft_logviewer_dialog from codraft.widgets.status import MemoryStatus DATAPATH = get_module_data_path("codraft", "data") def get_htmlhelp(): """Return HTML Help documentation link adapted to locale, if it exists""" if os.name == "nt": for suffix in ("_" + locale.getlocale()[0][:2], ""): path = osp.join(DATAPATH, f"CodraFT{suffix}.chm") if osp.isfile(path): return path return None class AppProxy: """Proxy to CodraFT application: object used from the embedded console to access CodraFT internal objects""" def __init__(self, win): self.win = win self.s = self.win.signalpanel.objlist self.i = self.win.imagepanel.objlist def is_frozen(module_name): """Test if module has been frozen (py2exe/cx_Freeze)""" datapath = get_module_path(module_name) parentdir = osp.normpath(osp.join(datapath, osp.pardir)) return not osp.isfile(__file__) or osp.isfile(parentdir) # library.zip class CodraFTMainWindow(QW.QMainWindow): """CodraFT main window""" __instance = None @staticmethod def get_instance(console=None, hide_on_close=False): """Return singleton instance""" if CodraFTMainWindow.__instance is None: return CodraFTMainWindow(console, hide_on_close) return CodraFTMainWindow.__instance def __init__(self, console=None, hide_on_close=False): """Initialize main window""" CodraFTMainWindow.__instance = self super().__init__() win32_fix_title_bar_background(self) self.setObjectName(APP_NAME) self.setWindowIcon(get_icon("codraft.svg")) self.__restore_pos_and_size() self.hide_on_close = hide_on_close self.__old_size = None self.__memory_warning = False self.memorystatus = None self.console = None self.app_proxy = None self.signal_toolbar = None self.image_toolbar = None self.signalpanel = None self.imagepanel = None self.tabwidget = None self.signal_image_docks = None self.h5inputoutput = H5InputOutput(self) self.openh5_action = None self.saveh5_action = None self.browseh5_action = None self.quit_action = None self.file_menu = None self.edit_menu = None self.operation_menu = None self.processing_menu = None self.computing_menu = None self.view_menu = None self.help_menu = None self.__is_modified = None self.set_modified(False) # Setup actions and menus if console is None: console = Conf.console.enable.get(True) self.setup(console) @property def panels(self): """Return the tuple of implemented panels (signal, image)""" return (self.signalpanel, self.imagepanel) def __set_low_memory_state(self, state): """Set memory warning state""" self.__memory_warning = state def confirm_memory_state(self): # pragma: no cover """Check memory warning state and eventually show a warning dialog""" if self.__memory_warning: threshold = Conf.main.available_memory_threshold.get() answer = QW.QMessageBox.critical( self, _("Warning"), _("Available memory is below %d MB.

Do you want to continue?") % threshold, QW.QMessageBox.Yes | QW.QMessageBox.No, ) return answer == QW.QMessageBox.Yes return True def check_stable_release(self): # pragma: no cover """Check if this is a stable release""" if __version__.replace(".", "").isdigit(): # This is a stable release return if "b" in __version__: # This is a beta release rel = _( "This software is in the beta stage of its release cycle. " "The focus of beta testing is providing a feature complete " "software for users interested in trying new features before " "the final release. However, beta software may not behave as " "expected and will probably have more bugs or performance issues " "than completed software." ) else: # This is an alpha release rel = _( "This software is in the alpha stage of its release cycle. " "The focus of alpha testing is providing an incomplete software " "for early testing of specific features by users. " "Please note that alpha software was not thoroughly tested " "by the developer before it is released." ) txtlist = [ f"{APP_NAME} v{__version__}:", "", _("This is not a stable release."), "", rel, ] QW.QMessageBox.warning(self, APP_NAME, "
".join(txtlist), QW.QMessageBox.Ok) def check_dependencies(self): # pragma: no cover """Check dependencies""" if is_frozen("codraft") or Conf.main.ignore_dependency_check.get(False): # No need to check dependencies if CodraFT has been frozen return try: state = dephash.check_dependencies_hash(DATAPATH) bad_deps = [name for name in state if not state[name]] if not bad_deps: # Everything is OK return except IOError: bad_deps = None txt0 = _("Non-compliant dependency:") if bad_deps is None or len(bad_deps) > 1: txt0 = _("Non-compliant dependencies:") if bad_deps is None: txtlist = [ _("CodraFT has not yet been qualified on your operating system."), ] else: txtlist = [ "" + txt0 + " " + ", ".join(bad_deps), "", "", _( "At least one dependency does not comply with CodraFT " "qualification standard reference (wrong dependency version " "has been installed, or dependency source code has been " "modified, or the application has not yet been qualified " "on your operating system)." ), ] txtlist += [ "", _( "This means that the application has not been officially qualified " "in this context and may not behave as expected." ), "", _( "Please click on the Ignore button " "to avoid showing this message at startup." ), ] txt = "
".join(txtlist) btn = QW.QMessageBox.information( self, APP_NAME, txt, QW.QMessageBox.Ok | QW.QMessageBox.Ignore ) Conf.main.ignore_dependency_check.set(btn == QW.QMessageBox.Ignore) def check_for_previous_crash(self): # pragma: no cover """Check for previous crash""" if execenv.unattended: self.show_log_viewer() elif Conf.main.faulthandler_log_available.get( False ) or Conf.main.traceback_log_available.get(False): txt = "
".join( [ _("Log files were generated during last session."), "", _("Do you want to see available log files?"), ] ) btns = QW.QMessageBox.StandardButton.Yes | QW.QMessageBox.StandardButton.No choice = QW.QMessageBox.warning(self, APP_NAME, txt, btns) if choice == QW.QMessageBox.StandardButton.Yes: self.show_log_viewer() def take_screenshot(self, name): # pragma: no cover """Take main window screenshot""" self.memorystatus.set_demo_mode(True) qth.grab_save_window(self, f"{name}") self.memorystatus.set_demo_mode(False) def take_menu_screenshots(self): # pragma: no cover """Take menu screenshots""" for panel in self.panels: self.tabwidget.setCurrentWidget(panel) for name in ( "file", "edit", "view", "operation", "processing", "computing", "help", ): menu = getattr(self, f"{name}_menu") menu.popup(self.pos()) qth.grab_save_window(menu, f"{panel.objectName()}_{name}") menu.close() # ------GUI setup def __restore_pos_and_size(self): """Restore main window position and size from configuration""" pos = Conf.main.window_position.get(None) if pos is not None: posx, posy = pos self.move(QC.QPoint(posx, posy)) size = Conf.main.window_size.get(None) if size is not None: width, height = size self.resize(QC.QSize(width, height)) if pos is not None and size is not None: sgeo = self.screen().availableGeometry() out_inf = posx < -int(0.9 * width) or posy < -int(0.9 * height) out_sup = posx > int(0.9 * sgeo.width()) or posy > int(0.9 * sgeo.height()) if len(QW.QApplication.screens()) == 1 and (out_inf or out_sup): # Main window is offscreen posx = min(max(posx, 0), sgeo.width() - width) posy = min(max(posy, 0), sgeo.height() - height) self.move(QC.QPoint(posx, posy)) def __save_pos_and_size(self): """Save main window position and size to configuration""" is_maximized = self.windowState() == QC.Qt.WindowMaximized Conf.main.window_maximized.set(is_maximized) if not is_maximized: size = self.size() Conf.main.window_size.set((size.width(), size.height())) pos = self.pos() Conf.main.window_position.set((pos.x(), pos.y())) def setup(self, console): """Setup main window""" self.statusBar().showMessage(_("Welcome to %s!") % APP_NAME, 5000) self.memorystatus = MemoryStatus(Conf.main.available_memory_threshold.get(500)) self.memorystatus.SIG_MEMORY_ALARM.connect(self.__set_low_memory_state) self.statusBar().addPermanentWidget(self.memorystatus) self.__setup_commmon_actions() curvewidget = self.__add_signal_panel() imagewidget = self.__add_image_panel() self.__add_tabwidget(curvewidget, imagewidget) self.__add_menus() if console: self.__setup_console() # Update selection dependent actions self.__update_actions() self.signal_image_docks[0].raise_() # Restoring current tab from last session tab_idx = Conf.main.current_tab.get(None) if tab_idx is not None: self.tabwidget.setCurrentIndex(tab_idx) def __setup_commmon_actions(self): """Setup common actions""" self.openh5_action = create_action( self, _("Open HDF5 files..."), icon=get_icon("h5open.svg"), tip=_("Open one or several HDF5 files"), triggered=lambda checked=False: self.open_h5_files(import_all=True), ) self.saveh5_action = create_action( self, _("Save to HDF5 file..."), icon=get_icon("h5save.svg"), tip=_("Save to HDF5 file"), triggered=self.save_to_h5_file, ) self.browseh5_action = create_action( self, _("Browse HDF5 file..."), icon=get_icon("h5browser.svg"), tip=_("Browse an HDF5 file"), triggered=lambda checked=False: self.open_h5_files(import_all=None), ) h5_toolbar = self.addToolBar(_("HDF5 I/O Toolbar")) add_actions( h5_toolbar, [self.openh5_action, self.saveh5_action, self.browseh5_action] ) # Quit action for "File menu" (added when populating menu on demand) if self.hide_on_close: quit_text = _("Hide window") quit_tip = _("Hide CodraFT window") else: quit_text = _("Quit") quit_tip = _("Quit application") self.quit_action = create_action( self, quit_text, shortcut=QG.QKeySequence(QG.QKeySequence.Quit), icon=get_icon("libre-gui-close.svg"), tip=quit_tip, triggered=self.close, ) def __add_signal_panel(self): """Setup signal toolbar, widgets and panel""" self.signal_toolbar = self.addToolBar(_("Signal Processing Toolbar")) curveplot_toolbar = self.addToolBar(_("Curve Plotting Toolbar")) curvewidget = DockablePlotWidget(self, CurveWidget, curveplot_toolbar) curveplot = curvewidget.get_plot() curveplot.add_item(make.legend("TR")) self.signalpanel = SignalPanel( self, curvewidget.plotwidget, self.signal_toolbar ) self.signalpanel.SIG_STATUS_MESSAGE.connect(self.statusBar().showMessage) return curvewidget def __add_image_panel(self): """Setup image toolbar, widgets and panel""" self.image_toolbar = self.addToolBar(_("Image Processing Toolbar")) imagevis_toolbar = self.addToolBar(_("Image Visualization Toolbar")) imagewidget = DockablePlotWidget(self, ImageWidget, imagevis_toolbar) self.imagepanel = ImagePanel(self, imagewidget.plotwidget, self.image_toolbar) # ----------------------------------------------------------------------------- # # Before eventually disabling the "peritem" mode by default, wait for the # # guiqwt bug to be fixed (peritem mode is not compatible with multiple image # # items): # for cspanel in ( # self.imagepanel.plotwidget.get_xcs_panel(), # self.imagepanel.plotwidget.get_ycs_panel(), # ): # cspanel.peritem_ac.setChecked(False) # ----------------------------------------------------------------------------- self.imagepanel.SIG_STATUS_MESSAGE.connect(self.statusBar().showMessage) return imagewidget def switch_to_signal_panel(self): """Switch to signal panel""" self.tabwidget.setCurrentWidget(self.signalpanel) def switch_to_image_panel(self): """Switch to image panel""" self.tabwidget.setCurrentWidget(self.imagepanel) def __add_tabwidget(self, curvewidget, imagewidget): """Setup tabwidget with signals and images""" self.tabwidget = DockableTabWidget() self.tabwidget.setMaximumWidth(500) self.tabwidget.addTab(self.signalpanel, get_icon("signal.svg"), _("Signals")) self.tabwidget.addTab(self.imagepanel, get_icon("image.svg"), _("Images")) self.__add_dockwidget(self.tabwidget, _("Main panel")) curve_dock = self.__add_dockwidget(curvewidget, title=_("Curve panel")) image_dock = self.__add_dockwidget(imagewidget, title=_("Image panel")) self.tabifyDockWidget(curve_dock, image_dock) self.signal_image_docks = curve_dock, image_dock self.tabwidget.currentChanged.connect(self.__tab_index_changed) self.signalpanel.SIG_OBJECT_ADDED.connect(self.switch_to_signal_panel) self.imagepanel.SIG_OBJECT_ADDED.connect(self.switch_to_image_panel) for panel in self.panels: panel.SIG_OBJECT_ADDED.connect(self.set_modified) panel.SIG_OBJECT_REMOVED.connect(self.set_modified) def __add_menus(self): """Adding menus""" self.file_menu = self.menuBar().addMenu(_("File")) self.file_menu.aboutToShow.connect(self.__update_file_menu) self.edit_menu = self.menuBar().addMenu(_("&Edit")) self.operation_menu = self.menuBar().addMenu(_("Operations")) self.processing_menu = self.menuBar().addMenu(_("Processing")) self.computing_menu = self.menuBar().addMenu(_("Computing")) self.view_menu = self.menuBar().addMenu(_("&View")) self.view_menu.aboutToShow.connect(self.__update_view_menu) self.help_menu = self.menuBar().addMenu("?") for menu in ( self.edit_menu, self.operation_menu, self.processing_menu, self.computing_menu, ): menu.aboutToShow.connect(self.__update_generic_menu) about_action = create_action( self, _("About..."), icon=get_icon("libre-gui-about.svg"), triggered=self.__about, ) homepage_action = create_action( self, _("Project home page"), icon=get_icon("libre-gui-globe.svg"), triggered=lambda: webbrowser.open(__homeurl__), ) issue_action = create_action( self, _("Bug report or feature request"), icon=get_icon("libre-gui-globe.svg"), triggered=lambda: webbrowser.open(__supporturl__), ) onlinedoc_action = create_action( self, _("Online documentation"), icon=get_icon("libre-gui-help.svg"), triggered=lambda: webbrowser.open(__docurl__), ) chmdoc_action = create_action( self, _("CHM documentation"), icon=get_icon("chm.svg"), triggered=lambda: os.startfile(get_htmlhelp()), ) chmdoc_action.setVisible(get_htmlhelp() is not None) logv_action = create_action( self, _("Show log files..."), icon=get_icon("logs.svg"), triggered=self.show_log_viewer, ) dep_action = create_action( self, _("About CodraFT installation") + "...", icon=get_icon("logs.svg"), triggered=lambda: exec_codraft_installconfig_dialog(self), ) errtest_action = create_action( self, "Test segfault/Python error", triggered=self.test_segfault_error ) errtest_action.setVisible(TEST_SEGFAULT_ERROR) about_action = create_action( self, _("About..."), icon=get_icon("libre-gui-about.svg"), triggered=self.__about, ) add_actions( self.help_menu, ( onlinedoc_action, chmdoc_action, None, errtest_action, logv_action, dep_action, None, homepage_action, issue_action, about_action, ), ) def __setup_console(self): """Add an internal console""" self.app_proxy = AppProxy(self) ns = { "app": self.app_proxy, "np": np, "sps": sps, "spi": spi, "os": os, "sys": sys, "osp": osp, "time": time, } msg = ( "Example: app.s[0] returns signal object #0\n" "Modules imported at startup: " "os, sys, os.path as osp, time, " "numpy as np, scipy.signal as sps, scipy.ndimage as spi" ) debug = os.environ.get("DEBUG") == "1" self.console = DockableConsole(self, namespace=ns, message=msg, debug=debug) self.console.setMaximumBlockCount(Conf.console.max_line_count.get(5000)) console_dock = self.__add_dockwidget(self.console, _("Console")) console_dock.hide() self.console.interpreter.widget_proxy.sig_new_prompt.connect( lambda txt: self.refresh_lists() ) # ------GUI refresh def has_objects(self): """Return True if sig/ima panels have any object""" return sum([len(panel.objlist) for panel in self.panels]) > 0 def set_modified(self, state=True): """Set mainwindow modified state""" state = state and self.has_objects() self.__is_modified = state self.setWindowTitle(APP_NAME + ("*" if state else "")) def __add_dockwidget(self, child, title): """Add QDockWidget and toggleViewAction""" dockwidget, location = child.create_dockwidget(title) self.addDockWidget(location, dockwidget) return dockwidget def refresh_lists(self): """Refresh signal/image lists""" for panel in self.panels: panel.objlist.refresh_list() def __update_actions(self): """Update selection dependent actions""" is_signal = self.tabwidget.currentWidget() is self.signalpanel panel = self.signalpanel if is_signal else self.imagepanel panel.selection_changed() self.signal_toolbar.setVisible(is_signal) self.image_toolbar.setVisible(not is_signal) def __tab_index_changed(self, index): """Switch from signal to image mode, or vice-versa""" dock = self.signal_image_docks[index] dock.raise_() self.__update_actions() def __update_generic_menu(self, menu=None): """Update menu before showing up -- Generic method""" if menu is None: menu = self.sender() menu.clear() panel = self.tabwidget.currentWidget() category = { self.file_menu: ActionCategory.FILE, self.edit_menu: ActionCategory.EDIT, self.view_menu: ActionCategory.VIEW, self.operation_menu: ActionCategory.OPERATION, self.processing_menu: ActionCategory.PROCESSING, self.computing_menu: ActionCategory.COMPUTING, }[menu] actions = panel.get_category_actions(category) add_actions(menu, actions) def __update_file_menu(self): """Update file menu before showing up""" self.saveh5_action.setEnabled(self.has_objects()) self.__update_generic_menu(self.file_menu) add_actions( self.file_menu, [ None, self.openh5_action, self.saveh5_action, self.browseh5_action, None, self.quit_action, ], ) def __update_view_menu(self): """Update view menu before showing up""" self.__update_generic_menu(self.view_menu) add_actions(self.view_menu, [None] + self.createPopupMenu().actions()) # ------Common features def reset_all(self): """Reset all application data""" for panel in self.panels: panel.remove_all_objects() @staticmethod def __check_h5file(filename, operation: str): """Check HDF5 filename""" filename = osp.abspath(osp.normpath(filename)) bname = osp.basename(filename) if operation == "load" and not osp.isfile(filename): raise IOError(f'File not found "{bname}"') if not filename.endswith(".h5"): raise IOError(f'Invalid HDF5 file "{bname}"') Conf.main.base_dir.set(filename) return filename def save_to_h5_file(self, filename=None): """Save to a CodraFT HDF5 file""" if filename is None: basedir = Conf.main.base_dir.get() with qth.save_restore_stds(): filters = f'{_("HDF5 files")} (*.h5)' filename, _filter = getsavefilename(self, _("Save"), basedir, filters) if not filename: return with qth.qt_try_loadsave_file(self.parent(), filename, "save"): filename = self.__check_h5file(filename, "save") self.h5inputoutput.save_file(filename) self.set_modified(False) def open_h5_files( self, h5files: List[str] = None, import_all: bool = None, reset_all: bool = None, ) -> None: """Open a CodraFT HDF5 file or import from any other HDF5 file :param h5files: HDF5 filenames (optionally with dataset name, separated by ":") :param import_all: Import all HDF5 file contents :param reset_all: Delete all CodraFT signals and images before importing data """ if not self.confirm_memory_state(): return if reset_all is None: reset_all = False if self.has_objects(): answer = QW.QMessageBox.question( self, _("Warning"), _( "Do you want to remove all signals and images " "before importing data from HDF5 files?" ), QW.QMessageBox.Yes | QW.QMessageBox.No, ) if answer == QW.QMessageBox.Yes: reset_all = True if h5files is None: basedir = Conf.main.base_dir.get() with qth.save_restore_stds(): filters = f'{_("HDF5 files")} (*.h5)' h5files, _filter = getopenfilenames(self, _("Open"), basedir, filters) for fname_with_dset in h5files: if "," in fname_with_dset: filename, dsetname = fname_with_dset.split(",") else: filename, dsetname = fname_with_dset, None if import_all is None and dsetname is None: self.import_h5_file(filename, reset_all) else: with qth.qt_try_loadsave_file(self, filename, "load"): filename = self.__check_h5file(filename, "load") if dsetname is None: self.h5inputoutput.open_file(filename, import_all, reset_all) else: self.h5inputoutput.import_dataset_from_file(filename, dsetname) reset_all = False def import_h5_file(self, filename: str, reset_all: bool = None) -> None: """Open CodraFT HDF5 browser to Import HDF5 file :param filename: HDF5 filename :param reset_all: Delete all CodraFT signals and images before importing data """ with qth.qt_try_loadsave_file(self, filename, "load"): filename = self.__check_h5file(filename, "load") self.h5inputoutput.import_file(filename, False, reset_all) def add_object(self, obj, refresh=True): """Add object - signal or image""" if self.confirm_memory_state(): if isinstance(obj, SignalParam): self.signalpanel.add_object(obj, refresh=refresh) elif isinstance(obj, ImageParam): self.imagepanel.add_object(obj, refresh=refresh) else: raise TypeError(f"Unsupported object type {type(obj)}") # ------? def __about(self): # pragma: no cover """About dialog box""" self.check_stable_release() QW.QMessageBox.about( self, _("About ") + APP_NAME, f"""{APP_NAME} v{__version__}
{APP_DESC}

%s Pierre Raybaut
Copyright © 2018-2022 CEA-CODRA

PythonQwt {qwt_ver}, guidata {guidata_ver}, guiqwt {guiqwt_ver}
Python {platform.python_version()}, Qt {QC.__version__}, PyQt {QC.PYQT_VERSION_STR} %s {platform.system()}""" % (_("Developped by"), _("on")), ) def show_log_viewer(self): """Show error logs""" exec_codraft_logviewer_dialog(self) @staticmethod def test_segfault_error(): """Generate errors (both fault and traceback)""" import ctypes # pylint: disable=import-outside-toplevel ctypes.string_at(0) raise RuntimeError("!!! Testing RuntimeError !!!") def show(self): """Reimplement QMainWindow method""" super().show() if self.__old_size is not None: self.resize(self.__old_size) # ------Close window def closeEvent(self, event): """Reimplement QMainWindow method""" if self.hide_on_close: self.__old_size = self.size() self.hide() else: if not env.execenv.unattended and self.__is_modified: answer = QW.QMessageBox.warning( self, _("Quit"), _( "Do you want to save all signals and images " "to an HDF5 file before quitting CodraFT?" ), QW.QMessageBox.Yes | QW.QMessageBox.No | QW.QMessageBox.Cancel, ) if answer == QW.QMessageBox.Yes: self.save_to_h5_file() if self.__is_modified: event.ignore() return elif answer == QW.QMessageBox.Cancel: event.ignore() return if self.console is not None: try: self.console.close() except RuntimeError: # TODO: [P3] Investigate further why the following error occurs when # restarting the mainwindow (this is *not* a production case): # "RuntimeError: wrapped C/C++ object of type DockableConsole # has been deleted". # Another solution to avoid this error would be to really restart # the application (run each unit test in a separate process), but # it would represent too much effort for an error occuring in test # configurations only. pass self.reset_all() self.__save_pos_and_size() # Saving current tab for next session Conf.main.current_tab.set(self.tabwidget.currentIndex()) event.accept() CodraFT-2.2.1/codraft/core/gui/objectlist.py000066400000000000000000000147341443562410300206630ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ Object (signal/image) list widgets """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import re from typing import Tuple from qtpy import QtCore as QC from qtpy import QtWidgets as QW from codraft.utils.qthelpers import block_signals class SimpleObjectList(QW.QListWidget): """Base object handling panel list widget, object (sig/ima) lists""" SIG_ITEM_DOUBLECLICKED = QC.Signal(int) SIG_CONTEXT_MENU = QC.Signal(QC.QPoint) def __init__(self, panel, parent=None): parent = panel if parent is None else parent super().__init__(parent) self.panel = panel self.prefix = panel.PREFIX self.setAlternatingRowColors(True) self._objects = [] # signals or images self.itemDoubleClicked.connect(self.item_double_clicked) def init_from(self, objlist): """Init from another SimpleObjectList, without making copies of objects""" self._objects = objlist.get_objects() self.refresh_list() self.setCurrentRow(objlist.currentRow()) def get_objects(self): """Get all objects""" return self._objects def set_current_row(self, row, extend=False, refresh=True): """Set list widget current row""" if row < 0: row += self.count() if extend: command = QC.QItemSelectionModel.Select else: command = QC.QItemSelectionModel.ClearAndSelect with block_signals(widget=self, enable=not refresh): self.setCurrentRow(row, command) def refresh_list(self, new_current_row=None): """ Refresh object list :param new_current_row: New row (if None, new current row is unchanged) """ row = self.currentRow() if new_current_row is not None: row = new_current_row self.clear() for idx, obj in enumerate(self._objects): item = QW.QListWidgetItem(f"{self.prefix}{idx:03d}: {obj.title}", self) item.setToolTip(obj.metadata_to_html()) self.addItem(item) if row < self.count(): self.set_current_row(row) def item_double_clicked(self, listwidgetitem): """Item was double-clicked: open a pop-up plot dialog""" self.SIG_ITEM_DOUBLECLICKED.emit(self.row(listwidgetitem)) def contextMenuEvent(self, event): # pylint: disable=C0103 """Override Qt method""" self.SIG_CONTEXT_MENU.emit(event.globalPos()) class GetObjectDialog(QW.QDialog): """Get object dialog box""" def __init__(self, parent, panel, title): super().__init__(parent) self.setWindowTitle(title) self.setLayout(QW.QVBoxLayout()) self.objlist = SimpleObjectList(panel, parent=parent) self.objlist.init_from(panel.objlist) self.objlist.SIG_ITEM_DOUBLECLICKED.connect(lambda row: self.accept()) self.layout().addWidget(self.objlist) bbox = QW.QDialogButtonBox(QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel) bbox.accepted.connect(self.accept) bbox.rejected.connect(self.reject) bbox.button(QW.QDialogButtonBox.Ok).setEnabled(self.objlist.count() > 0) self.layout().addSpacing(10) self.layout().addWidget(bbox) def get_object(self): """Return current object""" return self.objlist.get_objects()[self.objlist.currentRow()] class ObjectList(SimpleObjectList): """Object handling panel list widget, object (sig/ima) lists""" def __init__(self, panel): super().__init__(panel) self.setSelectionMode(QW.QListWidget.ExtendedSelection) def __len__(self): """Return number of objects""" return len(self._objects) def __getitem__(self, row): """Return object at row""" return self._objects[row] def __setitem__(self, row, obj): """Set object at row""" self._objects[row] = obj def __contains__(self, obj): """Return True if list contain obj""" return obj in self._objects def get_row(self, obj): """Return row associated to object obj""" return self._objects.index(obj) def __fix_obj_titles(self, row: int, sign: int) -> None: """Fix all object titles before adding (sign==1) or removing (sign==-1) an object at row index""" pfx = self.prefix oname = f"{pfx}%03d" for obj in self: for match in re.finditer(pfx + "[0-9]{3}", obj.title): before = match.group() i_match = int(before[1:]) if sign == -1 and i_match == row: after = f"{pfx}xxx" elif (sign == -1 and i_match > row) or (sign == 1 and i_match >= row): after = oname % (i_match + sign) else: continue obj.title = obj.title.replace(before, after) def __delitem__(self, row): """Del object at row""" self.__fix_obj_titles(row, -1) self._objects.pop(row) def __iter__(self): """Return an iterator over objects""" yield from self._objects def get_sel_object(self, position=0): """ Return currently selected object :param int position: Position in selection list (0 means first, -1 means last) :return: Current object or None if there is no selection """ rows = self.get_selected_rows() if rows: return self[rows[position]] return None def get_sel_objects(self): """Return selected objects""" return [self[row] for row in self.get_selected_rows()] def append(self, obj): """Append object""" self._objects.append(obj) def insert(self, row, obj): """Insert object at row index""" self.__fix_obj_titles(row, 1) self._objects.insert(row, obj) def remove_all(self): """Remove all objects""" self._objects = [] def select_rows(self, rows: Tuple): """Select multiple list widget rows""" for index, row in enumerate(sorted(rows)): self.set_current_row(row, extend=index != 0, refresh=row == len(rows) - 1) def select_all_rows(self): """Select all widget rows""" self.selectAll() def get_selected_rows(self): """Return selected rows""" return [index.row() for index in self.selectionModel().selectedRows()] CodraFT-2.2.1/codraft/core/gui/panel.py000066400000000000000000000766541443562410300176310ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Panel widgets (core.gui.panel) Signal and Image Panel widgets relie on components: * `ObjectProp`: widget handling signal/image properties using a guidata DataSet * `core.gui.panel.objectlist.ObjectList`: widget handling signal/image list * `core.gui.panel.actionhandler.SignalActionHandler` or `ImageActionHandler`: classes handling Qt actions * `core.gui.panel.plotitemlist.SignalItemList` or `ImageItemList`: classes handling guiqwt plot items * `core.gui.panel.processor.signal.SignalProcessor` or `core.gui.panel.processor.image.ImageProcessor`: classes handling computing features * `core.gui.panel.roieditor.SignalROIEditor` or `ImageROIEditor`: classes handling ROI editor widgets """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import abc import dataclasses import os.path as osp import re import warnings from typing import List import guidata.dataset.qtwidgets as gdq import numpy as np from guidata.configtools import get_icon from guidata.qthelpers import add_actions from guidata.utils import update_dataset from guidata.widgets.arrayeditor import ArrayEditor from guiqwt.io import imread, imwrite, iohandler from guiqwt.plot import CurveDialog, ImageDialog from guiqwt.tools import ( AnnotatedCircleTool, AnnotatedEllipseTool, AnnotatedPointTool, AnnotatedRectangleTool, AnnotatedSegmentTool, HCursorTool, LabelTool, RectangleTool, SegmentTool, VCursorTool, XCursorTool, ) from qtpy import QtCore as QC from qtpy import QtWidgets as QW from qtpy.compat import getopenfilename, getopenfilenames, getsavefilename from codraft.config import APP_NAME, Conf, _ from codraft.core.gui import actionhandler, objectlist, plotitemlist, roieditor from codraft.core.gui.processor.image import ImageProcessor from codraft.core.gui.processor.signal import SignalProcessor from codraft.core.io.signal import read_signal, write_signal from codraft.core.model.base import MetadataItem, ObjectItf, ResultShape from codraft.core.model.image import ( ImageDatatypes, ImageParam, create_image, create_image_from_param, new_image_param, ) from codraft.core.model.signal import ( SignalParam, create_signal_from_param, new_signal_param, ) from codraft.utils.qthelpers import ( exec_dialog, qt_try_except, qt_try_loadsave_file, save_restore_stds, ) # Registering MetadataItem edit widget gdq.DataSetEditLayout.register(MetadataItem, gdq.ButtonWidget) class ObjectProp(QW.QWidget): """Object handling panel properties""" def __init__(self, panel, paramclass): super().__init__(panel) self.paramclass = paramclass self.properties = gdq.DataSetEditGroupBox(_("Properties"), paramclass) self.properties.SIG_APPLY_BUTTON_CLICKED.connect(panel.properties_changed) self.properties.setEnabled(False) self.add_prop_layout = QW.QHBoxLayout() playout = self.properties.edit.layout playout.addLayout( self.add_prop_layout, playout.rowCount() - 1, 0, 1, 1, QC.Qt.AlignLeft ) hlayout = QW.QHBoxLayout() hlayout.addWidget(self.properties) vlayout = QW.QVBoxLayout() vlayout.addLayout(hlayout) vlayout.addStretch() self.setLayout(vlayout) def add_button(self, button): """Add additional button on bottom of properties panel""" self.add_prop_layout.addWidget(button) def update_properties_from(self, param: ObjectItf = None): """Update properties from signal/image dataset""" self.properties.setDisabled(param is None) if param is None: param = self.paramclass() self.properties.dataset.set_defaults() update_dataset(self.properties.dataset, param) self.properties.get() class BasePanelMeta(type(QW.QSplitter), abc.ABCMeta): """Mixed metaclass to avoid conflicts""" class BasePanel(QW.QSplitter, metaclass=BasePanelMeta): """Object handling the item list, the selected item properties and plot""" PANEL_STR = "" # e.g. "Signal Panel" PARAMCLASS = SignalParam # Replaced by the right class in child object DIALOGCLASS = CurveDialog # Idem ANNOTATION_TOOLS = ( LabelTool, VCursorTool, HCursorTool, XCursorTool, SegmentTool, RectangleTool, ) DIALOGSIZE = (800, 600) PREFIX = "" # e.g. "s" OPEN_FILTERS = "" # Qt file open dialog filters H5_PREFIX = "" SIG_STATUS_MESSAGE = QC.Signal(str) # emitted by "qt_try_except" decorator SIG_OBJECT_ADDED = QC.Signal() SIG_OBJECT_REMOVED = QC.Signal() SIG_UPDATE_PLOT_ITEM = QC.Signal(int) # Update plot item associated to row number SIG_UPDATE_PLOT_ITEMS = QC.Signal() # Update plot items associated to selected rows ROIDIALOGOPTIONS = {} ROIDIALOGCLASS = roieditor.BaseROIEditor # Replaced in child object @abc.abstractmethod def __init__(self, parent, plotwidget, toolbar): super().__init__(QC.Qt.Vertical, parent) self.setObjectName(self.PREFIX) self.mainwindow = parent self.objprop = ObjectProp(self, self.PARAMCLASS) self.objlist = objectlist.ObjectList(self) self.itmlist = None self.processor = None self.acthandler = None self.__metadata_clipboard = {} self.context_menu = QW.QMenu() self.__separate_views = {} def setup_panel(self): """Setup panel""" self.processor.SIG_ADD_SHAPE.connect(self.itmlist.add_shapes) self.SIG_UPDATE_PLOT_ITEM.connect(self.itmlist.refresh_plot) self.SIG_UPDATE_PLOT_ITEMS.connect(self.itmlist.refresh_plot) self.objlist.itemSelectionChanged.connect(self.selection_changed) self.objlist.SIG_ITEM_DOUBLECLICKED.connect( lambda row: self.open_separate_view([row]) ) self.objlist.SIG_CONTEXT_MENU.connect(self.__popup_contextmenu) self.objprop.properties.SIG_APPLY_BUTTON_CLICKED.connect( self.properties_changed ) self.addWidget(self.objlist) self.addWidget(self.objprop) self.add_results_button() def get_category_actions(self, category): # pragma: no cover """Return actions for category""" return self.acthandler.feature_actions[category] def __popup_contextmenu(self, position: QC.QPoint): # pragma: no cover """Popup context menu at position""" # Note: For now, this is completely unnecessary to clear context menu everytime, # but implementing it this way could be useful in the future in menu contents # should take into account current object selection self.context_menu.clear() add_actions(self.context_menu, self.acthandler.actlist_cmenu) self.context_menu.popup(position) # ------Creating, adding, removing objects------------------------------------------ def create_object(self, title=None): """Create object (signal or image) :param str title: Title of the object """ obj = self.PARAMCLASS(title=title) obj.title = title return obj @qt_try_except() def add_object(self, obj, refresh=True): """Add signal/image object and return associated plot item""" obj.check_data() self.objlist.append(obj) item = self.itmlist.append(None) if refresh: self.objlist.refresh_list(-1) self.SIG_OBJECT_ADDED.emit() return item # TODO: [P2] New feature: move objects up/down @qt_try_except() def insert_object(self, obj, row, refresh=True): """Insert signal/image object after row""" obj.check_data() self.objlist.insert(row, obj) self.itmlist.insert(row) if refresh: self.objlist.refresh_list(new_current_row=row + 1) self.SIG_OBJECT_ADDED.emit() def duplicate_object(self): """Duplication signal/image object""" if not self.mainwindow.confirm_memory_state(): return rows = sorted(self.objlist.get_selected_rows()) row = None for row in rows: obj = self.objlist[row] objcopy = self.create_object() objcopy.title = obj.title objcopy.copy_data_from(obj) self.add_object(objcopy, refresh=False) self.objlist.refresh_list(new_current_row=-1) self.SIG_UPDATE_PLOT_ITEMS.emit() def copy_metadata(self): """Copy object metadata""" row = self.objlist.get_selected_rows()[0] obj = self.objlist[row] self.__metadata_clipboard = obj.metadata.copy() pfx = self.objlist.prefix new_pref = f"{pfx}{row:03d}_" for key, value in obj.metadata.items(): if ResultShape.match(key, value): mshape = ResultShape.from_metadata_entry(key, value) if not re.match(pfx + r"[0-9]{3}[\s]*", mshape.label): # Handling additional result (e.g. diameter) for a_key, a_value in obj.metadata.items(): if isinstance(a_key, str) and a_key.startswith(mshape.label): self.__metadata_clipboard.pop(a_key) self.__metadata_clipboard[new_pref + a_key] = a_value mshape.label = new_pref + mshape.label # Handling result shape self.__metadata_clipboard.pop(key) self.__metadata_clipboard[mshape.key] = value def paste_metadata(self): """Paste metadata to selected object(s)""" rows = sorted(self.objlist.get_selected_rows(), reverse=True) row = None for row in rows: obj = self.objlist[row] obj.metadata.update(self.__metadata_clipboard) self.SIG_UPDATE_PLOT_ITEMS.emit() def remove_object(self): """Remove signal/image object""" rows = sorted(self.objlist.get_selected_rows(), reverse=True) for row in rows: for dlg, obj in self.__separate_views.items(): if obj is self.objlist[row]: dlg.done(QW.QDialog.DialogCode.Rejected) del self.objlist[row] del self.itmlist[row] self.objlist.refresh_list(max(0, rows[-1] - 1)) self.SIG_UPDATE_PLOT_ITEMS.emit() self.SIG_OBJECT_REMOVED.emit() def delete_all_objects(self): # pragma: no cover """Confirm before removing all objects""" if len(self.objlist) == 0: return answer = QW.QMessageBox.warning( self, _("Delete all"), _("Do you want to delete all objects (%s)?") % self.PANEL_STR, QW.QMessageBox.Yes | QW.QMessageBox.No, ) if answer == QW.QMessageBox.Yes: self.remove_all_objects() def remove_all_objects(self): """Remove all signal/image objects""" for dlg in self.__separate_views: dlg.done(QW.QDialog.DialogCode.Rejected) self.objlist.remove_all() self.itmlist.remove_all() self.objlist.refresh_list(0) self.SIG_UPDATE_PLOT_ITEMS.emit() self.SIG_OBJECT_REMOVED.emit() def delete_metadata(self): """Delete object metadata""" for index, row in enumerate(self.objlist.get_selected_rows()): self.objlist[row].metadata = {} if index == 0: self.selection_changed() self.SIG_UPDATE_PLOT_ITEMS.emit() @abc.abstractmethod def new_object(self, newparam=None, addparam=None, edit=True): """Create a new object (signal/image). :param guidata.dataset.DataSet newparam: new object parameters :param guidata.dataset.datatypes.DataSet addparam: additional parameters :param bool edit: Open a dialog box to edit parameters (default: True) """ @abc.abstractmethod def open_object(self, filename: str) -> None: """Open object from file (signal/image)""" def open_objects(self, filenames: List[str] = None) -> None: """Open objects from file (signals/images)""" if not self.mainwindow.confirm_memory_state(): return if filenames is None: # pragma: no cover basedir = Conf.main.base_dir.get() with save_restore_stds(): filenames, _filter = getopenfilenames( self, _("Open"), basedir, self.OPEN_FILTERS ) for filename in filenames: with qt_try_loadsave_file(self.parent(), filename, "load"): Conf.main.base_dir.set(filename) self.open_object(filename) def save_objects(self, filenames: List[str] = None) -> None: """Save selected objects to file (signal/image)""" rows = self.objlist.get_selected_rows() if filenames is None: # pragma: no cover filenames = [None] * len(rows) assert len(filenames) == len(rows) for index, row in enumerate(rows): filename = filenames[index] obj = self.objlist[row] self.save_object(obj, filename) @abc.abstractmethod def save_object(self, obj, filename: str = None) -> None: """Save object to file (signal/image)""" def import_metadata_from_file(self, filename: str = None): """Import metadata from file (JSON)""" if filename is None: # pragma: no cover basedir = Conf.main.base_dir.get() with save_restore_stds(): filename, _filter = getopenfilename( self, _("Import metadata"), basedir, "*.json" ) if filename: with qt_try_loadsave_file(self.parent(), filename, "load"): Conf.main.base_dir.set(filename) row = self.objlist.get_selected_rows()[0] obj = self.objlist[row] obj.import_metadata_from_file(filename) self.SIG_UPDATE_PLOT_ITEMS.emit() def export_metadata_from_file(self, filename: str = None): """Export metadata to file (JSON)""" row = self.objlist.get_selected_rows()[0] obj = self.objlist[row] if filename is None: # pragma: no cover basedir = Conf.main.base_dir.get() with save_restore_stds(): filename, _filt = getsavefilename( self, _("Export metadata"), basedir, "*.json" ) if filename: with qt_try_loadsave_file(self.parent(), filename, "save"): Conf.main.base_dir.set(filename) obj.export_metadata_to_file(filename) # ------Serializing/deserializing objects------------------------------------------- def serialize_to_hdf5(self, writer): """Serialize objects to a HDF5 file""" with writer.group(self.H5_PREFIX): for idx, obj in enumerate(self.objlist): title = re.sub("[^-a-zA-Z0-9_.() ]+", "", obj.title.replace("/", "_")) name = f"{self.PREFIX}{idx:03d}: {title}" with writer.group(name): obj.serialize(writer) def deserialize_from_hdf5(self, reader): """Deserialize objects from a HDF5 file""" with reader.group(self.H5_PREFIX): for name in reader.h5.get(self.H5_PREFIX, []): obj = self.PARAMCLASS() with reader.group(name): obj.deserialize(reader) self.add_object(obj) QW.QApplication.processEvents() # ------Refreshing GUI-------------------------------------------------------------- def selection_changed(self): """Signal list: selection changed""" row = self.objlist.currentRow() sel_objs = self.objlist.get_sel_objects() if not sel_objs: row = -1 self.objprop.update_properties_from(self.objlist[row] if row != -1 else None) self.SIG_UPDATE_PLOT_ITEMS.emit() self.acthandler.selection_rows_changed() def properties_changed(self): """The properties 'Apply' button was clicked: updating signal""" row = self.objlist.currentRow() update_dataset(self.objlist[row], self.objprop.properties.dataset) self.objlist.refresh_list() self.SIG_UPDATE_PLOT_ITEMS.emit() # ------Plotting data in modal dialogs---------------------------------------------- def open_separate_view(self, rows=None) -> QW.QDialog: """ Open separate view for visualizing selected objects :param list rows: List of row indexes for the objects to be shown in dialog :return: Dialog instance """ title = _("Annotations") if rows is None: rows = self.objlist.get_selected_rows() row = rows[0] obj = self.objlist[row] dlg = self.create_new_dialog(rows, edit=True, name="new_window") width, height = self.DIALOGSIZE dlg.resize(width, height) dlg.plot_widget.itemlist.setVisible(True) toolbar = QW.QToolBar(title, self) dlg.button_layout.insertWidget(0, toolbar) # dlg.layout().insertWidget(1, toolbar) # other possible location # dlg.plot_layout.addWidget(toolbar, 1, 0, 1, 1) # other possible location dlg.add_toolbar(toolbar, id(toolbar)) toolbar.setToolButtonStyle(QC.Qt.ToolButtonTextUnderIcon) for tool in self.ANNOTATION_TOOLS: dlg.add_tool(tool, toolbar_id=id(toolbar)) plot = dlg.get_plot() plot.unselect_all() for item in plot.items: item.set_selectable(False) for item in obj.iterate_shape_items(editable=True): plot.add_item(item) self.__separate_views[dlg] = obj dlg.show() dlg.finished.connect(self.__separate_view_finished) return dlg def __separate_view_finished(self, result: int): """Separate view was closed""" dlg = self.sender() if result == QW.QDialog.DialogCode.Accepted: items = dlg.get_plot().get_items() rw_items = [item for item in items if not item.is_readonly()] if rw_items: obj = self.__separate_views[dlg] obj.set_annotations_from_items(rw_items) self.selection_changed() self.SIG_UPDATE_PLOT_ITEMS.emit() def toggle_show_titles(self, state): """Toggle show annotations option""" Conf.view.show_label.set(state) for obj in self.objlist: obj.metadata[obj.METADATA_LBL] = state self.SIG_UPDATE_PLOT_ITEMS.emit() def create_new_dialog( self, rows, edit=False, toolbar=True, title=None, tools=None, name=None, options=None, ): """ Create new pop-up signal/image plot dialog :param list rows: List of row indexes for the objects to be shown in dialog :param bool edit: If True, show "OK" and "Cancel" buttons :param bool toolbar: If True, add toolbar :param str title: Title of the dialog box :param list tools: List of plot tools :param str name: Name of the widget (used as screenshot basename) :param dict options: Plot options """ if title is not None or len(rows) == 1: if title is None: title = self.objlist.get_sel_object().title title = f"{title} - {APP_NAME}" else: title = APP_NAME plot_options = self.itmlist.get_current_plot_options() if options is not None: plot_options.update(options) dlg = self.DIALOGCLASS( parent=self, wintitle=title, edit=edit, options=plot_options, toolbar=toolbar, ) dlg.setWindowIcon(get_icon("codraft.svg")) dlg.setObjectName(f"{self.PREFIX}_{name}") if tools is not None: for tool in tools: dlg.add_tool(tool) plot = dlg.get_plot() for row in rows: item = self.itmlist.make_item_from_existing(row) item.set_readonly(True) plot.add_item(item, z=0) plot.set_active_item(item) plot.replot() return dlg def create_new_dialog_for_selection( self, title, name, options=None, toolbar=False, tools=None ): """ Create new pop-up dialog for the currently selected signal/image :param str title: Title of the dialog box :param str name: Name of the widget (used as screenshot basename) :param dict options: Plot options :param list tools: List of plot tools :return: tuple (dialog, current_object) """ row = self.objlist.get_selected_rows()[0] obj = self.objlist[row] dlg = self.create_new_dialog( [row], edit=True, toolbar=toolbar, title=f"{title} - {obj.title}", tools=tools, name=name, options=options, ) return dlg, obj def get_roi_dialog(self, extract: bool, singleobj: bool) -> roieditor.ROIEditorData: """Get ROI data (array) from specific dialog box""" roi_s = _("Regions of interest") options = self.ROIDIALOGOPTIONS dlg, obj = self.create_new_dialog_for_selection(roi_s, "roi_dialog", options) plot = dlg.get_plot() plot.unselect_all() for item in plot.items: item.set_selectable(False) roi_editor = self.ROIDIALOGCLASS(dlg, obj, extract, singleobj) dlg.plot_layout.addWidget(roi_editor, 1, 0, 1, 1) if exec_dialog(dlg): return roi_editor.get_data() return None def get_object_dialog( self, parent: QW.QWidget, title: str ) -> objectlist.GetObjectDialog: """Get object dialog""" dlg = objectlist.GetObjectDialog(parent, self, title) if exec_dialog(dlg): return dlg.get_object() return None def add_results_button(self): """Add 'Show results' button""" btn = QW.QPushButton(get_icon("show_results.svg"), _("Show results"), self) btn.setToolTip(_("Show results obtained from previous computations")) self.objprop.add_button(btn) btn.clicked.connect(self.show_results) self.acthandler.actlist_1more.append(btn) def show_results(self): """Show results""" rows = self.objlist.get_selected_rows() @dataclasses.dataclass class ResultData: """Result data associated to a shapetype""" results: List[ResultShape] = None xlabels: List[str] = None ylabels: List[str] = None rdatadict = {} for idx, row in enumerate(rows): obj = self.objlist[row] for key, value in obj.metadata.items(): if ResultShape.match(key, value): result = ResultShape.from_metadata_entry(key, value) rdata = rdatadict.setdefault( result.shapetype, ResultData([], None, []) ) title = f"{result.label}" rdata.results.append(result) rdata.xlabels = result.xlabels for _i_row_res in range(result.array.shape[0]): ylabel = f"{self.PREFIX}{idx:03d}: {result.label}" rdata.ylabels.append(ylabel) if rdatadict: with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) for rdata in rdatadict.values(): dlg = ArrayEditor(self.parent()) title = _("Results") dlg.setup_and_check( np.vstack([result.array for result in rdata.results]), title, readonly=True, xlabels=rdata.xlabels, ylabels=rdata.ylabels, ) dlg.setObjectName(f"{self.PREFIX}_results") dlg.resize(750, 300) exec_dialog(dlg) else: msg = "
".join( [ _("No result currently available for this object."), "", _( "This feature shows result arrays as displayed after " 'calling one of the computing feature (see "Compute" menu).' ), ] ) QW.QMessageBox.information(self, APP_NAME, msg) class SignalPanel(BasePanel): """Object handling the item list, the selected item properties and plot, specialized for Signal objects""" PANEL_STR = _("Signal List") PARAMCLASS = SignalParam DIALOGCLASS = CurveDialog PREFIX = "s" OPEN_FILTERS = f'{_("Text files")} (*.txt *.csv)\n{_("NumPy arrays")} (*.npy)' H5_PREFIX = "CodraFT_Sig" ROIDIALOGCLASS = roieditor.SignalROIEditor # pylint: disable=duplicate-code def __init__(self, parent, plotwidget, toolbar): super().__init__(parent, plotwidget, toolbar) self.itmlist = plotitemlist.SignalItemList(self, self.objlist, plotwidget) self.processor = SignalProcessor(self, self.objlist, plotwidget) self.acthandler = actionhandler.SignalActionHandler( self, self.objlist, self.itmlist, self.processor, toolbar ) self.setup_panel() # ------Creating, adding, removing objects------------------------------------------ def new_object(self, newparam=None, addparam=None, edit=True): """Create a new signal. :param codraft.core.model.signal.SignalNewParam newparam: new signal parameters :param guidata.dataset.datatypes.DataSet addparam: additional parameters :param bool edit: Open a dialog box to edit parameters (default: True) """ if not self.mainwindow.confirm_memory_state(): return curobj = self.objlist.get_sel_object(-1) if curobj is not None: newparam = newparam if newparam is not None else new_signal_param() newparam.size = len(curobj.data) newparam.xmin = curobj.x.min() newparam.xmax = curobj.x.max() signal = create_signal_from_param( newparam, addparam=addparam, edit=edit, parent=self ) if signal is not None: self.add_object(signal) def open_object(self, filename: str) -> None: """Open object from file (signal/image)""" signal = read_signal(filename) self.add_object(signal) def save_object(self, obj, filename: str = None) -> None: """Save object to file (signal/image)""" if filename is None: basedir = Conf.main.base_dir.get() with save_restore_stds(): filename, _filter = getsavefilename( # pylint: disable=duplicate-code self, _("Save as"), basedir, self.OPEN_FILTERS ) if filename: with qt_try_loadsave_file(self.parent(), filename, "save"): Conf.main.base_dir.set(filename) write_signal(obj, filename) class ImagePanel(BasePanel): """Object handling the item list, the selected item properties and plot, specialized for Image objects""" PANEL_STR = _("Image List") PARAMCLASS = ImageParam DIALOGCLASS = ImageDialog DIALOGSIZE = (800, 800) ANNOTATION_TOOLS = ( AnnotatedCircleTool, AnnotatedSegmentTool, AnnotatedRectangleTool, AnnotatedPointTool, AnnotatedEllipseTool, LabelTool, ) PREFIX = "i" OPEN_FILTERS = iohandler.get_filters("load", dtype=None) H5_PREFIX = "CodraFT_Ima" ROIDIALOGOPTIONS = dict(show_itemlist=True, show_contrast=False) ROIDIALOGCLASS = roieditor.ImageROIEditor # pylint: disable=duplicate-code def __init__(self, parent, plotwidget, toolbar): super().__init__(parent, plotwidget, toolbar) self.itmlist = plotitemlist.ImageItemList(self, self.objlist, plotwidget) self.processor = ImageProcessor(self, self.objlist, plotwidget) self.acthandler = actionhandler.ImageActionHandler( self, self.objlist, self.itmlist, self.processor, toolbar ) self.setup_panel() # ------Refreshing GUI-------------------------------------------------------------- def properties_changed(self): """The properties 'Apply' button was clicked: updating signal""" row = self.objlist.currentRow() self.objlist[row].invalidate_maskdata_cache() super().properties_changed() # ------Creating, adding, removing objects------------------------------------------ def new_object(self, newparam=None, addparam=None, edit=True): """Create a new image. :param codraft.core.model.image.ImageNewParam newparam: new image parameters :param guidata.dataset.datatypes.DataSet addparam: additional parameters :param bool edit: Open a dialog box to edit parameters (default: True) """ if not self.mainwindow.confirm_memory_state(): return curobj = self.objlist.get_sel_object(-1) if curobj is not None: newparam = newparam if newparam is not None else new_image_param() newparam.width, newparam.height = curobj.size newparam.dtype = ImageDatatypes.from_dtype(curobj.data.dtype) image = create_image_from_param( newparam, addparam=addparam, edit=edit, parent=self ) if image is not None: self.add_object(image) def open_object(self, filename: str) -> None: """Open object from file (signal/image)""" data = imread(filename, to_grayscale=False) reducepath = osp.relpath(filename, osp.join(osp.dirname(filename), osp.pardir)) if filename.lower().endswith(".sif") and len(data.shape) == 3: for idx in range(data.shape[0]): image = create_image(reducepath + "_Im" + str(idx), data[idx, ::]) self.add_object(image) else: if data.ndim == 3: # Converting to grayscale data = data[..., :4].mean(axis=2) image = create_image(reducepath, data) if osp.splitext(filename)[1].lower() == ".dcm": from pydicom import dicomio # pylint: disable=C0415,E0401 image.dicom_template = dicomio.read_file( filename, stop_before_pixels=True, force=True ) self.add_object(image) def save_object(self, obj, filename: str = None) -> None: """Save object to file (signal/image)""" if filename is None: basedir = Conf.main.base_dir.get() with save_restore_stds(): filename, _filter = getsavefilename( # pylint: disable=duplicate-code self, _("Save as"), basedir, iohandler.get_filters( "save", dtype=obj.data.dtype, template=obj.dicom_template ), ) if filename: kwargs = {} if osp.splitext(filename)[1].lower() == ".dcm": kwargs["template"] = obj.dicom_template with qt_try_loadsave_file(self.parent(), filename, "save"): Conf.main.base_dir.set(filename) imwrite(filename, obj.data, **kwargs) def toggle_show_contrast(self, state): """Toggle show contrast option""" Conf.view.show_contrast.set(state) self.SIG_UPDATE_PLOT_ITEMS.emit() CodraFT-2.2.1/codraft/core/gui/plotitemlist.py000066400000000000000000000171031443562410300212430ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Plot item list classes These classes handle guiqwt plot items for signal and image panels. """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from guiqwt.builder import make from guiqwt.curve import GridItem from guiqwt.label import LegendBoxItem from guiqwt.styles import style_generator from codraft.config import Conf class BaseItemList: """Object handling plot items associated to objects (signals/images)""" def __init__(self, panel, objlist, plotwidget): self._enable_cleanup_dataview = True self.panel = panel self.objlist = objlist self.plotwidget = plotwidget self.plot = plotwidget.get_plot() self.__plotitems = [] # plot items associated to objects (sig/ima) self.__shapeitems = [] def __len__(self): """Return number of items""" return len(self.__plotitems) def __getitem__(self, row): """Return item at row""" return self.__plotitems[row] def __setitem__(self, row, item): """Set item at row""" self.__plotitems[row] = item def __delitem__(self, row): """Del item at row""" item = self.__plotitems.pop(row) self.plot.del_item(item) def __iter__(self): """Return an iterator over items""" yield from self.__plotitems def append(self, item): """Append item""" self.__plotitems.append(item) def insert(self, row): """Insert object at row index""" self.__plotitems.insert(row, None) def add_item_to_plot(self, row): """Add plot item to plot""" item = self.objlist[row].make_item() item.set_readonly(True) if row < len(self): self[row] = item else: self.append(item) self.plot.add_item(item) return item def make_item_from_existing(self, row): """Make plot item from existing object/item at row""" return self.objlist[row].make_item(update_from=self[row]) def update_item(self, row, ref_item=None): """Update plot item associated to data""" self.objlist[row].update_item(self[row], ref_item=ref_item) def add_shapes(self, row): """Add geometric shape items associated to computed results and annotations""" obj = self.objlist[row] if obj.metadata: # Performance optimization: block `guiqwt.baseplot.BasePlot` signals, # add all items except the last one, unblock signals, then add the last one # (this avoids some unnecessary refresh process by guiqwt) items = list(obj.iterate_shape_items(editable=False)) if items: block = self.plot.blockSignals(True) for item in items[:-1]: self.plot.add_item(item) self.__shapeitems.append(item) self.plot.blockSignals(block) self.plot.add_item(items[-1]) self.__shapeitems.append(items[-1]) def remove_all(self): """Remove all plot items""" self.__plotitems = [] self.plot.del_all_items() def remove_all_shape_items(self): """Remove all geometric shapes associated to result items""" if set(self.__shapeitems).issubset(set(self.plot.items)): self.plot.del_items(self.__shapeitems) self.__shapeitems = [] def refresh_plot(self, only_row: int = None): """Refresh plot (if row is not None, refresh only plot associated to row)""" if only_row is None: rows = self.objlist.get_selected_rows() if self._enable_cleanup_dataview and len(rows) == 1: self.cleanup_dataview() self.remove_all_shape_items() for item in self: if item is not None: item.hide() else: rows = [only_row] title_keys = ("title", "xlabel", "ylabel", "zlabel", "xunit", "yunit", "zunit") titles_dict = {} if rows: ref_item = None for i_row, row in enumerate(rows): for key in title_keys: title = getattr(self.objlist[row], key, "") value = titles_dict.get(key) if value is None: titles_dict[key] = title elif value != title: titles_dict[key] = "" item = self[row] if item is None: item = self.add_item_to_plot(row) else: if i_row == 0: make.style = style_generator() self.update_item(row, ref_item=ref_item) if ref_item is None: ref_item = item self.plot.set_item_visible(item, True, replot=False) self.plot.set_active_item(item) item.unselect() self.add_shapes(row) self.plot.replot() else: for key in title_keys: titles_dict[key] = "" tdict = titles_dict tdict["ylabel"] = (tdict["ylabel"], tdict.pop("zlabel")) tdict["yunit"] = (tdict["yunit"], tdict.pop("zunit")) self.plot.set_titles(**titles_dict) self.plot.do_autoscale() def toggle_cleanup_dataview(self, state): """Toggle clean up data view option""" self._enable_cleanup_dataview = state def cleanup_dataview(self): """Clean up data view""" # Performance optimization: using `baseplot.BasePlot.del_items` instead of # `baseplot.BasePlot.del_item` (avoid emitting unnecessary signals) self.plot.del_items( [ item for item in self.plot.items[:] if item not in self and not isinstance(item, (LegendBoxItem, GridItem)) ] ) def get_current_plot_options(self): """ Return standard signal/image plot options :return: Dictionary containing plot arguments for CurveDialog/ImageDialog """ return dict( xlabel=self.plot.get_axis_title("bottom"), ylabel=self.plot.get_axis_title("left"), xunit=self.plot.get_axis_unit("bottom"), yunit=self.plot.get_axis_unit("left"), ) class SignalItemList(BaseItemList): """Object handling signal plot items, plot dialogs, plot options""" # Nothing specific to signals, as of today class ImageItemList(BaseItemList): """Object handling image plot items, plot dialogs, plot options""" def refresh_plot(self, only_row: int = None): """Refresh plot (if row is not None, refresh only plot associated to row)""" super().refresh_plot(only_row) self.plotwidget.contrast.setVisible(Conf.view.show_contrast.get(True)) def cleanup_dataview(self): """Clean up data view""" for widget in (self.plotwidget.xcsw, self.plotwidget.ycsw): widget.hide() super().cleanup_dataview() def get_current_plot_options(self): """ Return standard signal/image plot options :return: Dictionary containing plot arguments for CurveDialog/ImageDialog """ options = super().get_current_plot_options() options.update( dict( zlabel=self.plot.get_axis_title("right"), zunit=self.plot.get_axis_unit("right"), show_contrast=True, ) ) return options CodraFT-2.2.1/codraft/core/gui/processor/000077500000000000000000000000001443562410300201555ustar00rootroot00000000000000CodraFT-2.2.1/codraft/core/gui/processor/__init__.py000066400000000000000000000000021443562410300222560ustar00rootroot00000000000000# CodraFT-2.2.1/codraft/core/gui/processor/base.py000066400000000000000000000413551443562410300214510ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Base Processor GUI module """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import abc import warnings from typing import Callable, Dict, List import guidata.dataset.dataitems as gdi import guidata.dataset.datatypes as gdt import numpy as np from guidata.configtools import get_icon from guidata.widgets.arrayeditor import ArrayEditor from qtpy import QtCore as QC from qtpy import QtWidgets as QW from codraft import env from codraft.config import _ from codraft.core.gui.objectlist import ObjectList from codraft.core.gui.roieditor import ROIEditorData from codraft.core.model.base import ResultShape from codraft.utils import misc from codraft.utils.qthelpers import create_progress_bar, exec_dialog, qt_try_except class GaussianParam(gdt.DataSet): """Gaussian filter parameters""" sigma = gdi.FloatItem("σ", default=1.0) class MovingAverageParam(gdt.DataSet): """Moving average parameters""" n = gdi.IntItem(_("Size of the moving window"), default=3, min=1) class MovingMedianParam(gdt.DataSet): """Moving median parameters""" n = gdi.IntItem(_("Size of the moving window"), default=3, min=1, even=False) class ThresholdParam(gdt.DataSet): """Threshold parameters""" value = gdi.FloatItem(_("Threshold")) class ClipParam(gdt.DataSet): """Data clipping parameters""" value = gdi.FloatItem(_("Clipping value")) class BaseProcessor(QC.QObject): """Object handling data processing: operations, processing, computing""" SIG_ADD_SHAPE = QC.Signal(int) EDIT_ROI_PARAMS = False def __init__(self, panel, objlist: ObjectList, plotwidget): super().__init__() self.panel = panel self.objlist = objlist self.plotwidget = plotwidget self.prefix = panel.PREFIX @qt_try_except() def compute_sum(self): """Compute sum""" rows = self.objlist.get_selected_rows() outobj = self.panel.create_object() outobj.title = "+".join([f"{self.prefix}{row:03d}" for row in rows]) roilist = [] for row in rows: obj = self.objlist[row] if obj.roi is not None: roilist.append(obj.roi) if outobj.data is None: outobj.copy_data_from(obj) else: outobj.data += np.array(obj.data, dtype=outobj.data.dtype) outobj.update_resultshapes_from(obj) if roilist: outobj.roi = np.vstack(roilist) self.panel.add_object(outobj) @qt_try_except() def compute_average(self): """Compute average""" rows = self.objlist.get_selected_rows() outobj = self.panel.create_object() title = ", ".join([f"{self.prefix}{row:03d}" for row in rows]) outobj.title = f'{_("Average")}({title})' original_dtype = self.objlist.get_sel_object().data.dtype new_dtype = complex if misc.is_complex_dtype(original_dtype) else float roilist = [] for row in rows: obj = self.objlist[row] if obj.roi is not None: roilist.append(obj.roi) if outobj.data is None: outobj.copy_data_from(obj, dtype=new_dtype) else: outobj.data += np.array(obj.data, dtype=outobj.data.dtype) outobj.update_resultshapes_from(obj) outobj.data /= float(len(rows)) if misc.is_integer_dtype(original_dtype): outobj.set_data_type(dtype=original_dtype) if roilist: outobj.roi = np.vstack(roilist) self.panel.add_object(outobj) @qt_try_except() def compute_product(self): """Compute product""" rows = self.objlist.get_selected_rows() outobj = self.panel.create_object() outobj.title = "*".join([f"{self.prefix}{row:03d}" for row in rows]) for row in rows: obj = self.objlist[row] if outobj.data is None: outobj.copy_data_from(obj) else: outobj.data *= np.array(obj.data, dtype=outobj.data.dtype) self.panel.add_object(outobj) @qt_try_except() def compute_difference(self, quad: bool): """Compute (quadratic) difference""" rows = self.objlist.get_selected_rows() outobj = self.panel.create_object() outobj.title = "-".join([f"{self.prefix}{row:03d}" for row in rows]) if quad: outobj.title = f"({outobj.title})/sqrt(2)" obj0, obj1 = self.objlist.get_sel_object(), self.objlist.get_sel_object(1) outobj.copy_data_from(obj0) outobj.data -= np.array(obj1.data, dtype=outobj.data.dtype) if quad: outobj.data = outobj.data / np.sqrt(2.0) if np.issubdtype(outobj.data.dtype, np.unsignedinteger): outobj.data[obj0.data < obj1.data] = 0 self.panel.add_object(outobj) @qt_try_except() def compute_division(self): """Compute division""" rows = self.objlist.get_selected_rows() outobj = self.panel.create_object() outobj.title = "/".join([f"{self.prefix}{row:03d}" for row in rows]) obj0, obj1 = self.objlist.get_sel_object(), self.objlist.get_sel_object(1) outobj.copy_data_from(obj0) outobj.data = outobj.data / np.array(obj1.data, dtype=outobj.data.dtype) self.panel.add_object(outobj) def _get_roieditordata( self, roidata: np.ndarray = None, singleobj: bool = None ) -> ROIEditorData: """Eventually open ROI Editing Dialog, and return ROI editor data""" # Expected behavior: # ----------------- # * If roidata argument is not None, skip the ROI dialog # * If first selected obj has a ROI, use this ROI as default but open # ROI Editor dialog anyway # * If multiple objs are selected, then apply the first obj ROI to all if roidata is None: roieditordata = self.edit_regions_of_interest( extract=True, singleobj=singleobj ) if roieditordata is not None and roieditordata.roidata is None: # This only happens in unattended mode (forcing QDialog accept) return None else: roieditordata = ROIEditorData(roidata=roidata, singleobj=singleobj) return roieditordata @abc.abstractmethod def extract_roi(self, roidata: np.ndarray = None) -> None: """Extract Region Of Interest (ROI) from data""" @abc.abstractmethod def swap_axes(self): """Swap data axes""" @abc.abstractmethod def compute_abs(self): """Compute absolute value""" @abc.abstractmethod def compute_log10(self): """Compute Log10""" # ------Data Processing @abc.abstractmethod def apply_11_func(self, obj, orig, func, param, message): """Apply 11 function: 1 object in --> 1 object out""" def compute_11( self, name: str, func: Callable, param: gdt.DataSet = None, suffix: Callable = None, func_obj: Callable = None, edit: bool = True, ): """Compute 11 function: 1 object in --> 1 object out""" if param is not None: if edit and not param.edit(parent=self.panel.parent()): return self._compute_11_subroutine([name], func, [param], suffix, func_obj) def compute_1n( self, names: List, func: Callable, params: List = None, suffix: Callable = None, func_obj: Callable = None, edit: bool = True, ): """Compute 1n function: 1 object in --> n objects out""" if params is not None: group = gdt.DataSetGroup(params, title=_("Parameters")) if edit and not group.edit(parent=self.panel.parent()): return self._compute_11_subroutine(names, func, params, suffix, func_obj) def _compute_11_subroutine( self, names: List, func: Callable, params: List, suffix: Callable, func_obj: Callable, ): """Compute 11 subroutine: used by compute 11 and compute 1n methods""" rows = self.objlist.get_selected_rows() with create_progress_bar( self.panel, names[0], max_=len(rows) * len(params) ) as progress: for i_row, row in enumerate(rows): for i_param, (param, name) in enumerate(zip(params, names)): progress.setValue(i_row * i_param) progress.setLabelText(name) QW.QApplication.processEvents() if progress.wasCanceled(): break orig = self.objlist[row] obj = self.panel.create_object() obj.title = f"{name}({self.prefix}{row:03d})" if suffix is not None: obj.title += "|" + suffix(param) obj.copy_data_from(orig) message = _("Computing:") + " " + obj.title self.apply_11_func(obj, orig, func, param, message) if func_obj is not None: if param is None: func_obj(obj) else: func_obj(obj, param) self.panel.add_object(obj) @abc.abstractmethod def apply_10_func(self, orig, func, param, message) -> ResultShape: """Apply 10 function: 1 object in --> 0 object out (scalar result)""" def compute_10( self, name: str, func: Callable, param: gdt.DataSet = None, suffix: Callable = None, edit: bool = True, ) -> Dict[int, ResultShape]: """Compute 10 function: 1 object in --> 0 object out (the result of this method is stored in original object's metadata)""" if param is not None: if edit and not param.edit(parent=self.panel.parent()): return None rows = self.objlist.get_selected_rows() with create_progress_bar(self.panel, name, max_=len(rows)) as progress: results = {} xlabels = None ylabels = [] title_suffix = "" if suffix is None else "|" + suffix(param) for idx, row in enumerate(rows): progress.setValue(idx) QW.QApplication.processEvents() if progress.wasCanceled(): break orig = self.objlist[row] title = f"{name}{title_suffix}" message = _("Computing:") + " " + title result = self.apply_10_func(orig, func, param, message) if result is None: continue results[row] = result xlabels = result.xlabels self.SIG_ADD_SHAPE.emit(row) self.panel.selection_changed() self.panel.SIG_UPDATE_PLOT_ITEM.emit(row) for _i_row_res in range(result.array.shape[0]): ylabel = f"{name}({self.prefix}{idx:03d}){title_suffix}" ylabels.append(ylabel) if results: with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) dlg = ArrayEditor(self.panel.parent()) title = _("Results") res = np.vstack([result.array for result in results.values()]) dlg.setup_and_check( res, title, readonly=True, xlabels=xlabels, ylabels=ylabels ) dlg.setObjectName(f"{self.prefix}_results") dlg.resize(750, 300) exec_dialog(dlg) return results @abc.abstractmethod @qt_try_except() def calibrate(self, param=None) -> None: """Compute data linear calibration""" @staticmethod @abc.abstractmethod def func_gaussian_filter(x, y, p): """Compute gaussian filter""" @qt_try_except() def compute_gaussian(self, param: GaussianParam = None) -> None: """Compute gaussian filter""" edit = param is None if edit: param = GaussianParam(_("Gaussian filter")) func = self.func_gaussian_filter self.compute_11( "GaussianFilter", func, param, suffix=lambda p: f"σ={p.sigma:.3f} pixels", edit=edit, ) @staticmethod @abc.abstractmethod def func_moving_average(x, y, p): """Moving average computing function""" @qt_try_except() def compute_moving_average(self, param: MovingAverageParam = None) -> None: """Compute moving average""" edit = param is None if edit: param = MovingAverageParam(_("Moving average")) func = self.func_moving_average self.compute_11("MovAvg", func, param, suffix=lambda p: f"n={p.n}", edit=edit) @staticmethod @abc.abstractmethod def func_moving_median(x, y, p): """Moving median computing function""" @qt_try_except() def compute_moving_median(self, param: MovingMedianParam = None) -> None: """Compute moving median""" edit = param is None if edit: param = MovingMedianParam(_("Moving median")) func = self.func_moving_median self.compute_11("MovMed", func, param, suffix=lambda p: f"n={p.n}", edit=edit) @abc.abstractmethod @qt_try_except() def compute_wiener(self): """Compute Wiener filter""" @abc.abstractmethod @qt_try_except() def compute_fft(self): """Compute iFFT""" @abc.abstractmethod @qt_try_except() def compute_ifft(self): """Compute FFT""" # ------Computing def edit_regions_of_interest( self, extract: bool = False, singleobj: bool = None ) -> ROIEditorData: """Define Region Of Interest (ROI) for computing functions""" roieditordata = self.panel.get_roi_dialog(extract=extract, singleobj=singleobj) if roieditordata is not None: row = self.objlist.get_selected_rows()[0] obj = self.objlist[row] roigroup = obj.roidata_to_params(roieditordata.roidata) if ( env.execenv.unattended or roieditordata.roidata.size == 0 or not self.EDIT_ROI_PARAMS or roigroup.edit(parent=self.panel) ): roidata = obj.params_to_roidata(roigroup) if roieditordata.modified: # If ROI has been modified, save ROI (even in "extract mode") obj.roi = roidata self.SIG_ADD_SHAPE.emit(row) self.panel.selection_changed() self.panel.SIG_UPDATE_PLOT_ITEMS.emit() return roieditordata def delete_regions_of_interest(self): """Delete Regions Of Interest""" for row in self.objlist.get_selected_rows(): obj = self.objlist[row] if obj.roi is not None: obj.roi = None self.panel.selection_changed() self.panel.SIG_UPDATE_PLOT_ITEMS.emit() @abc.abstractmethod def _get_stat_funcs(self): """Return statistics functions list""" @qt_try_except() def compute_stats(self): """Compute data statistics""" row = self.objlist.get_selected_rows()[0] obj = self.objlist.get_sel_object() stfuncs = self._get_stat_funcs() nbcal = len(stfuncs) roi_nb = 0 if obj.roi is None else obj.roi.shape[0] res = np.zeros((1 + roi_nb, nbcal)) xlabels = [None] * nbcal obj_t = f"{self.prefix}{row:03d}" ylabels = [None] * (roi_nb + 1) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) with np.errstate(all="ignore"): for iroi, roi_index in enumerate([None] + list(range(roi_nb))): for ical, (label, func) in enumerate(stfuncs): xlabels[ical] = label res[iroi, ical] = func(obj.get_data(roi_index=roi_index)) if roi_index is None: ylabels[iroi] = obj_t else: ylabels[iroi] = f"{obj_t}|ROI{roi_index:02d}" with warnings.catch_warnings(): warnings.simplefilter("ignore", RuntimeWarning) dlg = ArrayEditor(self.panel.parent()) title = _("Statistics") dlg.setup_and_check( res, title, readonly=True, xlabels=xlabels, ylabels=ylabels ) dlg.setObjectName(f"{self.prefix}_stats") dlg.setWindowIcon(get_icon("stats.svg")) dlg.resize(750, 300) exec_dialog(dlg) CodraFT-2.2.1/codraft/core/gui/processor/image.py000066400000000000000000000525531443562410300216230ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Image Processor GUI module """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import numpy as np import scipy.ndimage as spi import scipy.signal as sps from guidata.dataset.dataitems import BoolItem, ChoiceItem, FloatItem, IntItem from guidata.dataset.datatypes import DataSet, DataSetGroup, ValueProp from guiqwt.widgets.resizedialog import ResizeDialog from numpy import ma from qtpy import QtWidgets as QW from codraft.config import APP_NAME, _ from codraft.core.computation.image import ( distance_matrix, flatfield, get_2d_peaks_coords, get_centroid_fourier, get_contour_shapes, get_enclosing_circle, ) from codraft.core.gui.processor.base import BaseProcessor, ClipParam, ThresholdParam from codraft.core.model.base import BaseProcParam, ResultShape, ShapeTypes from codraft.core.model.image import ImageParam, RoiDataGeometries, RoiDataItem from codraft.utils.qthelpers import create_progress_bar, qt_try_except class LogP1Param(DataSet): """Log10 parameters""" n = FloatItem("n") class RotateParam(DataSet): """Rotate parameters""" boundaries = ("constant", "nearest", "reflect", "wrap") prop = ValueProp(False) angle = FloatItem(f"{_('Angle')} (°)") mode = ChoiceItem( _("Mode"), list(zip(boundaries, boundaries)), default=boundaries[0] ) cval = FloatItem( _("cval"), default=0.0, help=_( "Value used for points outside the " "boundaries of the input if mode is " "'constant'" ), ) reshape = BoolItem( _("Reshape the output array"), default=True, help=_( "Reshape the output array " "so that the input array is " "contained completely in the output" ), ) prefilter = BoolItem(_("Prefilter the input image"), default=True).set_prop( "display", store=prop ) order = IntItem( _("Order"), default=3, min=0, max=5, help=_("Spline interpolation order"), ).set_prop("display", active=prop) class ResizeParam(DataSet): """Resize parameters""" boundaries = ("constant", "nearest", "reflect", "wrap") prop = ValueProp(False) zoom = FloatItem(_("Zoom")) mode = ChoiceItem( _("Mode"), list(zip(boundaries, boundaries)), default=boundaries[0] ) cval = FloatItem( _("cval"), default=0.0, help=_( "Value used for points outside the " "boundaries of the input if mode is " "'constant'" ), ) prefilter = BoolItem(_("Prefilter the input image"), default=True).set_prop( "display", store=prop ) order = IntItem( _("Order"), default=3, min=0, max=5, help=_("Spline interpolation order"), ).set_prop("display", active=prop) class FlatFieldParam(BaseProcParam): """Flat-field parameters""" threshold = FloatItem(_("Threshold"), default=0.0) class ZCalibrateParam(DataSet): """Image linear calibration parameters""" a = FloatItem("a", default=1.0) b = FloatItem("b", default=0.0) class GenericDetectionParam(DataSet): """Generic detection parameters""" threshold = FloatItem( _("Relative threshold"), default=0.5, min=0.1, max=0.9, help=_( "Detection threshold, relative to difference between " "data maximum and minimum" ), ) class PeakDetectionParam(GenericDetectionParam): """Peak detection parameters""" size = IntItem( _("Neighborhoods size"), default=10, min=1, unit="pixels", help=_( "Size of the sliding window used in maximum/minimum filtering algorithm" ), ) create_rois = BoolItem(_("Create regions of interest"), default=True) class ContourShapeParam(GenericDetectionParam): """Contour shape parameters""" shapes = ( ("ellipse", _("Ellipse")), ("circle", _("Circle")), ) shape = ChoiceItem(_("Shape"), shapes, default="ellipse") class ImageProcessor(BaseProcessor): """Object handling image processing: operations, processing, computing""" # pylint: disable=duplicate-code EDIT_ROI_PARAMS = True def compute_logp1(self, param: LogP1Param = None) -> None: """Compute base 10 logarithm""" edit = param is None if edit: param = LogP1Param("Log10(z+n)") self.compute_11( "Log10(z+n)", lambda z, p: np.log10(z + p.n), param, suffix=lambda p: f"n={p.n}", edit=edit, ) def rotate_arbitrarily(self, param: RotateParam = None) -> None: """Rotate data arbitrarily""" edit = param is None if edit: param = RotateParam(_("Rotation")) # TODO: [P2] Instead of removing geometric shapes, apply rotation self.compute_11( "Rotate", lambda x, p: spi.rotate( x, p.angle, reshape=p.reshape, order=p.order, mode=p.mode, cval=p.cval, prefilter=p.prefilter, ), param, suffix=lambda p: f"α={p.angle:.3f}°, mode='{p.mode}'", func_obj=lambda obj, _param: obj.remove_resultshapes(), edit=edit, ) def rotate_90(self): """Rotate data 90°""" # TODO: [P2] Instead of removing geometric shapes, apply 90° rotation self.compute_11( "Rotate90", np.rot90, func_obj=lambda obj: obj.remove_resultshapes(), ) def rotate_270(self): """Rotate data 270°""" # TODO: [P2] Instead of removing geometric shapes, apply 270° rotation self.compute_11( "Rotate270", lambda x: np.rot90(x, 3), func_obj=lambda obj: obj.remove_resultshapes(), ) def flip_horizontally(self): """Flip data horizontally""" # TODO: [P2] Instead of removing geometric shapes, apply horizontal flip self.compute_11( "HFlip", np.fliplr, func_obj=lambda obj: obj.remove_resultshapes(), ) def flip_vertically(self): """Flip data vertically""" # TODO: [P2] Instead of removing geometric shapes, apply vertical flip self.compute_11( "VFlip", np.flipud, func_obj=lambda obj: obj.remove_resultshapes(), ) def resize_image(self, param: ResizeParam = None) -> None: """Resize image""" obj0 = self.objlist.get_sel_object(0) for obj in self.objlist.get_sel_objects(): if obj.size != obj0.size: QW.QMessageBox.warning( self.panel.parent(), APP_NAME, _("Warning:") + "\n" + _("Selected images do not have the same size"), ) edit = param is None if edit: original_size = obj0.size dlg = ResizeDialog( self.plotwidget, new_size=original_size, old_size=original_size, text=_("Destination size:"), ) if not dlg.exec(): return param = ResizeParam(_("Resize")) param.zoom = dlg.get_zoom() def func_obj(obj, param): """Zooming function""" if obj.dx is not None and obj.dy is not None: obj.dx, obj.dy = obj.dx / param.zoom, obj.dy / param.zoom # TODO: [P2] Instead of removing geometric shapes, apply zoom obj.remove_resultshapes() self.compute_11( "Zoom", lambda x, p: spi.interpolation.zoom( x, p.zoom, order=p.order, mode=p.mode, cval=p.cval, prefilter=p.prefilter, ), param, suffix=lambda p: f"zoom={p.zoom:.3f}", func_obj=func_obj, edit=edit, ) def extract_roi(self, roidata: np.ndarray = None, singleobj: bool = None) -> None: """Extract Region Of Interest (ROI) from data""" roieditordata = self._get_roieditordata(roidata, singleobj) if roieditordata is None or roieditordata.is_empty: return obj = self.objlist.get_sel_object() group = obj.roidata_to_params(roieditordata.roidata) if roieditordata.singleobj: def suffix_func(group: DataSetGroup): if len(group.datasets) == 1: p = group.datasets[0] return p.get_suffix() return "" def extract_roi_func(data: np.ndarray, group: DataSetGroup): """Extract ROI function on data""" if len(group.datasets) == 1: p = group.datasets[0] return data.copy()[p.y0 : p.y1, p.x0 : p.x1] out = np.zeros_like(data) for p in group.datasets: slice1, slice2 = slice(p.y0, p.y1 + 1), slice(p.x0, p.x1 + 1) out[slice1, slice2] = data[slice1, slice2] x0 = min([p.x0 for p in group.datasets]) y0 = min([p.y0 for p in group.datasets]) x1 = max([p.x1 for p in group.datasets]) y1 = max([p.y1 for p in group.datasets]) return out[y0:y1, x0:x1] def extract_roi_func_obj(image: ImageParam, group: DataSetGroup): """Extract ROI function on object""" image.x0 += min([p.x0 for p in group.datasets]) image.y0 += min([p.y0 for p in group.datasets]) image.remove_resultshapes() # TODO: [P2] Instead of removing geometric shapes, apply ROI extract self.compute_11( "ROI", extract_roi_func, group, suffix=suffix_func, func_obj=extract_roi_func_obj, edit=False, ) else: def extract_roi_func_obj(image: ImageParam, p: DataSet): """Extract ROI function on object""" image.x0 += p.x0 image.y0 += p.y0 image.remove_resultshapes() if p.geometry is RoiDataGeometries.CIRCLE: # Circular ROI image.roi = p.get_single_roi() # TODO: [P2] Instead of removing geometric shapes, apply roi extract self.compute_1n( [f"ROI{iroi}" for iroi in range(len(group.datasets))], lambda z, p: z.copy()[p.y0 : p.y1, p.x0 : p.x1], group.datasets, suffix=lambda p: p.get_suffix(), func_obj=extract_roi_func_obj, edit=False, ) def swap_axes(self): """Swap data axes""" self.compute_11( "SwapAxes", lambda z: z.T, func_obj=lambda obj: obj.remove_resultshapes(), ) def compute_abs(self): """Compute absolute value""" self.compute_11("Abs", np.abs) def compute_log10(self): """Compute Log10""" self.compute_11("Log10", np.log10) @qt_try_except() def flat_field_correction(self, param: FlatFieldParam = None) -> None: """Compute flat field correction""" edit = param is None rawdata = self.objlist.get_sel_object().data flatdata = self.objlist.get_sel_object(1).data if edit: param = FlatFieldParam(_("Flat field")) param.set_from_datatype(rawdata.dtype) if not edit or param.edit(self.panel.parent()): rows = self.objlist.get_selected_rows() robj = self.panel.create_object() robj.title = ( "FlatField(" + (",".join([f"{self.prefix}{row:03d}" for row in rows])) + f",threshold={param.threshold})" ) robj.data = flatfield(rawdata, flatdata, param.threshold) self.panel.add_object(robj) # ------Image Processing def apply_11_func(self, obj, orig, func, param, message): """Apply 11 function: 1 object in --> 1 object out""" # (self is used by @qt_try_except) # pylint: disable=unused-argument @qt_try_except(message) def apply_11_func_callback(self, obj, orig, func, param): """Apply 11 function callback: 1 object in --> 1 object out""" if param is None: obj.data = func(orig.data) else: obj.data = func(orig.data, param) return apply_11_func_callback(self, obj, orig, func, param) @qt_try_except() def calibrate(self, param: ZCalibrateParam = None) -> None: """Compute data linear calibration""" edit = param is None if edit: param = ZCalibrateParam(_("Linear calibration"), "y = a.x + b") self.compute_11( "LinearCal", lambda x, p: p.a * x + p.b, param, suffix=lambda p: "z={p.a}*z+{p.b}", edit=edit, ) @qt_try_except() def compute_threshold(self, param: ThresholdParam = None) -> None: """Compute threshold clipping""" edit = param is None if edit: param = ThresholdParam(_("Thresholding")) self.compute_11( "Threshold", lambda x, p: np.clip(x, p.value, x.max()), param, suffix=lambda p: f"min={p.value} lsb", edit=edit, ) @qt_try_except() def compute_clip(self, param: ClipParam = None) -> None: """Compute maximum data clipping""" edit = param is None if edit: param = ClipParam(_("Clipping")) self.compute_11( "Clip", lambda x, p: np.clip(x, x.min(), p.value), param, suffix=lambda p: f"max={p.value} lsb", edit=edit, ) @staticmethod def func_gaussian_filter(x, p): # pylint: disable=arguments-differ """Compute gaussian filter""" return spi.gaussian_filter(x, p.sigma) @qt_try_except() def compute_fft(self): """Compute FFT""" self.compute_11("FFT", np.fft.fft2) @qt_try_except() def compute_ifft(self): "Compute iFFT" "" self.compute_11("iFFT", np.fft.ifft2) @staticmethod def func_moving_average(x, p): # pylint: disable=arguments-differ """Moving average computing function""" return spi.uniform_filter(x, size=p.n, mode="constant") @staticmethod def func_moving_median(x, p): # pylint: disable=arguments-differ """Moving median computing function""" return sps.medfilt(x, kernel_size=p.n) @qt_try_except() def compute_wiener(self): """Compute Wiener filter""" self.compute_11("WienerFilter", sps.wiener) # ------Image Computing def apply_10_func(self, orig, func, param, message) -> ResultShape: """Apply 10 function: 1 object in --> 0 object out (scalar result)""" # (self is used by @qt_try_except) # pylint: disable=unused-argument @qt_try_except(message) def apply_10_func_callback(self, orig, func, param): """Apply 10 function cb: 1 object in --> 0 object out (scalar result)""" if param is None: return func(orig) return func(orig, param) return apply_10_func_callback(self, orig, func, param) @staticmethod def __apply_origin_size_roi(image, func, *args) -> np.ndarray: """Exec computation taking into account image x0, y0, dx, dy and ROIs""" res = [] for i_roi in image.iterate_roi_indexes(): coords = func(image.get_data(i_roi), *args) if coords.size: if image.roi is not None: x0, y0, _x1, _y1 = RoiDataItem(image.roi[i_roi]).get_rect() coords[:, ::2] += x0 coords[:, 1::2] += y0 coords[:, ::2] = image.dx * coords[:, ::2] + image.x0 coords[:, 1::2] = image.dy * coords[:, 1::2] + image.y0 idx = np.ones((coords.shape[0], 1)) * i_roi coords = np.hstack([idx, coords]) res.append(coords) if res: return np.vstack(res) return None @qt_try_except() def compute_centroid(self): """Compute image centroid""" def get_centroid_coords(data: np.ndarray): """Return centroid coordinates""" y, x = get_centroid_fourier(data) return np.array([(x, y)]) def centroid(image: ImageParam): """Compute centroid""" res = self.__apply_origin_size_roi(image, get_centroid_coords) if res is not None: return image.add_resultshape("Centroid", ShapeTypes.MARKER, res) return None self.compute_10(_("Centroid"), centroid) @qt_try_except() def compute_enclosing_circle(self): """Compute minimum enclosing circle""" def get_enclosing_circle_coords(data: np.ndarray): """Return diameter coords for the circle contour enclosing image values above threshold (FWHM)""" x, y, r = get_enclosing_circle(data) return np.array([[x - r, y, x + r, y]]) def enclosing_circle(image: ImageParam): """Compute minimum enclosing circle""" res = self.__apply_origin_size_roi(image, get_enclosing_circle_coords) if res is not None: return image.add_resultshape("MinEnclosCircle", ShapeTypes.CIRCLE, res) return None # TODO: [P2] Find a way to add the circle to the computing results # as in "enclosingcircle_test.py" self.compute_10(_("MinEnclosingCircle"), enclosing_circle) @qt_try_except() def compute_peak_detection(self, param: PeakDetectionParam = None) -> None: """Compute 2D peak detection""" def peak_detection(image: ImageParam, p: PeakDetectionParam): """Compute centroid""" res = self.__apply_origin_size_roi( image, get_2d_peaks_coords, p.size, p.threshold ) if res is not None: return image.add_resultshape("Peaks", ShapeTypes.POINT, res) return None edit = param is None if edit: data = self.objlist.get_sel_object().data param = PeakDetectionParam() param.size = max(min(data.shape) // 40, 50) results = self.compute_10(_("Peaks"), peak_detection, param, edit=edit) if param.create_rois: with create_progress_bar( self.panel, _("Create regions of interest"), max_=len(results) ) as progress: for idx, (row, result) in enumerate(results.items()): progress.setValue(idx) QW.QApplication.processEvents() if progress.wasCanceled(): break obj = self.objlist[row] dist = distance_matrix(result.data) dist_min = dist[dist != 0].min() assert dist_min > 0 radius = int(0.5 * dist_min / np.sqrt(2) - 1) assert radius >= 1 roicoords = [] ymax, xmax = obj.data.shape for x, y in result.data: coords = [ max(x - radius, 0), max(y - radius, 0), min(x + radius, xmax), min(y + radius, ymax), ] roicoords.append(coords) obj.roi = np.array(roicoords, int) self.SIG_ADD_SHAPE.emit(row) self.panel.selection_changed() self.panel.SIG_UPDATE_PLOT_ITEM.emit(row) @qt_try_except() def compute_contour_shape(self, param: ContourShapeParam = None) -> None: """Compute contour shape fit""" def contour_shape(image: ImageParam, p: ContourShapeParam): """Compute contour shape fit""" res = self.__apply_origin_size_roi( image, get_contour_shapes, p.shape, p.threshold ) if res is not None: shape = ShapeTypes.CIRCLE if p.shape == "circle" else ShapeTypes.ELLIPSE return image.add_resultshape("Contour", shape, res) return None edit = param is None if edit: param = ContourShapeParam() self.compute_10(_("Contour"), contour_shape, param, edit=edit) def _get_stat_funcs(self): """Return statistics functions list""" # Be careful to use systematically functions adapted to masked arrays # (e.g. numpy.ma median, and *not* numpy.median) return [ ("min(z)", lambda z: z.min()), ("max(z)", lambda z: z.max()), ("", lambda z: z.mean()), ("Median(z)", ma.median), ("σ(z)", lambda z: z.std()), ("Σ(z)", lambda z: z.sum()), ("/σ(z)", lambda z: z.mean() / z.std()), ] CodraFT-2.2.1/codraft/core/gui/processor/signal.py000066400000000000000000000377431443562410300220220ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Signal Processor GUI module """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import re import numpy as np import scipy.integrate as spt import scipy.ndimage as spi import scipy.optimize as spo import scipy.signal as sps from guidata.dataset.dataitems import ChoiceItem, FloatItem, IntItem from guidata.dataset.datatypes import DataSet, DataSetGroup from codraft.config import _ from codraft.core.computation import fit from codraft.core.computation.signal import ( derivative, moving_average, normalize, peak_indexes, xpeak, xy_fft, xy_ifft, ) from codraft.core.gui.processor.base import BaseProcessor, ClipParam, ThresholdParam from codraft.core.model.base import ResultShape, ShapeTypes from codraft.core.model.signal import SignalParam, create_signal from codraft.utils.qthelpers import exec_dialog, qt_try_except from codraft.widgets import fitdialog, signalpeakdialog class PeakDetectionParam(DataSet): """Peak detection parameters""" threshold = IntItem( _("Threshold"), default=30, min=0, max=100, slider=True, unit="%" ) min_dist = IntItem(_("Minimum distance"), default=1, min=1, unit="points") class NormalizeParam(DataSet): """Normalize parameters""" methods = ( (_("maximum"), "maximum"), (_("amplitude"), "amplitude"), (_("sum"), "sum"), (_("energy"), "energy"), ) method = ChoiceItem(_("Normalize with respect to"), methods) class XYCalibrateParam(DataSet): """Signal calibration parameters""" axes = (("x", _("X-axis")), ("y", _("Y-axis"))) axis = ChoiceItem(_("Calibrate"), axes, default="y") a = FloatItem("a", default=1.0) b = FloatItem("b", default=0.0) class PolynomialFitParam(DataSet): """Polynomial fitting parameters""" degree = IntItem(_("Degree"), 3, min=1, max=10, slider=True) class FWHMParam(DataSet): """FWHM parameters""" fittypes = ( ("GaussianModel", _("Gaussian")), ("LorentzianModel", _("Lorentzian")), ("VoigtModel", "Voigt"), ) fittype = ChoiceItem(_("Fit type"), fittypes, default="GaussianModel") class SignalProcessor(BaseProcessor): """Object handling signal processing: operations, processing, computing""" # pylint: disable=duplicate-code def extract_roi(self, roidata: np.ndarray = None, singleobj: bool = None) -> None: """Extract Region Of Interest (ROI) from data""" roieditordata = self._get_roieditordata(roidata, singleobj) if roieditordata is None or roieditordata.is_empty: return obj = self.objlist.get_sel_object() group = obj.roidata_to_params(roieditordata.roidata) if roieditordata.singleobj: def suffix_func(group: DataSetGroup): if len(group.datasets) == 1: p = group.datasets[0] return f"indexes={p.col1:d}:{p.col2:d}" return "" def extract_roi_func(x, y, group: DataSetGroup): """Extract ROI function""" xout, yout = np.ones_like(x) * np.nan, np.ones_like(y) * np.nan for p in group.datasets: slice0 = slice(p.col1, p.col2 + 1) xout[slice0], yout[slice0] = x[slice0], y[slice0] nans = np.isnan(xout) | np.isnan(yout) return xout[~nans], yout[~nans] # TODO: [P2] Instead of removing geometric shapes, apply roi extract self.compute_11( "ROI", extract_roi_func, group, suffix=suffix_func, func_obj=lambda obj, _group: obj.remove_resultshapes(), edit=False, ) else: # TODO: [P2] Instead of removing geometric shapes, apply roi extract self.compute_1n( [f"ROI{iroi}" for iroi in range(len(group.datasets))], lambda x, y, p: (x[p.col1 : p.col2 + 1], y[p.col1 : p.col2 + 1]), group.datasets, suffix=lambda p: f"indexes={p.col1:d}:{p.col2:d}", func_obj=lambda obj, _group: obj.remove_resultshapes(), edit=False, ) def swap_axes(self): """Swap data axes""" self.compute_11( "SwapAxes", lambda x, y: (y, x), func_obj=lambda obj: obj.remove_resultshapes(), ) def compute_abs(self): """Compute absolute value""" self.compute_11("Abs", lambda x, y: (x, np.abs(y))) def compute_log10(self): """Compute Log10""" self.compute_11("Log10", lambda x, y: (x, np.log10(y))) def detect_peaks(self, param: PeakDetectionParam = None) -> None: """Detect peaks from data""" obj = self.objlist.get_sel_object() edit = param is None if edit: dlg = signalpeakdialog.SignalPeakDetectionDialog(self.panel) dlg.setup_data(obj.x, obj.y) if exec_dialog(dlg): param = PeakDetectionParam(_("Peak detection")) param.threshold = int(dlg.get_threshold() * 100) param.min_dist = dlg.get_min_dist() def func(x, y, p): """Peak detection""" indexes = peak_indexes(y, thres=p.threshold * 0.01, min_dist=p.min_dist) return x[indexes], y[indexes] def func_obj(obj, param): # pylint: disable=unused-argument """Customize signal object""" obj.metadata["curvestyle"] = "Sticks" self.compute_11( "Peaks", func, param, suffix=lambda p: f"threshold={p.threshold}%, min_dist={p.min_dist}pts", func_obj=func_obj, edit=edit, ) # ------Signal Processing def apply_11_func(self, obj, orig, func, param, message): """Apply 11 function: 1 object in --> 1 object out""" # (self is used by @qt_try_except) # pylint: disable=unused-argument @qt_try_except(message) def apply_11_func_callback(self, obj, orig, func, param): """Apply 11 function callback: 1 object in --> 1 object out""" data = orig.xydata if len(data) == 2: # x, y signal x, y = data if param is None: obj.xydata = func(x, y) else: obj.xydata = func(x, y, param) elif len(data) == 4: # x, y, dx, dy error bar signal x, y, dx, dy = data if param is None: x2, y2 = func(x, y) _x3, dy2 = func(x, dy) else: x2, y2 = func(x, y, param) _x3, dy2 = func(x, dy, param) obj.xydata = x2, y2, dx, dy2 return apply_11_func_callback(self, obj, orig, func, param) @qt_try_except() def normalize(self, param: NormalizeParam = None) -> None: """Normalize data""" edit = param is None if edit: param = NormalizeParam(_("Normalize")) def func(x, y, p): return (x, normalize(y, p.method)) self.compute_11( "Normalize", func, param, suffix=lambda p: f"ref={p.method}", edit=edit ) @qt_try_except() def compute_derivative(self): """Compute derivative""" self.compute_11("Derivative", lambda x, y: (x, derivative(x, y))) @qt_try_except() def compute_integral(self): """Compute integral""" self.compute_11("Integral", lambda x, y: (x, spt.cumtrapz(y, x, initial=0.0))) @qt_try_except() def calibrate(self, param: XYCalibrateParam = None) -> None: """Compute data linear calibration""" edit = param is None if edit: param = XYCalibrateParam(_("Linear calibration"), "y = a.x + b") def func(x, y, p): """Compute linear calibration""" if p.axis == "x": return p.a * x + p.b, y return x, p.a * y + p.b self.compute_11( "LinearCal", func, param, suffix=lambda p: f"{p.axis}={p.a}*{p.axis}+{p.b}", edit=edit, ) @qt_try_except() def compute_threshold(self, param: ThresholdParam = None) -> None: """Compute threshold clipping""" edit = param is None if edit: param = ThresholdParam(_("Thresholding")) self.compute_11( "Threshold", lambda x, y, p: (x, np.clip(y, p.value, y.max())), param, suffix=lambda p: f"min={p.value} lsb", edit=edit, ) @qt_try_except() def compute_clip(self, param: ClipParam = None) -> None: """Compute maximum data clipping""" edit = param is None if edit: param = ClipParam(_("Clipping")) self.compute_11( "Clip", lambda x, y, p: (x, np.clip(y, y.min(), p.value)), param, suffix=lambda p: f"max={p.value} lsb", edit=edit, ) @staticmethod def func_gaussian_filter(x, y, p): """Compute gaussian filter""" return (x, spi.gaussian_filter1d(y, p.sigma)) @staticmethod def func_moving_average(x, y, p): """Moving average computing function""" return (x, moving_average(y, p.n)) @staticmethod def func_moving_median(x, y, p): """Moving median computing function""" return (x, sps.medfilt(y, kernel_size=p.n)) @qt_try_except() def compute_wiener(self): """Compute Wiener filter""" self.compute_11("WienerFilter", lambda x, y: (x, sps.wiener(y))) @qt_try_except() def compute_fft(self): """Compute iFFT""" self.compute_11("FFT", xy_fft) @qt_try_except() def compute_ifft(self): """Compute FFT""" self.compute_11("iFFT", xy_ifft) @qt_try_except() def compute_fit(self, name, fitdlgfunc): """Compute fitting curve""" rows = self.objlist.get_selected_rows() for row in rows: self.__row_compute_fit(row, name, fitdlgfunc) @qt_try_except() def compute_polyfit(self, param: PolynomialFitParam = None) -> None: """Compute polynomial fitting curve""" txt = _("Polynomial fit") edit = param is None if edit: param = PolynomialFitParam(txt) if not edit or param.edit(self): dlgfunc = fitdialog.polynomialfit self.compute_fit( txt, lambda x, y, degree=param.degree, parent=self.panel.parent(): dlgfunc( x, y, degree, parent=parent ), ) def __row_compute_fit(self, row, name, fitdlgfunc): """Curve fitting computing sub-method""" obj = self.objlist[row] output = fitdlgfunc(obj.x, obj.y, parent=self.panel.parent()) if output is not None: y, params = output results = {} for param in params: if re.match(r"[\S\_]*\d{2}$", param.name): shname = param.name[:-2] value = results.get(shname, np.array([])) results[shname] = np.array(list(value) + [param.value]) else: results[param.name] = param.value # Creating new signal signal = create_signal(f"{name}({obj.title})", obj.x, y, metadata=results) # Creating new plot item self.panel.add_object(signal, refresh=False) # Refreshing list self.objlist.refresh_list(-1) @qt_try_except() def compute_multigaussianfit(self): """Compute multi-Gaussian fitting curve""" rows = self.objlist.get_selected_rows() fitdlgfunc = fitdialog.multigaussianfit for row in rows: dlg = signalpeakdialog.SignalPeakDetectionDialog(self.panel) obj = self.objlist[row] dlg.setup_data(obj.x, obj.y) if exec_dialog(dlg): # Computing x, y peaks = dlg.get_peak_indexes() self.__row_compute_fit( row, _("Multi-Gaussian fit"), lambda x, y, peaks=peaks, parent=self.panel.parent(): fitdlgfunc( x, y, peaks, parent=parent ), ) # ------Signal Computing def apply_10_func(self, orig, func, param, message) -> ResultShape: """Apply 10 function: 1 object in --> 0 object out (scalar result)""" # (self is used by @qt_try_except) # pylint: disable=unused-argument @qt_try_except(message) def apply_10_func_callback(self, orig, func, param): """Apply 10 function cb: 1 object in --> 0 object out (scalar result)""" if param is None: return func(orig) return func(orig, param) return apply_10_func_callback(self, orig, func, param) @qt_try_except() def compute_fwhm(self, param: FWHMParam = None) -> None: """Compute FWHM""" title = _("FWHM") def fwhm(signal: SignalParam, param: FWHMParam): """Compute FWHM""" res = [] for i_roi in signal.iterate_roi_indexes(): x, y = signal.get_data(i_roi) dx = np.max(x) - np.min(x) dy = np.max(y) - np.min(y) base = np.min(y) sigma, mu = dx * 0.1, xpeak(x, y) FitModel = getattr(fit, param.fittype) amp = FitModel.get_amp_from_amplitude(dy, sigma) def func(params): """Fitting model function""" # pylint: disable=cell-var-from-loop return y - FitModel.func(x, *params) (amp, sigma, mu, base), _ier = spo.leastsq( func, np.array([amp, sigma, mu, base]) ) x0, y0, x1, y1 = FitModel.half_max_segment(amp, sigma, mu, base) res.append([i_roi, x0, y0, x1, y1]) return signal.add_resultshape(title, ShapeTypes.SEGMENT, np.array(res)) edit = param is None if edit: param = FWHMParam(title) self.compute_10(title, fwhm, param, edit=edit) @qt_try_except() def compute_fw1e2(self): """Compute FW at 1/e²""" title = _("FW") + "1/e²" def fw1e2(signal: SignalParam): """Compute FW at 1/e²""" res = [] for i_roi in signal.iterate_roi_indexes(): x, y = signal.get_data(i_roi) dx = np.max(x) - np.min(x) dy = np.max(y) - np.min(y) base = np.min(y) sigma, mu = dx * 0.1, xpeak(x, y) amp = fit.GaussianModel.get_amp_from_amplitude(dy, sigma) p_in = np.array([amp, sigma, mu, base]) def func(params): """Fitting model function""" # pylint: disable=cell-var-from-loop return y - fit.GaussianModel.func(x, *params) p_out, _ier = spo.leastsq(func, p_in) amp, sigma, mu, base = p_out hw = 2 * sigma amplitude = fit.GaussianModel.amplitude(amp, sigma) yhm = amplitude / np.e**2 + base res.append([i_roi, mu - hw, yhm, mu + hw, yhm]) return signal.add_resultshape(title, ShapeTypes.SEGMENT, np.array(res)) self.compute_10(title, fw1e2) def _get_stat_funcs(self): """Return statistics functions list""" return [ ("min(y)", lambda xy: xy[1].min()), ("max(y)", lambda xy: xy[1].max()), ("", lambda xy: xy[1].mean()), ("Median(y)", lambda xy: np.median(xy[1])), ("σ(y)", lambda xy: xy[1].std()), ("Σ(y)", lambda xy: xy[1].sum()), ("∫ydx", lambda xy: np.trapz(xy[1], xy[0])), ] CodraFT-2.2.1/codraft/core/gui/roieditor.py000066400000000000000000000210771443562410300205170ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ ROI editor widgets """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import abc import numpy as np from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action from guiqwt.annotations import AnnotatedCircle from guiqwt.builder import make from guiqwt.interfaces import IImageItemType from guiqwt.label import ObjectInfo from qtpy import QtWidgets as QW from codraft.config import Conf, _ from codraft.core.model.base import ObjectItf from codraft.core.model.image import RoiDataGeometries class ROIEditorData: """ROI Editor data""" def __init__(self, roidata: np.ndarray = None, singleobj: bool = None): self.__singleobj = None if roidata is not None: roidata = np.array(roidata, dtype=int) self.roidata = roidata self.singleobj = singleobj self.modified = None @property def is_empty(self) -> bool: """Return True if there is no ROI""" return self.roidata is None or self.roidata.size == 0 @property def singleobj(self) -> bool: """Return singleobj parameter""" return self.__singleobj @singleobj.setter def singleobj(self, value: bool): """Set singleobj parameter""" if value is None: value = Conf.proc.extract_roi_singleobj.get(False) self.__singleobj = value Conf.proc.extract_roi_singleobj.set(value) class BaseROIEditorMeta(type(QW.QWidget), abc.ABCMeta): """Mixed metaclass to avoid conflicts""" class BaseROIEditor(QW.QWidget, metaclass=BaseROIEditorMeta): """ROI Editor""" ICON_NAME = None OBJ_NAME = None def __init__( self, parent: QW.QDialog, obj: ObjectItf, extract: bool, singleobj: bool = None ): super().__init__(parent) parent.accepted.connect(self.dialog_accepted) self.plot = parent.get_plot() self.obj = obj self.extract = extract self.__modified = None self.__data = ROIEditorData(singleobj=singleobj) self.fmt = obj.metadata.get(obj.METADATA_FMT, "%s") self.roi_items = list(obj.iterate_roi_items(self.fmt, True)) for roi_item in self.roi_items: self.plot.add_item(roi_item) self.plot.set_active_item(roi_item) self.add_btn = None self.singleobj_btn = None self.setup_widget() self.update_roi_titles() self.plot.SIG_ITEMS_CHANGED.connect(lambda _plt: self.update_roi_titles()) self.plot.SIG_ITEM_REMOVED.connect(self.item_removed) self.plot.SIG_RANGE_CHANGED.connect(lambda _rng, _min, _max: self.item_moved()) self.plot.SIG_ANNOTATION_CHANGED.connect(lambda _plt: self.item_moved()) # In "extract mode", the dialog box OK button should always been enabled # when at least one ROI is defined, # whereas in non-extract mode (when editing ROIs) the OK button is by default # disabled (until ROI data is modified) self.modified = extract @property def modified(self) -> bool: """Return dialog modified state""" return self.__modified @modified.setter def modified(self, value: bool): """Set dialog modified state""" self.__modified = value dlg = self.parent() if self.extract: # In "extract mode", OK button is enabled when at least one ROI is defined value = value and len(self.roi_items) > 0 dlg.button_box.button(QW.QDialogButtonBox.Ok).setEnabled(value) def dialog_accepted(self): """Parent dialog was accepted: updating ROI Editor data""" coords = [] for roi_item in self.roi_items: coords.append(list(self.get_roi_item_coords(roi_item))) self.__data.roidata = self.obj.roi_coords_to_indexes(coords) if self.singleobj_btn is not None: self.__data.singleobj = self.singleobj_btn.isChecked() self.__data.modified = self.modified def get_data(self) -> ROIEditorData: """Get ROI Editor data (results of the dialog box)""" return self.__data def setup_widget(self): """Setup ROI editor widget""" self.add_btn = QW.QPushButton( get_icon(self.ICON_NAME), _("Add region of interest"), self ) layout = QW.QHBoxLayout() layout.addWidget(self.add_btn) if self.extract: self.singleobj_btn = QW.QCheckBox( _("Extract all regions of interest into a single %s object") % self.OBJ_NAME, self, ) layout.addWidget(self.singleobj_btn) self.singleobj_btn.setChecked(self.__data.singleobj) layout.addStretch() self.setLayout(layout) def add_roi_item(self, roi_item): """Add ROI item to plot and refresh titles""" self.plot.unselect_all() self.roi_items.append(roi_item) self.update_roi_titles() self.modified = True self.plot.add_item(roi_item) self.plot.set_active_item(roi_item) @abc.abstractmethod def update_roi_titles(self): """Update ROI annotation titles""" def item_removed(self, item): """Item was removed. Since all items are read-only except ROIs... this must be an ROI.""" assert item in self.roi_items self.roi_items.remove(item) self.modified = True self.update_roi_titles() def item_moved(self): """ROI plot item has just been moved""" self.modified = True @staticmethod @abc.abstractmethod def get_roi_item_coords(roi_item): """Return ROI item coords""" class ROIRangeInfo(ObjectInfo): """ObjectInfo for ROI selection""" def __init__(self, roi_items): self.roi_items = roi_items def get_text(self): textlist = [] for index, roi_item in enumerate(self.roi_items): x0, x1 = roi_item.get_range() textlist.append(f"ROI{index:02d}: {x0} ≤ x ≤ {x1}") return "
".join(textlist) class SignalROIEditor(BaseROIEditor): """Signal ROI Editor""" ICON_NAME = "signal_roi_new.svg" OBJ_NAME = _("signal") def setup_widget(self): """Setup ROI editor widget""" super().setup_widget() info = ROIRangeInfo(self.roi_items) info_label = make.info_label("BL", info, title=_("Regions of interest")) self.plot.add_item(info_label) self.info_label = info_label self.add_btn.clicked.connect(self.add_roi) def add_roi(self): """Simply add an ROI""" roi_item = self.obj.new_roi_item(self.fmt, True, editable=True) self.add_roi_item(roi_item) def update_roi_titles(self): """Update ROI annotation titles""" super().update_roi_titles() self.info_label.update_text() @staticmethod def get_roi_item_coords(roi_item): """Return ROI item coords""" return roi_item.get_range() class ImageROIEditor(BaseROIEditor): """Image ROI Editor""" ICON_NAME = "image_roi_new.svg" OBJ_NAME = _("image") def setup_widget(self): """Setup ROI editor widget""" super().setup_widget() item = self.plot.get_items(item_type=IImageItemType)[0] item.set_mask_visible(False) menu = QW.QMenu() rectact = create_action( self, _("Rectangular ROI"), lambda: self.add_roi(RoiDataGeometries.RECTANGLE), icon=get_icon("rectangle.png"), ) circact = create_action( self, _("Circular ROI"), lambda: self.add_roi(RoiDataGeometries.CIRCLE), icon=get_icon("circle.png"), ) add_actions(menu, (rectact, circact)) self.add_btn.setMenu(menu) def add_roi(self, geometry: RoiDataGeometries): """Add new ROI""" item = self.obj.new_roi_item(self.fmt, True, editable=True, geometry=geometry) self.add_roi_item(item) def update_roi_titles(self): """Update ROI annotation titles""" super().update_roi_titles() for index, roi_item in enumerate(self.roi_items): roi_item.annotationparam.title = f"ROI{index:02d}" roi_item.annotationparam.update_annotation(roi_item) @staticmethod def get_roi_item_coords(roi_item): """Return ROI item coords""" x0, y0, x1, y1 = roi_item.get_rect() if isinstance(roi_item, AnnotatedCircle): y0 = y1 = 0.5 * (y0 + y1) return x0, y0, x1, y1 CodraFT-2.2.1/codraft/core/io/000077500000000000000000000000001443562410300157615ustar00rootroot00000000000000CodraFT-2.2.1/codraft/core/io/__init__.py000066400000000000000000000004121443562410300200670ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT I/O module """ # Registering dynamic I/O features: from codraft.core.io import h5, image # pylint: disable=W0611 CodraFT-2.2.1/codraft/core/io/base.py000066400000000000000000000051061443562410300172470ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Base I/O common module (native HDF5 format) """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from guidata.hdf5io import HDF5Reader, HDF5Writer from codraft import __version__ H5_VERSION = "CodraFT_Version" LIST_LENGTH_STR = "__list_length__" class NativeH5Writer(HDF5Writer): """CodraFT signal/image objects HDF5 guidata Dataset Writer class, supporting dictionary serialization""" def __init__(self, filename): super().__init__(filename) self.h5[H5_VERSION] = __version__ def write_dict(self, val): """Write dictionary to h5 file""" # Keys must be strings # Values must be h5py supported data types group = self.get_parent_group() dict_group = group.create_group(self.option[-1]) for key, value in val.items(): if isinstance(value, dict): with self.group(key): self.write_dict(value) elif isinstance(value, list): with self.group(key): with self.group(LIST_LENGTH_STR): self.write(len(value)) for index, i_val in enumerate(value): with self.group("elt" + str(index)): self.write(i_val) else: try: dict_group.attrs[key] = value except TypeError: pass class NativeH5Reader(HDF5Reader): """CodraFT signal/image objects HDF5 guidata dataset Writer class, supporting dictionary deserialization""" def __init__(self, filename): super().__init__(filename) self.version = self.h5[H5_VERSION] def read_dict(self): """Read dictionary from h5 file""" group = self.get_parent_group() dict_group = group[self.option[-1]] dict_val = {} for key, value in dict_group.attrs.items(): dict_val[key] = value for key in dict_group: with self.group(key): if "__list_length__" in dict_group[key].attrs: with self.group(LIST_LENGTH_STR): list_len = self.read() dict_val[key] = [ dict_group[key]["elt" + str(index)][:] for index in range(list_len) ] else: dict_val[key] = self.read_dict() return dict_val CodraFT-2.2.1/codraft/core/io/conv.py000066400000000000000000000017241443562410300173040ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT I/O conversion functions """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from typing import List import numpy as np def data_to_xy(data: np.ndarray) -> List[np.ndarray]: """Convert 2-D array into a list of 1-D array data (x, y, dx, dy). This is useful for importing data and creating a CodraFT signal with it.""" rows, cols = data.shape for colnb in (2, 3, 4): if cols == colnb and rows > colnb: data = data.T break if len(data) == 1: data = data.T if len(data) not in (2, 3, 4): raise ValueError(f"Invalid data: len(data)={len(data)} (expected 2, 3 or 4)") x, y = data[:2] dx, dy = None, None if len(data) == 3: dy = data[2] if len(data) == 4: dx, dy = data[2:] return x, y, dx, dy CodraFT-2.2.1/codraft/core/io/h5/000077500000000000000000000000001443562410300162755ustar00rootroot00000000000000CodraFT-2.2.1/codraft/core/io/h5/__init__.py000066400000000000000000000005511443562410300204070ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT HDF5 importer module """ # Registering dynamic I/O features: from codraft.core.io.h5 import generic, mos07636 # pylint: disable=W0611 from codraft.core.io.h5.common import H5Importer # pylint: disable=W0611 CodraFT-2.2.1/codraft/core/io/h5/common.py000066400000000000000000000172111443562410300201410ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Common tools for exogenous HDF5 format support """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import abc import os.path as osp from typing import Callable, Dict import h5py import numpy as np from codraft.config import Conf from codraft.core.io.conv import data_to_xy from codraft.utils.misc import to_string class BaseNode(metaclass=abc.ABCMeta): """Object representing a HDF5 node""" IS_ARRAY = False def __init__(self, h5file, dname): self.h5file = h5file self.dset = h5file[dname] self.metadata = {} self.__obj = None self.children = [] self.uint32_wng = False @property def id(self): """Return node id""" return self.dset.name @property def name(self): """Return node name, constructed from dataset name""" return to_string(self.dset.name).split("/")[-1] @property def data(self): """Data associated to node, if available""" return None @property def icon_name(self): """Icon name associated to node""" @property def shape_str(self): """Return string representation of node shape, if any""" return "" @property def dtype_str(self): """Return string representation of node data type, if any""" return "" @property def text(self): """Return node textual representation""" @property def description(self): """Return node description""" return "" @classmethod def match(cls, dset): """Return True if h5 dataset match node pattern""" def create_object(self): # pylint: disable=no-self-use """Create native object, if supported""" return None def get_object(self): """Return native object, if supported""" if self.__obj is None: obj = self.create_object() # pylint: disable=assignment-from-none if obj is not None: self.__process_metadata(obj) self.__obj = obj return self.__obj def __process_metadata(self, obj): """Process metadata from dataset to obj""" obj.metadata = {} obj.metadata["HDF5Path"] = self.h5file.filename obj.metadata["HDF5Dataset"] = self.id for key, value in self.dset.attrs.items(): if isinstance(value, bytes): value = to_string(value) obj.metadata[key] = value obj.metadata.update(self.metadata) @property def object_title(self): """Return signal/image object title""" if Conf.io.h5_fullpath_in_title.get(False): title = self.id else: title = self.name if Conf.io.h5_fname_in_title.get(True): title += f" ({osp.basename(self.h5file.filename)})" return title def set_signal_data(self, obj): """Set signal data (handles various issues)""" data = self.data if data.dtype not in (float, np.complex128): data = np.array(data, dtype=float) if len(data.shape) == 1: obj.set_xydata(np.arange(data.size), data) else: x, y, dx, dy = data_to_xy(data) obj.set_xydata(x, y, dx, dy) def set_image_data(self, obj): """Set image data (handles various issues)""" data = self.data if data.dtype == np.uint32: self.uint32_wng = data.max() > np.iinfo(np.int32).max clipped_data = data.clip(0, np.iinfo(np.int32).max) data = np.array(clipped_data, dtype=np.int32) obj.data = data class H5Importer: """CodraFT HDF5 importer class""" def __init__(self, filename): self.h5file = h5py.File(filename) self.__nodes = {} self.root = RootNode(self.h5file) self.__nodes[self.root.id] = self.root.dset self.root.collect_children(self.__nodes) NODE_FACTORY.run_post_triggers(self) @property def nodes(self): """Return all nodes""" return self.__nodes.values() def get(self, node_id: str): """Return node associated to id""" return self.__nodes[node_id] def get_relative(self, node: BaseNode, relpath: str, ancestor: int = 0): """Return node using relative path to another node""" path = "/" + ( "/".join(node.id.split("/")[:-ancestor]) + "/" + relpath.strip("/") ).strip("/") return self.__nodes[path] def close(self): """Close HDF5 file""" self.__nodes = {} self.h5file.close() class NodeFactory: """Factory for node classes""" def __init__(self): self.__ignored_datasets = [] self.__generic_classes = [] self.__thirdparty_classes = [] self.__post_triggers = {} def add_ignored_datasets(self, names): """Add h5 dataset name to ignore list""" self.__ignored_datasets.extend(names) def add_post_trigger(self, nodecls: BaseNode, callback: Callable): """Add post trigger function, to be called at the end of the collect process. Callbacks take only one argument: H5Importer instance.""" triggers = self.__post_triggers.setdefault(nodecls, []) triggers.append(callback) def register(self, cls, is_generic=False): """Register node class. Generic classes are processed after specific classes (as a fallback solution)""" if is_generic: self.__generic_classes.append(cls) else: self.__thirdparty_classes.append(cls) def get(self, dset): """Return node class that matches h5 dataset""" for name in to_string(dset.name).split("/"): if name in self.__ignored_datasets: return None for cls in self.__thirdparty_classes + self.__generic_classes: if cls.match(dset): return cls if isinstance(dset, h5py.Group): return GroupNode return None def run_post_triggers(self, importer: H5Importer): """Run post-collect callbacks""" for node in importer.nodes: for nodecls, triggers in self.__post_triggers.items(): if isinstance(node, nodecls): for func in triggers: func(node, importer) NODE_FACTORY = NodeFactory() class GroupNode(BaseNode): """Object representing a HDF5 group node""" @property def icon_name(self): """Icon name associated to node""" return "h5group.svg" def collect_children(self, node_dict: Dict): """Construct tree""" for dset in self.dset.values(): child_cls = NODE_FACTORY.get(dset) if child_cls is not None: child = child_cls(self.h5file, dset.name) node_dict[child.id] = child self.children.append(child) if isinstance(child, GroupNode): child.collect_children(node_dict) @property def text(self): """Return node textual representation""" return self.dset.name class RootNode(GroupNode): """Object representing a HDF5 root node""" def __init__(self, h5file): super().__init__(h5file, "/") @property def icon_name(self): """Icon name associated to node""" return "h5file.svg" @property def name(self): """Return node name, constructed from dataset name""" return osp.basename(self.h5file.filename) @property def description(self): """Return node description""" return self.h5file.filename CodraFT-2.2.1/codraft/core/io/h5/generic.py000066400000000000000000000076731443562410300203000ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Generic HDF5 format support """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import h5py import numpy as np from codraft.core.io.h5 import common, utils from codraft.core.model.image import create_image from codraft.core.model.signal import create_signal from codraft.utils.misc import to_string class BaseGenericNode(common.BaseNode): """Object representing a generic HDF5 data node""" @classmethod def match(cls, dset): """Return True if h5 dataset match node pattern""" return not isinstance(dset, h5py.Group) @property def icon_name(self): """Icon name associated to node""" return "h5scalar.svg" @property def data(self): """Data associated to node, if available""" return self.dset[()] @property def dtype_str(self): """Return string representation of node data type, if any""" return str(self.data.dtype) @property def text(self): """Return node textual representation""" return to_string(self.data) class GenericScalarNode(BaseGenericNode): """Object representing a generic scalar HDF5 data node""" @classmethod def match(cls, dset): """Return True if h5 dataset match node pattern""" if not super().match(dset): return False data = dset[()] return np.issctype(data) and utils.is_supported_num_dtype(data) common.NODE_FACTORY.register(GenericScalarNode, is_generic=True) class GenericTextNode(BaseGenericNode): """Object representing a generic text HDF5 data node""" @classmethod def match(cls, dset): """Return True if h5 dataset match node pattern""" if not super().match(dset): return False data = dset[()] return isinstance(data, bytes) or utils.is_supported_str_dtype(data) @property def dtype_str(self): """Return string representation of node data type, if any""" return "string" @property def text(self): """Return node textual representation""" if utils.is_single_str_array(self.data): return self.data[0] return to_string(self.data) common.NODE_FACTORY.register(GenericTextNode, is_generic=True) class GenericArrayNode(BaseGenericNode): """Object representing a generic array HDF5 data node""" IS_ARRAY = True @classmethod def match(cls, dset): """Return True if h5 dataset match node pattern""" if not super().match(dset): return False data = dset[()] return ( utils.is_supported_num_dtype(data) and isinstance(data, np.ndarray) and len(data.shape) in (1, 2) ) @property def is_signal(self): """Return True if array represents a signal""" shape = self.data.shape return len(shape) == 1 or shape[0] in (1, 2) or shape[1] in (1, 2) @property def icon_name(self): """Icon name associated to node""" return "signal.svg" if self.is_signal else "image.svg" @property def shape_str(self): """Return string representation of node shape, if any""" return " x ".join([str(size) for size in self.data.shape]) @property def dtype_str(self): """Return string representation of node data type, if any""" return str(self.data.dtype) @property def text(self): """Return node textual representation""" def create_object(self): """Create native object, if supported""" if self.is_signal: obj = create_signal(self.object_title) self.set_signal_data(obj) else: obj = create_image(self.object_title) self.set_image_data(obj) return obj common.NODE_FACTORY.register(GenericArrayNode, is_generic=True) CodraFT-2.2.1/codraft/core/io/h5/mos07636.py000066400000000000000000000221241443562410300200540ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT MOS07636 HDF5 format support """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from guidata.utils import update_dataset from h5py import Group from codraft.core.io.h5 import common, utils from codraft.core.model.base import ANN_KEY from codraft.core.model.image import create_image from codraft.core.model.signal import create_signal from codraft.utils.misc import to_string # Add ignored dataset names common.NODE_FACTORY.add_ignored_datasets(("PALETTE",)) class BaseMOS07636Node(common.BaseNode): """Object representing a HDF5 node, according to MOS07636""" ATTR_PATTERN = (None, None) def __init__(self, h5file, dset): super().__init__(h5file, dset) self.xunit = None self.yunit = None self.zunit = None self.xlabel = None self.ylabel = None self.zlabel = None self.__obj_templates = [] self.__metadata_entries = {} def add_object_default_values(self, **template): """Add object default values (object template)""" self.__obj_templates.append(template) def update_from_object_default_values(self, obj): """Update object (signal/image) from default values (template), if available""" for template in self.__obj_templates: update_dataset(obj, template) def add_metadata_entry(self, key, value): """Add metadata entry to object""" self.__metadata_entries[key] = value @classmethod def match(cls, dset): """Return True if h5 dataset match node pattern""" name, value = cls.ATTR_PATTERN return dset.attrs.get(name) == value @property def data(self): """Data associated to node, if available""" if isinstance(self.dset, Group): return self.dset["valeur"][()] # This is not a valid dataset according to MOS07636! return self.dset[()] @property def shape_str(self): """Return string representation of node shape, if any""" try: shape = self.data.shape if shape: return " x ".join([str(size) for size in shape]) except AttributeError: pass return "" @property def dtype_str(self): """Return string representation of node data type, if any""" try: dstr = str(self.data.dtype) except AttributeError: if isinstance(self.data, (str, bytes)): return "string" return str(type(self.data)) if dstr.startswith("|S"): return "string" return dstr @property def description(self): """Return node description""" if isinstance(self.dset, Group): desc = utils.process_scalar_value(self.dset, "description", utils.fix_ldata) if desc is not None: return desc return super().description def create_object(self): """Create native object, if supported""" if isinstance(self.dset, Group): self.xunit, self.yunit, self.zunit = utils.process_label(self.dset, "unite") self.xlabel, self.ylabel, self.zlabel = utils.process_label( self.dset, "label" ) for label in ("description", "source"): if isinstance(self.dset, Group): val = utils.process_scalar_value(self.dset, label, utils.fix_ldata) if val is not None: self.metadata[label] = val self.metadata.update(self.__metadata_entries) class ScalarNode(BaseMOS07636Node): """Object representing a scalar HDF5 node, according to MOS07636""" ATTR_PATTERN = ("CLASS", b"ELEMENTAIRE") def __init__(self, h5file, dset): super().__init__(h5file, dset) if isinstance(self.dset, Group): self.xunit = utils.process_scalar_value(self.dset, "unite", self._fix_unit) @staticmethod def _fix_unit(scdata): """Fix unit data""" data = scdata[0] if not isinstance(data, bytes): data = data[0] # Should not be necessary (invalid format) if data == b"NULL": return "" return utils.fix_ldata(data) @property def data(self): """Data associated to node, if available""" try: return super().data except ValueError: # Handles invalid scalar datasets... return self.dset[()] @property def icon_name(self): """Icon name associated to node""" return "h5scalar.svg" @property def text(self): """Return node textual representation""" text = to_string(self.data) suffix = "" if self.xunit is None else " " + self.xunit if not text.endswith(suffix): # Should not be necessary (invalid format) text += suffix return text common.NODE_FACTORY.register(ScalarNode) class SignalNode(BaseMOS07636Node): """Object representing a Signal HDF5 node, according to MOS07636""" IS_ARRAY = True ATTR_PATTERN = ("CLASS", b"COURBE") @property def icon_name(self): """Icon name associated to node""" return "signal.svg" @property def text(self): """Return node textual representation""" def create_object(self): """Create native object, if supported""" super().create_object() obj = create_signal( self.object_title, units=(self.xunit, self.yunit), labels=(self.xlabel, self.ylabel), ) self.set_signal_data(obj) self.update_from_object_default_values(obj) return obj common.NODE_FACTORY.register(SignalNode) class ImageNode(BaseMOS07636Node): """Object representing an Image HDF5 node, according to MOS07636""" IS_ARRAY = True ATTR_PATTERN = ("CLASS", b"IMAGE") @property def icon_name(self): """Icon name associated to node""" return "image.svg" @property def text(self): """Return node textual representation""" def create_object(self): """Create native object, if supported""" super().create_object() obj = create_image( self.object_title, units=(self.xunit, self.yunit, self.zunit), labels=(self.xlabel, self.ylabel, self.zlabel), ) self.set_image_data(obj) x0, y0 = utils.process_xy_values(self.dset, "origine") if x0 is not None and y0 is not None: obj.x0, obj.y0 = x0, y0 dx, dy = utils.process_xy_values(self.dset, "resolution") if dx is not None and dy is not None: obj.dx, obj.dy = dx, dy self.update_from_object_default_values(obj) return obj common.NODE_FACTORY.register(ImageNode) def handle_margins(node: ImageNode, importer: common.H5Importer): """Post-collection trigger handling image margins when available (Vimba Cameras)""" try: # Vimba Camera HDF5 / node.id: "/Acquisition/AcquisitionBrute" margegauche = importer.get_relative(node, "/Parametres_ACQ/MargeGauche", 2) margehaute = importer.get_relative(node, "/Parametres_ACQ/MargeHaute", 2) binningx = importer.get_relative(node, "/Parametres_ACQ/BinningX", 2) binningy = importer.get_relative(node, "/Parametres_ACQ/BinningY", 2) except KeyError: try: # IStar Camera HDF5 / node.id: "/Entrees/Acquisition/AcquisitionBrute" margegauche = importer.get_relative(node, "/Parametres_IMG/MargeGauche", 2) margehaute = importer.get_relative(node, "/Parametres_IMG/MargeHaute", 2) binningx = importer.get_relative(node, "/Parametres_IMG/BinningX", 2) binningy = importer.get_relative(node, "/Parametres_IMG/BinningY", 2) except KeyError: return node.add_object_default_values( x0=margegauche.data, y0=margehaute.data, dx=binningx.data, dy=binningy.data ) common.NODE_FACTORY.add_post_trigger(ImageNode, handle_margins) def handle_streakcameratimeaxis(node: ImageNode, importer: common.H5Importer): """Post-collection trigger handling streak X-axis time conv. when available""" try: # Streak Camera HDF5 / node.id: "/Acquisition/AcquisitionCorrigee" tempspixel = importer.get_relative(node, "/TempsPixel", 1) offsettemporel = importer.get_relative(node, "/OffsetTemporel", 1) except KeyError: return if node.id.endswith("AcquisitionCorrigee"): node.add_object_default_values( x0=offsettemporel.data, dx=tempspixel.data, xunit=tempspixel.xunit ) common.NODE_FACTORY.add_post_trigger(ImageNode, handle_streakcameratimeaxis) def handle_annotations(node: ImageNode, importer: common.H5Importer): """Post-collection trigger handling annotations when available""" try: annotations = importer.get_relative(node, "/Annotations", 1) except KeyError: return node.add_metadata_entry(ANN_KEY, to_string(annotations.data)) common.NODE_FACTORY.add_post_trigger(ImageNode, handle_annotations) CodraFT-2.2.1/codraft/core/io/h5/utils.py000066400000000000000000000052521443562410300200130ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Utilities for exogenous HDF5 format support """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import numpy as np from codraft.utils.misc import to_string def fix_ldata(fuzzy): """Fix label data""" if fuzzy is not None: if fuzzy and isinstance(fuzzy, np.void) and len(fuzzy) > 1: # Shouldn't happen (invalid LMJ fmt) fuzzy = fuzzy[0] if isinstance(fuzzy, (np.string_, bytes)): fuzzy = to_string(fuzzy) if isinstance(fuzzy, str): return fuzzy return None def fix_ndata(fuzzy): """Fix numeric data""" if fuzzy is not None: if fuzzy and isinstance(fuzzy, np.void) and len(fuzzy) > 1: # Shouldn't happen (invalid LMJ fmt) fuzzy = fuzzy[0] try: if float(fuzzy) == int(fuzzy): return int(fuzzy) return float(fuzzy) except (TypeError, ValueError): pass return None def process_scalar_value(dset, name, callback): """Process dataset numeric/str value `name`""" try: scdata = dset[name][()] if scdata is not None: return callback(scdata) except (KeyError, ValueError): pass return None def process_label(dset, name): """Process dataset label `name`""" try: ldata = dset[name][()] if ldata is not None: xldata, yldata, zldata = None, None, None if len(ldata) == 2: xldata, yldata = ldata elif len(ldata) == 3: xldata, yldata, zldata = ldata return fix_ldata(xldata), fix_ldata(yldata), fix_ldata(zldata) except KeyError: pass return None, None, None def process_xy_values(dset, name): """Process dataset x,y values `name`""" try: ldata = dset[name][()] if ldata is not None: return fix_ndata(ldata[0]), fix_ndata(ldata[1]) except (KeyError, ValueError): pass return None, None def is_supported_num_dtype(data): """Return True if data type is a numerical type supported by CodraFT""" return data.dtype.name.startswith(("int", "uint", "float", "complex")) def is_single_str_array(data): """Return True if data is a single-item string array""" return np.issctype(data) and data.shape == (1,) and isinstance(data[0], str) def is_supported_str_dtype(data): """Return True if data type is a string type supported by preview""" return data.dtype.name.startswith("string") or is_single_str_array(data) CodraFT-2.2.1/codraft/core/io/image.py000066400000000000000000000362721443562410300174270ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Image I/O module """ import os import re import struct import time import numpy as np from guiqwt.io import _imread_pil, _imwrite_pil, iohandler from codraft.config import _ from codraft.utils.misc import to_string # ============================================================================== # SIF I/O functions # ============================================================================== # Original code: # -------------- # Zhenpeng Zhou # Copyright 2017 Zhenpeng Zhou # Licensed under MIT License Terms # # Changes: # ------- # * Calculating header length using the line beginning with "Counts" # * Calculating wavelenght info line number using line starting with "65538 " # * Handling wavelenght info line ending with "NM" # * Calculating data offset by detecting the first line containing NUL character after # header # class SIFFile: """ A class that reads the contents and metadata of an Andor .sif file. Compatible with images as well as spectra. Exports data as numpy array or xarray.DataArray. Example: SIFFile('my_spectrum.sif').read_all() In addition to the raw data, SIFFile objects provide a number of meta data variables: :ivar x_axis: the horizontal axis (can be pixel numbers or wvlgth in nm) :ivar original_filename: the original file name of the .sif file :ivar date: the date the file was recorded :ivar model: camera model :ivar temperature: sensor temperature in degrees Celsius :ivar exposuretime: exposure time in seconds :ivar cycletime: cycle time in seconds :ivar accumulations: number of accumulations :ivar readout: pixel readout rate in MHz :ivar xres: horizontal resolution :ivar yres: vertical resolution :ivar width: image width :ivar height: image height :ivar xbin: horizontal binning :ivar ybin: vertical binning :ivar gain: EM gain level :ivar vertical_shift_speed: vertical shift speed :ivar pre_amp_gain: pre-amplifier gain :ivar stacksize: number of frames :ivar filesize: size of the file in bytes :ivar m_offset: offset in the .sif file to the actual data """ # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-statements def __init__(self, filepath): self.filepath = filepath self.original_filename = None self.filesize = None self.left = None self.right = None self.top = None self.bottom = None self.width = None self.height = None self.grating = None self.stacksize = None self.datasize = None self.xres = None self.yres = None self.xbin = None self.ybin = None self.cycletime = None self.pre_amp_gain = None self.temperature = None self.center_wavelength = None self.readout = None self.gain = None self.date = None self.exposuretime = None self.m_offset = None self.accumulations = None self.vertical_shift_speed = None self.model = None self.grating_blaze = None self._read_header(filepath) def __repr__(self): info = ( ("Original Filename", self.original_filename), ("Date", self.date), ("Camera Model", self.model), ("Temperature (deg.C)", f"{self.temperature:f}"), ("Exposure Time", f"{self.exposuretime:f}"), ("Cycle Time", f"{self.cycletime:f}"), ("Number of accumulations", f"{self.accumulations:d}"), ("Pixel Readout Rate (MHz)", f"{self.readout:f}"), ("Horizontal Camera Resolution", f"{self.xres:d}"), ("Vertical Camera Resolution", f"{self.yres:d}"), ("Image width", f"{self.width:d}"), ("Image Height", f"{self.height:d}"), ("Horizontal Binning", f"{self.xbin:d}"), ("Vertical Binning", f"{self.ybin:d}"), ("EM Gain level", f"{self.gain:f}"), ("Vertical Shift Speed", f"{self.vertical_shift_speed:f}"), ("Pre-Amplifier Gain", f"{self.pre_amp_gain:f}"), ("Stacksize", f"{self.stacksize:d}"), ("Filesize", f"{self.filesize:d}"), ("Offset to Image Data", f"{self.m_offset:f}"), ) desc_len = max([len(d) for d in list(zip(*info))[0]]) + 3 res = "" for description, value in info: res += ("{:" + str(desc_len) + "}{}\n").format(description + ": ", value) res = object.__repr__(self) + "\n" + res return res def _read_header(self, filepath): """Read SIF file header""" with open(filepath, "rb") as sif_file: i_wavelength_info = None headerlen = None i = 0 self.m_offset = 0 while True: raw_line = sif_file.readline() line = raw_line.strip() if i == 0: if line != b"Andor Technology Multi-Channel File": sif_file.close() raise Exception(f"{filepath} is not an Andor SIF file") elif i == 2: tokens = line.split() self.temperature = float(tokens[5]) self.date = time.strftime("%c", time.localtime(float(tokens[4]))) self.exposuretime = float(tokens[12]) self.cycletime = float(tokens[13]) self.accumulations = int(tokens[15]) self.readout = 1 / float(tokens[18]) / 1e6 self.gain = float(tokens[21]) self.vertical_shift_speed = float(tokens[41]) self.pre_amp_gain = float(tokens[43]) elif i == 3: self.model = to_string(line) elif i == 5: self.original_filename = to_string(line) if i_wavelength_info is None and i > 7: if line.startswith(b"65538 ") and len(line) == 17: i_wavelength_info = i + 1 if i_wavelength_info is not None and i == i_wavelength_info: wavelength_info = line.split() self.center_wavelength = float(wavelength_info[3]) self.grating = float(wavelength_info[6]) blaze = wavelength_info[7] if blaze.endswith(b"NM"): blaze = blaze[:-2] self.grating_blaze = float(blaze) if headerlen is None: if line.startswith(b"Counts"): headerlen = i + 3 else: if i == headerlen - 2: if line[:12] == b"Pixel number": line = line[12:] tokens = line.split() if len(tokens) < 6: raise Exception("Not able to read stacksize.") self.yres = int(tokens[2]) self.xres = int(tokens[3]) self.stacksize = int(tokens[5]) elif i == headerlen - 1: tokens = line.split() if len(tokens) < 7: raise Exception("Not able to read Image dimensions.") self.left = int(tokens[1]) self.top = int(tokens[2]) self.right = int(tokens[3]) self.bottom = int(tokens[4]) self.xbin = int(tokens[5]) self.ybin = int(tokens[6]) elif i >= headerlen: if b"\x00" in line: break i += 1 self.m_offset += len(raw_line) width = self.right - self.left + 1 mod = width % self.xbin self.width = int((width - mod) / self.ybin) height = self.top - self.bottom + 1 mod = height % self.ybin self.height = int((height - mod) / self.xbin) self.filesize = os.path.getsize(filepath) self.datasize = self.width * self.height * 4 * self.stacksize def read_all(self): """ Returns all blocks (i.e. frames) in the .sif file as a numpy array. :return: a numpy array with shape (blocks, y, x) """ with open(self.filepath, "rb") as sif_file: sif_file.seek(self.m_offset) block = sif_file.read(self.width * self.height * self.stacksize * 4) data = np.fromstring(block, dtype=np.float32) return data.reshape(self.stacksize, self.height, self.width) def imread_sif(filename): """Open a SIF image""" sif_file = SIFFile(filename) return sif_file.read_all() # ============================================================================== # SPIRICON I/O functions # ============================================================================== class SCORFile: """Object representing a SPIRICON .scor-data file""" def __init__(self, filepath): self.filepath = filepath self.metadata = None self.width = None self.height = None self.m_offset = None self.filesize = None self.datasize = None self._read_header() def __repr__(self): info = ( ("Image width", f"{self.width:d}"), ("Image Height", f"{self.height:d}"), ("Filesize", f"{self.filesize:d}"), ("Datasize", f"{self.datasize:d}"), ("Offset to Image Data", f"{self.m_offset:f}"), ) desc_len = max([len(d) for d in list(zip(*info))[0]]) + 3 res = "" for description, value in info: res += ("{:" + str(desc_len) + "}{}\n").format(description + ": ", value) res = object.__repr__(self) + "\n" + res return res def _read_header(self): """Read file header""" with open(self.filepath, "rb") as data_file: metadata = {} key1 = None while True: bline = data_file.readline().strip() key1_match = re.match(b"\\[(\\S*)\\]", bline) if key1_match is not None: key1 = key1_match.groups()[0].decode() metadata[key1] = {} elif b"=" in bline: key2, value = bline.decode().split("=") metadata[key1][key2] = value else: break capture_size = metadata["Capture"]["CaptureSize"] self.width, self.height = [int(val) for val in capture_size.split(",")] self.filesize = os.path.getsize(self.filepath) self.datasize = self.width * self.height * 2 self.m_offset = self.filesize - self.datasize - 8 def read_all(self): """Read all data""" with open(self.filepath, "rb") as data_file: data_file.seek(self.m_offset) block = data_file.read(self.datasize) data = np.fromstring(block, dtype=np.int16) return data.reshape(self.height, self.width) def imread_scor(filename): """Open a SPIRICON image""" scor_file = SCORFile(filename) return scor_file.read_all() # ============================================================================== # FXD I/O functions # ============================================================================== class FXDFile: """Class implementing FXD Image file reading feature""" HEADER = " SignalParam: """Read CSV or NumPy files, return a signal object (`SignalParam` instance)""" reducepath = osp.relpath(filename, osp.join(osp.dirname(filename), osp.pardir)) signal = create_signal(reducepath) if osp.splitext(filename)[1] == ".npy": xydata = np.load(filename) else: for delimiter, comments in zip(("\t", ",", " ", ";"), (None, "#")): try: # Load everything readable (titles are eventually converted as NaNs) xydata = np.genfromtxt( filename, delimiter=delimiter, comments=comments, dtype=float ) # Removing lines with NaNs xydata = xydata[~np.isnan(xydata).any(axis=1), :] # Trying to read X,Y titles line0 = delimiter.join([str(val) for val in xydata[0]]) header = "" with open(filename, "r", encoding="utf-8") as fdesc: lines = fdesc.readlines() for rawline in lines: if rawline.startswith(comments): header += rawline continue line = rawline.replace(" ", "") if line == line0: break try: xlabel, ylabel = rawline.split(delimiter) signal.xlabel = xlabel.strip() signal.ylabel = ylabel.strip() # Trying to parse X,Y units pattern = r"([\S ]*) \(([\S]*)\)" # Matching "Label (unit)" match = re.match(pattern, signal.xlabel) if match is not None: signal.xlabel, signal.xunit = match.groups() match = re.match(pattern, signal.ylabel) if match is not None: signal.ylabel, signal.yunit = match.groups() except ValueError: pass break if header: signal.metadata[HEADER_KEY] = header break except ValueError: continue else: raise ValueError("Unable to open CSV file") assert len(xydata.shape) in (1, 2), "Data not supported" if len(xydata.shape) == 1: signal.set_xydata(np.arange(xydata.size), xydata) else: x, y, dx, dy = data_to_xy(xydata) signal.set_xydata(x, y, dx, dy) return signal def write_signal(obj: SignalParam, filename: str) -> None: """Write signal object to CSV or NumPy file""" if osp.splitext(filename)[1] == ".npy": np.save(filename, obj.xydata.T) else: xlabel, ylabel = obj.xlabel or "X", obj.ylabel or "Y" if obj.xunit: xlabel += f" ({obj.xunit})" if obj.yunit: ylabel += f" ({obj.yunit})" delimiter = "," np.savetxt( filename, obj.xydata.T, header=delimiter.join([xlabel, ylabel]), delimiter=delimiter, comments=obj.metadata.get(HEADER_KEY, ""), ) CodraFT-2.2.1/codraft/core/model/000077500000000000000000000000001443562410300164525ustar00rootroot00000000000000CodraFT-2.2.1/codraft/core/model/__init__.py000066400000000000000000000000021443562410300205530ustar00rootroot00000000000000# CodraFT-2.2.1/codraft/core/model/base.py000066400000000000000000000561631443562410300177510ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Datasets """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... import abc import enum import json import sys import guidata.dataset.dataitems as gdi import guidata.dataset.datatypes as gdt import numpy as np from guidata.jsonio import JSONHandler, JSONReader, JSONWriter from guiqwt.annotations import ( AnnotatedCircle, AnnotatedEllipse, AnnotatedPoint, AnnotatedShape, ) from guiqwt.builder import make from guiqwt.io import load_items, save_items from guiqwt.styles import AnnotationParam from codraft.config import Conf, _ from codraft.utils.misc import is_integer_dtype ROI_KEY = "_roi_" ANN_KEY = "_ann_" class MetadataItem(gdt.DataItem): """ Construct a data item representing a metadata dictionary * label [string]: name * default [dict]: default value (optional) * help [string]: text shown in tooltip (optional) * check [bool]: if False, value is not checked (optional, default=True) """ # pylint: disable=redefined-builtin,abstract-method def __init__(self, label, default=None, help="", check=True): gdt.DataItem.__init__(self, label, default=default, help=help, check=check) self.set_prop("display", callback=self.__dictedit) self.set_prop("display", icon="dictedit.png") @staticmethod # pylint: disable=unused-argument def __dictedit(instance, item, value, parent): """Open a dictionary editor""" # pylint: disable=import-outside-toplevel from guidata.widgets.collectionseditor import CollectionsEditor editor = CollectionsEditor(parent) value_was_none = value is None if value_was_none: value = {} editor.setup(value) if editor.exec(): return editor.get_value() if value_was_none: return None return value def serialize(self, instance, writer): """Serialize this item""" value = self.get_value(instance) writer.write_dict(value) def get_value_from_reader(self, reader): """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method""" return reader.read_dict() @enum.unique class Choices(enum.Enum): """Object associating an enum to guidata.dataset.dataitems.ChoiceItem choices""" # Reimplement enum.Enum method as suggested by Python documentation: # https://docs.python.org/3/library/enum.html#using-automatic-values # Here, it is only needed for ImageDatatypes (see core/model/image.py). # pylint: disable=unused-argument,no-self-argument,no-member def _generate_next_value_(name, start, count, last_values): return name.lower() @classmethod def get_choices(cls): """Return tuple of (key, value) choices to be used as parameter of guidata.dataset.dataitems.ChoiceItem""" return tuple((member, member.value) for member in cls) class BaseProcParam(gdt.DataSet): """Base class for processing parameters""" def __init__(self, title=None, comment=None, icon=""): super().__init__(title, comment, icon) self.set_global_prop("data", min=None, max=None) def apply_integer_range(self, vmin, vmax): # pylint: disable=unused-argument """Do something in case of integer min-max range""" def apply_float_range(self, vmin, vmax): # pylint: disable=unused-argument """Do something in case of float min-max range""" def set_from_datatype(self, dtype): """Set min/max range from NumPy datatype""" if is_integer_dtype(dtype): info = np.iinfo(dtype) self.apply_integer_range(info.min, info.max) else: info = np.finfo(dtype) self.apply_float_range(info.min, info.max) self.set_global_prop("data", min=info.min, max=info.max) class BaseRandomParam(BaseProcParam): """Random signal/image parameters""" seed = gdi.IntItem(_("Seed"), default=1) class UniformRandomParam(BaseRandomParam): """Uniform-law random signal/image parameters""" def apply_integer_range(self, vmin, vmax): """Do something in case of integer min-max range""" self.vmin, self.vmax = vmin, vmax vmin = gdi.FloatItem( "Vmin", default=-0.5, help=_("Uniform distribution lower bound") ) vmax = gdi.FloatItem( "Vmax", default=0.5, help=_("Uniform distribution higher bound") ).set_pos(col=1) class NormalRandomParam(BaseRandomParam): """Normal-law random signal/image parameters""" DEFAULT_RELATIVE_MU = 0.1 DEFAULT_RELATIVE_SIGMA = 0.02 def apply_integer_range(self, vmin, vmax): """Do something in case of integer min-max range""" delta = vmax - vmin self.mu = int(self.DEFAULT_RELATIVE_MU * delta + vmin) self.sigma = int(self.DEFAULT_RELATIVE_SIGMA * delta) mu = gdi.FloatItem( "μ", default=DEFAULT_RELATIVE_MU, help=_("Normal distribution mean") ) sigma = gdi.FloatItem( "σ", default=DEFAULT_RELATIVE_SIGMA, help=_("Normal distribution standard deviation"), ).set_pos(col=1) @enum.unique class ShapeTypes(enum.Enum): """Shape types for image metadata""" # Reimplement enum.Enum method as suggested by Python documentation: # https://docs.python.org/3/library/enum.html#using-automatic-values # pylint: disable=unused-argument,no-self-argument,no-member def _generate_next_value_(name, start, count, last_values): return f"_{name.lower()[:3]}_" RECTANGLE = enum.auto() CIRCLE = enum.auto() ELLIPSE = enum.auto() SEGMENT = enum.auto() MARKER = enum.auto() POINT = enum.auto() def config_annotated_shape(item: AnnotatedShape, fmt: str, lbl: bool, cmp: bool = None): """Configurate annotated shape""" param = item.annotationparam param.format = fmt param.show_label = lbl if cmp is not None: param.show_computations = cmp param.update_annotation(item) def set_plot_item_editable(item, state): """Set plot item editable state""" item.set_movable(state) item.set_resizable(state) item.set_rotatable(state) item.set_readonly(not state) # TODO: [P0] Replace 'array' by 'datalist', a list of NumPy arrays # With this new data model, the old 'array' attribute row (each row is a result) is # replaced by an element of the new 'datalist' attribute. So, when this change is done, # each 'datalist' element is a result. This means that each result no longer has to be # an array with the same number of columns: in other words, each result may be an # arbitrary NumPy array, with an arbitrary shape. This is the opportunity to introduce # a new ShapeTypes type (e.g. FREEFORM) represented by an AnnotatedPolygon (new class # to be written using AnnotatedRectangle as a model). This also has been made possible # due to a recent change in CodraFT HDF5 (de)serialization which now accepts nested # lists or dictionnaries. class ResultShape: """Object representing a geometrical shape serializable in signal/image metadata. Result `array` is a NumPy 2-D array: each row is a result, optionnally associated to a ROI (first column value). ROI index is starting at 0 (or is simply 0 if there is no ROI). :param ShapeTypes shapetype: shape type :param np.ndarray array: shape coordinates (multiple shapes: one shape per row), first column is ROI index (0 if there is no ROI) :param str label: shape label """ def __init__(self, shapetype: ShapeTypes, array: np.ndarray, label: str = ""): assert isinstance(label, str) assert isinstance(shapetype, ShapeTypes) self.label = self.show_label = label self.shapetype = shapetype if isinstance(array, (list, tuple)): if isinstance(array[0], (list, tuple)): array = np.array(array) else: array = np.array([array]) assert isinstance(array, np.ndarray) self.array = array if label.endswith("s"): self.show_label = label[:-1] self.check_array() @classmethod def label_shapetype_from_key(cls, key: str): """Return metadata shape label and shapetype from metadata key""" for member in ShapeTypes: if key.startswith(member.value): label = key[len(member.value) :] return label, member raise ValueError(f"Invalid metadata key `{key}`") @classmethod def from_metadata_entry(cls, key, value): """Create metadata shape object from (key, value) metadata entry""" if isinstance(key, str) and isinstance(value, np.ndarray): try: label, shapetype = cls.label_shapetype_from_key(key) return cls(shapetype, value, label) except ValueError: pass return None @classmethod def match(cls, key, value): """Return True if metadata dict entry (key, value) is a metadata result""" return cls.from_metadata_entry(key, value) is not None @property def key(self): """Return metadata key associated to result""" return self.shapetype.value + self.label @property def xlabels(self): """Return labels for result array columns""" if self.shapetype in (ShapeTypes.MARKER, ShapeTypes.POINT): labels = "ROI", "x", "y" elif self.shapetype in ( ShapeTypes.RECTANGLE, ShapeTypes.CIRCLE, ShapeTypes.SEGMENT, ): labels = "ROI", "x0", "y0", "x1", "y1" elif self.shapetype is ShapeTypes.ELLIPSE: labels = "ROI", "x0", "y0", "x1", "y1", "x2", "y2", "x3", "y3" else: raise NotImplementedError(f"Unsupported shapetype {self.shapetype}") return labels[-self.array.shape[1] :] def add_to(self, obj): """Add metadata shape to object (signal/image)""" obj.metadata[self.key] = self.array if self.shapetype in ( ShapeTypes.SEGMENT, ShapeTypes.CIRCLE, ShapeTypes.ELLIPSE, ): # Automatically adds segment norm / circle diameter to object metadata colnb = 2 if self.shapetype is ShapeTypes.ELLIPSE: colnb += 1 arr = self.array results = np.zeros((arr.shape[0], colnb), dtype=arr.dtype) results[:, 0] = arr[:, 0] # ROI indexes dx1, dy1 = arr[:, 3] - arr[:, 1], arr[:, 4] - arr[:, 2] results[:, 1] = np.linalg.norm(np.vstack([dx1, dy1]).T, axis=1) if self.shapetype is ShapeTypes.ELLIPSE: dx2, dy2 = arr[:, 7] - arr[:, 5], arr[:, 8] - arr[:, 6] results[:, 2] = np.linalg.norm(np.vstack([dx2, dy2]).T, axis=1) label = self.label if self.shapetype is ShapeTypes.CIRCLE: label += "Diameter" if self.shapetype is ShapeTypes.ELLIPSE: label += "Diameters" obj.metadata[label] = results def merge_with(self, obj, other_obj=None): """Merge object resultshape with another's: obj <-- other_obj or simply merge this resultshape with obj if other_obj is None""" if other_obj is None: other_obj = obj other_value = other_obj.metadata.get(self.key) if other_value is not None: other = ResultShape.from_metadata_entry(self.key, other_value) other_array = np.array(other.array, copy=True) if other_array.shape[1] > self.data_colnb: # Column 0 is the ROI index other_array[:, 0] += self.array[-1, 0] + 1 # Adding ROI index offset self.array = np.vstack([self.array, other_array]) self.add_to(obj) @property def data_colnb(self): """Return raw data results column number""" return { ShapeTypes.MARKER: 2, ShapeTypes.POINT: 2, ShapeTypes.RECTANGLE: 4, ShapeTypes.CIRCLE: 4, ShapeTypes.SEGMENT: 4, ShapeTypes.ELLIPSE: 8, }[self.shapetype] @property def data(self): """Return raw data (array without ROI informations)""" return self.array[:, -self.data_colnb :] def check_array(self): """Check if array is valid""" assert len(self.array.shape) == 2 assert self.array.shape[1] == self.data_colnb + 1 def iterate_plot_items(self, fmt: str, lbl: bool): """Iterate over metadata shape plot items :param str fmt: numeric format (e.g. "%.3f") :param bool lbl: if True, show shape labels """ for args in self.data: yield self.create_plot_item(args, fmt, lbl) def create_plot_item(self, args: np.ndarray, fmt: str, lbl: bool): """Make plot item""" if self.shapetype is ShapeTypes.MARKER: item = self.make_marker_item(args, fmt) elif self.shapetype is ShapeTypes.POINT: item = AnnotatedPoint(*args) sparam = item.shape.shapeparam sparam.symbol.marker = "Ellipse" sparam.symbol.size = 6 sparam.sel_symbol.marker = "Ellipse" sparam.sel_symbol.size = 6 sparam.update_shape(item.shape) param = item.annotationparam param.title = self.show_label param.update_annotation(item) elif self.shapetype is ShapeTypes.RECTANGLE: x0, y0, x1, y1 = args item = make.annotated_rectangle(x0, y0, x1, y1, title=self.show_label) elif self.shapetype is ShapeTypes.CIRCLE: x0, y0, x1, y1 = args param = AnnotationParam(_("Annotation"), icon="annotation.png") param.title = self.show_label item = AnnotatedCircle(x0, y0, x1, y1, param) item.set_style("plot", "shape/drag") elif self.shapetype is ShapeTypes.SEGMENT: x0, y0, x1, y1 = args item = make.annotated_segment(x0, y0, x1, y1, title=self.show_label) elif self.shapetype is ShapeTypes.ELLIPSE: x0, y0, x1, y1, x2, y2, x3, y3 = args param = AnnotationParam(_("Annotation"), icon="annotation.png") param.title = self.show_label item = AnnotatedEllipse(annotationparam=param) item.shape.switch_to_ellipse() item.set_xdiameter(x0, y0, x1, y1) item.set_ydiameter(x2, y2, x3, y3) item.set_style("plot", "shape/drag") else: print(f"Warning: unsupported item {self.shapetype}", file=sys.stderr) return None if isinstance(item, AnnotatedShape): config_annotated_shape(item, fmt, lbl) set_plot_item_editable(item, False) return item def make_marker_item(self, args, fmt): """Make marker item""" x0, y0 = args if np.isnan(x0): mstyle = "-" def label(x, y): # pylint: disable=unused-argument return (self.show_label + ": " + fmt) % y elif np.isnan(y0): mstyle = "|" def label(x, y): # pylint: disable=unused-argument return (self.show_label + ": " + fmt) % x else: mstyle = "+" txt = self.show_label + ": (" + fmt + ", " + fmt + ")" def label(x, y): return txt % (x, y) return make.marker( position=(x0, y0), markerstyle=mstyle, label_cb=label, linestyle="DashLine", color="yellow", ) def make_roi_item(func, coords: list, title: str, fmt: str, lbl: bool, editable: bool): """Make ROI item shape""" item = func(*coords, title) if not editable: if isinstance(item, AnnotatedShape): config_annotated_shape(item, fmt, lbl, cmp=editable) item.set_style("plot", "shape/mask") item.set_movable(False) item.set_resizable(False) item.set_readonly(True) return item class ObjectItfMeta(abc.ABCMeta, gdt.DataSetMeta): """Mixed metaclass to avoid conflicts""" class ObjectItf(metaclass=ObjectItfMeta): """Object (signal/image) interface""" metadata = {} # This is overriden in children classes with a gdi.DictItem instance # Metadata dictionary keys for special properties: METADATA_FMT = "__format" METADATA_LBL = "__showlabel" DEFAULT_FMT = "s" # This is overriden in children classes CONF_FMT = Conf.view.sig_format # This is overriden in children classes VALID_DTYPES = () @property @abc.abstractmethod def data(self): """Data""" def check_data(self): """Check if data is valid, raise an exception if that's not the case""" if self.data is not None: if self.data.dtype not in self.VALID_DTYPES: raise TypeError(f"Unsupported data type: {self.data.dtype}") def iterate_roi_indexes(self): """Iterate over object ROI indexes ([0] if there is no ROI)""" if self.roi is None: yield 0 else: yield from range(len(self.roi)) @abc.abstractmethod def get_data(self, roi_index: int = None) -> np.ndarray: """ Return original data (if ROI is not defined or `roi_index` is None), or ROI data (if both ROI and `roi_index` are defined). """ @abc.abstractmethod def copy_data_from(self, other, dtype=None): """Copy data from other dataset instance""" @abc.abstractmethod def set_data_type(self, dtype): """Change data type""" @abc.abstractmethod def make_item(self, update_from=None): """Make plot item from data""" @abc.abstractmethod def update_item(self, item, ref_item=None): """Update plot item from data""" @abc.abstractmethod def roi_coords_to_indexes(self, coords: list) -> np.ndarray: """Convert ROI coordinates to indexes""" @abc.abstractmethod def get_roi_param(self, title, *defaults): """Return ROI parameters dataset""" def roidata_to_params(self, roidata: np.ndarray) -> gdt.DataSetGroup: """Convert ROI array data to ROI dataset group""" roi_params = [] for index, parameters in enumerate(roidata): roi_param = self.get_roi_param(f"ROI{index:02d}", *parameters) roi_params.append(roi_param) group = gdt.DataSetGroup(roi_params, title=_("Regions of interest")) return group @staticmethod @abc.abstractmethod def params_to_roidata(params: gdt.DataSetGroup) -> np.ndarray: """Convert ROI dataset group to ROI array data""" @property def roi(self) -> np.ndarray: """Return object regions of interest array (one ROI per line)""" return self.metadata.get(ROI_KEY) @roi.setter def roi(self, roidata: np.ndarray): """Set object regions of interest array, using a list or ROI dataset params""" if roidata is None: if ROI_KEY in self.metadata: self.metadata.pop(ROI_KEY) else: self.metadata[ROI_KEY] = np.array(roidata, int) def add_resultshape( self, label: str, shapetype: ShapeTypes, array: np.ndarray ) -> ResultShape: """Add geometric shape as metadata entry, and return ResultShape instance""" mshape = ResultShape(shapetype, array, label) mshape.add_to(self) return mshape def update_resultshapes_from(self, other): """Update geometric shape from another object (merge metadata)""" for key, value in self.metadata.items(): if ResultShape.match(key, value): mshape = ResultShape.from_metadata_entry(key, value) mshape.merge_with(self, other) @abc.abstractmethod def iterate_roi_items(self, fmt: str, lbl: bool, editable: bool = True): """Make plot item representing a Region of Interest""" def __set_annotations(self, annotations: str): """Set object annotations (JSON string describing annotation plot items)""" self.metadata[ANN_KEY] = annotations def __get_annotations(self) -> str: """Get object annotations (JSON string describing annotation plot items)""" return self.metadata.get(ANN_KEY, "") annotations = property(__get_annotations, __set_annotations) def set_annotations_from_items(self, items: list): """Set object annotations (annotation plot items)""" writer = JSONWriter(None) save_items(writer, items) self.annotations = writer.get_json(indent=4) def iterate_shape_items(self, editable: bool = False): """Iterate over computing items encoded in metadata (if any)""" setdef = self.metadata.setdefault fmt = setdef(self.METADATA_FMT, "%" + self.CONF_FMT.get(self.DEFAULT_FMT)) lbl = setdef(self.METADATA_LBL, Conf.view.show_label.get(False)) for key, value in self.metadata.items(): if key == ROI_KEY: yield from self.iterate_roi_items(fmt=fmt, lbl=lbl, editable=False) elif ResultShape.match(key, value): mshape = ResultShape.from_metadata_entry(key, value) yield from mshape.iterate_plot_items(fmt, lbl) if self.annotations: try: for item in load_items(JSONReader(self.annotations)): set_plot_item_editable(item, editable) if isinstance(item, AnnotatedShape): config_annotated_shape(item, fmt, lbl) yield item except json.decoder.JSONDecodeError: pass def remove_resultshapes(self): """Remove metadata shapes and ROIs""" for key, value in list(self.metadata.items()): resultshape = ResultShape.from_metadata_entry(key, value) if resultshape is not None or key == ROI_KEY: # Metadata entry is a metadata shape or a ROI self.metadata.pop(key) def export_metadata_to_file(self, filename): """Export object metadata to file (JSON)""" handler = JSONHandler(filename) handler.set_json_dict(self.metadata) handler.save() def import_metadata_from_file(self, filename): """Import object metadata from file (JSON)""" handler = JSONHandler(filename) handler.load() self.metadata = handler.get_json_dict() def metadata_to_html(self): """Convert metadata to human-readable string""" textlines = [] for key, value in self.metadata.items(): if len(textlines) > 5: textlines.append("[...]") break if not key.startswith("_"): vlines = str(value).splitlines() if vlines: text = f"{key}: {vlines[0]}" if len(vlines) > 1: text += " [...]" textlines.append(text) if textlines: ptit = _("Object metadata") psub = _("(click on Metadata button for more details)") prefix = f"{ptit}: {psub}
" return f"

{prefix}{'
'.join(textlines)}

" return "" CodraFT-2.2.1/codraft/core/model/image.py000066400000000000000000000577601443562410300201250ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Datasets """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... # pylint: disable=duplicate-code import enum import re import weakref from collections import abc from copy import deepcopy import guidata.dataset.dataitems as gdi import guidata.dataset.datatypes as gdt import numpy as np from guidata.configtools import get_icon from guidata.utils import update_dataset from guiqwt.annotations import AnnotatedCircle from guiqwt.builder import make from guiqwt.image import MaskedImageItem from numpy import ma from skimage import draw from codraft.config import Conf, _ from codraft.core.computation.image import scale_data_to_min_max from codraft.core.model import base def make_roi_rectangle(x0: int, y0: int, x1: int, y1: int, title: str): """Make and return the annnotated rectangle associated to ROI""" return make.annotated_rectangle(x0, y0, x1, y1, title) def make_roi_circle(x0: int, y0: int, x1: int, y1: int, title: str): """Make and return the annnotated circle associated to ROI""" item = AnnotatedCircle(x0, y0, x1, y1) item.annotationparam.title = title item.annotationparam.update_annotation(item) item.set_style("plot", "shape/drag") return item def to_builtin(obj): """Convert an object implementing a numeric value or collection into the corresponding builtin/NumPy type. Return None if conversion fails.""" try: return int(obj) if int(obj) == float(obj) else float(obj) except (TypeError, ValueError): pass if isinstance(obj, abc.ByteString): return str(obj) if isinstance(obj, abc.Sequence): return str(obj) if len(obj) == len(str(obj)) else list(obj) if isinstance(obj, abc.Mapping): return dict(obj) if isinstance(obj, np.ndarray): return obj return None class RoiDataGeometries(enum.Enum): """ROI data geometry types""" RECTANGLE = 0 CIRCLE = 1 class RoiDataItem: """Object representing an image ROI.""" def __init__(self, data: np.ndarray): self._data = data @classmethod def from_image(cls, obj, geometry: RoiDataGeometries): """Construct roi data item from image object: called for making new ROI items""" x0, x1 = obj.x0, obj.size[0] + obj.x0 if geometry is RoiDataGeometries.RECTANGLE: y0, y1 = obj.y0, obj.size[1] + obj.y0 else: y0 = y1 = 0.5 * (2 * obj.y0 + obj.size[1]) coords = x0, y0, x1, y1 return cls(coords) @property def geometry(self) -> RoiDataGeometries: """ROI geometry""" _x0, y0, _x1, y1 = self._data if y0 == y1: return RoiDataGeometries.CIRCLE return RoiDataGeometries.RECTANGLE def get_rect(self): """Get rectangle coordinates""" x0, y0, x1, y1 = self._data if self.geometry is RoiDataGeometries.CIRCLE: y0 -= x1 - x0 y1 += x1 - x0 return x0, y0, x1, y1 def get_masked_view(self, data: np.ndarray, maskdata: np.ndarray) -> np.ndarray: """Return masked view for data""" x0, y0, x1, y1 = self.get_rect() masked_view = data.view(ma.MaskedArray) masked_view.mask = maskdata return masked_view[y0:y1, x0:x1] def apply_mask(self, data: np.ndarray, yxratio: float) -> np.ndarray: """Apply ROI to data as a mask and return masked array""" roi_mask = np.ones_like(data, dtype=bool) x0, y0, x1, y1 = self.get_rect() if self.geometry is RoiDataGeometries.RECTANGLE: roi_mask[y0:y1, x0:x1] = False else: xc, yc = 0.5 * (x0 + x1), 0.5 * (y0 + y1) radius = 0.5 * (x1 - x0) rr, cc = draw.ellipse(yc, xc, radius / yxratio, radius, shape=data.shape) roi_mask[rr, cc] = False return roi_mask def make_roi_item(self, index: int, fmt: str, lbl: bool, editable: bool = True): """Make ROI plot item""" coords = self._data if self.geometry is RoiDataGeometries.RECTANGLE: func = make_roi_rectangle else: func = make_roi_circle title = "ROI" if index is None else f"ROI{index:02d}" return base.make_roi_item(func, coords, title, fmt, lbl, editable) class ImageParam(gdt.DataSet, base.ObjectItf): """Image dataset""" CONF_FMT = Conf.view.ima_format DEFAULT_FMT = ".1f" VALID_DTYPES = ( np.uint8, np.uint16, np.int16, np.int32, np.float32, np.float64, np.complex128, ) def __init__(self, title=None, comment=None, icon=""): gdt.DataSet.__init__(self, title, comment, icon) self._dicom_template = None self._maskdata_cache = None self._roidata_cache = None # weak reference @property def size(self): """Returns (width, height)""" return self.data.shape[1], self.data.shape[0] def __add_metadata(self, key, value): """Add value to metadata if value can be converted into builtin/NumPy type""" stored_val = to_builtin(value) if stored_val is not None: self.metadata[key] = stored_val def set_metadata_from(self, obj): """Set metadata from object: dict-like (only string keys are considered) or any other object (iterating over supported attributes)""" self.metadata = {} ptn = r"__[\S_]*__$" if isinstance(obj, abc.Mapping): for key, value in obj.items(): if isinstance(key, str) and not re.match(ptn, key): self.__add_metadata(key, value) else: for attrname in dir(obj): if attrname != "GroupLength" and not re.match(ptn, attrname): try: attr = getattr(obj, attrname) if not callable(attr) and attr: self.__add_metadata(attrname, attr) except AttributeError: pass @property def dicom_template(self): """Get DICOM template""" return self._dicom_template @dicom_template.setter def dicom_template(self, template): """Set DICOM template""" if template is not None: ipp = getattr(template, "ImagePositionPatient", None) if ipp is not None: self.x0, self.y0 = float(ipp[0]), float(ipp[1]) pxs = getattr(template, "PixelSpacing", None) if pxs is not None: self.dy, self.dx = float(pxs[0]), float(pxs[1]) self.set_metadata_from(template) self._dicom_template = template _tabs = gdt.BeginTabGroup("all") _datag = gdt.BeginGroup(_("Data and metadata")) data = gdi.FloatArrayItem(_("Data")) metadata = base.MetadataItem(_("Metadata"), default={}) _e_datag = gdt.EndGroup(_("Data and metadata")) _dxdyg = gdt.BeginGroup(_("Origin and pixel spacing")) _origin = gdt.BeginGroup(_("Origin")) x0 = gdi.FloatItem("X0", default=0.0) y0 = gdi.FloatItem("Y0", default=0.0).set_pos(col=1) _e_origin = gdt.EndGroup(_("Origin")) _pixel_spacing = gdt.BeginGroup(_("Pixel spacing")) dx = gdi.FloatItem("Δx", default=1.0, nonzero=True) dy = gdi.FloatItem("Δy", default=1.0, nonzero=True).set_pos(col=1) _e_pixel_spacing = gdt.EndGroup(_("Pixel spacing")) _e_dxdyg = gdt.EndGroup(_("Origin and pixel spacing")) _unitsg = gdt.BeginGroup(_("Titles and units")) title = gdi.StringItem(_("Image title"), default=_("Untitled")) _tabs_u = gdt.BeginTabGroup("units") _unitsx = gdt.BeginGroup(_("X-axis")) xlabel = gdi.StringItem(_("Title"), default="") xunit = gdi.StringItem(_("Unit"), default="") _e_unitsx = gdt.EndGroup(_("X-axis")) _unitsy = gdt.BeginGroup(_("Y-axis")) ylabel = gdi.StringItem(_("Title"), default="") yunit = gdi.StringItem(_("Unit"), default="") _e_unitsy = gdt.EndGroup(_("Y-axis")) _unitsz = gdt.BeginGroup(_("Z-axis")) zlabel = gdi.StringItem(_("Title"), default="") zunit = gdi.StringItem(_("Unit"), default="") _e_unitsz = gdt.EndGroup(_("Z-axis")) _e_tabs_u = gdt.EndTabGroup("units") _e_unitsg = gdt.EndGroup(_("Titles and units")) _e_tabs = gdt.EndTabGroup("all") def get_data(self, roi_index: int = None) -> np.ndarray: """ Return original data (if ROI is not defined or `roi_index` is None), or ROI data (if both ROI and `roi_index` are defined). Returns a masked array. """ if self.roi is None or roi_index is None: return self.data roidataitem = RoiDataItem(self.roi[roi_index]) return roidataitem.get_masked_view(self.data, self.maskdata) def copy_data_from(self, other, dtype=None): """Copy data from other dataset instance""" self.x0 = other.x0 self.y0 = other.y0 self.dx = other.dx self.dy = other.dy self.metadata = deepcopy(other.metadata) self.data = np.array(other.data, copy=True, dtype=dtype) self.dicom_template = other.dicom_template def set_data_type(self, dtype): """Change data type""" self.data = np.array(self.data, dtype=dtype) def __viewable_data(self): """Return viewable data""" data = self.data.real if np.any(np.isnan(data)): data = np.nan_to_num(data, posinf=0, neginf=0) return data def __update_item_params(self, data: np.ndarray, item: MaskedImageItem): """Update plot item parameters""" for axis in ("x", "y", "z"): unit = getattr(self, axis + "unit") fmt = r"%.1f" if unit: fmt = r"%.1f (" + unit + ")" setattr(item.imageparam, axis + "format", fmt) # Updating origin and pixel spacing has_origin = self.x0 is not None and self.y0 is not None has_pixelspacing = self.dx is not None and self.dy is not None if has_origin or has_pixelspacing: x0, y0, dx, dy = 0.0, 0.0, 1.0, 1.0 if has_origin: x0, y0 = self.x0, self.y0 if has_pixelspacing: dx, dy = self.dx, self.dy item.imageparam.xmin, item.imageparam.xmax = x0, x0 + dx * data.shape[1] item.imageparam.ymin, item.imageparam.ymax = y0, y0 + dy * data.shape[0] update_dataset(item.imageparam, self.metadata) item.imageparam.update_image(item) def make_item(self, update_from: MaskedImageItem = None): """Make plot item from data""" data = self.__viewable_data() item = make.maskedimage( data, self.maskdata, title=self.title, colormap="jet", eliminate_outliers=Conf.view.ima_eliminate_outliers.get(0.1), interpolation="nearest", show_mask=True, ) if update_from is None: self.__update_item_params(data, item) else: update_dataset(item.imageparam, update_from.imageparam) item.imageparam.update_image(item) return item def update_item(self, item: MaskedImageItem, ref_item: MaskedImageItem = None): """Update plot item from data""" data = self.__viewable_data() item.set_data(data, lut_range=[item.min, item.max]) item.set_mask(self.maskdata) item.imageparam.label = self.title if ref_item is not None and Conf.view.ima_ref_lut_range.get(True): item.set_lut_range(ref_item.get_lut_range()) self.__update_item_params(data, item) item.plot().update_colormap_axis(item) def get_roi_param(self, title, *defaults): """Return ROI parameters dataset""" roidataitem = RoiDataItem(defaults) xd0, yd0, xd1, yd1 = defaults def s(name: str, index: int): """Returns nameindex""" return f"{name}{index}" if roidataitem.geometry is RoiDataGeometries.RECTANGLE: gtitle1 = _("Top left corner") gtitle2 = _("Bottom right corner") class ROIParam(gdt.DataSet): """ROI parameters""" geometry = roidataitem.geometry def get_suffix(self): """Get suffix text representation for ROI extraction""" return f"x={self.x0}:{self.x1},y={self.y0}:{self.y1}" def get_coords(self): """Get ROI coordinates""" return self.x0, self.y0, self.x1, self.y1 _tlcorner = gdt.BeginGroup(gtitle1) x0 = gdi.IntItem(s("X", 0), default=xd0, unit="pixel") y0 = gdi.IntItem(s("Y", 0), default=yd0, unit="pixel").set_pos(1) _e_tlcorner = gdt.EndGroup(gtitle1) _brcorner = gdt.BeginGroup(gtitle2) x1 = gdi.IntItem(s("X", 1), default=xd1, unit="pixel") y1 = gdi.IntItem(s("Y", 1), default=yd1, unit="pixel").set_pos(1) _e_brcorner = gdt.EndGroup(gtitle2) else: gtitle1 = _("Center coordinates") class ROIParam(gdt.DataSet): """ROI parameters""" geometry = roidataitem.geometry def get_single_roi(self): """Get single circular ROI, i.e. after extracting ROI from image""" return np.array([(0, self.r, self.x1 - self.x0, self.r)], int) def get_suffix(self): """Get suffix text representation for ROI extraction""" return f"xc={self.xc},yc={self.yc},r={self.r}" def get_coords(self): """Get ROI coordinates""" return self.x0, self.yc, self.x1, self.yc @property def x0(self): """Return rectangle top left corner X coordinate""" return self.xc - self.r @property def x1(self): """Return rectangle bottom right corner X coordinate""" return self.xc + self.r @property def y0(self): """Return rectangle top left corner Y coordinate""" return self.yc - self.r @property def y1(self): """Return rectangle bottom right corner Y coordinate""" return self.yc + self.r _tlcorner = gdt.BeginGroup(gtitle1) xc = gdi.IntItem( s("X", "C"), default=int(0.5 * (xd0 + xd1)), unit="pixel" ) yc = gdi.IntItem(s("Y", "C"), default=yd0, unit="pixel").set_pos(1) _e_tlcorner = gdt.EndGroup(gtitle1) r = gdi.IntItem( _("Radius"), default=int(0.5 * (xd1 - xd0)), unit="pixel" ) return ROIParam(title) @staticmethod def params_to_roidata(params: gdt.DataSetGroup) -> np.ndarray: """Convert list of dataset parameters to ROI data""" roilist = [] for roiparam in params.datasets: roilist.append(roiparam.get_coords()) if len(roilist) == 0: return None return np.array(roilist, int) def new_roi_item(self, fmt, lbl, editable, geometry: RoiDataGeometries): """Return a new ROI item from scratch""" roidataitem = RoiDataItem.from_image(self, geometry) return roidataitem.make_roi_item(None, fmt, lbl, editable) def roi_coords_to_indexes(self, coords: list) -> np.ndarray: """Convert ROI coordinates to indexes""" indexes = np.array(coords) if indexes.size > 0: indexes[:, ::2] -= self.x0 + 0.5 * self.dx indexes[:, ::2] /= self.dx indexes[:, 1::2] -= self.y0 + 0.5 * self.dy indexes[:, 1::2] /= self.dy return np.array(indexes, int) def iterate_roi_items(self, fmt: str, lbl: bool, editable: bool = True): """Iterate over plot items representing Regions of Interest""" if self.roi is not None: roicoords = np.array(self.roi, float) roicoords[:, ::2] *= self.dx roicoords[:, ::2] += self.x0 - 0.5 * self.dx roicoords[:, 1::2] *= self.dy roicoords[:, 1::2] += self.y0 - 0.5 * self.dy for index, coords in enumerate(roicoords): roidataitem = RoiDataItem(coords) yield roidataitem.make_roi_item(index, fmt, lbl, editable) @property def maskdata(self): """Return masked data (areas outside defined regions of interest)""" roi_changed = self._roidata_cache is not None and self._roidata_cache() is None if self.roi is None: if roi_changed: self._roidata_cache = None self._maskdata_cache = None elif roi_changed or self._maskdata_cache is None: mask = np.ones_like(self.data, dtype=bool) for roirow in self.roi: roidataitem = RoiDataItem(roirow) roi_mask = roidataitem.apply_mask(self.data, yxratio=self.dy / self.dx) mask &= roi_mask self._maskdata_cache = mask self._roidata_cache = weakref.ref(self.roi) return self._maskdata_cache def invalidate_maskdata_cache(self): """Invalidate mask data cache: force to rebuild it""" self._maskdata_cache = None def create_image( title, data: np.ndarray = None, metadata: dict = None, units: tuple = None, labels: tuple = None, ): """Create a new Image object :param str title: image title :param numpy.ndarray data: image data :param dict metadata: image metadata :param tuple units: X, Y, Z units (tuple of strings) :param tuple labels: X, Y, Z labels (tuple of strings) """ assert isinstance(title, str) assert data is None or isinstance(data, np.ndarray) image = ImageParam() image.title = title image.data = data if units is not None: image.xunit, image.yunit, image.zunit = units if labels is not None: image.xlabel, image.ylabel, image.zlabel = labels image.metadata = {} if metadata is None else metadata # Default visualization settings for name, opt in ( ("colormap", Conf.view.ima_def_colormap), ("interpolation", Conf.view.ima_def_interpolation), ): defval = opt.get(None) if defval is not None: image.metadata[name] = defval # TODO: [P2] Add default signal/image visualization settings # 1. Add signal visualization settings? # 2. Add more image visualization settings? # 3. Add a dialog box to edit default settings in main window # (use a guidata dataset with only a selection of items from guiqwt.styles # classes) # 4. Update all active objects when settings were changed return image class ImageDatatypes(base.Choices): """Image data types""" @classmethod def from_dtype(cls, dtype): """Return member from NumPy dtype""" return getattr(cls, str(dtype).upper(), cls.UINT8) @classmethod def check(cls): """Check if data types are valid""" for member in cls: assert hasattr(np, member.value) UINT8 = enum.auto() UINT16 = enum.auto() INT16 = enum.auto() FLOAT32 = enum.auto() FLOAT64 = enum.auto() ImageDatatypes.check() class ImageTypes(base.Choices): """Image types""" ZEROS = _("zeros") EMPTY = _("empty") GAUSS = _("gaussian") UNIFORMRANDOM = _("random (uniform law)") NORMALRANDOM = _("random (normal law)") class ImageParamNew(gdt.DataSet): """New image dataset""" title = gdi.StringItem(_("Title"), default=_("Untitled")) height = gdi.IntItem( _("Height"), help=_("Image height (total number of rows)"), min=1, default=500 ) width = gdi.IntItem( _("Width"), help=_("Image width (total number of columns)"), min=1, default=500 ) dtype = gdi.ChoiceItem( _("Data type"), ImageDatatypes.get_choices(), default=ImageDatatypes.UINT16 ) type = gdi.ChoiceItem(_("Type"), ImageTypes.get_choices()) def new_image_param(title=None, itype=None, height=None, width=None, dtype=None): """Create a new Image dataset instance. :param str title: dataset title (default: None, uses default title)""" if title is None: title = _("Untitled image") param = ImageParamNew(title=title, icon=get_icon("new_image.svg")) param.title = title if height is not None: param.height = height if width is not None: param.width = width if dtype is not None: param.dtype = dtype if itype is not None: param.type = itype return param IMG_NB = 0 class Gauss2DParam(gdt.DataSet): """2D Gaussian parameters""" a = gdi.FloatItem("Norm") xmin = gdi.FloatItem("Xmin", default=-10).set_pos(col=1) sigma = gdi.FloatItem("σ", default=1.0) xmax = gdi.FloatItem("Xmax", default=10).set_pos(col=1) mu = gdi.FloatItem("μ", default=0.0) ymin = gdi.FloatItem("Ymin", default=-10).set_pos(col=1) x0 = gdi.FloatItem("X0", default=0) ymax = gdi.FloatItem("Ymax", default=10).set_pos(col=1) y0 = gdi.FloatItem("Y0", default=0).set_pos(col=0, colspan=1) def create_image_from_param(newparam, addparam=None, edit=False, parent=None): """Create a new Image object from dialog box. :param ImageParamNew param: new image parameters :param guidata.dataset.datatypes.DataSet addparam: additional parameters :param bool edit: Open a dialog box to edit parameters (default: False) :param QWidget parent: parent widget """ global IMG_NB # pylint: disable=global-statement if newparam is None: newparam = new_image_param() incr_sig_nb = not newparam.title if incr_sig_nb: newparam.title = f"{newparam.title} {IMG_NB + 1:d}" if not edit or addparam is not None or newparam.edit(parent=parent): if incr_sig_nb: IMG_NB += 1 image = create_image(newparam.title) shape = (newparam.height, newparam.width) dtype = newparam.dtype.value p = addparam if newparam.type == ImageTypes.ZEROS: image.data = np.zeros(shape, dtype=dtype) elif newparam.type == ImageTypes.EMPTY: image.data = np.empty(shape, dtype=dtype) elif newparam.type == ImageTypes.GAUSS: if p is None: p = Gauss2DParam(_("New 2D-gaussian image")) if p.a is None: try: p.a = np.iinfo(dtype).max / 2.0 except ValueError: p.a = 10.0 if edit and not p.edit(parent=parent): return None x, y = np.meshgrid( np.linspace(p.xmin, p.xmax, shape[1]), np.linspace(p.ymin, p.ymax, shape[0]), ) zgauss = p.a * np.exp( -((np.sqrt((x - p.x0) ** 2 + (y - p.y0) ** 2) - p.mu) ** 2) / (2.0 * p.sigma**2) ) image.data = np.array(zgauss, dtype=dtype) elif newparam.type in (ImageTypes.UNIFORMRANDOM, ImageTypes.NORMALRANDOM): pclass = { ImageTypes.UNIFORMRANDOM: base.UniformRandomParam, ImageTypes.NORMALRANDOM: base.NormalRandomParam, }[newparam.type] if p is None: p = pclass(_("Image") + " - " + newparam.type.value) p.set_from_datatype(dtype) if edit and not p.edit(parent=parent): return None rng = np.random.default_rng(p.seed) if newparam.type == ImageTypes.UNIFORMRANDOM: data = rng.random(shape) image.data = scale_data_to_min_max(data, p.vmin, p.vmax) elif newparam.type == ImageTypes.NORMALRANDOM: image.data = rng.normal(p.mu, p.sigma, size=shape) else: raise NotImplementedError(f"New param type: {newparam.type.value}") return image return None CodraFT-2.2.1/codraft/core/model/signal.py000066400000000000000000000314371443562410300203110ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/__init__.py for details) """ CodraFT Datasets """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... # pylint: disable=duplicate-code from copy import deepcopy import guidata.dataset.dataitems as gdi import guidata.dataset.datatypes as gdt import numpy as np from guidata.configtools import get_icon from guidata.utils import update_dataset from guiqwt.builder import make from guiqwt.curve import CurveItem from guiqwt.styles import update_style_attr from codraft.config import Conf, _ from codraft.core.computation import fit from codraft.core.model import base from codraft.env import execenv class SignalParam(gdt.DataSet, base.ObjectItf): """Signal dataset""" CONF_FMT = Conf.view.sig_format DEFAULT_FMT = ".3f" VALID_DTYPES = (np.float32, np.float64, np.complex128) _tabs = gdt.BeginTabGroup("all") _datag = gdt.BeginGroup(_("Data and metadata")) title = gdi.StringItem(_("Signal title"), default=_("Untitled")) xydata = gdi.FloatArrayItem(_("Data"), transpose=True, minmax="rows") metadata = base.MetadataItem(_("Metadata"), default={}) _e_datag = gdt.EndGroup(_("Data and metadata")) _unitsg = gdt.BeginGroup(_("Titles and units")) title = gdi.StringItem(_("Signal title"), default=_("Untitled")) _tabs_u = gdt.BeginTabGroup("units") _unitsx = gdt.BeginGroup(_("X-axis")) xlabel = gdi.StringItem(_("Title"), default="") xunit = gdi.StringItem(_("Unit"), default="") _e_unitsx = gdt.EndGroup(_("X-axis")) _unitsy = gdt.BeginGroup(_("Y-axis")) ylabel = gdi.StringItem(_("Title"), default="") yunit = gdi.StringItem(_("Unit"), default="") _e_unitsy = gdt.EndGroup(_("Y-axis")) _e_tabs_u = gdt.EndTabGroup("units") _e_unitsg = gdt.EndGroup(_("Titles and units")) _e_tabs = gdt.EndTabGroup("all") def copy_data_from(self, other, dtype=None): """Copy data from other dataset instance""" if dtype not in (None, float, complex, np.complex128): raise RuntimeError("Signal data only supports float64/complex128 dtype") self.metadata = deepcopy(other.metadata) self.xydata = np.array(other.xydata, copy=True, dtype=dtype) def set_data_type(self, dtype): # pylint: disable=unused-argument,no-self-use """Change data type""" raise RuntimeError("Setting data type is not support for signals") def set_xydata(self, x, y, dx=None, dy=None): """Set xy data""" if x is not None: x = np.array(x) if y is not None: y = np.array(y) if dx is not None: dx = np.array(dx) if dy is not None: dy = np.array(dy) if dx is None and dy is None: self.xydata = np.vstack([x, y]) else: if dx is None: dx = np.zeros_like(dy) else: dy = np.zeros_like(dx) self.xydata = np.vstack((x, y, dx, dy)) def __get_x(self): """Get x data""" if self.xydata is not None: return self.xydata[0] return None def __set_x(self, data): """Set x data""" self.xydata[0] = np.array(data) def __get_y(self): """Get y data""" if self.xydata is not None: return self.xydata[1] return None def __set_y(self, data): """Set y data""" self.xydata[1] = np.array(data) x = property(__get_x, __set_x) y = data = property(__get_y, __set_y) def get_data(self, roi_index: int = None) -> np.ndarray: """ Return original data (if ROI is not defined or `roi_index` is None), or ROI data (if both ROI and `roi_index` are defined). """ if self.roi is None or roi_index is None: return self.x, self.y i1, i2 = self.roi[roi_index, :] return self.x[i1:i2], self.y[i1:i2] def make_item(self, update_from=None): """Make plot item from data""" if len(self.xydata) == 2: # x, y signal x, y = self.xydata item = make.mcurve(x.real, y.real, label=self.title) elif len(self.xydata) == 3: # x, y, dy error bar signal x, y, dy = self.xydata item = make.merror(x.real, y.real, dy.real, label=self.title) elif len(self.xydata) == 4: # x, y, dx, dy error bar signal x, y, dx, dy = self.xydata item = make.merror(x.real, y.real, dx.real, dy.real, label=self.title) else: raise RuntimeError("data not supported") if update_from is not None: update_dataset(item.curveparam, update_from.curveparam) return item def update_item(self, item: CurveItem, ref_item: CurveItem = None): """Update plot item from data""" if len(self.xydata) == 2: # x, y signal x, y = self.xydata item.set_data(x.real, y.real) elif len(self.xydata) == 3: # x, y, dy error bar signal x, y, dy = self.xydata item.set_data(x.real, y.real, dy=dy.real) elif len(self.xydata) == 4: # x, y, dx, dy error bar signal x, y, dx, dy = self.xydata item.set_data(x.real, y.real, dx.real, dy.real) item.curveparam.label = self.title if execenv.demo_mode: item.curveparam.line.width = 3 update_style_attr(next(make.style), item.curveparam) update_dataset(item.curveparam, self.metadata) item.update_params() def roi_coords_to_indexes(self, coords: list) -> np.ndarray: """Convert ROI coordinates to indexes""" indexes = np.array(coords, int) for row in range(indexes.shape[0]): for col in range(indexes.shape[1]): x0 = coords[row][col] indexes[row, col] = np.abs(self.x - x0).argmin() return indexes def get_roi_param(self, title, *defaults): """Return ROI parameters dataset""" imax = len(self.x) - 1 i0, i1 = defaults class ROIParam(gdt.DataSet): """Signal ROI parameters""" col1 = gdi.IntItem(_("First point index"), default=i0, min=-1, max=imax) col2 = gdi.IntItem(_("Last point index"), default=i1, min=-1, max=imax) return ROIParam(title) @staticmethod def params_to_roidata(params: gdt.DataSetGroup) -> np.ndarray: """Convert list of dataset parameters to ROI data""" roilist = [] for roiparam in params.datasets: roilist.append([roiparam.col1, roiparam.col2]) if len(roilist) == 0: return None return np.array(roilist, int) def new_roi_item(self, fmt, lbl, editable): """Return a new ROI item from scratch""" coords = self.x.min(), self.x.max() return base.make_roi_item( lambda x, y, _title: make.range(x, y), coords, "ROI", fmt, lbl, editable ) def iterate_roi_items(self, fmt: str, lbl: bool, editable: bool = True): """Make plot item representing a Region of Interest""" if self.roi is not None: for index, coords in enumerate(self.x[self.roi]): yield base.make_roi_item( lambda x, y, _title: make.range(x, y), coords, f"ROI{index:02d}", fmt, lbl, editable, ) def create_signal( title: str, x: np.ndarray = None, y: np.ndarray = None, dx: np.ndarray = None, dy: np.ndarray = None, metadata: dict = None, units: tuple = None, labels: tuple = None, ) -> SignalParam: """Create a new Signal object :param str title: signal title :param numpy.ndarray x: X data :param numpy.ndarray y: Y data :param numpy.ndarray dx: dX data (optional: error bars) :param numpy.ndarray dy: dY data (optional: error bars) :param dict metadata: signal metadata :param tuple units: X, Y units (tuple of strings) :param tuple labels: X, Y labels (tuple of strings) """ assert isinstance(title, str) signal = SignalParam() signal.title = title signal.set_xydata(x, y, dx=dx, dy=dy) if units is not None: signal.xunit, signal.yunit = units if labels is not None: signal.xlabel, signal.ylabel = labels signal.metadata = {} if metadata is None else metadata return signal class SignalTypes(base.Choices): """Signal types""" ZEROS = _("zeros") GAUSS = _("gaussian") LORENTZ = _("lorentzian") VOIGT = "Voigt" UNIFORMRANDOM = _("random (uniform law)") NORMALRANDOM = _("random (normal law)") class GaussLorentzVoigtParam(gdt.DataSet): """Parameters for Gaussian and Lorentzian functions""" a = gdi.FloatItem("A", default=1.0) ymin = gdi.FloatItem("Ymin", default=0.0).set_pos(col=1) sigma = gdi.FloatItem("σ", default=1.0) mu = gdi.FloatItem("μ", default=0.0).set_pos(col=1) class SignalParamNew(gdt.DataSet): """New signal dataset""" title = gdi.StringItem(_("Title"), default=_("Untitled")) xmin = gdi.FloatItem("Xmin", default=-10.0) xmax = gdi.FloatItem("Xmax", default=10.0) size = gdi.IntItem( _("Size"), help=_("Signal size (total number of points)"), min=1, default=500 ) type = gdi.ChoiceItem(_("Type"), SignalTypes.get_choices()) def new_signal_param(title=None, stype=None, xmin=None, xmax=None, size=None): """Create a new Signal dataset instance. :param str title: dataset title (default: None, uses default title)""" if title is None: title = _("Untitled signal") param = SignalParamNew(title=title, icon=get_icon("new_signal.svg")) param.title = title if xmin is not None: param.xmin = xmin if xmax is not None: param.xmax = xmax if size is not None: param.size = size if stype is not None: param.type = stype return param SIG_NB = 0 def create_signal_from_param(newparam, addparam=None, edit=False, parent=None): """Create a new Signal object from a dialog box. :param SignalParamNew param: new signal parameters :param guidata.dataset.datatypes.DataSet addparam: additional parameters :param bool edit: Open a dialog box to edit parameters (default: False) :param QWidget parent: parent widget """ global SIG_NB # pylint: disable=global-statement if newparam is None: newparam = new_signal_param() incr_sig_nb = not newparam.title if incr_sig_nb: newparam.title = f"{newparam.title} {SIG_NB + 1:d}" if not edit or addparam is not None or newparam.edit(parent=parent): if incr_sig_nb: SIG_NB += 1 signal = create_signal(newparam.title) xarr = np.linspace(newparam.xmin, newparam.xmax, newparam.size) p = addparam if newparam.type == SignalTypes.ZEROS: signal.set_xydata(xarr, np.zeros(newparam.size)) elif newparam.type in (SignalTypes.UNIFORMRANDOM, SignalTypes.NORMALRANDOM): pclass = { SignalTypes.UNIFORMRANDOM: base.UniformRandomParam, SignalTypes.NORMALRANDOM: base.NormalRandomParam, }[newparam.type] if p is None: p = pclass(_("Signal") + " - " + newparam.type.value) if edit and not p.edit(parent=parent): return None rng = np.random.default_rng(p.seed) if newparam.type == SignalTypes.UNIFORMRANDOM: yarr = rng.random((newparam.size,)) * (p.vmax - p.vmin) + p.vmin elif newparam.type == SignalTypes.NORMALRANDOM: yarr = rng.normal(p.mu, p.sigma, size=(newparam.size,)) else: raise NotImplementedError(f"New param type: {newparam.type.value}") signal.set_xydata(xarr, yarr) elif newparam.type == SignalTypes.GAUSS: if p is None: p = GaussLorentzVoigtParam(_("New gaussian function")) if edit and not p.edit(parent=parent): return None yarr = fit.GaussianModel.func(xarr, p.a, p.sigma, p.mu, p.ymin) signal.set_xydata(xarr, yarr) elif newparam.type == SignalTypes.LORENTZ: if p is None: p = GaussLorentzVoigtParam(_("New lorentzian function")) if edit and not p.edit(parent=parent): return None yarr = fit.LorentzianModel.func(xarr, p.a, p.sigma, p.mu, p.ymin) signal.set_xydata(xarr, yarr) elif newparam.type == SignalTypes.VOIGT: if p is None: p = GaussLorentzVoigtParam(_("New Voigt function")) if edit and not p.edit(parent=parent): return None yarr = fit.VoigtModel.func(xarr, p.a, p.sigma, p.mu, p.ymin) signal.set_xydata(xarr, yarr) return signal return None CodraFT-2.2.1/codraft/data/000077500000000000000000000000001443562410300153335ustar00rootroot00000000000000CodraFT-2.2.1/codraft/data/dependencies-py3-win32.txt000066400000000000000000000001411443562410300221670ustar00rootroot00000000000000guidata:2dc28319c0059cb4d9f13947a8516926da5da0f9 guiqwt:06180311623c5d021009827997116c50e5856c21 CodraFT-2.2.1/codraft/data/icons/000077500000000000000000000000001443562410300164465ustar00rootroot00000000000000CodraFT-2.2.1/codraft/data/icons/chm.svg000066400000000000000000000056011443562410300177400ustar00rootroot00000000000000 CodraFT-2.2.1/codraft/data/icons/collapse.png000066400000000000000000000011241443562410300207540ustar00rootroot00000000000000PNG  IHDRagAMA7tEXtSoftwarePaint.NET v3.30@GIDAT8ONQyy 4@= BCiS(`,Ŧb؎$:* $Ƨ';LBMYW;%tvOb'F~lZb.su_E\:|4qJ g:gd?By!|gfb?(y1uAk/Ɠ,6*q<银b<,cIL~/~]e99ur_u~m›C"qC kD1spYDh Fgi v]> '?ưy-yhs$5͖wP%|hW D"BަJ̟f|zD|AVb:a1i m5+%֋pV7HȉuYݾC,Ӻ#~ﯸS)z y\ZE{?$_*-IENDB`CodraFT-2.2.1/codraft/data/icons/collapse_selection.png000066400000000000000000000013101443562410300230160ustar00rootroot00000000000000PNG  IHDRagAMA7tEXtSoftwarePaint.NET v3.36%[IDAT8OOq"W]uEۖfJH["BSl5%,dk$KI|6@0Mtp-cM{2Ļ>jY$ě^k.6nNCVGPIZ<&R7ۧ|NHDFn1ԊDN@Cwb$bG;#d3x^fn b7ޫ"`G&~A6̾.H K~: OFSb?ְ^$^bN"0&5,2]H1=> 4bW^r &oב1O+!mn4Cڒ2ݩ#`c,!h;9RZT@L:3ߞHaAz'6Q=ZhZ4 r ,쭜1z7|c̔8TNt{ 5 092zhk(tj(&ey&(E (`A[W0ϸ9@`11+D䯅jE"ץ`>,;C\"$?p Vq! ZfPM&^IENDB`CodraFT-2.2.1/codraft/data/icons/delete.svg000066400000000000000000000024661443562410300204410ustar00rootroot00000000000000 CodraFT-2.2.1/codraft/data/icons/delete_all.svg000066400000000000000000000051471443562410300212700ustar00rootroot00000000000000 CodraFT-2.2.1/codraft/data/icons/dtype.png000066400000000000000000000013371443562410300203050ustar00rootroot00000000000000PNG  IHDRa pHYs.tEXtSoftwarewww.inkscape.org<lIDAT8KHagdm$oE"E. "ha"!,*XX-EB*B6$ti#P13-2BQYsyy`2;3,Qi!k_ך )#QQ8a%.Hk BsبPYlgP(K̚M"}B]cI ii=ޗ&Y-m57vmG( ܧ;tU]LP76Da`‡73] 1M򾠔<+,Br$;~`Im&K9|ß񼝰`4c}ʆ R.|QVS)b>c!03g8zq1-8r%]BpNhah AU$c[J6H=RFOm&ikFg͗IDÖ剶1U#+q삹<'(|:qnz}glUxs.L & X=:]Z{t˞R_+QsXjl E-H_mjw_mjwn-V9E,o%ӖbIENDB`CodraFT-2.2.1/codraft/data/icons/edit_shapes.svg000066400000000000000000000042451443562410300214640ustar00rootroot00000000000000 CodraFT-2.2.1/codraft/data/icons/expand.png000066400000000000000000000011371443562410300204350ustar00rootroot00000000000000PNG  IHDRagAMA7tEXtSoftwarePaint.NET v3.30@GIDAT8ONQyyZP %T) (h#1ŴAi (F)ibg SWF'w}k-60^#|2^ O6 ?D&T c+gpn[xԯ7xqEiʻgoF+x^b7+S?"$~Ɛ\)F-gޘD\O/aϙa͘47 gxv:'LYbRܷݖxCwc%^ѱ[X:yݾr)ӚA^7ouT[yH_B`!\&arIlA aE/wQםJ6H"&CM ңU `Jf`L}Vtj _yEMiMWZ} 4ج c] sǂb3pd-_N,i. !h–}v$.)Uּ:~nܿ"7=IENDB`CodraFT-2.2.1/codraft/data/icons/expand_selection.png000066400000000000000000000013301443562410300224750ustar00rootroot00000000000000PNG  IHDRagAMA7tEXtSoftwarePaint.NET v3.36%kIDAT8OOq"W]uU]"VxBAVWܴiM\NDs<7 B2_43V r7Í~|#?^0XEsĈk (a@Nj