Files
2025-05-28 16:29:44 -07:00

604 lines
25 KiB
Python

# =======================
# Standard Libraries
# =======================
from enum import Enum
from typing import Optional, Dict, Any, Sequence
from io import BytesIO
import base64
import subprocess
import sys
import importlib
import importlib.metadata
# =======================
# Third-Party Libraries
# =======================
import requests
from requests.adapters import HTTPAdapter, Retry
from PIL import Image, ImageOps
import torch
import torch.nn.functional as F
import numpy as np
# =======================
# Local Modules
# =======================
try:
from .mng_json import json_manager, TroubleSgltn
except ImportError:
from mng_json import json_manager, TroubleSgltn
class ImageFormat(Enum):
B64_IMAGE = "b64-image" # Base64 encoded image (JPEG/PNG)
BYTE_IMAGE = "byte-image" # Raw byte image (JPEG/PNG)
UNKNOWN = "unknown" # Neither base64 nor raw image
class CommUtils:
def __init__(self)->None:
self.j_mngr = json_manager()
def is_lm_server_up(self, endpoint:str, comm_retries:int=2, timeout:int=4): #should be util in api_requests.py
session = requests.Session()
retries = Retry(total=comm_retries, backoff_factor=0, status_forcelist=[500, 502, 503, 504])
session.mount('http://', HTTPAdapter(max_retries=retries))
try:
response = session.head(endpoint, timeout=timeout) # Use HEAD to minimize data transfer
if 200 <= response.status_code <= 300:
self.write_url(endpoint) #Save url to a text file
self.j_mngr.log_events(f"Local LLM Server is running with status code: {response.status_code}",
TroubleSgltn.Severity.INFO,
True)
return True
else:
self.j_mngr.log_events(f"Server returned response code: {response.status_code}",
TroubleSgltn.Severity.INFO,
True)
return True
except requests.RequestException as e:
self.j_mngr.log_events(f"Local LLM Server is not running: {e}",
TroubleSgltn.Severity.WARNING,
True)
return False
def get_data(self, endpoint:str="", timeout:int=8, retries:int=1, data_type:str="", headers:Optional[Dict[str,str]]=None )-> requests.Response | None:
"""
Sends a GET request to the specified endpoint with configurable timeout, retry logic, and headers.
Parameters:
endpoint (str): The API endpoint URL to send the GET request to.
timeout (int): The maximum number of seconds to wait for a response before timing out. Default is 8.
retries (int): The number of times to retry the request in case of failure due to certain HTTP errors (500, 502, 503, 504). Default is 1.
data_type (str): A descriptive label for the type of data being fetched, used for logging purposes.
headers (Optional[Dict[str, str]]): A dictionary of additional HTTP headers to include in the request.
Returns:
requests.Response | None: The response object if the request is successful, or None if an error occurs.
Raises:
requests.RequestException: Handled internally. Logs an error message and returns None if a request failure occurs.
Notes:
- Implements automatic retry logic for transient server errors.
- Logs a warning if the request fails, including the HTTP status code and error details.
"""
session = requests.Session()
gretries = Retry(total=retries, backoff_factor=0, status_forcelist=[500, 502, 503, 504])
session.mount('http://', HTTPAdapter(max_retries=gretries))
stat_code = 0
try:
response = session.get(endpoint, timeout=timeout, headers=headers,)
stat_code = response.status_code
response.raise_for_status() # Raises an HTTPError if the response status code indicates an error
return response
except requests.RequestException as e:
self.j_mngr.log_events(f"Unable to fetch data for: {data_type}. Server returned code: {stat_code}. Error: {e} ",
TroubleSgltn.Severity.WARNING,
True)
return None
def post_data(
self,
endpoint: str = "",
timeout: int = 8,
retries: int = 1,
data_type: str = "",
json: Optional[Dict[str, Any]] = None,
data: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
show_errors: bool = True
) -> requests.Response | None:
"""
Sends a POST request to the specified endpoint, supporting both JSON and form-encoded data.
Parameters:
endpoint (str): The API endpoint URL.
timeout (int): Timeout duration in seconds (default: 8).
retries (int): Number of retries on failure (default: 1).
data_type (str): A label for logging purposes.
json (Optional[Dict[str, Any]]): JSON payload (application/json).
data (Optional[Dict[str, Any]]): Form-encoded data (application/x-www-form-urlencoded).
headers (Optional[Dict[str, str]]): Additional headers.
Returns:
requests.Response | None: The response object if successful, otherwise None.
Notes:
- Implements automatic retry logic for transient server errors.
- Logs a warning if the request fails, including the HTTP status code and error details.
"""
session = requests.Session()
gretries = Retry(total=retries, backoff_factor=0, status_forcelist=[500, 502, 503, 504])
session.mount('http://', HTTPAdapter(max_retries=gretries))
stat_code = 0
try:
response = session.post(endpoint, timeout=timeout, headers=headers, json=json if json else None, data=data if data else None)
stat_code = response.status_code
response.raise_for_status() # Raises an HTTPError if the response status code indicates an error
return response
except requests.RequestException as e:
self.j_mngr.log_events(
f"Unable to post data for: {data_type}. Server returned code: {stat_code}. Error: {e}",
TroubleSgltn.Severity.WARNING,
show_errors
)
return None
def write_url(self, url:str) -> bool:
# Save the current open source url for startup retrieval of models
url_file = self.j_mngr.append_filename_to_path(self.j_mngr.script_dir, 'OpenSourceURL.txt')
url_result = self.j_mngr.write_string_to_file(url, url_file)
self._written_url = url
self.j_mngr.log_events("Open source LLM URL saved to file.",
TroubleSgltn.Severity.INFO,
True)
return url_result
class ImageUtils:
def __init__(self):
self.j_mngr = json_manager()
#self.trbl = TroubleSgltn()
def detect_image_format(self, image_data):
"""
Detect whether the content is a base64-encoded image or raw byte array (image).
Args:
image_data (str or bytes): The image data to check.
Returns:
ImageFormat: Enum indicating 'b64-image', 'byte-image', or 'unknown'.
"""
# Check if the content is a base64-encoded string
if isinstance(image_data, str):
try:
# Attempt to decode the base64 string
base64.b64decode(image_data, validate=True)
return ImageFormat.B64_IMAGE
except (ValueError, base64.binascii.Error) as e:
self.j_mngr.log_events(f"Unable to decode encoded image string. Error: {e}",
TroubleSgltn.Severity.ERROR,
True)
return ImageFormat.UNKNOWN # Not a valid base64 string
# Check if it's already raw bytes (regardless of format)
elif isinstance(image_data, bytes):
return ImageFormat.BYTE_IMAGE
self.j_mngr.log_events("Image is in an Unknown format. Unable to process image.",
TroubleSgltn.Severity.ERROR,
True)
return ImageFormat.UNKNOWN
def b64_to_tensor(self, b64_image: str) -> tuple[torch.Tensor,torch.Tensor]:
"""
Converts a base64-encoded image to a torch.Tensor.
Note: ComfyUI expects the image tensor in the [N, H, W, C] format.
For example with the shape torch.Size([1, 1024, 1024, 3])
Args:
b64_image (str): The b64 image to convert.
Returns:
torch.Tensor: an image Tensor.
"""
self.j_mngr.log_events("Converting b64 Image to Torch Tensor Image file",
is_trouble=True)
# Decode the base64 string
image_data = base64.b64decode(b64_image)
# Open the image with PIL and handle EXIF orientation
image = Image.open(BytesIO(image_data))
image = ImageOps.exif_transpose(image)
# Convert to RGBA for potential alpha channel handling
# Dalle doesn't provide an alpha channel, but this is here for
# broad compatibility
image = image.convert("RGBA")
image_np = np.array(image).astype(np.float32) / 255.0 # Normalize
# Split the image into RGB and Alpha channels
rgb_np, alpha_np = image_np[..., :3], image_np[..., 3]
# Convert RGB to PyTorch tensor and ensure it's in the [N, H, W, C] format
tensor_image = torch.from_numpy(rgb_np).unsqueeze(0) # Adds N dimension
# Create mask based on the presence or absence of an alpha channel
if image.mode == 'RGBA':
mask = torch.from_numpy(alpha_np).unsqueeze(0).unsqueeze(0) # Adds N and C dimensions
else: # Fallback if no alpha channel is present
mask = torch.zeros((1, tensor_image.shape[2], tensor_image.shape[3]), dtype=torch.float32) # [N, H, W]
return tensor_image, mask
def tensor_to_base64(self, tensor: torch.Tensor) -> str:
"""
Converts a PyTorch tensor to a base64-encoded image.
Note: ComfyUI provides the image tensor in the [N, H, W, C] format.
For example with the shape torch.Size([1, 1024, 1024, 3])
Args:
tensor (torch.Tensor): The image tensor to convert.
Returns:
str: Base64-encoded image string.
"""
self.j_mngr.log_events("Converting Torch Tensor image to b64 Image file",
is_trouble=True)
if tensor.is_cuda: # Check if the tensor is on GPU
tensor = tensor.cpu() # Move tensor to CPU
# Convert tensor to PIL Image
if tensor.ndim == 4:
tensor = tensor.squeeze(0) # Remove batch dimension if present
pil_image = Image.fromarray((tensor.numpy() * 255).astype('uint8'))
# Save PIL Image to a buffer
buffer = BytesIO()
pil_image.save(buffer, format="PNG") # Can change to JPEG if preferred
buffer.seek(0)
# Encode buffer to base64
base64_image = base64.b64encode(buffer.read()).decode('utf-8')
return base64_image
def tensor_to_bytes(self, tensor: torch.Tensor, ensure_alpha: bool = False, file_name:str ='image.png') -> BytesIO:
"""
Converts a PyTorch tensor to a bytes object.
Args:
tensor (torch.Tensor): The image tensor to convert.
ensure_alpha (bool): If True, ensures the image is in RGBA format.
file_name (str): The name attribute of the byte file (for identification purposes).
Returns:
BytesIO: BytesIO object containing the image data.
"""
if tensor.is_cuda: # Check if the tensor is on GPU
tensor = tensor.cpu() # Move tensor to CPU
# Convert tensor to PIL Image
if tensor.ndim == 4:
tensor = tensor.squeeze(0) # Remove batch dimension if present
# Convert tensor to numpy array and scale values properly
if tensor.dtype == torch.float32 or tensor.dtype == torch.float64:
# Assume values are in [0, 1] range
img_array = (tensor.numpy() * 255).astype('uint8')
else:
# Already in uint8 format
img_array = tensor.numpy()
pil_image = Image.fromarray(img_array)
if ensure_alpha:
# Ensure RGBA format
if pil_image.mode == "L": # If grayscale, use the grayscale values as alpha
pil_image = pil_image.convert("RGBA")
pil_image.putalpha(pil_image) # Use the grayscale values as the alpha channel
else:
pil_image = pil_image.convert("RGBA") # Convert to RGBA if not already
# Save PIL Image to a buffer
buffer = BytesIO()
pil_image.save(buffer, format="PNG") # Can change to JPEG if preferred
buffer.seek(0)
buffer.name = file_name # Set the name of the byte file for easier identification
return buffer
def bytes_to_tensor(self, image_data: bytes) -> torch.Tensor:
"""
Converts binary image data (bytes) to a torch.Tensor in [N, H, W, C] format.
Handles JPEG, PNG, and other formats.
Args:
image_data (bytes): The raw image bytes (binary data).
Returns:
torch.Tensor: The image tensor.
"""
# Load the image data from bytes into a PIL Image
image = Image.open(BytesIO(image_data))
# Convert the image to RGBA format (or RGB if you prefer)
image = image.convert("RGBA")
# Convert the PIL image to a NumPy array and normalize pixel values
image_np = np.array(image).astype(np.float32) / 255.0 # Normalize to [0, 1]
# Split the image into RGB and Alpha channels
rgb_np = image_np[..., :3] # Extract RGB channels
# Convert NumPy array to PyTorch tensor and ensure it's on CPU
tensor_image = torch.from_numpy(rgb_np)
if tensor_image.is_cuda:
tensor_image = tensor_image.cpu()
# Add batch dimension [N, H, W, C]
tensor_image = tensor_image.unsqueeze(0)
return tensor_image
def produce_images(self, response, response_key='data', field_name='b64_json', field2_name=""):
"""
Processes an API response to extract base64-encoded images and convert them into PyTorch tensors.
This function is designed to handle API responses with either shallow or nested JSON structures.
It extracts base64-encoded images or raw image byte data from the response, converts them into
PyTorch tensors, and concatenates them into a single tensor if multiple images are found.
Args:
response (dict or object): The API response, either a dictionary or an object (like DALL-E's ImagesResponse).
response_key (str): The key in the response dictionary or object attribute that contains the list of items
(default is 'data').
field_name (str): The key used to access the base64-encoded image data in each item
of the response (default is 'b64_json').
field2_name (str, optional): An optional key for accessing a nested structure. If provided,
this key will be used to access a nested dictionary inside each
item before attempting to extract 'field_name'. Default is ""
(i.e., no nested structure).
Returns:
torch.Tensor or None: Returns a PyTorch tensor containing all the processed images.
If no images are found, None is returned.
"""
image_list = []
# Function to extract and process images
def extract_and_process_images(items):
for index, item in enumerate(items):
# Access nested field or image data
if field2_name:
b64_image = item.get(field2_name, {}).get(field_name, None) if isinstance(item, dict) else getattr(item, field2_name, {}).get(field_name, None)
else:
b64_image = item.get(field_name, None) if isinstance(item, dict) else getattr(item, field_name, None)
if b64_image:
image_format = ImageUtils.detect_image_format(self, b64_image)
if image_format == ImageFormat.B64_IMAGE:
image_tensor, _ = ImageUtils.b64_to_tensor(self, b64_image)
image_list.append(image_tensor)
elif image_format == ImageFormat.BYTE_IMAGE:
image_list.append(ImageUtils.bytes_to_tensor(self, b64_image))
else:
self.j_mngr.log_events(f"No image found at index {index}")
# Check if response is a dictionary
if isinstance(response, dict):
if response_key in response and isinstance(response[response_key], list):
extract_and_process_images(response[response_key])
else:
self.j_mngr.log_events(f"No images found in the response under key '{response_key}'")
# Check if response is an object with the attribute
elif hasattr(response, response_key):
items = getattr(response, response_key)
if isinstance(items, list):
extract_and_process_images(items)
else:
self.j_mngr.log_events(f"No images found in the response under key '{response_key}'")
if image_list:
if len(image_list) > 1:
return torch.cat(image_list)
else:
return image_list[0].unsqueeze(0)
self.j_mngr.log_events(f"No images found in the response under key '{response_key}'")
return None
def extract_batch_size(self, tensor: torch.Tensor) -> int:
"""
Extracts the batch size (N) from a PyTorch tensor.
Args:
tensor (torch.Tensor): The image tensor in [N, H, W, C] format.
Returns:
int: The number of images (batch size).
"""
if tensor.ndim != 4:
raise ValueError(f"Expected a 4D tensor [N, H, W, C], but got shape {tensor.shape}")
return tensor.shape[0] # Return batch size (N)
def pad_and_cat_images(self, image_list, dim=2, pad_value=0.0):
"""
Pads image tensors (in [N, H, W, C] format) to the same height and width, then concatenates.
Args:
image_list (list of torch.Tensor): List of image tensors [N, H, W, C].
dim (int): Dimension along which to concatenate (1=vertical, 2=horizontal).
pad_value (float): Padding pixel value (default=0.0 for black padding).
Returns:
torch.Tensor: Concatenated image tensor.
"""
# Ensure all images have the same batch size (N)
assert all(img.shape[0] == image_list[0].shape[0] for img in image_list), "All images must have the same batch size"
# Determine max height and width
max_height = max(img.shape[1] for img in image_list)
max_width = max(img.shape[2] for img in image_list)
# Pad each image to match the max dimensions
padded_images = []
for img in image_list:
_, h, w, _ = img.shape # Ignore N and C dimensions
pad_h = max_height - h
pad_w = max_width - w
# Padding format: (left, right, top, bottom)
padded_img = F.pad(img, (0, 0, 0, pad_w, 0, pad_h), value=pad_value)
padded_images.append(padded_img)
return torch.cat(padded_images, dim=dim)
def pad_images_to_batch(self, image_list, pad_value=0.0):
"""
Pads a list of images ([N, H, W, C]) to match the largest image size, keeping batch format.
Logs the original image sizes before padding.
Args:
image_list (list of torch.Tensor): List of image tensors [N, H, W, C].
pad_value (float): Padding pixel value (default=0.0 for black padding).
Returns:
torch.Tensor: A properly padded batch tensor [N, max_H, max_W, C].
"""
# Ensure batch size (N) is consistent
assert all(img.shape[0] == image_list[0].shape[0] for img in image_list), "All images must have the same batch size"
# Move images to CPU if needed
image_list = [img.cpu() if img.is_cuda else img for img in image_list]
# Capture original sizes for logging
image_size_list = [(img.shape[1], img.shape[2]) for img in image_list] # [(H, W), ...]
self.j_mngr.log_events(f"Padding and batching images of sizes: {image_size_list}", is_trouble=True)
# Determine max height and width
max_height = max(h for h, _ in image_size_list)
max_width = max(w for _, w in image_size_list)
# Pad each image to the max dimensions
padded_images = []
for img in image_list:
_, h, w, _ = img.shape # Ignore N and C dimensions
pad_h = max_height - h
pad_w = max_width - w
# Corrected padding order
padded_img = F.pad(img, (0, 0, 0, pad_w, 0, pad_h), value=pad_value)
# **Check if the padded image has an unexpected shape**
if padded_img.shape[1] == 1:
self.j_mngr.log_events(f"WARNING: Padded image has unexpected shape {padded_img.shape}, squeezing!", is_trouble=True)
padded_img = padded_img.squeeze(1) # Remove any accidental extra dimension
padded_images.append(padded_img)
# Use torch.cat() instead of torch.stack()
batch_tensor = torch.cat(padded_images, dim=0) # Shape: [N, max_H, max_W, C]
# Debugging: Log final batch shape before returning
self.j_mngr.log_events(f"Final batch shape after cat: {batch_tensor.shape}", is_trouble=True)
return batch_tensor
class PythonUtils:
def __init__(self)->None:
self.j_mngr = json_manager()
def get_library_version(self, library_name: str) -> str:
"""
Returns the version number of the specified library in the current active Python environment if installed,
and logs the result.
Args:
library_name (str): The name of the library to check.
Returns:
str: Version number if found, or a message indicating not installed.
"""
try:
version = importlib.metadata.version(library_name)
self.j_mngr.log_events(
f"[LibVersion] {library_name} version found: {version}",
severity="INFO",
is_trouble=False
)
return version
except importlib.metadata.PackageNotFoundError:
self.j_mngr.log_events(
f"[LibVersion] {library_name} is not installed.",
severity="WARNING",
is_trouble=False
)
except Exception as e:
self.j_mngr.log_events(
f"[LibVersion] Error checking version for {library_name}: {e}",
severity="ERROR",
is_trouble=False
)
return ""
def run_python_module_commands(self, command_lists: list[Sequence[str]], label: str = "ScriptBatch"):
"""
Runs one or more module commands using the current Python environment's interpreter.
Args:
command_lists (list[Sequence[str]]): A list of argument lists or tuples. Each will be run as:
[sys.executable, "-m", *args]
label (str): A string label to prefix log messages.
"""
for args in command_lists:
full_cmd = [sys.executable, "-m"] + args
try:
subprocess.run(full_cmd, check=True)
self.j_mngr.log_events(
f"[{label}] Successfully ran: {' '.join(full_cmd)}",
TroubleSgltn.Severity.INFO,
False
)
except subprocess.CalledProcessError as e:
self.j_mngr.log_events(
f"[{label}] Failed command: {' '.join(full_cmd)}\nError: {e}",
TroubleSgltn.Severity.WARNING,
False
)
except Exception as e:
self.j_mngr.log_events(
f"[{label}] Unexpected error running: {' '.join(full_cmd)}\n{type(e).__name__}: {e}",
TroubleSgltn.Severity.WARNING,
False
)