From f85fbf344172c80ad1a75d57d52b700595555e34 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 11 Dec 2025 14:46:02 -0700 Subject: [PATCH] preserve 16-bit if present --- ...press-png-sequences-with-multithreading.md | 426 +++++++++++++++--- compress_pngs.bat | 1 + compress_pngs.py | 60 ++- 3 files changed, 419 insertions(+), 68 deletions(-) diff --git a/.specstory/history/2025-12-11_18-21Z-compress-png-sequences-with-multithreading.md b/.specstory/history/2025-12-11_18-21Z-compress-png-sequences-with-multithreading.md index 6085f43..6e948f9 100644 --- a/.specstory/history/2025-12-11_18-21Z-compress-png-sequences-with-multithreading.md +++ b/.specstory/history/2025-12-11_18-21Z-compress-png-sequences-with-multithreading.md @@ -1197,7 +1197,6 @@ _**Agent (model default, mode Agent)**_ --- - Creating a diagnostic script to identify which sequences were skipped and why: @@ -1337,7 +1336,6 @@ UnicodeEncodeError: 'charmap' codec can't encode character '\u2713' in position --- - Fixing the Unicode issue and improving the diagnostic script: @@ -1476,7 +1474,6 @@ TypeError: expected str, bytes or os.PathLike object, not list --- - Fixing the script and listing all folders in the input directory: @@ -1638,7 +1635,6 @@ HAS PNGs Pack2BagScan_animation 6d (600 PNG files) --- - Found 4 folders in the input directory with no PNG files, so they were skipped: 1. **Bag2PackScan_animation 2a** (0 PNG files) @@ -1666,7 +1662,6 @@ _**Agent (model default, mode Agent)**_ --- - Updating the script to process JPG files and report when we do: @@ -1684,7 +1679,6 @@ Updating the script to process JPG files and report when we do: --- - Updating the script to skip JPG files and report when we do: @@ -1789,7 +1783,6 @@ Lines added: 2, lines removed: undefined --- - Ensuring `jpg_files` is accessible in the final summary: @@ -1809,6 +1802,356 @@ Ensuring `jpg_files` is accessible in the final summary: +--- + + +
+ Tool use: **read_lints** • Read lints for 1 file + +Lint paths: + +- `compress_pngs.py` + +Lint results + + +**No lint errors found** +
+
+ +--- + + + +--- + +Updated the script to: + +1. Scan for both PNG and JPG/JPEG files +2. Report at the start how many JPG files were found and that they're being ignored +3. Process only PNG files +4. Include in the final summary how many JPG files were ignored + +When you run it, it will report any JPG files found and skip them, so you'll know which sequences contain JPG files instead of PNGs. + +--- + +_**User (2025-12-11 18:21Z)**_ + +create a gitignore and ignore the input and output folders pls. + +--- + +_**Agent (model default, mode Agent)**_ + + + +--- + + + +--- + + + +
Tool use: **write** • Edit file: .gitignore + +**Chunk 1** +Lines added: 3, lines removed: 1 + +```diff +@@ -1,1 +1,3 @@ +- ++ input/ ++ output/ ++ +``` + +
+ +
+ +--- + + + +--- + +Created a `.gitignore` file that ignores the `input/` and `output/` folders. Git will ignore these directories and their contents. + +--- + +_**User (2025-12-11 21:39Z)**_ + +What method does it use to compress the PNGs? are they 16-bit, or 8-bit color? Does it specify? + +--- + +_**Agent (model default, mode Agent)**_ + + + +--- + +Checking the compression method used in the script: + + +--- + + +
Tool use: **read_file** • Read file: compress_pngs.py + +
+
+ +--- + + + +--- + + +The script uses: + +1. Compression method: PIL/Pillow's PNG save with: + - `optimize=True` — enables PNG optimization + - `compress_level=9` — maximum zlib compression (0-9) + +2. Bit depth: Not explicitly set. PIL/Pillow defaults to 8-bit per channel for RGB/RGBA. If the input is 16-bit, it will be converted to 8-bit during the `convert()` call. + +3. Color mode: Converts to RGB or RGBA (preserves RGBA for transparency), but doesn't preserve 16-bit data. + +If you want to preserve 16-bit depth or use a different compression method, I can update the script. Options: +- Preserve 16-bit depth if present +- Use a different compression tool (e.g., pngquant for lossy, optipng/pngcrush for lossless) +- Add explicit bit depth control + +Should I update it? + +--- + +_**User (2025-12-11 21:39Z)**_ + +Yes, let's preserve 16-bit if present. + +--- + +_**Agent (model default, mode Agent)**_ + + + +--- + + +Updating the script to preserve 16-bit depth when present: + + +--- + + +
Tool use: **read_file** • Read file: compress_pngs.py + +
+
+ +--- + + + +--- + + + + + +--- + + + +
Tool use: **search_replace** • Edit file: compress_pngs.py + +**Chunk 1** +Lines added: 41, lines removed: 8 + +```diff +@@ -33,13 +33,46 @@ + img = Image.open(input_path) + +- # Convert to RGB if RGBA and no transparency needed +- if img.mode == 'RGBA': +- # Keep RGBA for transparency support +- img = img.convert('RGBA') +- elif img.mode != 'RGB': +- img = img.convert('RGB') ++ # Check if image is 16-bit by examining mode and pixel values ++ is_16bit = False ++ if img.mode == 'I': ++ # 'I' mode (32-bit integer) often represents 16-bit grayscale ++ is_16bit = True ++ elif img.mode in ('RGB', 'RGBA'): ++ # Check if max pixel value exceeds 8-bit range ++ try: ++ # Sample a few pixels to check bit depth ++ sample = img.crop((0, 0, min(100, img.width), min(100, img.height))) ++ max_val = max(sample.getdata()) ++ if max_val > 255: ++ is_16bit = True ++ except: ++ pass ++ ++ # Preserve 16-bit depth if present, otherwise convert as needed ++ if is_16bit: ++ # Preserve original mode for 16-bit images ++ if img.mode == 'I': ++ # Keep grayscale 16-bit ++ pass ++ elif img.mode == 'RGBA': ++ # Keep RGBA 16-bit ++ pass ++ elif img.mode == 'RGB': ++ # Keep RGB 16-bit ++ pass ++ else: ++ # Convert other 16-bit modes to RGB ++ img = img.convert('RGB') ++ else: ++ # 8-bit images: convert as needed ++ if img.mode == 'RGBA': ++ # Keep RGBA for transparency support ++ img = img.convert('RGBA') ++ elif img.mode != 'RGB': ++ img = img.convert('RGB') + +- # Save with maximum compression to output path +- img.save(str(output_path), 'PNG', optimize=True, compress_level=9) ++ # Save with maximum compression, preserving bit depth ++ save_kwargs = {'optimize': True, 'compress_level': 9} ++ img.save(str(output_path), 'PNG', **save_kwargs) + new_size = os.path.getsize(output_path) + savings = original_size - new_size +``` + +
+ +
+ +--- + + + +--- + + +Refining the 16-bit detection to be more accurate: + + +--- + + + +
Tool use: **search_replace** • Edit file: compress_pngs.py + +**Chunk 1** +Lines added: 29, lines removed: 17 + +```diff +@@ -33,35 +33,47 @@ + img = Image.open(input_path) + +- # Check if image is 16-bit by examining mode and pixel values ++ # Check if image is 16-bit ++ # PIL represents 16-bit grayscale as 'I' mode (32-bit integer) ++ # For color images, check pixel values to determine bit depth + is_16bit = False ++ original_mode = img.mode ++ + if img.mode == 'I': +- # 'I' mode (32-bit integer) often represents 16-bit grayscale ++ # 'I' mode typically represents 16-bit grayscale + is_16bit = True +- elif img.mode in ('RGB', 'RGBA'): ++ elif img.mode in ('RGB', 'RGBA', 'LA'): + # Check if max pixel value exceeds 8-bit range + try: +- # Sample a few pixels to check bit depth +- sample = img.crop((0, 0, min(100, img.width), min(100, img.height))) +- max_val = max(sample.getdata()) +- if max_val > 255: +- is_16bit = True ++ # Get a sample of pixels to check ++ pixels = list(img.getdata()) ++ if pixels: ++ # Flatten if needed (for multi-channel modes) ++ if isinstance(pixels[0], (tuple, list)): ++ max_val = max(max(p) for p in pixels[:1000]) # Sample first 1000 pixels ++ else: ++ max_val = max(pixels[:1000]) ++ if max_val > 255: ++ is_16bit = True + except: ++ # If we can't determine, assume 8-bit to be safe + pass + + # Preserve 16-bit depth if present, otherwise convert as needed + if is_16bit: +- # Preserve original mode for 16-bit images ++ # For 16-bit images, preserve the mode ++ # PIL will save 16-bit when mode is 'I' (grayscale) or when using specific modes + if img.mode == 'I': +- # Keep grayscale 16-bit ++ # Keep 16-bit grayscale + pass +- elif img.mode == 'RGBA': +- # Keep RGBA 16-bit +- pass +- elif img.mode == 'RGB': +- # Keep RGB 16-bit ++ elif img.mode in ('RGB', 'RGBA'): ++ # Keep color mode - PIL may preserve 16-bit depending on how it was loaded ++ # Note: PIL's PNG save may convert 16-bit RGB to 8-bit, but we preserve the mode + pass + else: +- # Convert other 16-bit modes to RGB +- img = img.convert('RGB') ++ # Convert other modes while trying to preserve bit depth ++ if 'A' in img.mode: ++ img = img.convert('RGBA') ++ else: ++ img = img.convert('RGB') + else: + # 8-bit images: convert as needed +``` + +**Chunk 2** +Lines added: 4, lines removed: 3 + +```diff +@@ -72,7 +84,8 @@ + img = img.convert('RGB') + +- # Save with maximum compression, preserving bit depth +- save_kwargs = {'optimize': True, 'compress_level': 9} +- img.save(str(output_path), 'PNG', **save_kwargs) ++ # Save with maximum compression ++ # PIL will preserve 16-bit for 'I' mode automatically ++ # For color 16-bit, PIL may convert to 8-bit, but we've preserved the mode ++ img.save(str(output_path), 'PNG', optimize=True, compress_level=9) + new_size = os.path.getsize(output_path) + savings = original_size - new_size +``` + +
+ +
+ +--- + + + +--- + + + --- @@ -1835,62 +2178,23 @@ Lint results --- -Updated the script to: +Updated the script to preserve 16-bit depth when present. It: -1. Scan for both PNG and JPG/JPEG files -2. Report at the start how many JPG files were found and that they're being ignored -3. Process only PNG files -4. Include in the final summary how many JPG files were ignored +1. **Detects 16-bit images** by: + - Checking if the mode is `'I'` (16-bit grayscale) + - Sampling pixel values to see if they exceed 255 (indicating 16-bit color) -When you run it, it will report any JPG files found and skip them, so you'll know which sequences contain JPG files instead of PNGs. - ---- - -_**User (2025-12-11 18:21Z)**_ - -create a gitignore and ignore the input and output folders pls. - ---- - -_**Agent (model default, mode Agent)**_ - - - ---- - - - - - ---- - - - -
Tool use: **write** • Edit file: .gitignore - -**Chunk 1** -Lines added: 3, lines removed: 1 - -```diff -@@ -1,1 +1,3 @@ -- -+ input/ -+ output/ -+ -``` - -
- -
- ---- - - - ---- - - -Created a `.gitignore` file that ignores the `input/` and `output/` folders. Git will ignore these directories and their contents. +2. **Preserves 16-bit** by: + - Keeping the original mode for 16-bit images + - Avoiding unnecessary conversions that would reduce bit depth + +3. **Handles both**: + - **16-bit grayscale** (`'I'` mode): Preserved as 16-bit + - **16-bit color** (RGB/RGBA): Mode is preserved; PIL may still save as 8-bit due to library limitations + +**Note**: PIL/Pillow fully supports 16-bit grayscale PNGs. For 16-bit color PNGs, PIL may convert to 8-bit when saving, which is a library limitation. If you need true 16-bit color preservation, a different tool (e.g., `pngcrush` or `optipng`) would be needed. + +The script now preserves 16-bit depth where possible and avoids unnecessary conversions. --- diff --git a/compress_pngs.bat b/compress_pngs.bat index d0d19fb..4c2bd29 100644 --- a/compress_pngs.bat +++ b/compress_pngs.bat @@ -2,3 +2,4 @@ python compress_pngs.py pause + diff --git a/compress_pngs.py b/compress_pngs.py index b39845d..d1f6b97 100644 --- a/compress_pngs.py +++ b/compress_pngs.py @@ -32,14 +32,60 @@ def compress_png(input_path, output_path): original_size = os.path.getsize(input_path) img = Image.open(input_path) - # Convert to RGB if RGBA and no transparency needed - if img.mode == 'RGBA': - # Keep RGBA for transparency support - img = img.convert('RGBA') - elif img.mode != 'RGB': - img = img.convert('RGB') + # Check if image is 16-bit + # PIL represents 16-bit grayscale as 'I' mode (32-bit integer) + # For color images, check pixel values to determine bit depth + is_16bit = False + original_mode = img.mode - # Save with maximum compression to output path + if img.mode == 'I': + # 'I' mode typically represents 16-bit grayscale + is_16bit = True + elif img.mode in ('RGB', 'RGBA', 'LA'): + # Check if max pixel value exceeds 8-bit range + try: + # Get a sample of pixels to check + pixels = list(img.getdata()) + if pixels: + # Flatten if needed (for multi-channel modes) + if isinstance(pixels[0], (tuple, list)): + max_val = max(max(p) for p in pixels[:1000]) # Sample first 1000 pixels + else: + max_val = max(pixels[:1000]) + if max_val > 255: + is_16bit = True + except: + # If we can't determine, assume 8-bit to be safe + pass + + # Preserve 16-bit depth if present, otherwise convert as needed + if is_16bit: + # For 16-bit images, preserve the mode + # PIL will save 16-bit when mode is 'I' (grayscale) or when using specific modes + if img.mode == 'I': + # Keep 16-bit grayscale + pass + elif img.mode in ('RGB', 'RGBA'): + # Keep color mode - PIL may preserve 16-bit depending on how it was loaded + # Note: PIL's PNG save may convert 16-bit RGB to 8-bit, but we preserve the mode + pass + else: + # Convert other modes while trying to preserve bit depth + if 'A' in img.mode: + img = img.convert('RGBA') + else: + img = img.convert('RGB') + else: + # 8-bit images: convert as needed + if img.mode == 'RGBA': + # Keep RGBA for transparency support + img = img.convert('RGBA') + elif img.mode != 'RGB': + img = img.convert('RGB') + + # Save with maximum compression + # PIL will preserve 16-bit for 'I' mode automatically + # For color 16-bit, PIL may convert to 8-bit, but we've preserved the mode img.save(str(output_path), 'PNG', optimize=True, compress_level=9) new_size = os.path.getsize(output_path) savings = original_size - new_size