├── .gitignore ├── crackle_viewer.png ├── requirements.txt ├── crackle_viewer.spec ├── .github └── workflows │ └── build.yml ├── README.md ├── LICENSE └── view_gui.py /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | original*/ 4 | *.txt 5 | !requirements.txt 6 | .vscode/ -------------------------------------------------------------------------------- /crackle_viewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schillij95/Crackle-Viewer/HEAD/crackle_viewer.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tqdm 2 | numpy==1.23.5 3 | pillow==9.3.0 4 | open3d==0.18.0 5 | opencv-python==4.10.0.84 6 | scipy==1.9.0 -------------------------------------------------------------------------------- /crackle_viewer.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | block_cipher = None 5 | 6 | 7 | a = Analysis( 8 | ['view_gui.py'], 9 | pathex=[], 10 | binaries=[], 11 | datas=[('crackle_viewer.png', '.')], 12 | hiddenimports=['PIL._tkinter_finder', 'PIL._imaging', 'PIL._imagingft', 'PIL._imagingtk'], 13 | hookspath=[], 14 | hooksconfig={}, 15 | runtime_hooks=[], 16 | excludes=[], 17 | win_no_prefer_redirects=False, 18 | win_private_assemblies=False, 19 | cipher=block_cipher, 20 | noarchive=False, 21 | ) 22 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) 23 | 24 | exe = EXE( 25 | pyz, 26 | a.scripts, 27 | a.binaries, 28 | a.zipfiles, 29 | a.datas, 30 | [], 31 | name='crackle_viewer', 32 | debug=False, 33 | bootloader_ignore_signals=False, 34 | strip=False, 35 | upx=False, 36 | upx_exclude=[], 37 | runtime_tmpdir=None, 38 | console=True, 39 | disable_windowed_traceback=False, 40 | argv_emulation=False, 41 | target_arch=None, 42 | codesign_identity=None, 43 | entitlements_file=None, 44 | ) 45 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Application 2 | 3 | permissions: 4 | contents: write 5 | actions: write 6 | checks: write 7 | deployments: write 8 | issues: write 9 | packages: write 10 | pull-requests: write 11 | repository-projects: write 12 | security-events: write 13 | statuses: write 14 | 15 | on: 16 | push: 17 | branches: 18 | - master 19 | pull_request: 20 | branches: 21 | - master 22 | workflow_dispatch: 23 | 24 | jobs: 25 | build: 26 | runs-on: ${{ matrix.os }} 27 | strategy: 28 | matrix: 29 | os: [ubuntu-20.04, macos-12, windows-2019] 30 | steps: 31 | - uses: actions/checkout@v2 32 | - name: Set up Python 33 | uses: actions/setup-python@v2 34 | with: 35 | python-version: '3.11' 36 | - name: Install PyInstaller and Dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install -r requirements.txt 40 | pip install pyinstaller 41 | - name: Build with PyInstaller 42 | run: | 43 | pyinstaller crackle_viewer.spec 44 | - name: Upload Artifacts 45 | uses: actions/upload-artifact@v4 46 | with: 47 | name: ${{ runner.os }}-Executable 48 | path: dist/* 49 | 50 | - name: Create or Get Release 51 | id: create_release 52 | uses: actions/github-script@v5 53 | with: 54 | script: | 55 | const latestCommitSha = process.env.GITHUB_SHA.substring(0, 7); // Use the first 7 characters of the SHA 56 | const fullCommitSha = process.env.GITHUB_SHA; // Full commit SHA for commitish 57 | const releaseName = `release-${latestCommitSha}`; 58 | const tagName = `v1.0.0-${latestCommitSha}`; // Valid tag name 59 | 60 | let release; 61 | try { 62 | // Try to get the release by tag 63 | release = await github.rest.repos.getReleaseByTag({ 64 | owner: context.repo.owner, 65 | repo: context.repo.repo, 66 | tag: tagName 67 | }); 68 | console.log(`Release already exists: ${release.data.id}`); 69 | } catch (error) { 70 | if (error.status === 404) { 71 | // If release does not exist, create a new one 72 | console.log('Creating new release'); 73 | release = await github.rest.repos.createRelease({ 74 | owner: context.repo.owner, 75 | repo: context.repo.repo, 76 | tag_name: tagName, 77 | name: releaseName, 78 | draft: false, 79 | prerelease: false, 80 | target_commitish: fullCommitSha 81 | }); 82 | } else { 83 | throw error; 84 | } 85 | } 86 | 87 | core.setOutput('upload_url', release.data.upload_url); 88 | 89 | - name: Zip Executable and Dependencies 90 | shell: pwsh 91 | run: | 92 | $osType = "${{ runner.os }}" 93 | if ($osType -eq "Windows") { 94 | # PowerShell command for Windows 95 | Compress-Archive -Path dist/* -DestinationPath "$osType-Executable.zip" 96 | } else { 97 | # Use PowerShell's native capabilities to handle Linux/macOS zipping if necessary 98 | & bash -c "cd dist && zip -r ../$osType-Executable.zip ./* && cd .." 99 | } 100 | 101 | - name: Upload Release Asset 102 | uses: actions/upload-release-asset@v1 103 | env: 104 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 105 | with: 106 | upload_url: ${{ steps.create_release.outputs.upload_url }} 107 | asset_path: ${{ runner.os }}-Executable.zip 108 | asset_name: ${{ runner.os }}.zip 109 | asset_content_type: application/zip 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This repository has been archived and is beeing further developed by the monorepo of the Vesuvius Organization Team over at https://github.com/ScrollPrize/villa/tree/main/crackle-viewer 2 | 3 | Thank you to everyone who helped develop it and continues to do so. 4 | 5 | # Crackle-Viewer 6 | 7 | ![Crackle-Viewer Logo](crackle_viewer.png) 8 | 9 | ## Awards 10 | This project won multiple awards in the [Vesuvius Challenge 2023](https://scrollprize.org/): 11 | 12 | - 3rd place entry for the [Ink Detection Followup Prize for the Vesuvius Challenge 2023](https://scrollprize.substack.com/p/ink-detection-followup-prize-winners). 13 | 14 | - Winning entry for the [Vesuvius Challenge Open Source Prizes December 2023](https://scrollprize.substack.com/p/open-source-prizes-awarded). 15 | 16 | ## Overview 17 | 18 | This tool is designed to assist researchers and enthusiasts in inspecting and labeling ink residue on virtually unrolled scrolls that were carbonized during the Vesuvius eruption nearly 2000 years ago. The primary goal is to generate a ground-truth ink dataset to make these virtually unrolled Vesuvius scrolls readable, as it's difficult to discern ink residue by the naked eye. 19 | 20 | This project was developed as part of the **Vesuvius Challenge 2023** and aims to contribute towards efforts in deciphering ancient texts carbonized during the Vesuvius eruption nearly 2000 years ago. 21 | 22 | ## Acknowledgment 23 | 24 | This tool is based on the [PythonImageViewer](https://github.com/ImagingSolution/PythonImageViewer) and is distributed under the Apache 2.0 License. 25 | 26 | ## Features 27 | 28 | ### User Interface 29 | 30 | - **Menu Bar**: Provides options to open images and access help. 31 | - **Overlay Controls**: A comprehensive set of buttons, sliders, and controls for overlay manipulation. 32 | - **Canvas**: A canvas area to display and interact with images. 33 | - **Status Bar**: Displays image information and pixel coordinates. 34 | 35 | ### Functionalities 36 | 37 | - **Image Navigation**: Open, zoom, rotate, translate and navigate through images. 38 | - **Overlay Manipulation**: Load existing overlays or create new ones. 39 | - **Labeling**: Draw on overlays to label ink residues. 40 | - **Overlay Management**: Save individual or combined overlays. 41 | - **Sub-Overlays**: Load and manage additional read-only overlays. 42 | - **Opacity Control**: Adjust the opacity for overlays and sub-overlays. 43 | - **Color Control**: Toggle or pick custom drawing colors. 44 | - **Pencil Size**: Adjust the pencil size for drawing. 45 | 46 | Please read trough the "Help" menu for more information. 47 | 48 | ### Keyboard and Mouse Shortcuts 49 | 50 | - Use the Ctrl key while dragging to draw on the overlay, additionally you can also use the right mouse to draw. 51 | - Double-click to fit the image to the screen and reset to intial orientation and image slice. 52 | - Mouse wheel for zooming, Ctrl + Mouse wheel to rotate the image. 53 | - Spacebar to toggle overlay visibility. 54 | - "R" key to reset to the middle image. 55 | - "C" key to toggle the drawing color. 56 | 57 | ## Requirements 58 | 59 | - Python 3 60 | - Numpy 61 | - PIL (Pillow) 62 | - Tkinter version 8.6 63 | 64 | ## Installation 65 | 66 | 1. First, install tkinter: 67 | ```bash 68 | sudo apt-get install python3-tk 69 | ``` 70 | 71 | 2. Clone the repository and navigate to its directory: 72 | ```bash 73 | git clone https://github.com/schillij95/Crackle-Viewer 74 | cd Crackle-Viewer 75 | ``` 76 | 77 | 3. Install the required Python packages: 78 | ```bash 79 | pip3 install -r requirements.txt 80 | ``` 81 | 82 | ## Usage 83 | 84 | ### Download Executable 85 | 86 | 1. Go to the [Latest Release](https://github.com/schillij95/Crackle-Viewer/releases/tag/latest-release) and download the .zip for your operating system. 87 | 2. Unpack the .zip folder. 88 | 3. Start the executable inside the unpacked folder. 89 | 90 | ### Basic Usage 91 | 92 | 1. To run the tool using Python: 93 | ```bash 94 | python3 view_gui.py 95 | ``` 96 | 97 | ### Advanced Usage (Executable) 98 | 99 | 1. First, make the `crackle_viewer` executable: 100 | ```bash 101 | chmod +x crackle_viewer 102 | ``` 103 | 104 | 2. To run the tool, execute: 105 | ```bash 106 | ./crackle_viewer 107 | ``` 108 | 109 | ## Building Executable (Optional) 110 | 111 | 1. Delete `build` and `dist` folders if they exist. 112 | 2. Install pyinstaller 113 | ```bash 114 | pip3 install pyinstaller==5.13.0 115 | ``` 116 | 3. Run the following command to build an executable: 117 | ```bash 118 | pyinstaller --onefile --name crackle_viewer --hidden-import=PIL._tkinter_finder --add-data "crackle_viewer.png:." view_gui.py 119 | ``` 120 | or 121 | ```bash 122 | pyinstaller crackle_viewer.spec 123 | ``` 124 | 125 | ## Help 126 | 127 | For details on the functionalities and controls, please refer to the `Help` menu within the application. 128 | 129 | The Crackle Viewer was tested on Ubuntu 20.04. 130 | 131 | ## Contributing 132 | 133 | Feel free to contribute to this project. Fork the repository, make your changes, and create a pull request. 134 | 135 | ## License 136 | 137 | This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for details. 138 | 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Akira 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /view_gui.py: -------------------------------------------------------------------------------- 1 | 2 | ### Julian Schilliger - Crackle Viewer - Vesuvius Challenge 2023 3 | 4 | import tkinter as tk 5 | import tkinter.colorchooser 6 | import threading 7 | from collections import deque 8 | from tkinter import filedialog 9 | import textwrap 10 | from PIL import Image, ImageTk, ImageDraw, ImageChops, ImageEnhance 11 | # Increase the image pixel limit to the desired value 12 | # Image.MAX_IMAGE_PIXELS = 300000000 13 | Image.MAX_IMAGE_PIXELS = None 14 | import math 15 | import numpy as np 16 | import os 17 | import sys 18 | import glob 19 | from tqdm import tqdm 20 | from multiprocessing import Pool 21 | from scipy.spatial import KDTree 22 | import open3d as o3d 23 | import cv2 24 | 25 | def load_image_disk(filename): 26 | pil_image = np.array(Image.open(filename)) 27 | # Convert to 8-bit and grayscale if needed 28 | if pil_image.dtype == np.uint16: 29 | pil_image = np.uint16(pil_image//256) 30 | return pil_image 31 | 32 | def load_image_parallel(filename): 33 | return filename, load_image_disk(filename) 34 | 35 | def compute_uv_bounding_box(uv_vertices): 36 | """Compute the bounding box of a triangle in UV space.""" 37 | min_uv = np.min(uv_vertices, axis=0) 38 | max_uv = np.max(uv_vertices, axis=0) 39 | return min_uv, max_uv 40 | 41 | def preprocess_uv_triangles(mesh): 42 | """Preprocess the mesh to create a KDTree with UV bounding boxes.""" 43 | bounding_boxes = [] 44 | triangle_data = [] 45 | min_uv_ = np.array([1000, 1000]) 46 | max_uv_ = np.array([-10000, -10000]) 47 | triangle_uvs = np.array(mesh.triangle_uvs).reshape((-1,3,2)) 48 | 49 | for i, triangle in enumerate(mesh.triangles): 50 | # Get the UV coordinates of the vertices of this triangle 51 | uv_vertices = triangle_uvs[i] 52 | 53 | # Calculate the bounding box for this triangle in UV space 54 | min_uv, max_uv = compute_uv_bounding_box(uv_vertices) 55 | min_uv_ = np.minimum(min_uv_, min_uv) 56 | max_uv_ = np.maximum(max_uv_, max_uv) 57 | 58 | # Use the center of the bounding box as the KDTree point and store the data 59 | center_uv = (min_uv + max_uv) / 2 60 | bounding_boxes.append(center_uv) 61 | triangle_data.append((triangle, uv_vertices, min_uv, max_uv)) 62 | 63 | print(f"UV bounding box: {min_uv_} - {max_uv_}") 64 | # Create KDTree for the bounding boxes' centers 65 | kd_tree = KDTree(bounding_boxes) 66 | return kd_tree, triangle_data 67 | 68 | def barycentric_coordinates(p, a, b, c): 69 | # Calculate vectors from a to b, a to c, and a to p 70 | v0 = b - a 71 | v1 = c - a 72 | v2 = p - a 73 | 74 | # Compute dot products 75 | d00 = np.dot(v0, v0) 76 | d01 = np.dot(v0, v1) 77 | d11 = np.dot(v1, v1) 78 | d20 = np.dot(v2, v0) 79 | d21 = np.dot(v2, v1) 80 | 81 | # Compute denominator 82 | denom = d00 * d11 - d01 * d01 83 | 84 | # Barycentric coordinates 85 | v = (d11 * d20 - d01 * d21) / denom 86 | w = (d00 * d21 - d01 * d20) / denom 87 | u = 1.0 - v - w 88 | return u, v, w 89 | 90 | def find_uv_triangle(mesh_vertices, uv_point, kd_tree, triangle_data): 91 | """Find the 3D point corresponding to a 2D UV point by querying preprocessed triangles.""" 92 | # Query KDTree for nearby bounding boxes 93 | _, idxs = kd_tree.query(uv_point, k=500) # Adjust `k` as needed for balance between precision and performance 94 | 95 | for idx in idxs: 96 | triangle, uv_vertices, min_uv, max_uv = triangle_data[idx] 97 | 98 | # First, check if the point is within the bounding box in UV space 99 | if np.all(uv_point >= min_uv) and np.all(uv_point <= max_uv): 100 | # Calculate barycentric coordinates 101 | u, v, w = barycentric_coordinates(uv_point, uv_vertices[0], uv_vertices[1], uv_vertices[2]) 102 | if u >= 0 and v >= 0 and w >= 0: 103 | # Retrieve the 3D vertices and interpolate 104 | vertices = np.array([mesh_vertices[i] for i in triangle]) 105 | point_3d = u * vertices[0] + v * vertices[1] + w * vertices[2] 106 | return point_3d 107 | 108 | # If no triangle contains the uv point 109 | return None 110 | 111 | class Application(tk.Frame): 112 | def __init__(self, master=None): 113 | super().__init__(master) 114 | self.base_dir = os.path.dirname(os.path.abspath(__file__)) 115 | self.my_title = "Vesuvius Crackle Viewer" 116 | self.master.title(self.my_title) 117 | if getattr(sys, 'frozen', False): 118 | # Running as compiled 119 | base_path = sys._MEIPASS 120 | else: 121 | # Running as script 122 | base_path = os.path.dirname(__file__) 123 | icon_path = os.path.join(base_path, 'crackle_viewer.png') 124 | # Set the window icon 125 | icon = tk.PhotoImage(file=icon_path) 126 | self.master.tk.call('wm', 'iconphoto', self.master._w, icon) 127 | self.master.geometry("800x600") 128 | self.master.minsize(width=800, height=600) 129 | self.load_last_directory() # Load the last directory 130 | self.images_folder = "" 131 | self.pil_image = None 132 | self.min_value = 0.0 133 | self.max_value = 65535.0 134 | self.sub_overlays = [] 135 | self.sub_overlay_colors = ['white', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta'] 136 | self.sub_overlay_names = ['overlay.png'] 137 | self.current_sub_overlay = tk.StringVar() 138 | self.current_sub_overlay.set("None") 139 | self.current_sub_overlay.trace("w", self.suboverlay_selected) 140 | self.create_menu() 141 | self.create_widget() 142 | self.reset_transform() 143 | self.image_list = [] 144 | self.image_index = 0 145 | self.last_directory_overlay = None 146 | self.last_directory_suboverlay = None 147 | self.cursor_circle = None 148 | self.shift_pressed = False 149 | self.mouse_is_pressed = False 150 | self.overlay_image = None 151 | self.overlay_visibility = tk.BooleanVar(value=True) 152 | self.pencil_color = 'white' 153 | self.pencil_size = 45 154 | self.flood_fill_active = False 155 | self.ff_threshold = 10 156 | self.max_propagation_steps = 10 157 | self.global_scale_factor = 1.0 158 | self.micron_factor = 0.00324 159 | self.resampling_methods = { 160 | "NEAREST": Image.Resampling.NEAREST, 161 | "BILINEAR": Image.Resampling.BILINEAR, 162 | "BICUBIC": Image.Resampling.BICUBIC, 163 | } 164 | self.resample_method = tk.StringVar(value="NEAREST") 165 | 166 | self.create_overlay_controls() 167 | 168 | def menu_open_clicked(self, event=None): 169 | self.load_images() 170 | try: 171 | self.load_obj() 172 | except: 173 | pass 174 | 175 | def menu_quit_clicked(self): 176 | self.master.destroy() 177 | 178 | def create_menu(self): 179 | self.menu_bar = tk.Menu(self) 180 | 181 | self.file_menu = tk.Menu(self.menu_bar, tearoff = tk.OFF) 182 | self.menu_bar.add_cascade(label="File", menu=self.file_menu) 183 | 184 | self.file_menu.add_command(label="Open", command = self.menu_open_clicked, accelerator="Ctrl+O") 185 | self.file_menu.add_separator() 186 | self.file_menu.add_command(label="Exit", command = self.menu_quit_clicked) 187 | 188 | self.menu_bar.bind_all("", self.menu_open_clicked) 189 | 190 | self.help_menu = tk.Menu(self.menu_bar, tearoff=tk.OFF) 191 | self.menu_bar.add_command(label="Help", command=self.show_help) 192 | 193 | self.master.config(menu=self.menu_bar) 194 | 195 | def create_overlay_controls(self): 196 | self.overlay_frame = tk.Frame(self.master) 197 | self.overlay_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=10) 198 | 199 | self.overlay_btn = tk.Button(self.overlay_frame, text="Load Overlay", command=self.load_overlay_image) 200 | self.overlay_btn.pack(side=tk.LEFT) 201 | 202 | self.create_empty_image_btn = tk.Button(self.overlay_frame, text="Create Empty Image", command=self.create_empty_overlay_image) 203 | self.create_empty_image_btn.pack(side=tk.LEFT, padx=5) 204 | 205 | 206 | self.save_btn = tk.Button(self.overlay_frame, text="Save Overlay", command=self.save_overlay) 207 | self.save_btn.pack(side=tk.LEFT) 208 | 209 | self.save_combined_btn = tk.Button(self.overlay_frame, text="Save Combined Overlays", command=self.save_combined_overlays) 210 | self.save_combined_btn.pack(side=tk.LEFT) 211 | 212 | self.save_displayed_btn = tk.Button(self.overlay_frame, text="Save Displayed Image", command=self.save_displayed_image) 213 | self.save_displayed_btn.pack(side=tk.LEFT) 214 | 215 | 216 | self.overlay_check = tk.Checkbutton(self.overlay_frame, text="Show Overlay", variable=self.overlay_visibility, command=self.toggle_overlay) 217 | self.overlay_check.pack(side=tk.LEFT) 218 | 219 | self.color_btn = tk.Button(self.overlay_frame, text="Toggle Color", command=self.toggle_color) 220 | self.color_btn.pack(side=tk.LEFT) 221 | 222 | self.color_label = tk.Label(self.overlay_frame, text="", width=5, background=self.pencil_color) 223 | self.color_label.pack(side=tk.LEFT, padx=5) 224 | self.color_label.bind("", lambda e: self.toggle_color()) # Add this line in create_overlay_controls method 225 | 226 | # Add slider for overlay opacity control 227 | self.overlay_opacity_scale = tk.Scale(self.overlay_frame, from_=0, to_=255, orient=tk.HORIZONTAL, label="Overlay Opacity", length=175) 228 | self.overlay_opacity_scale.bind("", self.adjust_overlay_opacity) # Bind the slider's motion to adjust opacity 229 | self.overlay_opacity_scale.pack(side=tk.LEFT) 230 | self.overlay_opacity_scale.config(from_=0, to=1, resolution=0.01) 231 | self.overlay_opacity_scale.set(1.0) 232 | 233 | # Entry to set opacity value manually 234 | self.overlay_opacity_entry = tk.Entry(self.overlay_frame, width=5) 235 | self.overlay_opacity_entry.pack(side=tk.LEFT, padx=5) 236 | self.overlay_opacity_entry.insert(tk.END, '1.0') 237 | self.overlay_opacity_entry.bind('', self.set_opacity_from_entry) 238 | 239 | self.pick_color_btn = tk.Button(self.overlay_frame, text="Pick Color", command=self.pick_color) 240 | self.pick_color_btn.pack(side=tk.LEFT) 241 | 242 | self.size_scale = tk.Scale(self.overlay_frame, from_=1, to_=500, orient=tk.HORIZONTAL, label="Pencil Size", length=125) 243 | self.size_scale.set(self.pencil_size) 244 | self.size_scale.pack(side=tk.LEFT) 245 | 246 | self.suboverlay_frame = tk.Frame(self.master) # Create a new frame for SubOverlay-related controls 247 | self.suboverlay_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=10) # Pack it below the existing frame 248 | 249 | # Dropdown to select a sub-overlay 250 | self.suboverlay_label = tk.Label(self.suboverlay_frame, text="Select Overlay:") 251 | self.suboverlay_label.pack(side=tk.LEFT) 252 | 253 | self.select_suboverlay_optionmenu = tk.OptionMenu(self.suboverlay_frame, self.current_sub_overlay, "None", *self.sub_overlay_names) 254 | self.select_suboverlay_optionmenu.pack(side=tk.LEFT) 255 | 256 | self.add_suboverlay_btn = tk.Button(self.suboverlay_frame, text="Add SubOverlay", command=self.load_suboverlay) 257 | self.add_suboverlay_btn.pack(side=tk.LEFT) 258 | 259 | self.clear_suboverlays_btn = tk.Button(self.suboverlay_frame, text="Clear SubOverlays", command=self.clear_suboverlays) 260 | self.clear_suboverlays_btn.pack(side=tk.LEFT) 261 | 262 | # make sure to add enough space to display full name of the suboverlay 263 | self.suboverlay_opacity_scale = tk.Scale(self.suboverlay_frame, from_=0, to=255, orient=tk.HORIZONTAL, label="SubOverlay Opacity", length=175) 264 | self.suboverlay_opacity_scale.bind("", self.adjust_suboverlay_opacity) 265 | self.suboverlay_opacity_scale.pack(side=tk.LEFT) 266 | self.suboverlay_opacity_scale.config(from_=0, to=1, resolution=0.01) 267 | self.suboverlay_opacity_scale.set(0.4) 268 | # spaceing between the two scales 269 | tk.Label(self.suboverlay_frame, text=" ").pack(side=tk.LEFT) 270 | 271 | self.suboverlay_brightness_scale = tk.Scale(self.suboverlay_frame, from_=0, to=255, orient=tk.HORIZONTAL, label="SubOverlay Brightness", length=175) 272 | self.suboverlay_brightness_scale.bind("", self.adjust_suboverlay_opacity) 273 | self.suboverlay_brightness_scale.pack(side=tk.LEFT) 274 | self.suboverlay_brightness_scale.config(from_=0, to=10.0, resolution=0.01) 275 | self.suboverlay_brightness_scale.set(1.0) 276 | 277 | # Dropdown menu for resampling method 278 | 279 | self.resample_method_label = tk.Label(self.suboverlay_frame, text="Resampling Method:") 280 | self.resample_method_label.pack(side=tk.LEFT) 281 | self.resample_method_optionmenu = tk.OptionMenu( 282 | self.suboverlay_frame, 283 | self.resample_method, 284 | *self.resampling_methods.keys(), 285 | command=self.on_resample_method_changed 286 | ) 287 | self.resample_method_optionmenu.pack(side=tk.LEFT) 288 | 289 | # Set min and max values for the image 290 | self.minmax_label = tk.Label(self.overlay_frame, text="Min Max image values:") 291 | self.minmax_label.pack(side=tk.LEFT) 292 | self.min_value_entry = tk.Entry(self.overlay_frame, width=5) 293 | self.min_value_entry.pack(side=tk.LEFT, padx=5) 294 | self.min_value_entry.insert(tk.END, '0') 295 | self.min_value_entry.bind('', self.set_max_min_from_entry) 296 | 297 | self.max_value_entry = tk.Entry(self.overlay_frame, width=5) 298 | self.max_value_entry.pack(side=tk.LEFT, padx=5) 299 | self.max_value_entry.insert(tk.END, '65535') 300 | self.max_value_entry.bind('', self.set_max_min_from_entry) 301 | 302 | # micron factor for the image 303 | self.micron_label = tk.Label(self.overlay_frame, text="Micron Factor:") 304 | self.micron_label.pack(side=tk.LEFT) 305 | self.micron_entry = tk.Entry(self.overlay_frame, width=5) 306 | self.micron_entry.pack(side=tk.LEFT, padx=5) 307 | self.micron_entry.insert(tk.END, '0.00324') 308 | self.micron_entry.bind('', self.set_micron_factor) 309 | 310 | self.reset_slice_btn = tk.Button(self.overlay_frame, text="Reset Slice", command=self.reset_to_middle_image) 311 | self.reset_slice_btn.pack(side=tk.LEFT, padx=5) 312 | 313 | self.max_propagation_var = tk.IntVar(value=self.max_propagation_steps) 314 | max_propagation_label = tk.Label(self.suboverlay_frame, text="Max Propagation:") 315 | max_propagation_label.pack(side=tk.LEFT, padx=(10, 2)) 316 | 317 | max_propagation_slider = tk.Scale(self.suboverlay_frame, from_=1, to=500, orient=tk.HORIZONTAL, command=self.update_max_propagation) 318 | max_propagation_slider.set(self.max_propagation_steps) 319 | max_propagation_slider.pack(side=tk.LEFT, padx=2) 320 | 321 | max_propagation_value_label = tk.Label(self.suboverlay_frame, textvariable=self.max_propagation_var) 322 | max_propagation_value_label.pack(side=tk.LEFT, padx=(0, 10)) 323 | 324 | self.bucket_threshold_var = tk.StringVar(value="10") 325 | bucket_threshold_label = tk.Label(self.suboverlay_frame, text="FF Threshold:") 326 | bucket_threshold_label.pack(side=tk.LEFT, padx=(10, 2)) 327 | 328 | self.bucket_threshold_slider = tk.Scale(self.suboverlay_frame, from_=0, to=100, orient=tk.HORIZONTAL, command=self.update_threshold_value) 329 | self.bucket_threshold_slider.pack(side=tk.LEFT, padx=2) 330 | 331 | bucket_threshold_value_label = tk.Label(self.suboverlay_frame, textvariable=self.bucket_threshold_var) 332 | bucket_threshold_value_label.pack(side=tk.LEFT, padx=(0, 10)) 333 | 334 | # New Frame for Image Processing Controls 335 | self.image_processing_frame = tk.Frame(self.master) 336 | self.image_processing_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=10) 337 | 338 | # Operation Dropdown 339 | self.operation_var = tk.StringVar(value="max") 340 | self.operation_menu = tk.OptionMenu( 341 | self.image_processing_frame, 342 | self.operation_var, 343 | "max", "min", "mean", 344 | command=self.operation_changed 345 | ) 346 | self.operation_menu.pack(side=tk.LEFT) 347 | 348 | # Radius Input 349 | self.radius_var = tk.StringVar(value="0") 350 | self.radius_entry = tk.Entry( 351 | self.image_processing_frame, 352 | textvariable=self.radius_var, 353 | width=5 354 | ) 355 | self.radius_entry.pack(side=tk.LEFT) 356 | self.radius_entry.bind('', self.update_radius_and_refocus) 357 | 358 | # Direction Radio Buttons 359 | self.direction_var = tk.StringVar(value="omi") 360 | directions = [("omi", "omi"), ("front", "front"), ("back", "back")] 361 | for text, mode in directions: 362 | tk.Radiobutton( 363 | self.image_processing_frame, 364 | text=text, 365 | variable=self.direction_var, 366 | value=mode 367 | ).pack(side=tk.LEFT) 368 | 369 | self.direction_var.trace("w", lambda name, index, mode: self.process_images()) 370 | 371 | # Preload Images Checkbox 372 | self.toggle_contrast_var = tk.BooleanVar(value=False) 373 | self.toggle_contrast_check = tk.Checkbutton( 374 | self.image_processing_frame, 375 | text="Contrast Enhance", 376 | variable=self.toggle_contrast_var, 377 | command=self.toggle_contrast 378 | ) 379 | self.toggle_contrast_check.pack(side=tk.LEFT) 380 | 381 | # Preload Images Checkbox 382 | self.preload_images_var = tk.BooleanVar(value=False) 383 | self.preload_images_check = tk.Checkbutton( 384 | self.image_processing_frame, 385 | text="Preload Images", 386 | variable=self.preload_images_var, 387 | command=self.toggle_preload 388 | ) 389 | self.preload_images_check.pack(side=tk.LEFT) 390 | 391 | self.layer_control_frame = tk.Frame(self.master) 392 | self.layer_control_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=10) 393 | 394 | self.layer_index_label = tk.Label(self.layer_control_frame, text="Layer Index:") 395 | self.layer_index_label.pack(side=tk.LEFT, padx=5) 396 | 397 | self.layer_index_var = tk.StringVar(value=str(self.image_index)) 398 | self.layer_index_entry = tk.Entry(self.layer_control_frame, textvariable=self.layer_index_var, width=6) 399 | self.layer_index_entry.pack(side=tk.LEFT, padx=5) 400 | self.layer_index_entry.bind('', self.set_layer_from_entry) 401 | 402 | self.set_layer_btn = tk.Button(self.layer_control_frame, text="Set Layer", command=self.set_layer_from_entry) 403 | self.set_layer_btn.pack(side=tk.LEFT, padx=5) 404 | 405 | def create_widget(self): 406 | 407 | frame_statusbar = tk.Frame(self.master, bd=1, relief = tk.SUNKEN) 408 | self.label_image_info = tk.Label(frame_statusbar, text="image info", anchor=tk.E, padx = 5) 409 | self.label_image_pixel = tk.Label(frame_statusbar, text="2D: (x, y) 3D: (x, y, z)", anchor=tk.W, padx = 5) 410 | self.label_image_info.pack(side=tk.RIGHT) 411 | self.label_image_pixel.pack(side=tk.LEFT) 412 | frame_statusbar.pack(side=tk.BOTTOM, fill=tk.X) 413 | 414 | # Canvas 415 | self.canvas = tk.Canvas(self.master, background="black") 416 | self.canvas.pack(expand=True, fill=tk.BOTH) 417 | 418 | self.canvas.bind("", self.mouse_down_left) # MouseDown 419 | self.master.bind("", self.mouse_up_left) # MouseUp 420 | self.canvas.bind("", self.mouse_move_left) # MouseDrag 421 | self.canvas.bind("", self.mouse_down_right) # MouseDown 422 | self.master.bind("", self.mouse_up_right) # MouseUp 423 | self.canvas.bind("", self.mouse_move_right) # MouseDrag 424 | self.master.bind("f", self.threaded_flood_fill) # FloodFill 425 | self.canvas.bind("", self.mouse_move) # MouseMove 426 | self.canvas.bind("", self.mouse_leave_canvas) # MouseLeave 427 | self.canvas.bind("", self.mouse_double_click_left) # MouseDoubleClick 428 | self.master.bind("", self.shift_press) 429 | self.master.bind("", self.shift_release) 430 | self.master.bind("", self.shift_press) 431 | self.master.bind("", self.shift_release) 432 | self.master.bind("", lambda _: {self.overlay_visibility.set(not self.overlay_visibility.get()), self.redraw_image()}) # Spacebar 433 | self.master.bind("r", self.reset_to_middle_image) 434 | self.master.bind("c", self.toggle_color) 435 | 436 | 437 | if sys.platform == 'linux': # Linux OS 438 | self.canvas.bind("", self.mouse_wheel) 439 | self.canvas.bind("", self.mouse_wheel) 440 | else: # Windows OS 441 | self.master.bind("", self.mouse_wheel) 442 | 443 | # Bind left and right arrow keys for previous and next functionality 444 | self.master.bind("", self.show_previous_image) 445 | self.master.bind("", lambda event: self.show_previous_image(event, 5) if event.state & 0x1 else self.show_previous_image(event)) 446 | self.master.bind("", self.show_next_image) 447 | self.master.bind("", lambda event: self.show_next_image(event, 5) if event.state & 0x1 else self.show_next_image(event)) 448 | 449 | # Update radius and refocus method 450 | def update_radius_and_refocus(self, event=None): 451 | self.master.focus() # Set focus back to the master window 452 | self.process_images() 453 | 454 | def show_help(self): 455 | help_message = textwrap.dedent(""" 456 | Vesuvius Crackle Viewer Usage: 457 | 458 | - Open: Ctrl+O to open image. 459 | - Exit: Close the application. 460 | - Use the Ctrl key while dragging to draw on the overlay. 461 | - Double click to zoom fit. 462 | - Use the mouse wheel to zoom in/out. 463 | - Ctrl + Mouse wheel to rotate the image. 464 | - Space to toggle overlay visibility. 465 | - R to reset to the middle image. 466 | - C to toggle the drawing color. 467 | - Double click inside the image to reset the zoom, rotation and slice. 468 | - F to flood fill from the selected point 469 | 470 | Overlay Controls: 471 | - Load Overlay: Load an overlay image. 472 | - Create Empty Image: Create an empty overlay. 473 | - Save Overlay: Save the current overlay. 474 | - Save Combined Overlays: Save the combined image of the overlay and all sub-overlays. 475 | - Toggle Color: Switch between drawing colors. 476 | - Overlay Opacity: Adjust the opacity of the overlay. 477 | - Pick Color: Choose a custom drawing color. 478 | - Adjust the Pencil Size for drawing on the overlay. 479 | - Adjust the image brightness and contrast with the Min Max image values. 480 | - Adjust the micron factor for the image for the scale bar. 481 | - Dropdown to select active overlay for drawing. 482 | - Add SubOverlay: Load one or multiple SubOverlay image. These images are displayed only and not mutable. 483 | - Clear SubOverlays: Clear all SubOverlays. 484 | - SubOverlay Opacity: Adjust the opacity of the SubOverlay. 485 | - Max Propagation: Select the max numbers of points to color with flood fill 486 | - FF Threshold: Specify the threshold to color adjacent points with flood fill 487 | - Reset Slice: Reset to the middle image. 488 | - Composite image: Compose multiple tif images into one image. Can use min, max or mean operation. Can specify the number of slices and direction of the images to be composed. 489 | - Preload Images: Preload all images in the folder. This will speed up the navigation between images and composition of images. 490 | - Layer Index: Set the current image to the specified layer index. 491 | """) 492 | tk.messagebox.showinfo("Help", help_message) 493 | 494 | def save_last_directory(self): 495 | file_path = os.path.join(self.base_dir, "last_directory.txt") 496 | with open(file_path, "w") as file: 497 | file.write(self.last_directory) 498 | 499 | def load_last_directory(self): 500 | file_path = os.path.join(self.base_dir, "last_directory.txt") 501 | try: 502 | with open(file_path, "r") as file: 503 | self.last_directory = file.read().strip() 504 | except FileNotFoundError: 505 | self.last_directory = None 506 | 507 | def toggle_contrast(self): 508 | self.process_images() 509 | 510 | def toggle_preload(self): 511 | if self.preload_images_var.get(): 512 | self.preload_all_images() 513 | else: 514 | self.flush_preloaded_images() 515 | 516 | def preload_all_images(self): 517 | self.preloaded_images = {} 518 | 519 | with Pool() as pool: 520 | results = list(tqdm(pool.imap(load_image_parallel, self.image_list), total=len(self.image_list))) 521 | 522 | for filename, image in results: 523 | self.preloaded_images[filename] = image 524 | 525 | ## Single threaded version 526 | # def preload_all_images(self): 527 | # self.preloaded_images = {} 528 | # for i, img_path in enumerate(tqdm(self.image_list)): 529 | # self.preloaded_images[img_path] = self.load_image(img_path, as_np=True) 530 | 531 | def flush_preloaded_images(self): 532 | self.preloaded_images = {} 533 | 534 | def load_image(self, filename, as_np=False): 535 | if self.preload_images_var.get() and filename in self.preloaded_images: 536 | pil_image = self.preloaded_images[filename] 537 | else: 538 | pil_image = load_image_disk(filename) 539 | # pil_image = np.clip(pil_image, 0, 255) 540 | if not as_np: 541 | pil_image = Image.fromarray(np.uint8(pil_image)).convert("L") 542 | return pil_image 543 | 544 | def load_images(self): 545 | initial_dir =self.last_directory if self.last_directory else os.getcwd() 546 | images_path = tk.filedialog.askdirectory( 547 | initialdir = initial_dir 548 | ) 549 | if images_path: 550 | self.last_directory = images_path 551 | # Create surface volume dir 552 | surface_volume_path = os.path.join(images_path, "layers") 553 | if not os.path.exists(surface_volume_path): 554 | surface_volume_path = os.path.join(images_path, "surface_volume") 555 | print(surface_volume_path) 556 | self.images_folder = surface_volume_path.rsplit('/', 1)[-1] 557 | self.save_last_directory() # Save the last_directory 558 | self.image_list = sorted(glob.glob(os.path.join(surface_volume_path, f'*.tif'))) 559 | #hacky way to get png and jpg file stacks 560 | if len(self.image_list) == 0: 561 | self.image_list = sorted(glob.glob(os.path.join(surface_volume_path, f'*.png'))) 562 | if len(self.image_list) == 0: 563 | self.image_list = sorted(glob.glob(os.path.join(surface_volume_path, f'*.jpg'))) 564 | if len(self.image_list) == 0: 565 | print("No tif, png or jpg images found in the directory.") 566 | self.image_index = len(self.image_list) // 2 567 | 568 | if self.preload_images_var.get(): 569 | self.preload_all_images() 570 | self.set_image(self.image_list[self.image_index]) 571 | 572 | def load_obj(self): 573 | # Load the obj file(s) from the self.last_directory 574 | obj_files = sorted(glob.glob(os.path.join(self.last_directory, f'*.obj'))) 575 | # filter the obj files with uv coordinates. load and check 576 | obj_file = None 577 | for obj in obj_files: 578 | # open3d open 579 | mesh = o3d.io.read_triangle_mesh(obj) 580 | if len(mesh.triangle_uvs) > 0: 581 | obj_file = obj 582 | break 583 | if obj_file: 584 | print(f"Loaded obj file: {obj_file}") 585 | self.mesh = o3d.io.read_triangle_mesh(obj_file) 586 | self.mesh_vertices = np.array(self.mesh.vertices) 587 | print("Preprocessing obj transformation ... ") 588 | self.kd_tree, self.triangle_data = preprocess_uv_triangles(self.mesh) 589 | print("Preprocessing obj transformation done.") 590 | 591 | def load_overlay_image(self): 592 | initial_dir = self.last_directory_overlay if self.last_directory_overlay else self.last_directory if self.last_directory else os.getcwd() 593 | try: 594 | file_path = tk.filedialog.askopenfilename(filetypes=[('PNG files', '*.png')], initialdir=initial_dir) 595 | except: 596 | file_path = tk.filedialog.askopenfilename(filetypes=[('PNG files', '*.png')], initialdir=os.getcwd()) 597 | if file_path: 598 | self.last_directory_overlay = file_path 599 | print(file_path, self.last_directory_overlay) 600 | self.overlay_image = Image.open(file_path).convert("L") 601 | # self.overlay_image = Image.fromarray(np.uint8(np.array(Image.open(file_path)))).convert("L") 602 | if len(self.sub_overlays) == 0: 603 | self.sub_overlays.append(self.overlay_image) 604 | else: 605 | self.sub_overlays[0] = self.overlay_image 606 | self.redraw_image() 607 | # strip the file name from the path and save directory 608 | self.sub_overlay_names[0] = file_path.rsplit('/', 1)[-1] 609 | self.update_suboverlay_dropdown() 610 | 611 | def create_empty_overlay_image(self): 612 | if not self.image_list: 613 | return 614 | reference_image = Image.open(self.image_list[0]) 615 | width, height = reference_image.size 616 | # Changed from RGBA to 'L' for grayscale and set initial color to black 617 | self.overlay_image = Image.new("L", (width, height), "black") 618 | if len(self.sub_overlays) == 0: 619 | self.sub_overlays.append(self.overlay_image) 620 | else: 621 | self.sub_overlays[0] = self.overlay_image 622 | self.sub_overlay_names[0] = "newly_created_overlay.png" 623 | self.update_suboverlay_dropdown() 624 | self.redraw_image() 625 | 626 | # Method to load SubOverlay 627 | def load_suboverlay(self): 628 | initial_dir = self.last_directory_suboverlay if self.last_directory_suboverlay else self.last_directory if self.last_directory else os.getcwd() 629 | try: 630 | file_path = tk.filedialog.askopenfilename(filetypes=[('PNG files', '*.png'), ('TIF files', '*.tif')], initialdir=initial_dir) 631 | except: 632 | file_path = tk.filedialog.askopenfilename(filetypes=[('PNG files', '*.png'), ('TIF files', '*.tif')], initialdir=os.getcwd()) 633 | if file_path: 634 | self.last_directory_suboverlay = file_path 635 | if ".png" in file_path: 636 | sub_overlay = Image.open(file_path).convert("L") 637 | elif ".tif" in file_path: 638 | sub_overlay = Image.fromarray(np.uint8(np.array(Image.open(file_path))//256)).convert("L") 639 | else: 640 | raise ValueError("File type not supported.") 641 | self.sub_overlays.append(sub_overlay) 642 | self.redraw_image() 643 | # strip the file name from the path and save directory 644 | self.sub_overlay_names.append(file_path.rsplit('/', 1)[-1]) 645 | if len(self.sub_overlay_names) >= len(self.sub_overlay_colors): 646 | self.sub_overlay_colors.append(self.sub_overlay_colors[len(self.sub_overlay_colors)-1]) 647 | self.update_suboverlay_dropdown() 648 | 649 | def update_suboverlay_dropdown(self): 650 | menu = self.select_suboverlay_optionmenu['menu'] 651 | menu.delete(0, 'end') 652 | new_choices = self.sub_overlay_names 653 | for choice in new_choices: 654 | menu.add_command(label=choice, command=tk._setit(self.current_sub_overlay, choice)) 655 | 656 | def suboverlay_selected(self, *args): 657 | selected_name = self.current_sub_overlay.get() 658 | try: 659 | selected_index = self.sub_overlay_names.index(selected_name) 660 | 661 | # Swap the 0-th element with the selected_index element 662 | name0, name1 = self.sub_overlay_names[selected_index], self.sub_overlay_names[0] 663 | self.sub_overlay_names[0], self.sub_overlay_names[selected_index] = name0, name1 664 | 665 | # Swap the 0-th element with the selected_index element for colors as well 666 | color0, color1 = self.sub_overlay_colors[selected_index], self.sub_overlay_colors[0] 667 | self.sub_overlay_colors[0], self.sub_overlay_colors[selected_index] = color0, color1 668 | 669 | # Swap the 0-th element with the selected_index element for sub_overlays as well 670 | sub_overlay0, sub_overlay1 = self.sub_overlays[selected_index], self.sub_overlays[0] 671 | self.sub_overlays[0], self.sub_overlays[selected_index] = sub_overlay0, sub_overlay1 672 | 673 | self.overlay_image = self.sub_overlays[0] 674 | 675 | # Update the dropdown 676 | self.update_suboverlay_dropdown() 677 | 678 | # Set the current value to the new 0-th element 679 | self.current_sub_overlay.set(self.sub_overlay_names[0]) 680 | 681 | self.redraw_image() 682 | self.toggle_color() 683 | self.toggle_color() 684 | 685 | except ValueError: 686 | print("Selected value is not in the list.") 687 | 688 | 689 | def save_overlay(self): 690 | if self.overlay_image: 691 | initial_dir = self.last_directory_overlay if self.last_directory_overlay else os.getcwd() 692 | try: 693 | save_path = tk.filedialog.asksaveasfilename(filetypes=[('PNG files', '*.png')], initialdir=initial_dir) 694 | except: 695 | save_path = tk.filedialog.asksaveasfilename(filetypes=[('PNG files', '*.png')], initialdir=os.getcwd()) 696 | 697 | if save_path: 698 | # Convert to grayscale and remove alpha channel 699 | bw_image = self.overlay_image.convert("1") 700 | bw_image.save(save_path) 701 | 702 | def save_combined_overlays(self): 703 | if self.pil_image: 704 | # Create a base image 705 | combined = Image.new("L", (self.pil_image.width, self.pil_image.height), color="black") 706 | 707 | # Add sub-overlays 708 | for sub_overlay in self.sub_overlays: 709 | combined = ImageChops.lighter(combined, sub_overlay.convert("L")) 710 | 711 | # Add the main overlay 712 | if self.overlay_image: 713 | combined = ImageChops.lighter(combined, self.overlay_image.convert("L")) 714 | 715 | # Save the combined image in grayscale and without an alpha channel 716 | initial_dir = self.last_directory_overlay if self.last_directory_overlay else os.getcwd() 717 | try: 718 | save_path = tk.filedialog.asksaveasfilename(filetypes=[('PNG files', '*.png')], initialdir=initial_dir) 719 | except: 720 | save_path = tk.filedialog.asksaveasfilename(filetypes=[('PNG files', '*.png')], initialdir=os.getcwd()) 721 | 722 | if save_path: 723 | bw_combined = combined.convert("1") 724 | bw_combined.save(save_path) 725 | 726 | def save_displayed_image(self): 727 | if self.pil_image is None: 728 | tk.messagebox.showerror("Error", "No image to save.") 729 | return 730 | 731 | initial_dir = self.last_directory_overlay if self.last_directory_overlay else os.getcwd() 732 | # Asks the user for the location and name of the file to save 733 | try: 734 | file_path = tk.filedialog.asksaveasfilename( 735 | defaultextension=".tif", 736 | filetypes=[("TIFF files", "*.tif"), ("All files", "*.*")], 737 | initial_dir=initial_dir 738 | ) 739 | except: 740 | file_path = tk.filedialog.asksaveasfilename( 741 | defaultextension=".tif", 742 | filetypes=[("TIFF files", "*.tif"), ("All files", "*.*")], 743 | initialdir=os.getcwd() 744 | ) 745 | 746 | if not file_path: 747 | # User cancelled the save operation 748 | return 749 | 750 | # The current state of the image is in self.pil_image 751 | # You might need to apply any additional transformations or overlays 752 | # that you want to be included in the saved image 753 | self.pil_image.save(file_path) 754 | 755 | 756 | def toggle_overlay(self): 757 | self.redraw_image() 758 | 759 | def adjust_overlay_opacity(self, event=None): 760 | self.overlay_opacity_entry.delete(0, tk.END) 761 | self.overlay_opacity_entry.insert(tk.END, f"{self.overlay_opacity_scale.get():.2f}") 762 | self.redraw_image() 763 | 764 | def adjust_suboverlay_opacity(self, event=None): 765 | self.redraw_image() 766 | 767 | def set_opacity_from_entry(self, event=None): 768 | self.master.focus() 769 | try: 770 | val = float(self.overlay_opacity_entry.get()) 771 | if 0 <= val <= 1: 772 | self.overlay_opacity_scale.set(val) 773 | self.adjust_overlay_opacity() 774 | except ValueError: 775 | pass 776 | 777 | def set_max_min_from_entry(self, event=None): 778 | self.master.focus() 779 | try: 780 | val = float(self.min_value_entry.get()) 781 | self.min_value = val 782 | val = float(self.max_value_entry.get()) 783 | self.max_value = val 784 | self.set_image(self.image_list[self.image_index]) 785 | except ValueError: 786 | pass 787 | 788 | def set_micron_factor(self, event=None): 789 | self.master.focus() 790 | try: 791 | val = float(self.micron_entry.get()) 792 | self.micron_factor = val 793 | self.redraw_image() 794 | except ValueError: 795 | pass 796 | 797 | def pick_color(self): 798 | color = tkinter.colorchooser.askcolor(title="Choose Overlay Color") 799 | if color and color[1]: 800 | self.sub_overlay_colors[0] = color[1] 801 | 802 | self.toggle_color() 803 | self.toggle_color() 804 | self.redraw_image() 805 | 806 | def toggle_color(self, event=None): 807 | self.pencil_color = 'white' if self.pencil_color == 'black' else 'black' 808 | label_color = 'black' if self.pencil_color == 'black' else self.sub_overlay_colors[0] 809 | self.color_label.config(background=label_color) # Update the color preview$ 810 | 811 | def on_resample_method_changed(self, selected_method): 812 | self.resample_method.set(selected_method) 813 | self.redraw_image() 814 | 815 | def calculate_image_range(self, radius, direction): 816 | if direction == "omi": 817 | start_index = max(0, self.image_index - radius) 818 | end_index = min(len(self.image_list), self.image_index + radius + 1) 819 | elif direction == "front": 820 | start_index = self.image_index 821 | end_index = min(len(self.image_list), self.image_index + radius + 1) 822 | elif direction == "back": 823 | start_index = max(0, self.image_index - radius) 824 | end_index = self.image_index + 1 825 | return start_index, end_index 826 | 827 | def enhance_image(self, image): 828 | if self.toggle_contrast_var.get(): 829 | clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(12,12)) 830 | image = clahe.apply(image.astype(np.uint8)) 831 | return image 832 | 833 | def process_images(self): 834 | radius = int(self.radius_var.get()) 835 | direction = self.direction_var.get() 836 | start_index, end_index = self.calculate_image_range(radius, direction) 837 | 838 | # Stack images as a 3D NumPy array 839 | images = np.stack([self.load_image(self.image_list[i], as_np=True) for i in tqdm(range(start_index, end_index))]) 840 | if images.size > 0: 841 | if images.size == 1: 842 | result_image = images[0] 843 | else: 844 | operation = self.operation_var.get() 845 | if operation == "max": 846 | result_image = np.max(images, axis=0) 847 | elif operation == "min": 848 | result_image = np.min(images, axis=0) 849 | elif operation == "mean": 850 | result_image = np.mean(images, axis=0) 851 | 852 | if self.min_value != 0 or self.max_value != 65535: 853 | result_image = (result_image - (self.min_value / 256.0)) * ( 65535.0 / (self.max_value - self.min_value)) 854 | result_image = np.clip(result_image, 0, 255) 855 | result_image = result_image.astype(np.uint8) 856 | 857 | result_image = self.enhance_image(result_image) 858 | 859 | self.pil_image = Image.fromarray(result_image).convert("L") 860 | self.redraw_image() 861 | 862 | def set_image(self, filename): 863 | if not filename: 864 | return 865 | 866 | self.process_images() 867 | # self.draw_image(self.pil_image) 868 | 869 | self.master.title(self.my_title + " - " + os.path.basename(filename)) 870 | self.label_image_info["text"] = f"{self.pil_image.format} : {self.pil_image.width} x {self.pil_image.height} {self.pil_image.mode}" 871 | os.chdir(os.path.dirname(filename)) 872 | 873 | # Method to clear all SubOverlays 874 | def clear_suboverlays(self): 875 | self.sub_overlays = [self.sub_overlays[0]] 876 | self.sub_overlay_names = [self.sub_overlay_names[0]] 877 | self.redraw_image() 878 | self.update_suboverlay_dropdown() 879 | 880 | def operation_changed(self, _): 881 | self.process_images() 882 | 883 | def reset_to_middle_image(self, event=None): 884 | self.image_index = len(self.image_list) // 2 885 | self.set_image(self.image_list[self.image_index]) 886 | 887 | def set_layer_from_entry(self, event=None): 888 | try: 889 | layer_index = int(self.layer_index_var.get()) 890 | if 0 <= layer_index < len(self.image_list): 891 | self.image_index = layer_index 892 | self.set_image(self.image_list[self.image_index]) 893 | else: 894 | tk.messagebox.showerror("Error", "Layer index out of range.") 895 | except ValueError: 896 | tk.messagebox.showerror("Error", "Invalid layer index.") 897 | self.master.focus() # Shift focus away from the entry field 898 | 899 | def show_previous_image(self, event, image_offset=1): 900 | if self.image_index - image_offset > 0: 901 | self.image_index -= image_offset 902 | self.set_image(self.image_list[self.image_index]) 903 | 904 | def show_next_image(self, event, image_offset=1): 905 | if self.image_index < len(self.image_list) - image_offset: 906 | self.image_index += image_offset 907 | self.set_image(self.image_list[self.image_index]) 908 | 909 | def generate_line(self, event): 910 | draw = ImageDraw.Draw(self.overlay_image) 911 | old_point = tuple(self.to_image_point(self.__old_event.x, self.__old_event.y)[:2]) 912 | new_point = tuple(self.to_image_point(event.x, event.y)[:2]) 913 | width = self.size_scale.get() 914 | draw.line([old_point, new_point], fill=self.pencil_color, width=width, joint='curve') 915 | # Draw circle with radius with/2 916 | draw.ellipse([old_point[0]-width/2, old_point[1]-width/2, old_point[0]+width/2, old_point[1]+width/2], fill=self.pencil_color) 917 | draw.ellipse([new_point[0]-width/2, new_point[1]-width/2, new_point[0]+width/2, new_point[1]+width/2], fill=self.pencil_color) 918 | 919 | self.redraw_image() 920 | 921 | def mouse_down_left(self, event): 922 | self.__old_event = event 923 | if self.shift_pressed and self.overlay_image: 924 | self.generate_line(event) 925 | self.mouse_is_pressed = True # Mouse button pressed 926 | 927 | def mouse_up_left(self, event): 928 | self.mouse_is_pressed = False # Mouse button released 929 | 930 | def mouse_down_right(self, event): 931 | self.__old_event = event 932 | if self.overlay_image: 933 | self.generate_line(event) 934 | self.mouse_is_pressed_right = True # Mouse button pressed 935 | 936 | def mouse_up_right(self, event): 937 | self.mouse_is_pressed_right = False # Mouse button released 938 | 939 | def mouse_move_left(self, event): 940 | if (self.pil_image == None) or (not self.mouse_is_pressed): # Check if mouse button is pressed 941 | return 942 | 943 | # Check if shift is pressed and mouse is dragged to draw 944 | if self.shift_pressed and self.mouse_is_pressed and self.overlay_image: 945 | self.generate_line(event) 946 | else: # Else case for dragging 947 | self.translate(event.x - self.__old_event.x, event.y - self.__old_event.y) 948 | 949 | self.redraw_image() 950 | self.__old_event = event 951 | 952 | def mouse_move_right(self, event): 953 | if (self.pil_image == None) or (not self.mouse_is_pressed_right): # Check if mouse button is pressed 954 | return 955 | 956 | if self.overlay_image: 957 | self.generate_line(event) 958 | 959 | self.redraw_image() 960 | self.__old_event = event 961 | 962 | def mouse_move(self, event): 963 | # Remove the old circle if it exists 964 | if self.cursor_circle: 965 | self.canvas.delete(self.cursor_circle) 966 | 967 | # Draw the new circle 968 | x, y = event.x, event.y 969 | 970 | r = (self.size_scale.get() * self.global_scale_factor)// 2 # radius of circle 971 | self.cursor_circle = self.canvas.create_oval(x-r, y-r, x+r, y+r, outline=self.pencil_color) 972 | 973 | if (self.pil_image == None): 974 | return 975 | 976 | image_point = self.to_image_point(event.x, event.y) 977 | if image_point != []: 978 | uv_point = np.array([image_point[0] / self.pil_image.width, 1.0 - image_point[1] / self.pil_image.height]) 979 | point_3d = find_uv_triangle(self.mesh_vertices, uv_point, self.kd_tree, self.triangle_data) 980 | if point_3d is None: 981 | point_3d = ["--", "--", "--"] 982 | else: 983 | point_3d = [f"{point_3d[0]:.2f}", f"{point_3d[1]:.2f}", f"{point_3d[2]:.2f}"] 984 | self.label_image_pixel["text"] = (f"2D: (x: {image_point[0]:.2f}, y: {image_point[1]}) 3D: (x: {point_3d[0]}, y: {point_3d[1]}, z: {point_3d[2]})") 985 | else: 986 | self.label_image_pixel["text"] = ("2D: (x: --, y: --) 3D: (x: --, y: --, z: --)") 987 | 988 | def mouse_leave_canvas(self, event): 989 | if self.cursor_circle: 990 | self.canvas.delete(self.cursor_circle) 991 | self.cursor_circle = None 992 | 993 | 994 | def mouse_double_click_left(self, event): 995 | if self.pil_image == None: 996 | return 997 | self.zoom_fit(self.pil_image.width, self.pil_image.height) 998 | self.redraw_image() 999 | self.reset_to_middle_image() 1000 | 1001 | def shift_press(self, event): 1002 | self.shift_pressed = True 1003 | 1004 | def shift_release(self, event): 1005 | self.shift_pressed = False 1006 | 1007 | def mouse_wheel(self, event): 1008 | if self.pil_image == None: 1009 | return 1010 | 1011 | if event.num == 5 or event.delta < 0: 1012 | scale_factor = 0.8 if not self.shift_pressed else -5 1013 | else: # event.num == 4 or event.delta > 0 1014 | scale_factor = 1.25 if not self.shift_pressed else 5 1015 | 1016 | if not self.shift_pressed: 1017 | self.scale_at(scale_factor, event.x, event.y) 1018 | else: 1019 | self.rotate_at(scale_factor, event.x, event.y) 1020 | self.redraw_image() # redraw the image 1021 | 1022 | def reset_transform(self): 1023 | self.mat_affine = np.eye(3) 1024 | 1025 | def translate(self, offset_x, offset_y): 1026 | mat = np.eye(3) 1027 | mat[0, 2] = float(offset_x) 1028 | mat[1, 2] = float(offset_y) 1029 | 1030 | self.mat_affine = np.dot(mat, self.mat_affine) 1031 | 1032 | def scale(self, scale:float): 1033 | mat = np.eye(3) 1034 | mat[0, 0] = scale 1035 | mat[1, 1] = scale 1036 | 1037 | self.mat_affine = np.dot(mat, self.mat_affine) 1038 | scale_x = (self.mat_affine[0, 0]**2 + self.mat_affine[0, 1]**2)**0.5 1039 | scale_y = (self.mat_affine[1, 0]**2 + self.mat_affine[1, 1]**2)**0.5 1040 | self.global_scale_factor = scale_x 1041 | # Correct small errors in the affine matrix 1042 | scale_y = scale_x 1043 | self.mat_affine[1, 1] = self.mat_affine[0, 0] 1044 | self.mat_affine[1, 0] = -self.mat_affine[0, 1] 1045 | # assert self.mat_affine[0, 0] == self.mat_affine[1, 1], f"Non-uniform scaling is not supported on affine (0,0) vs (1,1) {self.mat_affine[0, 0]} != {self.mat_affine[1, 1]}" 1046 | # assert self.mat_affine[0, 1] == -self.mat_affine[1, 0], f"Non-uniform scaling is not supported on affine (0,1) -(1,0) {self.mat_affine[0, 1]} != {self.mat_affine[1, 0]}" 1047 | # assert scale_x == scale_y, f"Non-uniform scaling is not supported on scale {scale_x} != {scale_y}" 1048 | 1049 | def scale_at(self, scale:float, cx:float, cy:float): 1050 | self.translate(-cx, -cy) 1051 | self.scale(scale) 1052 | self.translate(cx, cy) 1053 | 1054 | def rotate(self, deg:float): 1055 | mat = np.eye(3) 1056 | mat[0, 0] = math.cos(math.pi * deg / 180) 1057 | mat[1, 0] = math.sin(math.pi * deg / 180) 1058 | mat[0, 1] = -mat[1, 0] 1059 | mat[1, 1] = mat[0, 0] 1060 | 1061 | self.mat_affine = np.dot(mat, self.mat_affine) 1062 | 1063 | def rotate_at(self, deg:float, cx:float, cy:float): 1064 | 1065 | self.translate(-cx, -cy) 1066 | self.rotate(deg) 1067 | self.translate(cx, cy) 1068 | 1069 | def zoom_fit(self, image_width, image_height): 1070 | 1071 | canvas_width = self.canvas.winfo_width() 1072 | canvas_height = self.canvas.winfo_height() 1073 | 1074 | if (image_width * image_height <= 0) or (canvas_width * canvas_height <= 0): 1075 | return 1076 | 1077 | self.reset_transform() 1078 | 1079 | scale = 1.0 1080 | offsetx = 0.0 1081 | offsety = 0.0 1082 | 1083 | if (canvas_width * image_height) > (image_width * canvas_height): 1084 | scale = canvas_height / image_height 1085 | offsetx = (canvas_width - image_width * scale) / 2 1086 | else: 1087 | scale = canvas_width / image_width 1088 | offsety = (canvas_height - image_height * scale) / 2 1089 | 1090 | self.scale(scale) 1091 | self.translate(offsetx, offsety) 1092 | 1093 | def to_image_point_unchecked(self, x, y): 1094 | mat_inv = np.linalg.inv(self.mat_affine) 1095 | image_point = np.dot(mat_inv, (x, y, 1.)) 1096 | return image_point 1097 | 1098 | def to_image_point(self, x, y): 1099 | if self.pil_image == None: 1100 | return [] 1101 | image_point = self.to_image_point_unchecked(x, y) 1102 | if image_point[0] < 0 or image_point[1] < 0 or image_point[0] > self.pil_image.width or image_point[1] > self.pil_image.height: 1103 | return [] 1104 | 1105 | return image_point 1106 | 1107 | def create_ruler(self, img_width, img_height, width, height, unit_size, min_unit_length=15): 1108 | """ 1109 | Create a ruler image with the specified width, height, and unit size. 1110 | """ 1111 | unit_size = int(unit_size) 1112 | unit_size = max(1, unit_size) # Ensure unit size is at least 1 1113 | ruler = Image.new('RGBA', (img_width, img_height), (255, 255, 255, 0)) 1114 | draw = ImageDraw.Draw(ruler) 1115 | 1116 | unit_range = range(height, width, unit_size) 1117 | if len(unit_range) < min_unit_length: 1118 | unit_range = [i for i in range(height, height + min_unit_length*unit_size, unit_size)] 1119 | 1120 | # Draw ruler lines and numbers 1121 | for i in unit_range: 1122 | line_height = height // 2 if (i - height) % (5 * unit_size) else height 1123 | draw.line([(i + height, 0), (i + height, line_height)], fill="white", width=1) 1124 | draw.text((i + height, line_height), str((i - height) // unit_size), fill="white") 1125 | 1126 | # Draw ruler lines and numbers 1127 | for i in unit_range: 1128 | line_height = height // 2 if (i - height) % (5 * unit_size) else height 1129 | draw.line([(0, i + height), (line_height, i + height)], fill="white", width=1) 1130 | draw.text((line_height, i + height), str((i - height) // unit_size), fill="white") 1131 | 1132 | return ruler 1133 | 1134 | def update_threshold_value(self, val): 1135 | self.ff_threshold = int(float(val)) 1136 | self.bucket_threshold_var.set(f"{self.ff_threshold}") 1137 | print(self.ff_threshold) 1138 | 1139 | def update_max_propagation(self, val): 1140 | self.max_propagation_steps = int(float(val)) 1141 | self.max_propagation_var.set(f"{self.max_propagation_steps}") 1142 | 1143 | def threaded_flood_fill(self, event): 1144 | if self.flood_fill_active: 1145 | return 1146 | self.flood_fill_active = True 1147 | click_coordinates = self.to_image_point(event.x, event.y)[:2] 1148 | click_coordinates[0] = int(click_coordinates[0]) 1149 | click_coordinates[1] = int(click_coordinates[1]) 1150 | click_coordinates = tuple(click_coordinates) 1151 | # Run flood_fill_3d in a separate thread 1152 | thread = threading.Thread(target=self.flood_fill_2d, args=(click_coordinates,)) 1153 | thread.start() 1154 | 1155 | def flood_fill_2d(self, start_coord): 1156 | pil_image = self.pil_image 1157 | queue = deque([start_coord]) 1158 | target_color = int(pil_image.getpixel(start_coord)) 1159 | visited = set() 1160 | counter = 0 1161 | if self.overlay_image.mode == 'RGB': 1162 | # Convert to a tuple of integers for RGB 1163 | value = (int(255), int(255), int(255)) 1164 | else: 1165 | value = int(255) 1166 | while self.flood_fill_active and queue and counter < self.max_propagation_steps: 1167 | cx, cy = queue.popleft() 1168 | 1169 | if (cx, cy) in visited or not (0 <= cx < pil_image.width and 0 <= cy < pil_image.height): 1170 | continue 1171 | 1172 | visited.add((cx, cy)) 1173 | 1174 | 1175 | pixel_value = int(pil_image.getpixel((cx,cy))) 1176 | 1177 | if abs(pixel_value - target_color) <= self.ff_threshold: 1178 | try: 1179 | self.overlay_image.putpixel((int(cx), int(cy)), value) 1180 | except TypeError as e: 1181 | print(f"Error: {e}, Coordinates: ({cx}, {cy}), Value: {value}, Mode: {self.overlay_image.mode}") 1182 | counter += 1 1183 | for dx in [-1, 0, 1]: 1184 | for dy in [-1, 0, 1]: 1185 | if dx == 0 and dy == 0: 1186 | continue 1187 | queue.append((cx + dx, cy + dy)) 1188 | 1189 | if counter % 10 == 0: 1190 | self.redraw_image() 1191 | 1192 | if self.flood_fill_active == True: 1193 | self.flood_fill_active = False 1194 | self.redraw_image() 1195 | 1196 | def draw_image(self, pil_image): 1197 | if pil_image == None: 1198 | return 1199 | 1200 | self.pil_image = pil_image 1201 | 1202 | canvas_width = self.canvas.winfo_width() 1203 | canvas_height = self.canvas.winfo_height() 1204 | 1205 | mat_inv = np.linalg.inv(self.mat_affine) 1206 | 1207 | affine_inv = ( 1208 | mat_inv[0, 0], mat_inv[0, 1], mat_inv[0, 2], 1209 | mat_inv[1, 0], mat_inv[1, 1], mat_inv[1, 2] 1210 | ) 1211 | 1212 | dst = self.pil_image.transform( 1213 | (canvas_width, canvas_height), 1214 | Image.Transform.AFFINE, 1215 | affine_inv, 1216 | self.resampling_methods[self.resample_method.get()] 1217 | ) 1218 | 1219 | if dst.mode != 'RGBA': 1220 | dst = dst.convert('RGBA') 1221 | 1222 | if self.overlay_visibility.get(): 1223 | # Overlaying SubOverlays 1224 | for i, sub_overlay in enumerate(self.sub_overlays): 1225 | if i == 0: continue # skip overlay image 1226 | 1227 | sub_overlay_transformed = sub_overlay.transform( 1228 | (canvas_width, canvas_height), 1229 | Image.Transform.AFFINE, 1230 | affine_inv, 1231 | self.resampling_methods[self.resample_method.get()] 1232 | ) 1233 | 1234 | # Ensure the image is RGBA (has an alpha channel) 1235 | if sub_overlay_transformed.mode != 'RGBA': 1236 | sub_overlay_transformed = sub_overlay_transformed.convert('RGBA') 1237 | 1238 | r, g, b, a = sub_overlay_transformed.split() 1239 | grayscale = sub_overlay_transformed.convert("L") 1240 | # Scale the RGB values based on the brightness slider 1241 | grayscale = ImageEnhance.Brightness(grayscale).enhance(self.suboverlay_brightness_scale.get()) 1242 | alpha = grayscale # Use grayscale directly, without inverting 1243 | sub_overlay_color = Image.new('RGB', sub_overlay_transformed.size, self.sub_overlay_colors[i]) 1244 | final_sub_overlay = Image.composite(sub_overlay_color, sub_overlay_transformed, grayscale) 1245 | final_sub_overlay.putalpha(alpha) 1246 | 1247 | 1248 | # Adjust the opacity based on the value of the slider 1249 | alpha = ImageEnhance.Brightness(alpha).enhance(self.suboverlay_opacity_scale.get()) 1250 | final_sub_overlay.putalpha(alpha) 1251 | 1252 | dst.paste(final_sub_overlay, (0, 0), final_sub_overlay) 1253 | 1254 | # Overlaying the additional PNG 1255 | if self.overlay_visibility.get() and self.overlay_image: 1256 | overlay_transformed = self.overlay_image.transform( 1257 | (canvas_width, canvas_height), 1258 | Image.Transform.AFFINE, 1259 | affine_inv, 1260 | self.resampling_methods[self.resample_method.get()] 1261 | ) 1262 | 1263 | # Ensure the image is RGBA (has an alpha channel) 1264 | if overlay_transformed.mode != 'RGBA': 1265 | overlay_transformed = overlay_transformed.convert('RGBA') 1266 | 1267 | r, g, b, a = overlay_transformed.split() 1268 | grayscale = overlay_transformed.convert("L") 1269 | alpha = grayscale # Use grayscale directly, without inverting 1270 | yellow = Image.new('RGB', overlay_transformed.size, self.sub_overlay_colors[0]) 1271 | final_overlay = Image.composite(yellow, overlay_transformed, grayscale) 1272 | final_overlay.putalpha(alpha) 1273 | 1274 | # Adjust the opacity based on the value of the slider 1275 | alpha = ImageEnhance.Brightness(alpha).enhance(self.overlay_opacity_scale.get()) 1276 | final_overlay.putalpha(alpha) 1277 | 1278 | dst.paste(final_overlay, (0, 0), final_overlay) 1279 | 1280 | # Add a ruler to the bottom right of the image 1281 | ruler_width, ruler_height = 500, 100 # Customize as needed 1282 | unit_size = self.global_scale_factor * (1.0 / self.micron_factor) # Customize the unit size for the ruler 1283 | image_width, image_height = dst.size 1284 | ruler = self.create_ruler(image_width, image_height, ruler_width, ruler_height, unit_size) 1285 | 1286 | # Calculate position for the ruler (bottom right) 1287 | ruler_position = (0, 0) 1288 | 1289 | # Paste the ruler onto the image 1290 | dst.paste(ruler, ruler_position, ruler) 1291 | 1292 | im = ImageTk.PhotoImage(image=dst) 1293 | 1294 | item = self.canvas.create_image( 1295 | 0, 0, 1296 | anchor='nw', 1297 | image=im 1298 | ) 1299 | 1300 | self.image = im 1301 | # Update the layer index display 1302 | self.layer_index_var.set(str(self.image_index)) 1303 | 1304 | def redraw_image(self): 1305 | if self.pil_image == None: 1306 | return 1307 | self.draw_image(self.pil_image) 1308 | 1309 | 1310 | if __name__ == "__main__": 1311 | root = tk.Tk() 1312 | app = Application(master=root) 1313 | app.mainloop() 1314 | --------------------------------------------------------------------------------