mil-kit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mil_kit/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ def main() -> None:
2
+ print("Hello from psd-toolkit!")
mil_kit/job.py ADDED
@@ -0,0 +1,308 @@
1
+ """
2
+ Batch Processing Module for PSD Files
3
+ Manages parallel processing of PSD files with enhanced error handling and logging.
4
+ """
5
+
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from pathlib import Path
8
+ from typing import Optional, Tuple, List
9
+ from datetime import datetime
10
+ import logging
11
+ from tqdm import tqdm
12
+ from mil_kit.psd.processor import PSDProcessor
13
+ import os
14
+ import shutil
15
+
16
+
17
+ class BatchJob:
18
+ """
19
+ Manages the processing of a directory of files with parallel execution.
20
+
21
+ Features:
22
+ - Parallel processing using ThreadPoolExecutor
23
+ - Progress tracking with tqdm
24
+ - Detailed logging and error handling
25
+ - Flexible output options
26
+ - Processing statistics and reporting
27
+ """
28
+
29
+ SUPPORTED_FORMATS = ["png", "jpg", "jpeg", "tiff", "bmp", "webp"]
30
+
31
+ def __init__(
32
+ self,
33
+ input_dir: str,
34
+ output_dir: Optional[str] = None,
35
+ recursive: bool = False,
36
+ output_format: str = "png",
37
+ max_workers: Optional[int] = None,
38
+ log_file: Optional[str] = None,
39
+ overwrite: bool = True,
40
+ verbose: bool = True,
41
+ ):
42
+ """
43
+ Initialize the BatchJob processor.
44
+
45
+ Args:
46
+ input_dir: Directory containing PSD files
47
+ output_dir: Output directory (defaults to input_dir)
48
+ recursive: Search subdirectories recursively
49
+ output_format: Output image format (png, jpg, etc.)
50
+ max_workers: Max parallel workers (None = CPU count)
51
+ log_file: Path to log file (None = no file logging)
52
+ overwrite: Overwrite existing output files
53
+ verbose: Print detailed progress messages
54
+ """
55
+ self.input_dir = Path(input_dir)
56
+ self.recursive = recursive
57
+ self.output_format = output_format.lower()
58
+ self.max_workers = max_workers
59
+ self.overwrite = overwrite
60
+ self.verbose = verbose
61
+
62
+ # Validate input directory
63
+ if not self.input_dir.exists():
64
+ raise FileNotFoundError(f"Input directory not found: {self.input_dir}")
65
+
66
+ if not self.input_dir.is_dir():
67
+ raise NotADirectoryError(f"Input path is not a directory: {self.input_dir}")
68
+
69
+ # Validate output format
70
+ if self.output_format not in self.SUPPORTED_FORMATS:
71
+ raise ValueError(
72
+ f"Unsupported format: {self.output_format}. "
73
+ f"Supported formats: {', '.join(self.SUPPORTED_FORMATS)}"
74
+ )
75
+
76
+ # Set up output directory
77
+ self.output_dir = Path(output_dir) if output_dir else self.input_dir
78
+ self.output_dir.mkdir(parents=True, exist_ok=True)
79
+
80
+ # Initialize statistics
81
+ self.stats = {
82
+ "success": 0,
83
+ "failed": 0,
84
+ "skipped": 0,
85
+ "total_layers_hidden": 0,
86
+ "start_time": None,
87
+ "end_time": None,
88
+ }
89
+
90
+ self.failed_files = [] # Track failed files for reporting
91
+
92
+ # Set up logging
93
+ self._setup_logging(log_file)
94
+
95
+ def _setup_logging(self, log_file: Optional[str]):
96
+ """Configure logging for the batch job."""
97
+ self.logger = logging.getLogger(__name__)
98
+ self.logger.setLevel(logging.DEBUG if self.verbose else logging.INFO)
99
+
100
+ # Clear existing handlers
101
+ self.logger.handlers.clear()
102
+
103
+ # Console handler
104
+ console_handler = logging.StreamHandler()
105
+ console_handler.setLevel(logging.INFO)
106
+ console_format = logging.Formatter('%(message)s')
107
+ console_handler.setFormatter(console_format)
108
+ self.logger.addHandler(console_handler)
109
+
110
+ # File handler (if specified)
111
+ if log_file:
112
+ log_path = Path(log_file)
113
+ log_path.parent.mkdir(parents=True, exist_ok=True)
114
+ file_handler = logging.FileHandler(log_file, mode='a')
115
+ file_handler.setLevel(logging.DEBUG)
116
+ file_format = logging.Formatter(
117
+ '%(asctime)s - %(levelname)s - %(message)s',
118
+ datefmt='%Y-%m-%d %H:%M:%S'
119
+ )
120
+ file_handler.setFormatter(file_format)
121
+ self.logger.addHandler(file_handler)
122
+
123
+ def run(self):
124
+ """
125
+ Execute the batch processing with parallel execution.
126
+
127
+ Returns:
128
+ Dictionary containing processing statistics
129
+ """
130
+ self.stats["start_time"] = datetime.now()
131
+
132
+ files = list(self._get_files())
133
+ total_files = len(files)
134
+
135
+ if total_files == 0:
136
+ message = f"No PSD files found in {self.input_dir}"
137
+ if self.recursive:
138
+ message += " (including subdirectories)"
139
+ self.logger.warning(message)
140
+ return self.stats
141
+
142
+ self.logger.info(
143
+ f"Found {total_files} PSD file(s) in {self.input_dir}"
144
+ )
145
+ self.logger.info(f"Output directory: {self.output_dir}")
146
+ self.logger.info(f"Output format: {self.output_format.upper()}")
147
+ self.logger.info(f"Max workers: {self.max_workers or 'Auto (CPU count)'}\n")
148
+
149
+ if total_files == 1:
150
+ # Single file - no need for parallelism
151
+ self._process_single_file_wrapper(files[0])
152
+ else:
153
+ # Multiple files - use parallel processing
154
+ self._process_multiple_files(files, total_files)
155
+
156
+ self.stats["end_time"] = datetime.now()
157
+ self._print_summary(total_files)
158
+ self._copy_failed_file()
159
+
160
+ def _process_multiple_files(self, files: List[Path], total_files: int):
161
+ """Process multiple files in parallel."""
162
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
163
+ # Submit all tasks
164
+ futures = {
165
+ executor.submit(self._process_single_file, psd_path): psd_path
166
+ for psd_path in files
167
+ }
168
+
169
+ with tqdm(
170
+ total=total_files,
171
+ desc="Processing PSD files",
172
+ unit="file",
173
+ disable=not self.verbose,
174
+ ) as pbar:
175
+ for future in as_completed(futures):
176
+ psd_path = futures[future]
177
+
178
+ try:
179
+ success, message, count = future.result()
180
+ self._update_stats(success, count, psd_path if not success else None)
181
+
182
+ if self.verbose:
183
+ tqdm.write(message)
184
+
185
+ except Exception as e:
186
+ self._update_stats(False, 0, psd_path)
187
+ error_msg = f"✗ {psd_path.name}: Unexpected error - {e}"
188
+ if self.verbose:
189
+ tqdm.write(error_msg)
190
+ self.logger.error(error_msg)
191
+
192
+ pbar.update(1)
193
+
194
+ def _process_single_file_wrapper(self, psd_path: Path):
195
+ """Wrapper for processing a single file (non-parallel)."""
196
+ try:
197
+ success, message, count = self._process_single_file(psd_path)
198
+ self._update_stats(success, count, psd_path if not success else None)
199
+ self.logger.info(message)
200
+ except Exception as e:
201
+ self._update_stats(False, 0, psd_path)
202
+ error_msg = f"✗ {psd_path.name}: Unexpected error - {e}"
203
+ self.logger.error(error_msg)
204
+
205
+ def _process_single_file(self, psd_path: Path) -> Tuple[bool, str, int]:
206
+ """
207
+ Process a single PSD file.
208
+
209
+ Returns:
210
+ Tuple of (success: bool, message: str, layers_hidden: int)
211
+ """
212
+ try:
213
+ dest_path = self._generate_output_path(psd_path)
214
+
215
+ # Check if output exists and overwrite is disabled
216
+ if dest_path.exists() and not self.overwrite:
217
+ return (
218
+ False,
219
+ f"⊘ {psd_path.name}: Skipped (output exists, overwrite=False)",
220
+ 0,
221
+ )
222
+
223
+ # Load and process PSD
224
+ processor = PSDProcessor(psd_path)
225
+ processor.load()
226
+
227
+ # Hide text layers
228
+ count = processor.hide_non_image_layers()
229
+
230
+ # Export to specified format
231
+ processor.export(dest_path, format=self.output_format)
232
+
233
+ return (
234
+ True,
235
+ f"✓ {psd_path.name}: Hidden {count} text layer(s) → {dest_path.name}",
236
+ count,
237
+ )
238
+
239
+ except FileNotFoundError as e:
240
+ return (False, f"✗ {psd_path.name}: File not found - {e}", 0)
241
+
242
+ except PermissionError as e:
243
+ return (False, f"✗ {psd_path.name}: Permission denied - {e}", 0)
244
+
245
+ except Exception as e:
246
+ return (False, f"✗ {psd_path.name}: Processing failed - {e}", 0)
247
+
248
+ def _update_stats(self, success: bool, layers_hidden: int, failed_path: Optional[Path] = None):
249
+ """Update processing statistics."""
250
+ if success:
251
+ self.stats["success"] += 1
252
+ self.stats["total_layers_hidden"] += layers_hidden
253
+ else:
254
+ self.stats["failed"] += 1
255
+ if failed_path:
256
+ self.failed_files.append(failed_path)
257
+
258
+ def _get_files(self) -> List[Path]:
259
+ """Generator that yields PSD files."""
260
+ pattern = "**/*.psd" if self.recursive else "*.psd"
261
+ for path in self.input_dir.glob(pattern):
262
+ if path.is_file():
263
+ yield path
264
+
265
+ def _generate_output_path(self, psd_path: Path) -> Path:
266
+ """Generate the output file path, preserving directory structure if recursive."""
267
+ if self.recursive and self.output_dir != self.input_dir:
268
+ # Preserve subdirectory structure
269
+ relative_path = psd_path.relative_to(self.input_dir)
270
+ output_subdir = self.output_dir / relative_path.parent
271
+ output_subdir.mkdir(parents=True, exist_ok=True)
272
+ return output_subdir / f"{psd_path.stem}.{self.output_format}"
273
+ else:
274
+ return self.output_dir / f"{psd_path.stem}.{self.output_format}"
275
+
276
+ def _copy_failed_file(self):
277
+ """Copy failed file to output directory for review (if needed)."""
278
+ failed_dir = os.path.join(self.output_dir, "failed_files")
279
+ os.makedirs(failed_dir, exist_ok=True)
280
+ self.logger.info(f"Copying failed files to {failed_dir} for review.")
281
+ for file in self.failed_files:
282
+ shutil.copy(file, failed_dir)
283
+
284
+
285
+ def _print_summary(self, total_files: int):
286
+ """Print detailed processing summary."""
287
+ duration = self.stats["end_time"] - self.stats["start_time"]
288
+
289
+ print("\n" + "=" * 60)
290
+ print("PROCESSING COMPLETE")
291
+ print("=" * 60)
292
+ print(f"Total files: {total_files}")
293
+ print(f"✓ Successful: {self.stats['success']}")
294
+ print(f"✗ Failed: {self.stats['failed']}")
295
+ print(f"⊘ Skipped: {self.stats['skipped']}")
296
+ print(f"Text layers hidden: {self.stats['total_layers_hidden']}")
297
+ print(f"Processing time: {duration}")
298
+ print(f"Output directory: {self.output_dir}")
299
+ print("=" * 60)
300
+
301
+ # Log failed files if any
302
+ if self.failed_files:
303
+ print("\nFailed files:")
304
+ for failed_file in self.failed_files:
305
+ print(f" - {failed_file}")
306
+
307
+ # Log to file
308
+ self.logger.info(f"Batch job completed: {self.stats['success']}/{total_files} successful")
mil_kit/main.py ADDED
@@ -0,0 +1,45 @@
1
+ import argparse
2
+ import sys
3
+ from mil_kit.job import BatchJob
4
+
5
+
6
+ def main():
7
+ parser = argparse.ArgumentParser(
8
+ description="Batch hide text layers in PSDs and export PNGs."
9
+ )
10
+ parser.add_argument(
11
+ "-d",
12
+ "--dir",
13
+ required=True,
14
+ help="Input directory containing PSD files",
15
+ )
16
+ parser.add_argument(
17
+ "-o",
18
+ "--output",
19
+ help="Output directory (default: input directory)",
20
+ )
21
+ parser.add_argument(
22
+ "-f",
23
+ "--output-format",
24
+ default="png",
25
+ help="Output format (default: png)",
26
+ )
27
+ parser.add_argument(
28
+ "-r",
29
+ "--recursive",
30
+ action="store_true",
31
+ help="Process subdirectories recursively",
32
+ )
33
+
34
+ args = parser.parse_args()
35
+
36
+ try:
37
+ job = BatchJob(args.dir, args.output, args.recursive, output_format=args.output_format)
38
+ job.run()
39
+ except Exception as e:
40
+ print(f"Critical Error: {e}")
41
+ sys.exit(1)
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
File without changes
@@ -0,0 +1,77 @@
1
+ from psd_tools import PSDImage
2
+ from pathlib import Path
3
+
4
+
5
+ class PSDProcessor:
6
+ """
7
+ Handles the loading, modification, and exporting of a single PSD file.
8
+ """
9
+
10
+ def __init__(self, file_path):
11
+ self.file_path = Path(file_path)
12
+ self.psd = None
13
+ self.hidden_count = 0
14
+
15
+ def load(self):
16
+ """Loads the PSD file."""
17
+ try:
18
+ self.psd = PSDImage.open(self.file_path)
19
+ except Exception as e:
20
+ raise IOError(f"Failed to open PSD: {e}")
21
+
22
+ def hide_text_layers(self):
23
+ """Iterates through all layers and hides those of kind 'type'."""
24
+ if not self.psd:
25
+ raise RuntimeError("PSD not loaded. Call load() first.")
26
+
27
+ self.hidden_count = 0
28
+ # descendants() iterates recursively through groups
29
+ for layer in self.psd.descendants():
30
+ if layer.kind == "type" and layer.visible:
31
+ layer.visible = False
32
+ self.hidden_count += 1
33
+
34
+ return self.hidden_count
35
+
36
+ def hide_non_image_layers(self):
37
+ """
38
+ Hides all non-raster layers including text, vectors, shapes, and adjustments.
39
+ Only keeps pixel/image layers visible.
40
+ """
41
+ if not self.psd:
42
+ raise RuntimeError("PSD not loaded. Call load() first.")
43
+
44
+ self.hidden_count = 0
45
+ self.hidden_by_type = {
46
+ "type": 0, # Text layers
47
+ "shape": 0, # Vector/shape layers
48
+ "adjustment": 0, # Adjustment layers
49
+ "other": 0 # Other non-pixel layers
50
+ }
51
+
52
+ # descendants() iterates recursively through groups
53
+ for layer in self.psd.descendants():
54
+ # Only keep pixel/image layers visible
55
+ if layer.visible and layer.kind != "pixel":
56
+ layer.visible = False
57
+ self.hidden_count += 1
58
+
59
+ # Track what type was hidden
60
+ if layer.kind in self.hidden_by_type:
61
+ self.hidden_by_type[layer.kind] += 1
62
+ else:
63
+ self.hidden_by_type["other"] += 1
64
+
65
+ return self.hidden_count
66
+
67
+
68
+ def export(self, output_path, format="png"):
69
+ """Composites the PSD and saves as PNG."""
70
+ if not self.psd:
71
+ raise RuntimeError("PSD not loaded.")
72
+
73
+ # Ensure the target directory exists
74
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
75
+
76
+ # Composite merges layers; save exports using PIL/Pillow
77
+ self.psd.composite().save(output_path, format=format.upper())
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: mil-kit
3
+ Version: 0.3.0
4
+ Summary: A toolkit for processing the ASM's Mammal Image Library (MIL).
5
+ Project-URL: Homepage, https://github.com/hhandika/mil-kit
6
+ Project-URL: Repository, https://github.com/hhandika/mil-kit
7
+ Project-URL: Issues, https://github.com/hhandika/mil-kit/issues
8
+ Author-email: Heru Handika <herubiolog@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: automation,batch-processing,image-processing,photoshop,psd
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: pillow>=12.0.0
25
+ Requires-Dist: psd-tools>=1.12.0
26
+ Requires-Dist: tqdm>=4.67.1
27
+ Description-Content-Type: text/markdown
28
+
29
+ # mil-kit
30
+
31
+ ![Tests](https://github.com/hhandika/psd-toolkit/actions/workflows/test.yml/badge.svg)
32
+ ![GitHub Tag](https://img.shields.io/github/v/tag/hhandika/psd-toolkit?label=GitHub)
33
+ ![PyPI - Version](https://img.shields.io/pypi/v/psd-toolkit?color=blue)
34
+
35
+ A Python toolkit for batch processing the Mammal Image Library (MIL) images. Reshape, convert, and optimize images for the mammal diversity database and other applications.
36
+
37
+ ## Features
38
+
39
+ - 🚀 Batch process multiple PSD files in a directory
40
+ - ⚡ Parallel processing for faster execution
41
+ - 📊 Progress bar with detailed status
42
+ - 📝 Automatically hide all text layers
43
+ - 🖼️ Export processed files as PNG (default) or other formats
44
+ - 📁 Support for recursive directory processing
45
+ - ⚡ Preserve folder structure in output
46
+
47
+ ## Installation
48
+
49
+ Install using pip:
50
+
51
+ ```bash
52
+ pip install mil-kit
53
+ ```
54
+
55
+ Or using uv:
56
+
57
+ ```bash
58
+ uv add mil-kit
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ### Command Line
64
+
65
+ Process PSD files in a directory:
66
+
67
+ ```bash
68
+ mil-kit -d /path/to/psd/files
69
+ ```
70
+
71
+ Process recursively, specify output directory, and use JPEG format:
72
+
73
+ ```bash
74
+ mil-kit -d /path/to/psd/files -o /path/to/output -r -f jpeg
75
+ ```
76
+
77
+ ### Options
78
+
79
+ - `-d, --dir`: Input directory containing PSD files (required)
80
+ - `-o, --output`: Output directory for processed files (default: input directory)
81
+ - `-f, --output-format`: Output image format (default: png)
82
+ - `-r, --recursive`: Process subdirectories recursively
83
+
84
+ ### Python API
85
+
86
+ You can also use mil-kit as a Python library:
87
+
88
+ ```python
89
+ from mil_kit.psd.processor import PSDProcessor
90
+ from mil_kit.job import BatchJob
91
+
92
+ # Process a single file
93
+ processor = PSDProcessor("image.psd")
94
+ processor.load()
95
+ processor.hide_non_image_layers()
96
+ processor.export("output.jpg", format="jpeg")
97
+
98
+ # Batch process
99
+ job = BatchJob(
100
+ input_dir="./psd_files",
101
+ output_dir="./output",
102
+ recursive=True,
103
+ output_format="png",
104
+ max_workers=4
105
+ )
106
+ job.run()
107
+ ```
108
+
109
+ ## Requirements
110
+
111
+ - Python >= 3.10
112
+ - psd-tools >= 1.12.0
113
+ - pillow >= 12.0.0
114
+ - tqdm >= 4.67.1
115
+
116
+ ## License
117
+
118
+ MIT License - see [LICENSE](LICENSE) file for details.
119
+
120
+ ## Contributing
121
+
122
+ Contributions are welcome! Please feel free to submit a Pull Request.
123
+
124
+ ## Issues
125
+
126
+ Report bugs and request features on [GitHub Issues](https://github.com/hhandika/psd-toolkit/issues).
@@ -0,0 +1,10 @@
1
+ mil_kit/__init__.py,sha256=dsQfNrRHFHjthFmgDbgWHD2Ho2VUjv2glCibmLg9XDQ,57
2
+ mil_kit/job.py,sha256=TpPWMGVk00m4TDNixvGZHVXz_SlYidDfzyJAbyssHkc,11643
3
+ mil_kit/main.py,sha256=Yw8TpRWWnxVkjxfJ8EXUUPgoi6kVv_mF126jFiBVWD0,1020
4
+ mil_kit/psd/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ mil_kit/psd/processor.py,sha256=mKYTZv32fKfuP_uBwjL4jwdWcMGK4rhu2uuDgvx9SrI,2577
6
+ mil_kit-0.3.0.dist-info/METADATA,sha256=XL5sJfhDfL4MyPUgoG61W9WmWym_1JAo3B6SZoPbNk8,3418
7
+ mil_kit-0.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ mil_kit-0.3.0.dist-info/entry_points.txt,sha256=pdJaTXH8FgBxIi_p4-yEKeWONK6kD4fqUJRE65dYukw,46
9
+ mil_kit-0.3.0.dist-info/licenses/LICENSE,sha256=KFqQz-OHVHOQWPwC7dROatkfKRXVDlH0wZnTJd44LsU,1069
10
+ mil_kit-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mil-kit = mil_kit.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Heru Handika
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.