| import base64 |
| import hashlib |
| import os |
| import sys |
| import threading |
| from collections import OrderedDict |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| from io import BytesIO |
| from typing import Callable, Dict, List, Optional, Tuple |
|
|
| import time |
|
|
| import numpy as np |
| import torch |
| from PIL import Image, ImageDraw, ImageFont |
|
|
| import comfy.model_management |
|
|
| MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) |
| if MODULE_DIR not in sys.path: |
| sys.path.insert(0, MODULE_DIR) |
|
|
| from logger import logger |
|
|
|
|
| class ImageCodec: |
| """负责 Tensor/Base64 转换、缓存与实时预览构建。""" |
|
|
| def __init__( |
| self, |
| logger_instance=logger, |
| ensure_not_interrupted: Optional[Callable[[], None]] = None, |
| cache_size: int = 16, |
| ): |
| self.logger = logger_instance |
| self.ensure_not_interrupted = ensure_not_interrupted |
| self._image_cache: "OrderedDict[str, str]" = OrderedDict() |
| self._cache_lock = threading.Lock() |
| self._cache_size = max(1, cache_size) |
|
|
| def _maybe_interrupt(self): |
| if self.ensure_not_interrupted: |
| self.ensure_not_interrupted() |
|
|
| def _tensor_cache_key( |
| self, |
| tensor: Optional[torch.Tensor] = None, |
| np_data: Optional[np.ndarray] = None, |
| ) -> Optional[str]: |
| if tensor is None and np_data is None: |
| return None |
| try: |
| target = np_data |
| if target is None: |
| target = tensor.detach().cpu().numpy() |
| return hashlib.sha1(target.tobytes()).hexdigest() |
| except Exception: |
| return None |
|
|
| def _get_cached_image_b64(self, cache_key: Optional[str]) -> Optional[str]: |
| if not cache_key: |
| return None |
| with self._cache_lock: |
| value = self._image_cache.get(cache_key) |
| if value is not None: |
| self._image_cache.move_to_end(cache_key) |
| return value |
|
|
| def _set_cached_image_b64(self, cache_key: Optional[str], value: str) -> None: |
| if not cache_key or not value: |
| return |
| with self._cache_lock: |
| self._image_cache[cache_key] = value |
| self._image_cache.move_to_end(cache_key) |
| while len(self._image_cache) > self._cache_size: |
| self._image_cache.popitem(last=False) |
|
|
| def extract_numpy_images(self, tensor: torch.Tensor) -> List[np.ndarray]: |
| images: List[np.ndarray] = [] |
| if tensor is None: |
| return images |
| try: |
| np_data = tensor.detach().cpu().numpy() |
| except Exception as exc: |
| self.logger.error(f"输入图像转换失败: {exc}") |
| return images |
|
|
| if np_data.ndim == 3: |
| np_data = np_data[np.newaxis, ...] |
| np_data = np.clip(np_data, 0.0, 1.0) |
|
|
| for sample in np_data: |
| if sample.ndim == 2: |
| sample = np.expand_dims(sample, axis=-1) |
| if sample.shape[-1] == 1: |
| sample = np.repeat(sample, 3, axis=-1) |
| images.append(np.ascontiguousarray(sample)) |
| return images |
|
|
| def tensor_to_base64( |
| self, |
| tensor: Optional[torch.Tensor] = None, |
| np_image: Optional[np.ndarray] = None, |
| ) -> str: |
| if np_image is None: |
| if tensor is None: |
| raise ValueError("必须提供 tensor 或 numpy 图像数据用于编码") |
| samples = self.extract_numpy_images(tensor) |
| if not samples: |
| raise ValueError("无法从 tensor 中提取有效图像数据") |
| np_image = samples[0] |
|
|
| img_array = np.clip(np_image, 0.0, 1.0) |
| img_uint8 = (img_array * 255).astype(np.uint8) |
| img = Image.fromarray(img_uint8) |
| buffered = BytesIO() |
| img.save(buffered, format="PNG") |
| return base64.b64encode(buffered.getvalue()).decode() |
|
|
| def prepare_input_images(self, tensors: List[torch.Tensor]) -> List[str]: |
| if not tensors: |
| return [] |
| encoded_images: List[str] = [] |
| for tensor in tensors: |
| if tensor is None: |
| continue |
| for sample in self.extract_numpy_images(tensor): |
| cache_key = self._tensor_cache_key(np_data=sample) |
| cached_value = self._get_cached_image_b64(cache_key) |
| if cached_value is None: |
| base64_value = self.tensor_to_base64(np_image=sample) |
| self._set_cached_image_b64(cache_key, base64_value) |
| else: |
| base64_value = cached_value |
| encoded_images.append(base64_value) |
| return encoded_images |
|
|
| def base64_to_tensor_single(self, b64_str: str) -> np.ndarray: |
| try: |
| img_data = base64.b64decode(b64_str) |
| img = Image.open(BytesIO(img_data)).convert('RGB') |
| img_array = np.array(img).astype(np.float32) / 255.0 |
| return img_array |
| except Exception as exc: |
| self.logger.error(f"图片解码失败: {exc}") |
| return np.zeros((64, 64, 3), dtype=np.float32) |
|
|
| def base64_to_tensor_parallel( |
| self, |
| base64_strings: List[str], |
| log_prefix: Optional[str] = None, |
| max_workers: Optional[int] = None, |
| ) -> torch.Tensor: |
| if not isinstance(base64_strings, list) or len(base64_strings) == 0: |
| return torch.zeros((1, 64, 64, 3), dtype=torch.float32) |
|
|
| decode_start = time.time() |
| images = [] |
| worker_cap = max_workers if max_workers is not None else max(4, os.cpu_count() or 1) |
| worker_cap = max(1, worker_cap) |
| effective_workers = min(worker_cap, len(base64_strings)) |
|
|
| self._maybe_interrupt() |
| executor = ThreadPoolExecutor(max_workers=effective_workers) |
| try: |
| future_to_index = {executor.submit(self.base64_to_tensor_single, b64): i |
| for i, b64 in enumerate(base64_strings)} |
|
|
| results = [None] * len(base64_strings) |
| try: |
| for future in as_completed(future_to_index): |
| index = future_to_index[future] |
| try: |
| self._maybe_interrupt() |
| results[index] = future.result() |
| except comfy.model_management.InterruptProcessingException: |
| for pending in future_to_index: |
| pending.cancel() |
| raise |
| except Exception as exc: |
| self.logger.error(f"图片{index+1}解码异常: {exc}") |
| results[index] = np.zeros((64, 64, 3), dtype=np.float32) |
|
|
| images = [r for r in results if r is not None] |
| except comfy.model_management.InterruptProcessingException: |
| executor.shutdown(wait=False, cancel_futures=True) |
| raise |
| finally: |
| if not executor._shutdown: |
| executor.shutdown(wait=False, cancel_futures=True) |
|
|
| decode_time = time.time() - decode_start |
| prefix = log_prefix or "" |
| prefix = f"{prefix} " if prefix else "" |
| self.logger.success(f"{prefix}并发解码 {len(images)} 张图片完成,耗时: {decode_time:.2f}s") |
|
|
| return torch.from_numpy(np.stack(images)) |
|
|
| def build_preview_tuple( |
| self, |
| tensor: Optional[torch.Tensor], |
| batch_index: int, |
| max_size: int = 512, |
| ) -> Optional[Tuple[str, Image.Image, int]]: |
| if tensor is None or tensor.shape[0] == 0: |
| return None |
|
|
| try: |
| preview_tensor = tensor[0].detach().cpu() |
| preview_tensor = torch.clamp(preview_tensor, 0.0, 1.0) |
| preview_array = (preview_tensor.numpy() * 255).astype(np.uint8) |
|
|
| if preview_array.ndim == 3 and preview_array.shape[2] == 1: |
| preview_array = np.repeat(preview_array, 3, axis=2) |
| elif preview_array.ndim == 2: |
| preview_array = np.stack([preview_array] * 3, axis=2) |
|
|
| preview_image = Image.fromarray(preview_array) |
| return ("PNG", preview_image, max_size) |
| except Exception as exc: |
| self.logger.error(f"实时预览生成失败: 批次 {batch_index + 1}: {str(exc)[:80]}") |
| return None |
|
|
|
|
| class ErrorCanvas: |
| """统一的错误画布与字体管理。""" |
|
|
| def __init__(self, logger_instance=logger): |
| self.logger = logger_instance |
| self._font_cache: Dict[int, ImageFont.ImageFont] = {} |
|
|
| def _get_error_font_paths(self) -> List[str]: |
| candidates = [] |
| windir = os.environ.get("WINDIR") |
| if windir: |
| for name in ("msyh.ttc", "msyh.ttf", "msjh.ttc", "simhei.ttf", "msmincho.ttc"): |
| candidates.append(os.path.join(windir, "Fonts", name)) |
| candidates.append(os.path.join(os.path.dirname(__file__), "msyh.ttc")) |
| return candidates |
|
|
| def _load_error_font(self, size: int) -> ImageFont.ImageFont: |
| cached = self._font_cache.get(size) |
| if cached is not None: |
| return cached |
| for font_path in self._get_error_font_paths(): |
| if font_path and os.path.exists(font_path): |
| try: |
| font = ImageFont.truetype(font_path, size) |
| self._font_cache[size] = font |
| return font |
| except Exception: |
| continue |
| fallback = ImageFont.load_default() |
| self._font_cache[size] = fallback |
| return fallback |
|
|
| @staticmethod |
| def _wrap_text_segments(draw: ImageDraw.ImageDraw, text: str, |
| font: ImageFont.ImageFont, max_width: int) -> List[str]: |
| if not text: |
| return [""] |
| segments: List[str] = [] |
| current = "" |
| for ch in text: |
| tentative = current + ch |
| if draw.textlength(tentative, font=font) <= max_width or not current: |
| current = tentative |
| else: |
| segments.append(current) |
| current = ch |
| if current: |
| segments.append(current) |
| return segments |
|
|
| def build_error_image_tensor( |
| self, |
| title: str, |
| lines: List[str], |
| size: Tuple[int, int] = (640, 640), |
| ) -> torch.Tensor: |
| lines = [line.strip() for line in lines if line and line.strip()] |
| if not lines: |
| lines = ["发生未知错误"] |
|
|
| width, height = size |
| background = (248, 248, 248) |
| accent = (255, 235, 235) |
| title_color = (180, 30, 30) |
| text_color = (45, 45, 45) |
|
|
| img = Image.new("RGB", (width, height), background) |
| draw = ImageDraw.Draw(img) |
| font_title = self._load_error_font(26) |
| font_body = self._load_error_font(18) |
|
|
| margin = 32 |
| y = margin |
| max_text_width = max(10, width - 2 * margin) |
| max_y = height - margin |
|
|
| draw.rectangle([margin - 6, margin - 6, width - margin + 6, y + 40], fill=accent) |
| draw.text((margin, y), title, fill=title_color, font=font_title) |
| y += font_title.getbbox(title)[3] - font_title.getbbox(title)[1] + 16 |
|
|
| for line in lines: |
| wrapped = self._wrap_text_segments(draw, line, font_body, max_text_width) |
| for seg in wrapped: |
| bbox = font_body.getbbox(seg) |
| line_height = bbox[3] - bbox[1] + 6 |
| if y + line_height > max_y: |
| break |
| draw.text((margin, y), seg, fill=text_color, font=font_body) |
| y += line_height |
|
|
| arr = np.array(img).astype(np.float32) / 255.0 |
| return torch.from_numpy(arr).unsqueeze(0) |
|
|
| def build_error_tensor_from_text(self, title: str, text: str) -> torch.Tensor: |
| normalized = text.replace("\r\n", "\n").replace("\r", "\n") |
| lines = [line.strip() for line in normalized.split("\n") if line.strip()] |
| if not lines: |
| lines = ["发生未知错误"] |
| return self.build_error_image_tensor(title, lines) |
|
|