├── run.sh ├── run.bat ├── .gitignore ├── setup_linux.sh ├── setup_windows.bat ├── README.md ├── LICENSE └── camo_studio.py /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 0. Auto-Update Check 4 | if command -v git &> /dev/null; then 5 | echo "[*] Checking for updates..." 6 | git pull origin main 7 | else 8 | echo "[!] Git not found. Skipping update check." 9 | fi 10 | echo "" 11 | 12 | # 1. Run App 13 | if [ ! -d "venv" ]; then 14 | echo "[!] Virtual environment not found! Please run ./setup_linux.sh first." 15 | exit 1 16 | fi 17 | 18 | source venv/bin/activate 19 | python3 camo_studio.py 20 | -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | TITLE Camo Studio 3 | 4 | :: 0. AUTO-UPDATE CHECK 5 | ECHO [*] Checking for updates... 6 | git --version >nul 2>&1 7 | IF %ERRORLEVEL% EQU 0 ( 8 | git pull origin main 9 | ) ELSE ( 10 | ECHO [!] Git not found. Skipping update check. 11 | ) 12 | ECHO. 13 | 14 | :: 1. RUN APP 15 | IF NOT EXIST "venv" ( 16 | ECHO [!] Virtual environment not found! Please run setup_windows.bat first. 17 | PAUSE 18 | EXIT /B 19 | ) 20 | 21 | CALL venv\Scripts\activate.bat 22 | python camo_studio.py 23 | PAUSE 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | --- Python Standard --- 2 | 3 | pycache/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | --- Virtual Environments --- 8 | 9 | Common names for virtual envs 10 | 11 | venv/ 12 | env/ 13 | .venv/ 14 | .env/ 15 | Scripts/ 16 | Lib/ 17 | 18 | --- IDE Settings (VS Code, PyCharm, etc) --- 19 | 20 | .vscode/ 21 | .idea/ 22 | *.swp 23 | 24 | --- Project Outputs (Don't commit generated files) --- 25 | 26 | Ignore output folders generated by the script 27 | 28 | *_output/ 29 | 30 | Ignore generated file types in the root directory 31 | 32 | (Keep these if you want to commit example outputs, otherwise ignore them) 33 | 34 | *.svg 35 | *.stl 36 | *.gcode 37 | 38 | Ignore temporary previews 39 | 40 | *_PREVIEW.jpg 41 | *_PREVIEW.png 42 | _PREVIEW_combined.jpg 43 | 44 | --- OS Generated Files --- 45 | 46 | .DS_Store 47 | Thumbs.db 48 | Desktop.ini 49 | 50 | --- Logs --- 51 | 52 | *.log 53 | 54 | 55 | --- Other --- 56 | assets/ 57 | user_settings.json 58 | -------------------------------------------------------------------------------- /setup_linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "========================================================" 4 | echo " CAMO STUDIO - LINUX SETUP WIZARD" 5 | echo "========================================================" 6 | echo "" 7 | 8 | # 0. Check for Updates 9 | echo "[*] Checking for Git..." 10 | if command -v git &> /dev/null; then 11 | echo "[OK] Git found. Pulling latest updates..." 12 | git pull origin main 13 | if [ $? -ne 0 ]; then 14 | echo "[!] Git pull failed or not a git repository. Continuing with local version." 15 | else 16 | echo "[OK] Code updated." 17 | fi 18 | else 19 | echo "[!] Git not found. Skipping update." 20 | fi 21 | echo "" 22 | 23 | # 1. Check for Python 3 24 | echo "[*] Checking for Python 3..." 25 | if ! command -v python3 &> /dev/null; then 26 | echo "[!] Python 3 could not be found." 27 | echo " Please install Python 3 manually." 28 | exit 1 29 | fi 30 | echo "[OK] Python 3 found." 31 | echo "" 32 | 33 | # 2. Create Virtual Environment 34 | if [ -d "venv" ]; then 35 | echo "[*] Virtual environment 'venv' already exists. Skipping creation." 36 | else 37 | echo "[*] Creating virtual environment..." 38 | python3 -m venv venv 39 | 40 | if [ $? -ne 0 ]; then 41 | echo "[!] Failed to create virtual environment." 42 | echo " (Note: On some distros like Ubuntu/Debian, you may need to manually" 43 | echo " install the 'python3-venv' package using your package manager)." 44 | exit 1 45 | fi 46 | echo "[OK] Virtual environment created." 47 | fi 48 | echo "" 49 | 50 | # 3. Activate and Install 51 | echo "[*] Activating virtual environment..." 52 | source venv/bin/activate 53 | 54 | echo "[*] Upgrading pip..." 55 | pip install --upgrade pip 56 | 57 | echo "" 58 | echo "[*] Installing required libraries..." 59 | echo " (opencv-python, numpy, svgwrite, Pillow, trimesh, shapely, scipy, mapbox_earcut)" 60 | echo "" 61 | 62 | pip install opencv-python numpy svgwrite Pillow trimesh shapely scipy mapbox_earcut 63 | 64 | if [ $? -ne 0 ]; then 65 | echo "" 66 | echo "[!] There was an error installing dependencies." 67 | exit 1 68 | fi 69 | 70 | echo "" 71 | echo "========================================================" 72 | echo "[OK] SETUP COMPLETE!" 73 | echo "========================================================" 74 | echo "" 75 | echo "You can now run the application using './run.sh'" 76 | echo "" 77 | -------------------------------------------------------------------------------- /setup_windows.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | TITLE Camo Studio - Automated Setup 3 | CLS 4 | 5 | ECHO ======================================================== 6 | ECHO CAMO STUDIO - ENVIRONMENT SETUP WIZARD 7 | ECHO ======================================================== 8 | ECHO. 9 | 10 | :: 0. CHECK FOR UPDATES (GIT) 11 | ECHO [*] Checking for Git installation... 12 | git --version >nul 2>&1 13 | IF %ERRORLEVEL% EQU 0 ( 14 | ECHO [OK] Git found. Attempting to pull latest updates... 15 | git pull origin main 16 | IF %ERRORLEVEL% NEQ 0 ( 17 | ECHO [!] Git pull failed or not a git repository. Continuing with local version. 18 | ) ELSE ( 19 | ECHO [OK] Successfully updated to latest version. 20 | ) 21 | ) ELSE ( 22 | ECHO [!] Git not found. Skipping auto-update. 23 | ) 24 | ECHO. 25 | 26 | :: 1. CHECK FOR PYTHON 27 | ECHO [*] Checking for Python installation... 28 | python --version >nul 2>&1 29 | IF %ERRORLEVEL% NEQ 0 ( 30 | ECHO [!] Python is not detected! 31 | ECHO Please install Python 3.10 or newer from python.org 32 | ECHO Make sure to check "Add Python to PATH" during installation. 33 | PAUSE 34 | EXIT /B 35 | ) 36 | ECHO [OK] Python found. 37 | ECHO. 38 | 39 | :: 2. CREATE VIRTUAL ENVIRONMENT 40 | IF EXIST "venv" ( 41 | ECHO [*] Virtual environment 'venv' already exists. Skipping creation. 42 | ) ELSE ( 43 | :: Fixed: Removed parentheses from the text below to prevent syntax errors 44 | ECHO [*] Creating virtual environment - this may take a moment... 45 | python -m venv venv 46 | 47 | :: Check for error immediately after command 48 | IF ERRORLEVEL 1 ( 49 | ECHO [!] Failed to create virtual environment. 50 | PAUSE 51 | EXIT /B 52 | ) 53 | ECHO [OK] Virtual environment created. 54 | ) 55 | ECHO. 56 | 57 | :: 3. ACTIVATE AND INSTALL 58 | ECHO [*] Activating virtual environment... 59 | CALL venv\Scripts\activate.bat 60 | 61 | ECHO [*] Upgrading pip... 62 | python -m pip install --upgrade pip 63 | 64 | ECHO. 65 | ECHO [*] Installing required libraries... 66 | ECHO (opencv-python, numpy, svgwrite, Pillow, trimesh, shapely, scipy, mapbox_earcut) 67 | ECHO. 68 | 69 | :: Install directly to ensure all specific libs are present even if requirements.txt is missing 70 | :: Using ^ to split the command across multiple lines for readability 71 | pip install opencv-python ^ 72 | numpy ^ 73 | svgwrite ^ 74 | Pillow ^ 75 | trimesh ^ 76 | shapely ^ 77 | scipy ^ 78 | mapbox_earcut 79 | 80 | IF %ERRORLEVEL% NEQ 0 ( 81 | ECHO. 82 | ECHO [!] There was an error installing dependencies. 83 | PAUSE 84 | EXIT /B 85 | ) 86 | 87 | ECHO. 88 | ECHO ======================================================== 89 | ECHO [OK] SETUP COMPLETE! 90 | ECHO ======================================================== 91 | ECHO. 92 | ECHO You can now run the application using 'run.bat' 93 | ECHO. 94 | PAUSE 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Camo Stencil Studio v2.0 2 | 3 | **Camo Stencil Studio** is a professional-grade desktop application designed to bridge the gap between 2D image processing and physical fabrication. It converts standard photographs or reference images into multi-layer camouflage patterns, vector stencils for vinyl cutters, and robust 3D models for printing. 4 | 5 | **Version 2.0** introduces a fully persistent workspace, advanced 3D geometry processing (auto-bridging), and a project management system. 6 | 7 | ## New in v2.0 8 | * **Project Files (`.json`):** Save your work—including the source image, palette, and layer mappings—and resume exactly where you left off. 9 | * **Smart Memory:** The app now remembers your global settings (Denoise strength, Dimensions, etc.) and your last opened folder between sessions. 10 | * **3D Auto-Bridging:** Automatically detects "floating islands" (e.g., the center of a donut shape) in stencil mode and cuts structural bridges to anchor them to the frame. 11 | * **Stability:** Fixed JSON serialization crashes and optimized thread handling. 12 | 13 | ## Key Features 14 | 15 | ### Workspace & Management 16 | * **Persistent Configuration:** Set your preferred export units, smoothing levels, and bridge widths once; the app remembers them automatically. 17 | * **New Project Workflow:** Dedicated controls to clear the workspace or load previous projects instantly. 18 | * **Center-Stage UI:** Clean interface with a drag-and-drop style "Open Image" workflow and tabbed result previews. 19 | 20 | ### Color & Layering 21 | * **YOLO Mode (Auto-Scan):** One-click analysis that detects dominant colors, sorts them by visual similarity (brightness/hue), and assigns layer numbers automatically. 22 | * **Hybrid Selection:** Combine auto-detection with manual color picking by clicking anywhere on the canvas. 23 | * **Bulk Assignment:** Select multiple color swatches via checkboxes and assign them to a single output layer in one click. 24 | * **Orphan Detection:** Optional mode to capture background "white space" or unassigned pixels to ensure 100% surface coverage. 25 | 26 | ### Processing Engine 27 | * **Smart Denoising:** Configurable Gaussian blur and morphological operations to smooth out pixel noise before vectorization. 28 | * **Min Blob Filtering:** Automatically removes tiny, isolated speckles ("dust") that are too small to cut or print. 29 | * **Vector Smoothing:** Adjustable Ramer-Douglas-Peucker simplification for organic or angular aesthetic styles. 30 | 31 | ### Export Capabilities 32 | * **2D Vector (SVG):** Exports clean, layered SVG bundles compatible with Laser Cutters, Cricut, and Silhouette machines. 33 | * **3D Model (STL):** 34 | * **Stencil Mode:** Inverted plates with holes (for spray painting). Includes **Auto-Bridging** logic to make stencils physically viable. 35 | * **Solid Mode:** Positive extrusions for texture mapping or physical inlays. 36 | * **Precision:** Define exact physical width (mm/in), extrusion height, and border width. 37 | 38 | ## Installation 39 | 40 | ### Prerequisites 41 | Ensure you have Python 3.x, venv, and pip installed. The application relies on the following libraries: 42 | 43 | ```bash 44 | pip install opencv-python numpy svgwrite Pillow trimesh shapely 45 | ``` 46 | 47 | ### Quick Start (Windows) 48 | 1. Double-click **`setup_windows.bat`** (if provided) to install dependencies. 49 | 2. Double-click **`run.bat`** to launch. 50 | 51 | ### Quick Start (Linux) 52 | 1. Open a terminal in the directory. 53 | 2. Run setup: `chmod +x setup_linux.sh && ./setup_linux.sh` 54 | 3. Launch: `./run.sh` 55 | 56 | ## Workflow Guide 57 | 58 | ### 1. Import 59 | Click the central **OPEN IMAGE** button (or `Ctrl+O`). Supported formats: JPG, PNG, BMP. 60 | 61 | ### 2. Define Colors 62 | * **Auto:** Click **YOLO Scan** to let the app decide. 63 | * **Manual:** Click on the image to pick colors. 64 | * **Refine:** Use the sidebar to merge similar colors into the same **Layer #**. Grouping 3 shades of green into "Layer 1" creates a complex, organic pattern. 65 | 66 | ### 3. Process 67 | Click **PROCESS IMAGE** (`Ctrl+P`). The app will generate masks, apply smoothing, and create a preview. Switch between tabs to see individual layers. 68 | 69 | ### 4. Export 70 | * **2D:** File > Export SVG Bundle (`Ctrl+E`). 71 | * **3D:** File > Export STL Models (`Ctrl+Shift+E`). 72 | * *Tip:* For spray stencils, ensure **Invert** is checked and **Bridge Width** is set to at least 2.0mm. 73 | 74 | ## Keyboard Shortcuts 75 | 76 | | Action | Shortcut | 77 | | :--- | :--- | 78 | | **New Project** | `Ctrl + N` | 79 | | **Open Image** | `Ctrl + O` | 80 | | **Open Project** | `Ctrl + Shift + O` | 81 | | **Save Project** | `Ctrl + S` | 82 | | **Process** | `Ctrl + P` | 83 | | **YOLO Scan** | `Ctrl + Y` | 84 | | **Export 2D** | `Ctrl + E` | 85 | | **Export 3D** | `Ctrl + Shift + E` | 86 | | **Config** | `Ctrl + ,` | 87 | 88 | ## Configuration Details 89 | 90 | Settings are accessed via **Properties > Configuration** and are auto-saved on exit. 91 | 92 | | Setting | Description | 93 | | :--- | :--- | 94 | | **Max Color Count** | (Auto-Mode only) How many clusters K-Means should look for. | 95 | | **Denoise Strength** | Higher values blur the input more, resulting in smoother, rounder blobs. | 96 | | **Path Smoothing** | Lower (0.0001) = High detail/organic. Higher (0.005) = Low poly/angular. | 97 | | **Min Blob Size** | Filters out small islands (noise) in pixels. | 98 | | **Orphaned Blobs** | Forces the app to create a layer for any pixels not covered by selected colors. | 99 | | **Filename Template** | Naming convention for exports (e.g., `%INPUTFILENAME%-%COLOR%`). | 100 | 101 | -------------------------------------------------------------------------------- /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 [yyyy] [name of copyright owner] 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 | -------------------------------------------------------------------------------- /camo_studio.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import filedialog, messagebox, ttk 3 | import cv2 4 | import numpy as np 5 | import svgwrite 6 | import os 7 | import threading 8 | import json 9 | from PIL import Image, ImageTk 10 | 11 | # --- 3D IMPORTS --- 12 | import trimesh 13 | from shapely.geometry import Polygon, MultiPolygon, LineString 14 | from shapely.ops import unary_union, nearest_points 15 | 16 | # --- DEFAULTS --- 17 | DEFAULT_MAX_COLORS = 3 18 | DEFAULT_MAX_WIDTH = 4096 19 | DEFAULT_SMOOTHING = 0.0001 20 | DEFAULT_DENOISE = 3 21 | DEFAULT_MIN_BLOB = 100 22 | DEFAULT_TEMPLATE = "%INPUTFILENAME%-%COLOR%-%INDEX%" 23 | 24 | def bgr_to_hex(bgr): 25 | return '#{:02x}{:02x}{:02x}'.format(int(bgr[2]), int(bgr[1]), int(bgr[0])) 26 | 27 | def is_bright(bgr): 28 | return (bgr[2] * 0.299 + bgr[1] * 0.587 + bgr[0] * 0.114) > 186 29 | 30 | def filter_small_blobs(mask, min_size): 31 | """ Optimized area filtering using Connected Components (Raster). """ 32 | if min_size <= 0: return mask 33 | n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) 34 | valid_labels = (stats[:, cv2.CC_STAT_AREA] >= min_size) 35 | valid_labels[0] = False 36 | lut = np.zeros(n, dtype=np.uint8) 37 | lut[valid_labels] = 255 38 | return lut[labels] 39 | 40 | class AutoResizingCanvas(tk.Canvas): 41 | def __init__(self, parent, pil_image, **kwargs): 42 | super().__init__(parent, **kwargs) 43 | self.pil_image = pil_image 44 | self.displayed_image = None 45 | self.scale_ratio = 1.0 46 | self.offset_x = 0 47 | self.offset_y = 0 48 | self.bind("", self.on_resize) 49 | 50 | def on_resize(self, event): 51 | if not self.pil_image: return 52 | canvas_width = event.width 53 | canvas_height = event.height 54 | if canvas_width < 10 or canvas_height < 10: return 55 | 56 | img_w, img_h = self.pil_image.size 57 | self.scale_ratio = min(canvas_width / img_w, canvas_height / img_h) 58 | 59 | new_w = int(img_w * self.scale_ratio) 60 | new_h = int(img_h * self.scale_ratio) 61 | 62 | resized_pil = self.pil_image.resize((new_w, new_h), Image.Resampling.LANCZOS) 63 | self.displayed_image = ImageTk.PhotoImage(resized_pil) 64 | 65 | self.delete("all") 66 | self.offset_x = (canvas_width - new_w) // 2 67 | self.offset_y = (canvas_height - new_h) // 2 68 | self.create_image(self.offset_x, self.offset_y, anchor="nw", image=self.displayed_image) 69 | 70 | def get_image_coordinates(self, screen_x, screen_y): 71 | if not self.pil_image: return None 72 | rel_x = screen_x - self.offset_x 73 | rel_y = screen_y - self.offset_y 74 | img_x = int(rel_x / self.scale_ratio) 75 | img_y = int(rel_y / self.scale_ratio) 76 | w, h = self.pil_image.size 77 | if 0 <= img_x < w and 0 <= img_y < h: 78 | return (img_x, img_y) 79 | return None 80 | 81 | class CamoStudioApp: 82 | def __init__(self, root): 83 | self.root = root 84 | self.root.title("Camo Studio v29 - Persistent Config") 85 | self.root.geometry("1200x850") 86 | 87 | # --- 1. Define Settings File & Directory Memory --- 88 | self.settings_file = "user_settings.json" 89 | self.last_opened_dir = os.getcwd() 90 | 91 | # --- 2. Initialize Variables with Defaults --- 92 | self.config = { 93 | "max_colors": tk.IntVar(value=DEFAULT_MAX_COLORS), 94 | "max_width": tk.IntVar(value=DEFAULT_MAX_WIDTH), 95 | "denoise_strength": tk.IntVar(value=DEFAULT_DENOISE), 96 | "min_blob_size": tk.IntVar(value=DEFAULT_MIN_BLOB), 97 | "filename_template": tk.StringVar(value=DEFAULT_TEMPLATE), 98 | "smoothing": tk.DoubleVar(value=DEFAULT_SMOOTHING), 99 | "orphaned_blobs": tk.BooleanVar(value=False) 100 | } 101 | 102 | # 3D Export Vars 103 | self.exp_units = tk.StringVar(value="mm") 104 | self.exp_width = tk.DoubleVar(value=100.0) 105 | self.exp_height = tk.DoubleVar(value=2.0) 106 | self.exp_border = tk.DoubleVar(value=5.0) 107 | self.exp_bridge = tk.DoubleVar(value=2.0) 108 | self.exp_invert = tk.BooleanVar(value=True) 109 | 110 | # --- 3. Load Persistent Settings --- 111 | self.load_app_settings() 112 | 113 | self.original_image_path = None 114 | self.cv_original_full = None 115 | self.current_base_name = "camo" 116 | 117 | # State 118 | self.picked_colors = [] 119 | self.layer_vars = [] 120 | self.select_vars = [] 121 | self.bulk_target_layer = tk.IntVar(value=1) 122 | self.last_select_index = -1 123 | self.processed_data = None 124 | self.preview_images = {} 125 | 126 | self._create_ui() 127 | self._bind_shortcuts() 128 | 129 | # --- 4. Bind Close Event to Save --- 130 | self.root.protocol("WM_DELETE_WINDOW", self.on_close) 131 | 132 | def load_app_settings(self): 133 | """Loads configuration and last directory from JSON on startup.""" 134 | if not os.path.exists(self.settings_file): return 135 | try: 136 | with open(self.settings_file, 'r') as f: 137 | data = json.load(f) 138 | 139 | # Load Config Dict 140 | cfg = data.get("config", {}) 141 | for k, v in cfg.items(): 142 | if k in self.config: 143 | try: 144 | self.config[k].set(v) 145 | except: pass 146 | 147 | # Load Export Settings 148 | exp = data.get("export", {}) 149 | self.exp_units.set(exp.get("units", "mm")) 150 | self.exp_width.set(exp.get("width", 100.0)) 151 | self.exp_height.set(exp.get("height", 2.0)) 152 | self.exp_border.set(exp.get("border", 5.0)) 153 | self.exp_bridge.set(exp.get("bridge", 2.0)) 154 | self.exp_invert.set(exp.get("invert", True)) 155 | 156 | # Load Last Directory 157 | last_dir = data.get("last_directory", "") 158 | if last_dir and os.path.exists(last_dir): 159 | self.last_opened_dir = last_dir 160 | 161 | print("Settings loaded successfully.") 162 | 163 | except Exception as e: 164 | print(f"Failed to load settings: {e}") 165 | 166 | def save_app_settings(self): 167 | """Saves current configuration and directory to JSON.""" 168 | data = { 169 | "config": {k: v.get() for k, v in self.config.items()}, 170 | "export": { 171 | "units": self.exp_units.get(), 172 | "width": self.exp_width.get(), 173 | "height": self.exp_height.get(), 174 | "border": self.exp_border.get(), 175 | "bridge": self.exp_bridge.get(), 176 | "invert": self.exp_invert.get() 177 | }, 178 | "last_directory": self.last_opened_dir 179 | } 180 | try: 181 | with open(self.settings_file, 'w') as f: 182 | json.dump(data, f, indent=4) 183 | print("Settings saved.") 184 | except Exception as e: 185 | print(f"Failed to save settings: {e}") 186 | 187 | def on_close(self): 188 | """Handler for window close event.""" 189 | self.save_app_settings() 190 | self.root.destroy() 191 | 192 | def _bind_shortcuts(self): 193 | self.root.bind("", lambda e: self.reset_project()) 194 | self.root.bind("", lambda e: self.load_image()) 195 | self.root.bind("", lambda e: self.save_project_json()) 196 | self.root.bind("", lambda e: self.load_project_json()) 197 | self.root.bind("", self.trigger_process) 198 | self.root.bind("", self.yolo_scan) 199 | self.root.bind("", self.export_bundle_2d) 200 | self.root.bind("", self.open_config_window) 201 | 202 | def _create_ui(self): 203 | menubar = tk.Menu(self.root) 204 | file_menu = tk.Menu(menubar, tearoff=0) 205 | file_menu.add_command(label="New Project (Ctrl+N)", command=self.reset_project) 206 | file_menu.add_command(label="Open Image... (Ctrl+O)", command=lambda: self.load_image()) 207 | file_menu.add_separator() 208 | file_menu.add_command(label="Open Project... (Ctrl+Shift+O)", command=self.load_project_json) 209 | file_menu.add_command(label="Save Project (Ctrl+S)", command=self.save_project_json) 210 | file_menu.add_separator() 211 | file_menu.add_command(label="Export SVG Bundle (Ctrl+E)", command=self.export_bundle_2d) 212 | file_menu.add_command(label="Export STL Models (Ctrl+Shift+E)", command=self.open_3d_export_window) 213 | file_menu.add_separator() 214 | file_menu.add_command(label="Exit", command=self.on_close) # Use on_close here too 215 | menubar.add_cascade(label="File", menu=file_menu) 216 | 217 | prop_menu = tk.Menu(menubar, tearoff=0) 218 | prop_menu.add_command(label="Configuration (Ctrl+,)", command=self.open_config_window) 219 | menubar.add_cascade(label="Properties", menu=prop_menu) 220 | self.root.config(menu=menubar) 221 | 222 | self.toolbar = tk.Frame(self.root, padx=10, pady=10, bg="#ddd") 223 | self.toolbar.pack(side=tk.TOP, fill=tk.X) 224 | tk.Label(self.toolbar, text="Pick Colors -> Assign Layers -> Process -> Export", bg="#ddd", fg="#555").pack(side=tk.LEFT) 225 | self.btn_process = tk.Button(self.toolbar, text="PROCESS IMAGE", command=self.trigger_process, bg="#4CAF50", fg="white", font=("Arial", 10, "bold")) 226 | self.btn_process.pack(side=tk.RIGHT, padx=10) 227 | 228 | self.notebook = ttk.Notebook(self.root) 229 | self.notebook.pack(expand=True, fill="both", padx=10, pady=5) 230 | 231 | self.tab_main = tk.Frame(self.notebook) 232 | self.notebook.add(self.tab_main, text="Input / Preview") 233 | 234 | self.input_container = tk.Frame(self.tab_main) 235 | self.input_container.pack(fill="both", expand=True) 236 | 237 | self.swatch_sidebar = tk.Frame(self.input_container, width=280, bg="#f0f0f0", padx=5, pady=5) 238 | self.swatch_sidebar.pack(side=tk.LEFT, fill="y") 239 | self.swatch_sidebar.pack_propagate(False) 240 | 241 | self.sidebar_tools = tk.Frame(self.swatch_sidebar, bg="#f0f0f0") 242 | self.sidebar_tools.pack(side=tk.TOP, fill="x", pady=(0, 5)) 243 | tk.Button(self.sidebar_tools, text="YOLO Scan (Auto-Detect)", command=self.yolo_scan, 244 | bg="#FF9800", fg="white", font=("Arial", 9, "bold")).pack(fill="x", padx=5) 245 | 246 | self.bulk_frame = tk.Frame(self.swatch_sidebar, bg="#e0e0e0", padx=5, pady=5) 247 | self.bulk_frame.pack(side=tk.BOTTOM, fill="x") 248 | 249 | bf_header = tk.Frame(self.bulk_frame, bg="#e0e0e0") 250 | bf_header.pack(fill="x", pady=(0,2)) 251 | tk.Label(bf_header, text="Bulk Assign:", bg="#e0e0e0", font=("Arial", 8, "bold")).pack(side=tk.LEFT) 252 | tk.Button(bf_header, text="Clear List", command=self.reset_picks, bg="#ffdddd", font=("Arial", 7)).pack(side=tk.RIGHT) 253 | 254 | bf_inner = tk.Frame(self.bulk_frame, bg="#e0e0e0") 255 | bf_inner.pack(fill="x", pady=2) 256 | tk.Label(bf_inner, text="Sel. to Layer:", bg="#e0e0e0", font=("Arial", 8)).pack(side=tk.LEFT) 257 | tk.Spinbox(bf_inner, from_=1, to=999, width=4, textvariable=self.bulk_target_layer).pack(side=tk.LEFT, padx=5) 258 | tk.Button(bf_inner, text="Apply", command=self.apply_bulk_layer, bg="#ccc", font=("Arial", 8)).pack(side=tk.LEFT) 259 | 260 | self.swatch_container = tk.Frame(self.swatch_sidebar, bg="#f0f0f0") 261 | self.swatch_container.pack(side=tk.LEFT, fill="both", expand=True) 262 | 263 | self.swatch_canvas = tk.Canvas(self.swatch_container, bg="#f0f0f0", highlightthickness=0) 264 | self.swatch_scrollbar = ttk.Scrollbar(self.swatch_container, orient="vertical", command=self.swatch_canvas.yview) 265 | 266 | self.swatch_list_frame = tk.Frame(self.swatch_canvas, bg="#f0f0f0") 267 | self.swatch_list_frame.bind("", lambda e: self.swatch_canvas.configure(scrollregion=self.swatch_canvas.bbox("all"))) 268 | self.swatch_window = self.swatch_canvas.create_window((0, 0), window=self.swatch_list_frame, anchor="nw") 269 | self.swatch_canvas.bind("", lambda e: self.swatch_canvas.itemconfig(self.swatch_window, width=e.width)) 270 | self.swatch_canvas.configure(yscrollcommand=self.swatch_scrollbar.set) 271 | 272 | self.swatch_scrollbar.pack(side=tk.RIGHT, fill="y") 273 | self.swatch_canvas.pack(side=tk.LEFT, fill="both", expand=True) 274 | 275 | def _on_mousewheel(event): 276 | self.swatch_canvas.yview_scroll(int(-1*(event.delta/120)), "units") 277 | self.swatch_canvas.bind_all("", _on_mousewheel) 278 | 279 | self.canvas_frame = tk.Frame(self.input_container, bg="#333") 280 | self.canvas_frame.pack(side=tk.LEFT, fill="both", expand=True) 281 | 282 | # --- OPEN BUTTON CENTERED --- 283 | self.btn_main_load = tk.Button(self.canvas_frame, text="OPEN IMAGE", command=lambda: self.load_image(), 284 | font=("Arial", 16, "bold"), bg="#555", fg="white", padx=20, pady=10, cursor="hand2") 285 | self.btn_main_load.place(relx=0.5, rely=0.5, anchor="center") 286 | 287 | self.main_canvas = None 288 | 289 | self.progress_var = tk.DoubleVar() 290 | self.progress = ttk.Progressbar(self.root, variable=self.progress_var, maximum=100) 291 | self.progress.pack(side=tk.BOTTOM, fill=tk.X) 292 | self.lbl_status = tk.Label(self.root, text="Ready.", anchor="w") 293 | self.lbl_status.pack(side=tk.BOTTOM, fill=tk.X) 294 | 295 | def open_config_window(self, event=None): 296 | top = tk.Toplevel(self.root) 297 | top.title("Properties") 298 | top.geometry("600x600") 299 | form = tk.Frame(top, padx=20, pady=20) 300 | form.pack(fill="both", expand=True) 301 | form.columnconfigure(1, weight=1) 302 | 303 | row = 0 304 | tk.Label(form, text="Max Color Count (Auto-Mode):").grid(row=row, column=0, sticky="w") 305 | tk.Entry(form, textvariable=self.config["max_colors"]).grid(row=row, column=1, sticky="ew", pady=5); row+=1 306 | tk.Label(form, text="Denoise Strength:").grid(row=row, column=0, sticky="w") 307 | tk.Scale(form, from_=0, to=20, orient=tk.HORIZONTAL, variable=self.config["denoise_strength"]).grid(row=row, column=1, sticky="ew", pady=5); row+=1 308 | tk.Label(form, text="Path Smoothing:").grid(row=row, column=0, sticky="w") 309 | tk.Scale(form, from_=0.0001, to=0.005, resolution=0.0001, orient=tk.HORIZONTAL, variable=self.config["smoothing"]).grid(row=row, column=1, sticky="ew", pady=5); row+=1 310 | tk.Label(form, text="Lower = More Detail. Higher = Smoother.", font=("Arial", 8), fg="gray").grid(row=row, column=1, sticky="w"); row+=1 311 | tk.Label(form, text="Min Blob Size (px):").grid(row=row, column=0, sticky="w") 312 | tk.Entry(form, textvariable=self.config["min_blob_size"]).grid(row=row, column=1, sticky="ew", pady=5); row+=1 313 | 314 | tk.Label(form, text="Orphaned Blobs:").grid(row=row, column=0, sticky="w") 315 | tk.Checkbutton(form, text="Detect & Assign Random Color", variable=self.config["orphaned_blobs"]).grid(row=row, column=1, sticky="w", pady=5); row+=1 316 | 317 | tk.Label(form, text="Max Width (px):").grid(row=row, column=0, sticky="w") 318 | tk.Entry(form, textvariable=self.config["max_width"]).grid(row=row, column=1, sticky="ew", pady=5); row+=1 319 | tk.Label(form, text="Filename Template:").grid(row=row, column=0, sticky="w") 320 | tk.Entry(form, textvariable=self.config["filename_template"]).grid(row=row, column=1, sticky="ew", pady=5); row+=1 321 | tk.Button(top, text="Close", command=top.destroy).pack(pady=10) 322 | 323 | def open_3d_export_window(self, event=None): 324 | if not self.processed_data: 325 | messagebox.showwarning("No Data", "Process an image first.") 326 | return 327 | win = tk.Toplevel(self.root) 328 | win.title("Export 3D Models") 329 | win.geometry("450x450") 330 | form = tk.Frame(win, padx=20, pady=20) 331 | form.pack(fill="both", expand=True) 332 | tk.Label(form, text="3D Stencil Settings", font=("Arial", 10, "bold")).pack(pady=10) 333 | tk.Checkbutton(form, text="Invert (Stencil Mode)", variable=self.exp_invert, font=("Arial", 9, "bold")).pack(pady=5) 334 | tk.Label(form, text="Checked: Blobs are holes.\nUnchecked: Blobs are solid.", font=("Arial", 8), fg="gray").pack(pady=(0, 10)) 335 | u_frame = tk.Frame(form); u_frame.pack(fill="x", pady=5) 336 | tk.Label(u_frame, text="Units:").pack(side=tk.LEFT) 337 | tk.Radiobutton(u_frame, text="Millimeters", variable=self.exp_units, value="mm").pack(side=tk.LEFT, padx=10) 338 | tk.Radiobutton(u_frame, text="Inches", variable=self.exp_units, value="in").pack(side=tk.LEFT) 339 | w_frame = tk.Frame(form); w_frame.pack(fill="x", pady=5) 340 | tk.Label(w_frame, text="Total Width:").pack(side=tk.LEFT) 341 | tk.Entry(w_frame, textvariable=self.exp_width, width=10).pack(side=tk.RIGHT) 342 | h_frame = tk.Frame(form); h_frame.pack(fill="x", pady=5) 343 | tk.Label(h_frame, text="Extrusion Height:").pack(side=tk.LEFT) 344 | tk.Entry(h_frame, textvariable=self.exp_height, width=10).pack(side=tk.RIGHT) 345 | b_frame = tk.Frame(form); b_frame.pack(fill="x", pady=5) 346 | tk.Label(b_frame, text="Solid Border Width:").pack(side=tk.LEFT) 347 | tk.Entry(b_frame, textvariable=self.exp_border, width=10).pack(side=tk.RIGHT) 348 | 349 | br_frame = tk.Frame(form); br_frame.pack(fill="x", pady=5) 350 | tk.Label(br_frame, text="Stencil Bridge Width:").pack(side=tk.LEFT) 351 | tk.Entry(br_frame, textvariable=self.exp_bridge, width=10).pack(side=tk.RIGHT) 352 | tk.Label(form, text="(Automatically connects floating islands)", font=("Arial", 7), fg="gray").pack() 353 | 354 | tk.Button(form, text="Export STL Files", command=lambda: self.trigger_3d_export(win), bg="blue", fg="white").pack(pady=20, fill="x") 355 | 356 | def trigger_3d_export(self, parent_window): 357 | # Update directory from dialog 358 | target_dir = filedialog.askdirectory(initialdir=self.last_opened_dir) 359 | if not target_dir: return 360 | self.last_opened_dir = target_dir # Remember this dir 361 | 362 | parent_window.destroy() 363 | self.progress['mode'] = 'determinate' 364 | self.progress_var.set(0) 365 | threading.Thread(target=self.export_3d_thread, args=(target_dir,)).start() 366 | 367 | def reset_project(self): 368 | """Clears all data and UI to start fresh.""" 369 | if self.picked_colors and not messagebox.askyesno("New Project", "Discard current changes?"): 370 | return 371 | 372 | self.original_image_path = None 373 | self.cv_original_full = None 374 | self.current_base_name = "camo" 375 | 376 | # Clear Data 377 | self.picked_colors = [] 378 | self.layer_vars = [] 379 | self.select_vars = [] 380 | self.last_select_index = -1 381 | self.processed_data = None 382 | self.preview_images = {} 383 | 384 | # Clear UI 385 | self.update_pick_ui() 386 | if self.main_canvas: 387 | self.main_canvas.destroy() 388 | self.main_canvas = None 389 | 390 | # Remove extra tabs 391 | for tab in self.notebook.tabs(): 392 | if tab != str(self.tab_main): self.notebook.forget(tab) 393 | 394 | # Show Open Button 395 | self.btn_main_load.place(relx=0.5, rely=0.5, anchor="center") 396 | self.lbl_status.config(text="Project cleared.") 397 | 398 | def load_image(self, from_path=None): 399 | path = from_path 400 | if not path: 401 | path = filedialog.askopenfilename(initialdir=self.last_opened_dir, filetypes=[("Images", "*.jpg *.jpeg *.png *.bmp")]) 402 | 403 | if not path: return 404 | self.last_opened_dir = os.path.dirname(path) # Remember this dir 405 | 406 | # If file not found (e.g. moved project file) 407 | if not os.path.exists(path): 408 | messagebox.showerror("Error", f"Image file not found:\n{path}\nPlease locate it manually.") 409 | path = filedialog.askopenfilename(initialdir=self.last_opened_dir, filetypes=[("Images", "*.jpg *.jpeg *.png *.bmp")]) 410 | if not path: return 411 | self.last_opened_dir = os.path.dirname(path) 412 | 413 | self.original_image_path = path 414 | self.current_base_name = os.path.splitext(os.path.basename(path))[0] 415 | self.cv_original_full = cv2.imread(path) 416 | rgb_img = cv2.cvtColor(self.cv_original_full, cv2.COLOR_BGR2RGB) 417 | pil_img = Image.fromarray(rgb_img) 418 | 419 | # Hide the center button 420 | self.btn_main_load.place_forget() 421 | 422 | if self.main_canvas: self.main_canvas.destroy() 423 | self.main_canvas = AutoResizingCanvas(self.canvas_frame, pil_image=pil_img, bg="#333", highlightthickness=0) 424 | self.main_canvas.pack(fill="both", expand=True) 425 | self.main_canvas.bind("", self.on_canvas_click) 426 | 427 | # Only reset picks if we are doing a manual load, not a project load 428 | if from_path is None: 429 | self.reset_picks() 430 | 431 | self.lbl_status.config(text=f"Loaded: {os.path.basename(path)}") 432 | 433 | def save_project_json(self): 434 | if not self.original_image_path: 435 | messagebox.showwarning("Warning", "No image loaded to save.") 436 | return 437 | 438 | # Sanitize colors: Convert numpy.uint8 to standard python int 439 | sanitized_colors = [tuple(int(x) for x in c) for c in self.picked_colors] 440 | 441 | data = { 442 | "version": "1.0", 443 | "image_path": self.original_image_path, 444 | "config": {k: v.get() for k, v in self.config.items()}, 445 | "colors": sanitized_colors, 446 | "layers": [v.get() for v in self.layer_vars], 447 | "3d_export": { 448 | "units": self.exp_units.get(), 449 | "width": self.exp_width.get(), 450 | "height": self.exp_height.get(), 451 | "border": self.exp_border.get(), 452 | "bridge": self.exp_bridge.get(), 453 | "invert": self.exp_invert.get() 454 | } 455 | } 456 | 457 | path = filedialog.asksaveasfilename(initialdir=self.last_opened_dir, defaultextension=".json", filetypes=[("Camo Project", "*.json")]) 458 | if path: 459 | self.last_opened_dir = os.path.dirname(path) # Remember this dir 460 | try: 461 | with open(path, 'w') as f: 462 | json.dump(data, f, indent=4) 463 | self.lbl_status.config(text=f"Project saved to {os.path.basename(path)}") 464 | except Exception as e: 465 | messagebox.showerror("Save Error", str(e)) 466 | 467 | def load_project_json(self): 468 | if self.picked_colors and not messagebox.askyesno("Open Project", "Discard current changes?"): 469 | return 470 | 471 | path = filedialog.askopenfilename(initialdir=self.last_opened_dir, filetypes=[("Camo Project", "*.json")]) 472 | if not path: return 473 | self.last_opened_dir = os.path.dirname(path) # Remember this dir 474 | 475 | try: 476 | with open(path, 'r') as f: 477 | data = json.load(f) 478 | 479 | # 1. Load Image 480 | self.load_image(from_path=data.get("image_path")) 481 | 482 | # 2. Restore Config 483 | if "config" in data: 484 | for k, v in data["config"].items(): 485 | if k in self.config: 486 | try: 487 | self.config[k].set(v) 488 | except: pass 489 | 490 | # 3. Restore 3D Settings 491 | if "3d_export" in data: 492 | ex = data["3d_export"] 493 | self.exp_units.set(ex.get("units", "mm")) 494 | self.exp_width.set(ex.get("width", 100.0)) 495 | self.exp_height.set(ex.get("height", 2.0)) 496 | self.exp_border.set(ex.get("border", 5.0)) 497 | self.exp_bridge.set(ex.get("bridge", 2.0)) 498 | self.exp_invert.set(ex.get("invert", True)) 499 | 500 | # 4. Restore Palette & Layers 501 | self.picked_colors = [tuple(c) for c in data.get("colors", [])] 502 | saved_layers = data.get("layers", []) 503 | 504 | self.layer_vars = [] 505 | self.select_vars = [] 506 | 507 | for i in range(len(self.picked_colors)): 508 | lid = saved_layers[i] if i < len(saved_layers) else 1 509 | self.layer_vars.append(tk.IntVar(value=lid)) 510 | self.select_vars.append(tk.BooleanVar(value=False)) 511 | 512 | self.update_pick_ui() 513 | self.lbl_status.config(text=f"Project loaded: {os.path.basename(path)}") 514 | 515 | except Exception as e: 516 | messagebox.showerror("Load Error", f"Failed to load project:\n{str(e)}") 517 | 518 | def yolo_scan(self, event=None): 519 | if self.cv_original_full is None: 520 | messagebox.showinfo("Info", "Load an image first.") 521 | return 522 | 523 | if self.picked_colors: 524 | if not messagebox.askyesno("YOLO Mode", "This will replace your current palette. Continue?"): 525 | return 526 | 527 | self.picked_colors = [] 528 | self.layer_vars = [] 529 | self.select_vars = [] 530 | 531 | img = self.cv_original_full.copy() 532 | max_analysis_w = 300 533 | h, w = img.shape[:2] 534 | if w > max_analysis_w: 535 | scale = max_analysis_w / w 536 | img = cv2.resize(img, (max_analysis_w, int(h * scale)), interpolation=cv2.INTER_AREA) 537 | 538 | data = img.reshape((-1, 3)).astype(np.float32) 539 | 540 | unique_colors = np.unique(data.astype(np.uint8), axis=0) 541 | final_colors = [] 542 | 543 | if len(unique_colors) <= 64: 544 | print(f"YOLO: Found {len(unique_colors)} unique colors. Using Exact.") 545 | final_colors = [tuple(int(x) for x in c) for c in unique_colors] 546 | else: 547 | print(f"YOLO: Too many colors. Quantizing to 32.") 548 | criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0) 549 | ret, label, center = cv2.kmeans(data, 32, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) 550 | center = np.uint8(center) 551 | final_colors = [tuple(int(x) for x in c) for c in center] 552 | 553 | self.picked_colors = final_colors 554 | self.reorder_palette_by_similarity() 555 | 556 | target_layers = self.config["max_colors"].get() 557 | if len(self.picked_colors) > target_layers: 558 | print(f"YOLO: Grouping {len(self.picked_colors)} colors into {target_layers} layers.") 559 | palette_data = np.array(self.picked_colors, dtype=np.float32) 560 | criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0) 561 | ret, labels, centers = cv2.kmeans(palette_data, target_layers, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) 562 | centers_info = [] 563 | for i, center in enumerate(centers): 564 | centers_info.append( {'id': i, 'val': sum(center)} ) 565 | centers_info.sort(key=lambda x: x['val'], reverse=True) 566 | cluster_to_layer_map = {} 567 | for new_layer_num, info in enumerate(centers_info): 568 | cluster_to_layer_map[info['id']] = new_layer_num + 1 569 | for i, cluster_idx in enumerate(labels.flatten()): 570 | new_layer_id = cluster_to_layer_map[cluster_idx] 571 | self.layer_vars[i].set(new_layer_id) 572 | 573 | self.update_pick_ui() 574 | self.lbl_status.config(text=f"YOLO Mode: {len(self.picked_colors)} colors grouped into {target_layers} layers.") 575 | 576 | def on_canvas_click(self, event): 577 | if self.cv_original_full is None: return 578 | coords = self.main_canvas.get_image_coordinates(event.x, event.y) 579 | if coords: 580 | x, y = coords 581 | if y < self.cv_original_full.shape[0] and x < self.cv_original_full.shape[1]: 582 | bgr_color = self.cv_original_full[y, x] 583 | bgr_tuple = tuple(int(x) for x in bgr_color) 584 | if bgr_tuple in self.picked_colors: 585 | self.lbl_status.config(text="Color already in palette.") 586 | return 587 | self.picked_colors.append(bgr_tuple) 588 | self.reorder_palette_by_similarity() 589 | self.update_pick_ui() 590 | self.lbl_status.config(text=f"Color added & sorted. Total: {len(self.picked_colors)}") 591 | 592 | def reorder_palette_by_similarity(self): 593 | if not self.picked_colors: return 594 | while len(self.layer_vars) < len(self.picked_colors): 595 | existing_ids = [v.get() for v in self.layer_vars] 596 | next_id = max(existing_ids) + 1 if existing_ids else 1 597 | self.layer_vars.append(tk.IntVar(value=next_id)) 598 | while len(self.select_vars) < len(self.picked_colors): 599 | self.select_vars.append(tk.BooleanVar(value=False)) 600 | 601 | groups = {} 602 | for i, color in enumerate(self.picked_colors): 603 | lid = self.layer_vars[i].get() 604 | if lid not in groups: groups[lid] = [] 605 | groups[lid].append({'color': color, 'var': self.layer_vars[i], 'select': self.select_vars[i]}) 606 | 607 | group_metrics = [] 608 | for lid, items in groups.items(): 609 | avg_b = np.mean([sum(x['color']) for x in items]) 610 | group_metrics.append({'lid': lid, 'brightness': avg_b, 'items': items}) 611 | 612 | group_metrics.sort(key=lambda x: x['brightness'], reverse=True) 613 | 614 | new_colors = [] 615 | new_layer_vars = [] 616 | new_select_vars = [] 617 | current_layer_num = 1 618 | 619 | for g in group_metrics: 620 | items = g['items'] 621 | items.sort(key=lambda x: sum(x['color']), reverse=True) 622 | for item in items: 623 | new_colors.append(item['color']) 624 | new_layer_vars.append(tk.IntVar(value=current_layer_num)) 625 | new_select_vars.append(item['select']) 626 | current_layer_num += 1 627 | 628 | self.picked_colors = new_colors 629 | self.layer_vars = new_layer_vars 630 | self.select_vars = new_select_vars 631 | 632 | def remove_color(self, index): 633 | if 0 <= index < len(self.picked_colors): 634 | del self.picked_colors[index] 635 | del self.layer_vars[index] 636 | del self.select_vars[index] 637 | self.compact_layer_ids() 638 | self.update_pick_ui() 639 | self.lbl_status.config(text=f"Color removed. Total: {len(self.picked_colors)}") 640 | 641 | def reset_picks(self, event=None): 642 | self.picked_colors = [] 643 | self.layer_vars = [] 644 | self.select_vars = [] 645 | self.last_select_index = -1 646 | self.update_pick_ui() 647 | if self.cv_original_full is not None: 648 | for tab in self.notebook.tabs(): 649 | if tab != str(self.tab_main): self.notebook.forget(tab) 650 | 651 | def apply_bulk_layer(self): 652 | target = self.bulk_target_layer.get() 653 | changed = False 654 | for i, var in enumerate(self.select_vars): 655 | if var.get(): 656 | self.layer_vars[i].set(target) 657 | changed = True 658 | var.set(False) 659 | if changed: 660 | self.compact_layer_ids() 661 | self.update_pick_ui() 662 | self.lbl_status.config(text="Bulk assignment complete. Layers re-numbered.") 663 | else: 664 | messagebox.showinfo("Info", "No colors selected.") 665 | 666 | def compact_layer_ids(self): 667 | current_ids = sorted(list(set(v.get() for v in self.layer_vars))) 668 | id_map = {old: new+1 for new, old in enumerate(current_ids)} 669 | for var in self.layer_vars: 670 | var.set(id_map[var.get()]) 671 | 672 | def handle_click_selection(self, index, event): 673 | if event and (event.state & 0x0001): # Shift Key Held 674 | if self.last_select_index != -1: 675 | start = min(self.last_select_index, index) 676 | end = max(self.last_select_index, index) 677 | for i in range(start, end + 1): 678 | self.select_vars[i].set(True) 679 | else: 680 | self.last_select_index = index 681 | 682 | def update_pick_ui(self): 683 | for widget in self.swatch_list_frame.winfo_children(): 684 | widget.destroy() 685 | if not self.picked_colors: 686 | tk.Label(self.swatch_list_frame, text="Auto-Mode", bg="#f0f0f0").pack(pady=10) 687 | return 688 | h_frame = tk.Frame(self.swatch_list_frame, bg="#f0f0f0") 689 | h_frame.pack(fill="x", pady=2) 690 | tk.Label(h_frame, text="Sel", bg="#f0f0f0", font=("Arial", 7)).pack(side=tk.LEFT, padx=2) 691 | tk.Label(h_frame, text="Color", bg="#f0f0f0", font=("Arial", 8, "bold")).pack(side=tk.LEFT, padx=5) 692 | btn_sort = tk.Button(h_frame, text="Resort", command=lambda: [self.reorder_palette_by_similarity(), self.update_pick_ui()], font=("Arial", 7), padx=2, pady=0) 693 | btn_sort.pack(side=tk.RIGHT, padx=2) 694 | tk.Label(h_frame, text="Layer #", bg="#f0f0f0", font=("Arial", 8, "bold")).pack(side=tk.RIGHT, padx=2) 695 | for i, bgr in enumerate(self.picked_colors): 696 | var = self.layer_vars[i] 697 | sel_var = self.select_vars[i] 698 | hex_c = bgr_to_hex(bgr) 699 | fg = "black" if is_bright(bgr) else "white" 700 | f = tk.Frame(self.swatch_list_frame, bg=hex_c, height=30, highlightthickness=1, highlightbackground="#999") 701 | f.pack(fill="x", padx=5, pady=2) 702 | f.pack_propagate(False) 703 | chk = tk.Checkbutton(f, variable=sel_var, bg=hex_c, activebackground=hex_c) 704 | chk.pack(side=tk.LEFT, padx=2) 705 | chk.bind("", lambda e, idx=i: self.handle_click_selection(idx, e)) 706 | chk.bind("", lambda e, idx=i: self.handle_click_selection(idx, None)) 707 | btn_del = tk.Label(f, text="X", bg="red", fg="white", font=("Arial", 8, "bold"), width=3) 708 | btn_del.pack(side=tk.LEFT, fill="y") 709 | btn_del.bind("", lambda e, idx=i: self.remove_color(idx)) 710 | lbl = tk.Label(f, text=hex_c, bg=hex_c, fg=fg, font=("Consolas", 9, "bold")) 711 | lbl.pack(side=tk.LEFT, expand=True) 712 | spin = tk.Spinbox(f, from_=1, to=999, width=4, textvariable=var, font=("Arial", 10)) 713 | spin.pack(side=tk.RIGHT, padx=5) 714 | 715 | def trigger_process(self, event=None): 716 | if self.cv_original_full is None: return 717 | self.lbl_status.config(text="Processing...") 718 | self.progress['mode'] = 'indeterminate' 719 | self.progress.start(10) 720 | 721 | # Snapshot config to avoid thread safety issues 722 | snapshot_config = { 723 | "max_width": self.config["max_width"].get(), 724 | "max_colors": self.config["max_colors"].get(), 725 | "denoise_strength": self.config["denoise_strength"].get(), 726 | "min_blob_size": self.config["min_blob_size"].get(), 727 | "orphaned_blobs": self.config["orphaned_blobs"].get() 728 | } 729 | 730 | # Snapshot lists 731 | snapshot_colors = list(self.picked_colors) 732 | snapshot_layers = [v.get() for v in self.layer_vars] 733 | 734 | threading.Thread(target=self.process_thread, args=(self.cv_original_full, snapshot_config, snapshot_colors, snapshot_layers)).start() 735 | 736 | def process_thread(self, img_original, config, picked_colors, layer_ids): 737 | try: 738 | img = img_original.copy() 739 | max_w = config["max_width"] 740 | h, w = img.shape[:2] 741 | if max_w and w > max_w: 742 | scale = max_w / w 743 | img = cv2.resize(img, (max_w, int(h * scale)), interpolation=cv2.INTER_AREA) 744 | 745 | denoise_val = config["denoise_strength"] 746 | if denoise_val > 0: 747 | k = denoise_val if denoise_val % 2 == 1 else denoise_val + 1 748 | img = cv2.GaussianBlur(img, (k, k), 0) 749 | 750 | h, w = img.shape[:2] 751 | data = img.reshape((-1, 3)).astype(np.float32) 752 | 753 | raw_masks = [] 754 | raw_centers = [] 755 | 756 | # 1. Determine Colors (Auto vs Manual) 757 | if len(picked_colors) > 0: 758 | centers = np.array(picked_colors, dtype=np.float32) 759 | distances = np.zeros((data.shape[0], len(centers)), dtype=np.float32) 760 | for i, center in enumerate(centers): 761 | distances[:, i] = np.sum((data - center) ** 2, axis=1) 762 | labels_reshaped = np.argmin(distances, axis=1).reshape((h, w)) 763 | raw_centers = np.uint8(centers) 764 | num_raw_colors = len(centers) 765 | else: 766 | max_k = config["max_colors"] 767 | criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0) 768 | ret, label, center = cv2.kmeans(data, max_k, None, criteria, 10, cv2.KMEANS_RANDOM_CENTERS) 769 | raw_centers = np.uint8(center) 770 | labels_reshaped = label.flatten().reshape((h, w)) 771 | num_raw_colors = len(raw_centers) 772 | 773 | # 2. Generate Raw Masks 774 | for i in range(num_raw_colors): 775 | mask = cv2.inRange(labels_reshaped, i, i) 776 | raw_masks.append(mask) 777 | 778 | final_masks = [] 779 | final_centers = [] 780 | total_coverage_mask = np.zeros((h, w), dtype=np.uint8) # Accumulator for optimization 781 | 782 | min_blob = config["min_blob_size"] 783 | kernel = None 784 | if denoise_val > 0: 785 | kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (denoise_val, denoise_val)) 786 | 787 | # 3. Merge & Filter Layers 788 | if len(picked_colors) > 0: 789 | # Group by Layer ID 790 | layer_map = {} 791 | for idx, lid in enumerate(layer_ids): 792 | if lid not in layer_map: layer_map[lid] = [] 793 | layer_map[lid].append(idx) 794 | 795 | sorted_layer_ids = sorted(layer_map.keys()) 796 | 797 | for lid in sorted_layer_ids: 798 | indices = layer_map[lid] 799 | combined_mask = np.zeros((h, w), dtype=np.uint8) 800 | avg_color = np.zeros(3, dtype=np.float32) 801 | 802 | for idx in indices: 803 | combined_mask = cv2.bitwise_or(combined_mask, raw_masks[idx]) 804 | avg_color += raw_centers[idx] 805 | 806 | avg_color = (avg_color / len(indices)).astype(np.uint8) 807 | 808 | if kernel is not None: 809 | combined_mask = cv2.morphologyEx(combined_mask, cv2.MORPH_CLOSE, kernel) 810 | combined_mask = cv2.morphologyEx(combined_mask, cv2.MORPH_OPEN, kernel) 811 | 812 | # Optimize: Use raster filtering instead of vector filtering 813 | filtered = filter_small_blobs(combined_mask, min_blob) 814 | final_masks.append(filtered) 815 | final_centers.append(avg_color) 816 | 817 | # Accumulate coverage 818 | total_coverage_mask = cv2.bitwise_or(total_coverage_mask, filtered) 819 | 820 | else: 821 | # Auto Mode 822 | for i, mask in enumerate(raw_masks): 823 | if kernel is not None: 824 | mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) 825 | mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) 826 | 827 | filtered = filter_small_blobs(mask, min_blob) 828 | final_masks.append(filtered) 829 | 830 | # Accumulate coverage 831 | total_coverage_mask = cv2.bitwise_or(total_coverage_mask, filtered) 832 | final_centers = raw_centers 833 | 834 | # 4. Orphaned Blobs (Optimized) 835 | if config["orphaned_blobs"]: 836 | orphans = cv2.bitwise_not(total_coverage_mask) 837 | 838 | if kernel is not None: 839 | orphans = cv2.morphologyEx(orphans, cv2.MORPH_OPEN, kernel) 840 | orphans = cv2.morphologyEx(orphans, cv2.MORPH_CLOSE, kernel) 841 | 842 | orphans_final = filter_small_blobs(orphans, min_blob) 843 | 844 | if cv2.countNonZero(orphans_final) > 0: 845 | # Safety break for color picking to avoid infinite loop 846 | attempts = 0 847 | rand_c = np.array([0, 255, 0], dtype=np.uint8) # Default green if search fails 848 | 849 | while attempts < 50: 850 | attempts += 1 851 | candidate = np.random.randint(0, 256, 3).astype(np.uint8) 852 | dists = [np.sum((c - candidate)**2) for c in final_centers] 853 | 854 | # Adaptive threshold: if crowded (many layers), accept lower contrast 855 | threshold = 2000 if len(final_centers) < 10 else 500 856 | 857 | if not dists or min(dists) > threshold: 858 | rand_c = candidate 859 | break 860 | 861 | final_masks.append(orphans_final) 862 | final_centers.append(rand_c) 863 | print("Added Orphaned Blobs layer.") 864 | 865 | self.processed_data = { 866 | "centers": final_centers, 867 | "masks": final_masks, 868 | "width": w, 869 | "height": h 870 | } 871 | self.root.after(0, lambda: self._generate_previews(final_centers, final_masks, w, h)) 872 | self.root.after(0, self.update_ui_after_process) 873 | 874 | except Exception as e: 875 | print(e) 876 | self.root.after(0, self.progress.stop) 877 | 878 | def _generate_previews(self, centers, masks, w, h): 879 | combined = np.ones((h, w, 3), dtype=np.uint8) * 255 880 | for i, mask in enumerate(masks): 881 | combined[mask == 255] = centers[i] 882 | self.preview_images["All"] = Image.fromarray(cv2.cvtColor(combined, cv2.COLOR_BGR2RGB)) 883 | for i, mask in enumerate(masks): 884 | layer = np.ones((h, w, 3), dtype=np.uint8) * 255 885 | layer[mask == 255] = centers[i] 886 | self.preview_images[i] = Image.fromarray(cv2.cvtColor(layer, cv2.COLOR_BGR2RGB)) 887 | 888 | def update_ui_after_process(self): 889 | self.progress.stop() 890 | self.progress['mode'] = 'determinate' 891 | self.progress_var.set(100) 892 | self.lbl_status.config(text="Processing Complete.") 893 | for tab in self.notebook.tabs(): 894 | if tab != str(self.tab_main): self.notebook.forget(tab) 895 | self._add_tab("Combined Result", self.preview_images["All"]) 896 | centers = self.processed_data["centers"] 897 | for i in range(len(centers)): 898 | hex_c = bgr_to_hex(centers[i]) 899 | self._add_tab(f"L{i+1} {hex_c}", self.preview_images[i]) 900 | self.notebook.select(1) 901 | 902 | def _add_tab(self, title, pil_image): 903 | frame = tk.Frame(self.notebook, bg="#333") 904 | self.notebook.add(frame, text=title) 905 | canvas = AutoResizingCanvas(frame, pil_image=pil_image, bg="#333", highlightthickness=0) 906 | canvas.pack(fill="both", expand=True) 907 | 908 | def export_bundle_2d(self, event=None): 909 | if not self.processed_data: return 910 | # Update directory from dialog 911 | target_dir = filedialog.askdirectory(initialdir=self.last_opened_dir) 912 | if not target_dir: return 913 | self.last_opened_dir = target_dir # Remember this dir 914 | 915 | self.progress['mode'] = 'determinate' 916 | self.progress_var.set(0) 917 | threading.Thread(target=self.export_2d_thread, args=(target_dir,)).start() 918 | 919 | def export_2d_thread(self, target_dir): 920 | try: 921 | centers = self.processed_data["centers"] 922 | masks = self.processed_data["masks"] 923 | width = self.processed_data["width"] 924 | height = self.processed_data["height"] 925 | tmpl = self.config["filename_template"].get() 926 | smooth = self.config["smoothing"].get() 927 | 928 | for i in range(len(centers)): 929 | self.progress_var.set(((i+1)/len(centers))*100) 930 | bgr = centers[i] 931 | hex_c = bgr_to_hex(bgr) 932 | fname = tmpl.replace("%INPUTFILENAME%", self.current_base_name).replace("%COLOR%", hex_c.replace("#","")).replace("%INDEX%", str(i+1)) 933 | if not fname.endswith(".svg"): fname += ".svg" 934 | path = os.path.join(target_dir, fname) 935 | 936 | dwg = svgwrite.Drawing(path, profile='tiny', size=(width, height)) 937 | contours, _ = cv2.findContours(masks[i], cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) 938 | for c in contours: 939 | epsilon = smooth * cv2.arcLength(c, True) 940 | approx = cv2.approxPolyDP(c, epsilon, True) 941 | if len(approx) < 3: continue 942 | pts = approx.squeeze().tolist() 943 | if not pts: continue 944 | if isinstance(pts[0], int): d = f"M {pts[0]},{pts[1]} " 945 | else: 946 | d = f"M {pts[0][0]},{pts[0][1]} " 947 | for p in pts[1:]: d += f"L {p[0]},{p[1]} " 948 | d += "Z " 949 | dwg.add(dwg.path(d=d, fill=hex_c, stroke='none')) 950 | dwg.save() 951 | self.root.after(0, lambda: messagebox.showinfo("Success", "2D Export Complete")) 952 | except Exception as e: 953 | print(e) 954 | self.root.after(0, lambda: messagebox.showerror("Error", str(e))) 955 | 956 | def apply_stencil_bridges(self, polys, bridge_width): 957 | """ 958 | Iterates through polygons. If a polygon has a hole (interior), 959 | it creates a bridge (cut) connecting the interior to the exterior, 960 | anchoring the island to the main frame. 961 | """ 962 | bridged_polys = [] 963 | 964 | for poly in polys: 965 | if not poly.is_valid: poly = poly.buffer(0) 966 | 967 | # If the polygon has no holes, it's safe (no floating islands) 968 | if len(poly.interiors) == 0: 969 | bridged_polys.append(poly) 970 | continue 971 | 972 | # It has holes! We need to anchor the islands inside. 973 | temp_poly = poly 974 | 975 | for interior in poly.interiors: 976 | # 1. Find the shortest distance between the inner island and the outer frame 977 | p1, p2 = nearest_points(temp_poly.exterior, interior) 978 | 979 | # 2. Create a line connecting them 980 | bridge_line = LineString([p1, p2]) 981 | 982 | # 3. Thicken the line into a rectangle (The Bridge) 983 | bridge_shape = bridge_line.buffer(bridge_width / 2) 984 | 985 | # 4. Subtract the bridge from the polygon (Cutting the ring) 986 | try: 987 | temp_poly = temp_poly.difference(bridge_shape) 988 | if not temp_poly.is_valid: temp_poly = temp_poly.buffer(0) 989 | except Exception as e: 990 | print(f"Bridge failed on one island: {e}") 991 | 992 | # Handle case where difference returns a MultiPolygon (if we cut it in half) 993 | if isinstance(temp_poly, MultiPolygon): 994 | for geom in temp_poly.geoms: 995 | bridged_polys.append(geom) 996 | else: 997 | bridged_polys.append(temp_poly) 998 | 999 | return bridged_polys 1000 | 1001 | def export_3d_thread(self, target_dir): 1002 | try: 1003 | centers = self.processed_data["centers"] 1004 | masks = self.processed_data["masks"] 1005 | orig_w = self.processed_data["width"] 1006 | orig_h = self.processed_data["height"] 1007 | tmpl = self.config["filename_template"].get() 1008 | smooth = self.config["smoothing"].get() 1009 | 1010 | target_w = self.exp_width.get() 1011 | extrusion = self.exp_height.get() 1012 | border_w = self.exp_border.get() 1013 | is_stencil = self.exp_invert.get() 1014 | 1015 | scale = target_w / orig_w 1016 | target_h = orig_h * scale 1017 | 1018 | for i in range(len(centers)): 1019 | self.progress_var.set(((i+1)/len(centers))*100) 1020 | bgr = centers[i] 1021 | hex_c = bgr_to_hex(bgr) 1022 | 1023 | fname = tmpl.replace("%INPUTFILENAME%", self.current_base_name).replace("%COLOR%", hex_c.replace("#","")).replace("%INDEX%", str(i+1)) 1024 | if is_stencil: fname += "_stencil" 1025 | fname += ".stl" 1026 | full_path = os.path.join(target_dir, fname) 1027 | 1028 | contours, hierarchy = cv2.findContours(masks[i], cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE) 1029 | shapely_polys = [] 1030 | 1031 | if hierarchy is not None: 1032 | hierarchy = hierarchy[0] 1033 | for j, c in enumerate(contours): 1034 | if hierarchy[j][3] == -1: 1035 | epsilon = smooth * cv2.arcLength(c, True) 1036 | approx = cv2.approxPolyDP(c, epsilon, True) 1037 | if len(approx) < 3: continue 1038 | 1039 | outer_pts = approx.squeeze() * scale 1040 | outer_pts[:, 1] = target_h - outer_pts[:, 1] 1041 | 1042 | holes = [] 1043 | current_child_idx = hierarchy[j][2] 1044 | while current_child_idx != -1: 1045 | child_c = contours[current_child_idx] 1046 | eps_child = smooth * cv2.arcLength(child_c, True) 1047 | approx_child = cv2.approxPolyDP(child_c, eps_child, True) 1048 | if len(approx_child) >= 3: 1049 | hole_pts = approx_child.squeeze() * scale 1050 | hole_pts[:, 1] = target_h - hole_pts[:, 1] 1051 | holes.append(hole_pts) 1052 | current_child_idx = hierarchy[current_child_idx][0] 1053 | 1054 | try: 1055 | poly = Polygon(shell=outer_pts, holes=holes) 1056 | clean_poly = poly.buffer(0) 1057 | if clean_poly.is_empty: continue 1058 | shapely_polys.append(clean_poly) 1059 | except: pass 1060 | 1061 | # --- APPLY BRIDGES (ISLAND FIX) --- 1062 | if is_stencil: 1063 | bridge_w = self.exp_bridge.get() 1064 | if bridge_w > 0: 1065 | shapely_polys = self.apply_stencil_bridges(shapely_polys, bridge_w) 1066 | # ---------------------------------- 1067 | 1068 | scene_mesh = trimesh.Trimesh() 1069 | 1070 | if is_stencil: 1071 | min_x, min_y = -border_w, -border_w 1072 | max_x, max_y = target_w + border_w, target_h + border_w 1073 | plate_poly = Polygon([(min_x, min_y), (max_x, min_y), (max_x, max_y), (min_x, max_y)]) 1074 | 1075 | final_shape = plate_poly 1076 | if shapely_polys: 1077 | try: 1078 | blobs = unary_union(shapely_polys) 1079 | final_shape = plate_poly.difference(blobs) 1080 | except Exception as e: 1081 | print(f"Boolean diff failed: {e}") 1082 | 1083 | polys_to_extrude = [] 1084 | if isinstance(final_shape, MultiPolygon): 1085 | for geom in final_shape.geoms: polys_to_extrude.append(geom) 1086 | else: 1087 | polys_to_extrude.append(final_shape) 1088 | 1089 | for p in polys_to_extrude: 1090 | if not p.is_valid: p = p.buffer(0) 1091 | if p.is_empty: continue 1092 | mesh_part = trimesh.creation.extrude_polygon(p, height=extrusion) 1093 | scene_mesh += mesh_part 1094 | 1095 | else: 1096 | if shapely_polys: 1097 | combined_poly = unary_union(shapely_polys) 1098 | polys_to_extrude = [] 1099 | if isinstance(combined_poly, MultiPolygon): 1100 | for geom in combined_poly.geoms: polys_to_extrude.append(geom) 1101 | else: 1102 | polys_to_extrude.append(combined_poly) 1103 | 1104 | for p in polys_to_extrude: 1105 | if not p.is_valid: p = p.buffer(0) 1106 | if p.is_empty: continue 1107 | mesh_part = trimesh.creation.extrude_polygon(p, height=extrusion) 1108 | scene_mesh += mesh_part 1109 | 1110 | if border_w > 0: 1111 | outer_box = [[-border_w, -border_w], [target_w + border_w, -border_w], 1112 | [target_w + border_w, target_h + border_w], [-border_w, target_h + border_w]] 1113 | inner_box = [[0, 0], [target_w, 0], [target_w, target_h], [0, target_h]] 1114 | border_poly = Polygon(shell=outer_box, holes=[inner_box]) 1115 | border_mesh = trimesh.creation.extrude_polygon(border_poly, height=extrusion) 1116 | scene_mesh += border_mesh 1117 | 1118 | if not scene_mesh.is_empty: 1119 | scene_mesh.export(full_path) 1120 | 1121 | self.root.after(0, lambda: messagebox.showinfo("Success", f"Exported 3D models to {target_dir}")) 1122 | self.root.after(0, lambda: self.lbl_status.config(text="3D Export Complete.")) 1123 | 1124 | except Exception as e: 1125 | print(e) 1126 | err_msg = str(e) 1127 | self.root.after(0, lambda: messagebox.showerror("Export Error", err_msg)) 1128 | self.root.after(0, self.progress.stop) 1129 | 1130 | if __name__ == "__main__": 1131 | root = tk.Tk() 1132 | app = CamoStudioApp(root) 1133 | root.mainloop() 1134 | --------------------------------------------------------------------------------