Files
zccrs-comfyui-dci/py/dci_reader.py
T
JiDe Zhang 1db44183d1 feat: Add decimal scale factor support for DCI images
1. Modify DCIImage node scale parameter from INT to FLOAT type
2. Support decimal scale range from 0.1 to 10.0 with 0.1 step increment
3. Update DCIIconBuilder.add_icon_image method to handle float scale values
4. Improve DCI reader to correctly parse and display decimal scale factors
5. Update create_dci_icon function default scales to include decimal examples
6. Add comprehensive documentation for decimal scaling functionality
7. Include test cases to verify decimal scale calculation accuracy

This enhancement enables fine-grained scaling control for modern high-DPI
displays, supporting scales like 1.25x, 1.5x which are commonly used in
125% and 150% display scaling scenarios. The implementation maintains
backward compatibility with integer scales while providing smoother
scaling transitions for better visual quality.

feat: 为DCI图像添加小数缩放因子支持

1. 修改DCIImage节点的scale参数从INT类型改为FLOAT类型
2. 支持0.1到10.0范围的小数缩放,步长为0.1
3. 更新DCIIconBuilder.add_icon_image方法以处理浮点缩放值
4. 改进DCI读取器以正确解析和显示小数缩放因子
5. 更新create_dci_icon函数默认缩放值包含小数示例
6. 添加小数缩放功能的全面文档说明
7. 包含测试用例以验证小数缩放计算的准确性

此增强功能为现代高DPI显示器提供精细的缩放控制,支持1.25x、1.5x等
常用于125%和150%显示缩放场景的缩放比例。实现保持与整数缩放的向后
兼容性,同时提供更平滑的缩放过渡以获得更好的视觉质量.
2025-06-09 16:48:44 +08:00

393 lines
14 KiB
Python

import struct
import os
from typing import List, Dict, Optional, Tuple
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
import re
class DCIReader:
"""DCI file reader and parser"""
MAGIC = b'DCI\x00'
# File types
FILE_TYPE_RESERVED = 0
FILE_TYPE_FILE = 1
FILE_TYPE_DIRECTORY = 2
FILE_TYPE_LINK = 3
def __init__(self, file_path: str = None, binary_data: bytes = None):
self.file_path = file_path
self.binary_data = binary_data
self.files = []
self.directory_structure = {}
def read(self) -> bool:
"""Read and parse DCI file from file path or binary data"""
try:
if self.binary_data is not None:
return self._read_from_binary_data()
elif self.file_path is not None:
return self._read_from_file()
else:
raise ValueError("Either file_path or binary_data must be provided")
except Exception as e:
print(f"Error reading DCI file: {e}")
return False
def _read_from_file(self) -> bool:
"""Read and parse DCI file from file path"""
with open(self.file_path, 'rb') as f:
return self._read_from_stream(f)
def _read_from_binary_data(self) -> bool:
"""Read and parse DCI file from binary data"""
stream = BytesIO(self.binary_data)
return self._read_from_stream(stream)
def _read_from_stream(self, stream) -> bool:
"""Read and parse DCI file from a stream"""
# Read header
magic = stream.read(4)
if magic != self.MAGIC:
raise ValueError(f"Invalid DCI file: wrong magic header {magic}")
version = struct.unpack('<B', stream.read(1))[0]
if version != 1:
raise ValueError(f"Unsupported DCI version: {version}")
# Read file count (3 bytes)
file_count_bytes = stream.read(3) + b'\x00'
file_count = struct.unpack('<I', file_count_bytes)[0]
# Read files
for i in range(file_count):
file_info = self._read_file_entry(stream)
self.files.append(file_info)
# Parse directory structure
self._parse_directory_structure()
return True
def _read_file_entry(self, f) -> Dict:
"""Read a single file entry from DCI"""
# File type (1 byte)
file_type = struct.unpack('<B', f.read(1))[0]
# File name (63 bytes, null-terminated)
name_bytes = f.read(63)
name = name_bytes.rstrip(b'\x00').decode('utf-8')
# Content size (8 bytes)
content_size = struct.unpack('<Q', f.read(8))[0]
# Content
content = f.read(content_size)
return {
'name': name,
'type': file_type,
'size': content_size,
'content': content
}
def _parse_directory_structure(self):
"""Parse the directory structure from files"""
for file_info in self.files:
if file_info['type'] == self.FILE_TYPE_DIRECTORY:
self._parse_directory_content(file_info['name'], file_info['content'])
def _parse_directory_content(self, dir_name: str, content: bytes):
"""Parse directory content recursively"""
if dir_name not in self.directory_structure:
self.directory_structure[dir_name] = {}
content_stream = BytesIO(content)
while content_stream.tell() < len(content):
try:
# Read file type
file_type_bytes = content_stream.read(1)
if not file_type_bytes:
break
file_type = struct.unpack('<B', file_type_bytes)[0]
# Read file name
name_bytes = content_stream.read(63)
if len(name_bytes) < 63:
break
name = name_bytes.rstrip(b'\x00').decode('utf-8')
# Read content size
size_bytes = content_stream.read(8)
if len(size_bytes) < 8:
break
size = struct.unpack('<Q', size_bytes)[0]
# Read content
file_content = content_stream.read(size)
if len(file_content) < size:
break
if file_type == self.FILE_TYPE_DIRECTORY:
# Recursively parse subdirectory
subdir_path = f"{dir_name}/{name}"
self._parse_directory_content(subdir_path, file_content)
else:
# Store file info
self.directory_structure[dir_name][name] = {
'type': file_type,
'size': size,
'content': file_content
}
except Exception as e:
print(f"Error parsing directory content: {e}")
break
def get_icon_images(self) -> List[Dict]:
"""Extract all icon images with metadata"""
images = []
for dir_path, files in self.directory_structure.items():
# Parse directory path to extract metadata
path_parts = dir_path.split('/')
if len(path_parts) >= 3:
# Expected format: size/state.tone/scale
size_str = path_parts[0]
state_tone_str = path_parts[1] if len(path_parts) > 1 else ""
scale_str = path_parts[2] if len(path_parts) > 2 else ""
# Parse state and tone
state, tone = self._parse_state_tone(state_tone_str)
# Parse size and scale
try:
size = int(size_str) if size_str.isdigit() else 0
# Support both integer and float scale values
try:
scale = float(scale_str) if scale_str else 1.0
except ValueError:
scale = 1.0
except ValueError:
size = 0
scale = 1.0
# Process files in this directory
for filename, file_info in files.items():
if file_info['type'] == self.FILE_TYPE_FILE:
# Parse layer filename for additional metadata
layer_info = self._parse_layer_filename(filename)
try:
# Load image from content
image = Image.open(BytesIO(file_info['content']))
images.append({
'image': image,
'size': size,
'state': state,
'tone': tone,
'scale': scale,
'format': layer_info.get('format', 'unknown'),
'priority': layer_info.get('priority', 1),
'path': dir_path,
'filename': filename,
'file_size': file_info['size']
})
except Exception as e:
print(f"Error loading image {filename}: {e}")
return images
def _parse_state_tone(self, state_tone_str: str) -> Tuple[str, str]:
"""Parse state.tone string"""
if '.' in state_tone_str:
parts = state_tone_str.split('.')
state = parts[0] if parts[0] else 'unknown'
tone = parts[1] if len(parts) > 1 and parts[1] else 'unknown'
else:
state = state_tone_str if state_tone_str else 'unknown'
tone = 'unknown'
return state, tone
def _parse_layer_filename(self, filename: str) -> Dict:
"""Parse layer filename to extract metadata"""
# Expected format: priority.padding.palette.hue.saturation.brightness.red.green.blue.alpha.format
parts = filename.split('.')
if len(parts) >= 2:
format_ext = parts[-1]
layer_info = {'format': format_ext}
if len(parts) >= 11: # Full layer info
try:
layer_info.update({
'priority': int(parts[0]) if parts[0].isdigit() else 1,
'padding': float(parts[1]) if parts[1].replace('.', '').isdigit() else 0.0,
'palette': int(parts[2]) if parts[2].isdigit() else 0,
'hue': float(parts[3]) if parts[3].replace('.', '').isdigit() else 0.0,
'saturation': float(parts[4]) if parts[4].replace('.', '').isdigit() else 0.0,
'brightness': float(parts[5]) if parts[5].replace('.', '').isdigit() else 0.0,
'red': float(parts[6]) if parts[6].replace('.', '').isdigit() else 0.0,
'green': float(parts[7]) if parts[7].replace('.', '').isdigit() else 0.0,
'blue': float(parts[8]) if parts[8].replace('.', '').isdigit() else 0.0,
'alpha': float(parts[9]) if parts[9].replace('.', '').isdigit() else 0.0,
})
except (ValueError, IndexError):
pass
return layer_info
return {'format': 'unknown'}
class DCIPreviewGenerator:
"""Generate preview images with metadata annotations"""
def __init__(self):
self.font_size = 12
self.margin = 10
self.label_height = 80
def create_preview_grid(self, images: List[Dict], grid_cols: int = 4) -> Image.Image:
"""Create a grid preview of all images with metadata"""
if not images:
return self._create_empty_preview()
# Sort images by size, state, tone, scale
sorted_images = sorted(images, key=lambda x: (
x['size'], x['state'], x['tone'], x['scale']
))
# Calculate grid dimensions
grid_rows = (len(sorted_images) + grid_cols - 1) // grid_cols
# Find maximum image size for consistent cell sizing
max_size = max(img['image'].size[0] for img in sorted_images)
cell_width = max_size + self.margin * 2
cell_height = max_size + self.label_height + self.margin * 2
# Create preview canvas
canvas_width = cell_width * grid_cols
canvas_height = cell_height * grid_rows
canvas = Image.new('RGB', (canvas_width, canvas_height), (240, 240, 240))
# Draw images and labels
for i, img_info in enumerate(sorted_images):
row = i // grid_cols
col = i % grid_cols
x = col * cell_width + self.margin
y = row * cell_height + self.margin
self._draw_image_cell(canvas, img_info, x, y, max_size)
return canvas
def _draw_image_cell(self, canvas: Image.Image, img_info: Dict, x: int, y: int, cell_size: int):
"""Draw a single image cell with metadata"""
image = img_info['image']
# Resize image to fit cell while maintaining aspect ratio
image_size = min(cell_size, max(image.size))
if image.size[0] != image_size or image.size[1] != image_size:
image = image.resize((image_size, image_size), Image.Resampling.LANCZOS)
# Center image in cell
img_x = x + (cell_size - image.size[0]) // 2
img_y = y + (cell_size - image.size[1]) // 2
# Paste image
if image.mode == 'RGBA':
canvas.paste(image, (img_x, img_y), image)
else:
canvas.paste(image, (img_x, img_y))
# Draw metadata labels
draw = ImageDraw.Draw(canvas)
# Try to load a font, fall back to default if not available
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", self.font_size)
except:
try:
font = ImageFont.truetype("arial.ttf", self.font_size)
except:
font = ImageFont.load_default()
# Create metadata text
metadata_lines = [
f"Size: {img_info['size']}px",
f"State: {img_info['state']}",
f"Tone: {img_info['tone']}",
f"Scale: {img_info['scale']:g}x",
f"Format: {img_info['format']}",
f"File: {img_info['file_size']}B"
]
# Draw metadata text
text_y = y + cell_size + 5
for line in metadata_lines:
draw.text((x, text_y), line, fill=(0, 0, 0), font=font)
text_y += self.font_size + 2
def _create_empty_preview(self) -> Image.Image:
"""Create an empty preview image"""
canvas = Image.new('RGB', (400, 200), (240, 240, 240))
draw = ImageDraw.Draw(canvas)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16)
except:
font = ImageFont.load_default()
text = "No DCI images found"
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (canvas.width - text_width) // 2
y = (canvas.height - text_height) // 2
draw.text((x, y), text, fill=(100, 100, 100), font=font)
return canvas
def create_metadata_summary(self, images: List[Dict]) -> Dict:
"""Create a summary of metadata from all images"""
if not images:
return {}
summary = {
'total_images': len(images),
'sizes': set(),
'states': set(),
'tones': set(),
'scales': set(),
'formats': set(),
'total_file_size': 0
}
for img in images:
summary['sizes'].add(img['size'])
summary['states'].add(img['state'])
summary['tones'].add(img['tone'])
summary['scales'].add(img['scale'])
summary['formats'].add(img['format'])
summary['total_file_size'] += img['file_size']
# Convert sets to sorted lists
summary['sizes'] = sorted(list(summary['sizes']))
summary['states'] = sorted(list(summary['states']))
summary['tones'] = sorted(list(summary['tones']))
summary['scales'] = sorted(list(summary['scales']))
summary['formats'] = sorted(list(summary['formats']))
return summary