unsloth/studio/backend/utils/datasets/format_conversion.py
Andrew Barnes 2c5d3c48ec
fix: subprocess crash during map operation on Windows (#4507)
* fix: handle Windows subprocess crash during dataset.map()

Windows uses spawn (not fork) for multiprocessing. Spawned workers
cannot resolve Unsloth's dynamically compiled cache modules from
unsloth_compiled_cache/, causing ModuleNotFoundError and RuntimeError
during dataset.map() tokenization.

Add two platform-guarded patches for sys.platform == "win32":
1. Force HF_DATASETS_MULTITHREADING_MAX_WORKERS=1 and set spawn method
2. Monkey-patch Dataset.map() to force num_proc=None

Fixes #4490

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* address review: extend spawn fix to macOS, add multiprocess fallback

- Change platform checks from sys.platform == "win32" to
  sys.platform != "linux" so macOS (also spawn-based) is covered
- Wrap multiprocess import in try/except falling back to stdlib
  multiprocessing when the multiprocess package isn't installed
- Rename _win32_safe_map to _spawn_safe_map to reflect broader scope

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: replace global Dataset.map monkey-patch with targeted num_proc routing

The previous approach had issues: Patch 1 set HF_DATASETS_MULTITHREADING_MAX_WORKERS
and forced set_start_method (dead code on platforms already using spawn), and Patch 2
globally monkey-patched Dataset.map() (too broad, missed Dataset.filter()).

Replace with a two-layer fix:

1. Studio layer: Add dataset_map_num_proc() that returns None on spawn platforms
   (Windows, macOS). Unlike num_proc=1 which still creates Pool(1) and spawns a
   worker, num_proc=None runs Dataset.map()/filter() truly in-process.
   Update all dataset.map() callsites to use it. ThreadPoolExecutor callers
   (format_conversion.py) keep using safe_num_proc() since threads are unaffected.

2. Root-cause layer: Propagate UNSLOTH_COMPILE_LOCATION via PYTHONPATH on spawn
   platforms so spawned workers can import compiled modules. Mirrors the .venv_t5
   pattern in worker.py. Does not import unsloth_zoo.compiler (heavy torch/triton
   imports). Completely skipped on Linux.

Also extend safe_num_proc() to return 1 on macOS (was only guarding Windows),
and narrow the transformers 5.x dataloader guard from != "linux" to explicit
("win32", "darwin").

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: add safe_thread_num_proc() for ThreadPoolExecutor callsites

safe_num_proc() correctly caps to 1 on macOS/Windows for process-based
multiprocessing, but format_conversion.py reuses it for ThreadPoolExecutor
workers. Threads share address space and are unaffected by spawn, so
capping to 1 makes image URL downloads sequential -- a real regression.

Add safe_thread_num_proc() that skips the platform guard but keeps the
cpu_count heuristic, and switch both ThreadPoolExecutor callsites in
format_conversion.py to use it.

* fix: remove double-wrap in dataset_num_proc + fix num_proc=1 in datasets route

- trainer.py:3009: Replace safe_num_proc(max(1, os.cpu_count() // 4))
  with max(1, (os.cpu_count() or 1) // 4) to avoid double-wrapping
  inside dataset_map_num_proc which already calls safe_num_proc
- trainer.py:15-20: Clarify comment on PYTHONPATH propagation
- datasets.py:445: Change num_proc=1 to num_proc=None for 10-row
  preview slice (avoids unnecessary multiprocessing overhead)

* fix: guard os.cpu_count() against None in worker-count helpers

os.cpu_count() can return None on some platforms. Use (os.cpu_count() or 1)
to prevent TypeError in safe_num_proc() and safe_thread_num_proc().

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-03-22 05:21:09 -07:00

892 lines
32 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""
Format conversion utilities for dataset processing.
This module contains functions for converting between dataset formats
(Alpaca, ShareGPT, ChatML) and standardizing chat formats.
"""
import os
from datasets import IterableDataset
from loggers import get_logger
logger = get_logger(__name__)
def standardize_chat_format(
dataset,
tokenizer = None,
aliases_for_system = [
"system",
],
aliases_for_user = [
"user",
"human",
"input",
],
aliases_for_assistant = [
"gpt",
"assistant",
"output",
],
batch_size = 1000,
num_proc = None,
):
"""
Our own standardization function that handles BOTH messages and conversations.
Converts non-standard role names and keys to standard format.
"""
import collections
import itertools
from datasets import IterableDataset
# Check if vision tokenizer is used
is_vlm = False
if tokenizer is not None:
if hasattr(tokenizer, "image_processor") or hasattr(tokenizer, "tokenizer"):
is_vlm = True
column_names = set(next(iter(dataset)).keys())
# Check for both 'conversations' and 'messages'
chat_column = None
if "conversations" in column_names:
chat_column = "conversations"
elif "messages" in column_names:
chat_column = "messages"
elif "texts" in column_names:
chat_column = "texts"
else:
return dataset # No chat column found
# Inspect structure
examples = itertools.islice(dataset, 10)
uniques = collections.defaultdict(list)
for example in examples:
for message in example[chat_column]:
for key, value in message.items():
if type(value) is not str:
continue # Skip non-string values
uniques[key].append(value)
if len(uniques.keys()) != 2:
return dataset # Unexpected structure
keys = list(uniques.keys())
length_first = len(set(uniques[keys[0]]))
length_second = len(set(uniques[keys[1]]))
# Determine which is role and which is content
if length_first < length_second:
role_key = keys[0]
content_key = keys[1]
else:
role_key = keys[1]
content_key = keys[0]
# Mapping for aliases
aliases_mapping = {}
for x in aliases_for_system:
aliases_mapping[x] = "system"
for x in aliases_for_user:
aliases_mapping[x] = "user"
for x in aliases_for_assistant:
aliases_mapping[x] = "assistant"
def _standardize_dataset(examples):
convos = examples[chat_column]
all_convos = []
for convo in convos:
new_convo = []
for message in convo:
# Get original role and content
original_role = message.get(role_key, "")
original_content = message.get(content_key, "")
# Map to standard role name
standard_role = aliases_mapping.get(original_role, original_role)
# Handle VLM format
if is_vlm:
original_content = [{"type": "text", "text": original_content}]
# Create dict with EXPLICIT ORDER
new_message = {"role": standard_role, "content": original_content}
new_convo.append(new_message)
all_convos.append(new_convo)
return {chat_column: all_convos}
dataset_map_kwargs = {
"batched": True,
"batch_size": batch_size,
}
if not isinstance(dataset, IterableDataset):
from utils.hardware import dataset_map_num_proc
if num_proc is None or type(num_proc) is not int:
num_proc = dataset_map_num_proc()
else:
num_proc = dataset_map_num_proc(num_proc)
dataset_map_kwargs["num_proc"] = num_proc
dataset_map_kwargs["desc"] = "Standardizing chat format"
return dataset.map(_standardize_dataset, **dataset_map_kwargs)
def convert_chatml_to_alpaca(dataset, batch_size = 1000, num_proc = None):
"""
Converts ChatML format (messages OR conversations) to Alpaca format.
Handles both standardized and ShareGPT formats.
Supports:
- "messages" or "conversations" column
- "role"/"content" (standard) or "from"/"value" (ShareGPT)
"""
from torch.utils.data import IterableDataset
def _convert(examples):
# Auto-detect which column name is used
chatml_data = (
examples.get("messages")
or examples.get("conversations")
or examples.get("texts")
)
if chatml_data is None:
raise ValueError(
"No 'messages' or 'conversations' or 'texts' column found."
)
instructions = []
outputs = []
inputs = []
for convo in chatml_data:
instruction = ""
output = ""
for msg in convo:
# Handle both standard and ShareGPT formats
role = msg.get("role") or msg.get("from")
content = msg.get("content") or msg.get("value")
# Get first user message as instruction
if role in ["user", "human", "input"] and not instruction:
instruction = content
# Get first assistant message as output
elif role in ["assistant", "gpt", "output"] and not output:
output = content
break # Stop after first assistant response
instructions.append(instruction)
inputs.append("") # Alpaca typically has empty input
outputs.append(output)
return {"instruction": instructions, "input": inputs, "output": outputs}
dataset_map_kwargs = {
"batched": True,
"batch_size": batch_size,
}
if not isinstance(dataset, IterableDataset):
from utils.hardware import dataset_map_num_proc
if num_proc is None or type(num_proc) is not int:
num_proc = dataset_map_num_proc()
else:
num_proc = dataset_map_num_proc(num_proc)
dataset_map_kwargs["num_proc"] = num_proc
dataset_map_kwargs["desc"] = "Converting ChatML to Alpaca format"
return dataset.map(_convert, **dataset_map_kwargs)
def convert_alpaca_to_chatml(dataset, batch_size = 1000, num_proc = None):
"""
Converts Alpaca format to ChatML format.
Output format: Uses 'conversations' column with standard 'role'/'content' structure.
"""
from torch.utils.data import IterableDataset
def _convert(examples):
conversations = []
for i in range(len(examples["instruction"])):
instruction = examples["instruction"][i]
input_text = examples.get("input", [""] * len(examples["instruction"]))[i]
output = examples["output"][i]
# Combine instruction and input (if exists) for user message
if input_text and input_text.strip():
user_content = f"{instruction}\n\n{input_text}".strip()
else:
user_content = instruction
# Build conversation in standard ChatML format
convo = [
{"role": "user", "content": user_content},
{"role": "assistant", "content": output},
]
conversations.append(convo)
return {"conversations": conversations}
dataset_map_kwargs = {
"batched": True,
"batch_size": batch_size,
}
if not isinstance(dataset, IterableDataset):
from utils.hardware import dataset_map_num_proc
if num_proc is None or type(num_proc) is not int:
num_proc = dataset_map_num_proc()
else:
num_proc = dataset_map_num_proc(num_proc)
dataset_map_kwargs["num_proc"] = num_proc
dataset_map_kwargs["desc"] = "Converting Alpaca to ChatML format"
return dataset.map(_convert, **dataset_map_kwargs)
def _format_eta(seconds):
"""Format seconds into a human-readable ETA string."""
if seconds < 60:
return f"{seconds:.0f}s"
elif seconds < 3600:
m, s = divmod(int(seconds), 60)
return f"{m}m {s}s"
else:
h, remainder = divmod(int(seconds), 3600)
m, _ = divmod(remainder, 60)
return f"{h}h {m}m"
def convert_to_vlm_format(
dataset,
instruction = None,
text_column = "text",
image_column = "image",
dataset_name = None,
progress_callback = None,
):
"""
Converts simple {image, text} format to VLM messages format.
Returns a LIST, not a HuggingFace Dataset (to preserve PIL Images).
For URL-based image datasets, runs a 200-sample parallel probe first to
estimate download speed and failure rate, then reports time estimate or
warning through progress_callback before proceeding with the full conversion.
Args:
progress_callback: Optional callable(status_message=str) to report
progress to the training overlay.
Returns:
list: List of dicts with 'messages' field
"""
from PIL import Image
from .vlm_processing import generate_smart_vlm_instruction
def _notify(msg):
"""Send status update to the training overlay if callback is available."""
if progress_callback:
progress_callback(status_message = msg)
# Generate smart instruction if not provided
if instruction is None:
instruction_info = generate_smart_vlm_instruction(
dataset,
text_column = text_column,
image_column = image_column,
dataset_name = dataset_name,
)
instruction = instruction_info["instruction"]
instruction_column = instruction_info.get("instruction_column")
uses_dynamic = instruction_info["uses_dynamic_instruction"]
logger.info(
f"📝 Auto-detected instruction type: {instruction_info['instruction_type']}"
)
logger.info(f"📝 Confidence: {instruction_info['confidence']:.2f}")
if not uses_dynamic:
logger.info(f"📝 Using instruction: '{instruction}'")
else:
logger.info(
f"📝 Using dynamic instructions from column: '{instruction_column}'"
)
else:
instruction_column = None
uses_dynamic = False
def _convert_single_sample(sample):
"""Convert a single sample to VLM format."""
# Get image (might be PIL Image, local path, URL, or bare filename)
image_data = sample[image_column]
if isinstance(image_data, str):
if image_data.startswith(("http://", "https://")):
import fsspec
from io import BytesIO
with fsspec.open(image_data, "rb", expand = True) as f:
image_data = Image.open(BytesIO(f.read())).convert("RGB")
elif _image_lookup is not None and image_data in _image_lookup:
# Bare filename → resolve via HF repo lookup
from huggingface_hub import hf_hub_download
local_path = hf_hub_download(
dataset_name,
_image_lookup[image_data],
repo_type = "dataset",
)
image_data = Image.open(local_path).convert("RGB")
else:
image_data = Image.open(image_data).convert("RGB")
# Get text (if list of strings, pick a random one — e.g. multiple captions)
text_data = sample[text_column]
if isinstance(text_data, list) and len(text_data) > 0:
import random
text_data = random.choice(text_data)
# Get instruction (static or dynamic)
if uses_dynamic and instruction_column:
current_instruction = sample[instruction_column]
else:
current_instruction = instruction
# Build VLM messages - simple structure
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": current_instruction},
{"type": "image", "image": image_data}, # PIL object
],
},
{"role": "assistant", "content": [{"type": "text", "text": text_data}]},
]
# Return dict with messages
return {"messages": messages}
total = len(dataset)
first_image = next(iter(dataset))[image_column]
has_urls = isinstance(first_image, str) and first_image.startswith(
("http://", "https://")
)
# ── Bare-filename detection: images stored as filenames (e.g. "img_001.png")
# that don't exist locally. Build a basename→repo_path lookup so we can
# resolve them via hf_hub_download during conversion.
_image_lookup = None
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff")
if (
not has_urls
and isinstance(first_image, str)
and not os.path.exists(first_image)
and dataset_name
):
try:
from huggingface_hub import HfApi
_notify("Resolving image filenames from HF repo...")
logger.info(
f"🔍 Image column contains bare filenames (e.g. '{first_image}') — building repo lookup..."
)
repo_files = HfApi().list_repo_files(dataset_name, repo_type = "dataset")
_image_lookup = {
os.path.basename(f): f
for f in repo_files
if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS)
}
if first_image in _image_lookup:
logger.info(
f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}''{_image_lookup[first_image]}')"
)
else:
logger.info(
f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found — falling back to local open"
)
_image_lookup = None
except Exception as e:
logger.info(f"⚠️ Failed to build HF repo image lookup: {e}")
_image_lookup = None
# ── URL probe: 200 samples with parallel workers to estimate speed + failure rate ──
PROBE_SIZE = 200
MAX_FAIL_RATE = 0.3
if has_urls and total > PROBE_SIZE:
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from utils.hardware import safe_thread_num_proc
num_workers = safe_thread_num_proc()
_notify(f"Probing {PROBE_SIZE} image URLs with {num_workers} workers...")
logger.info(
f"🔍 Probing {PROBE_SIZE}/{total} image URLs with {num_workers} workers..."
)
probe_samples = [dataset[i] for i in range(PROBE_SIZE)]
probe_ok = 0
probe_fail = 0
probe_start = time.time()
with ThreadPoolExecutor(max_workers = num_workers) as executor:
futures = {
executor.submit(_convert_single_sample, s): s for s in probe_samples
}
for future in as_completed(futures):
try:
future.result()
probe_ok += 1
except Exception:
probe_fail += 1
probe_elapsed = time.time() - probe_start
probe_total = probe_ok + probe_fail
fail_rate = probe_fail / probe_total if probe_total > 0 else 0
throughput = probe_total / probe_elapsed if probe_elapsed > 0 else 0
if fail_rate >= MAX_FAIL_RATE:
issues = [
f"{fail_rate:.0%} of the first {PROBE_SIZE} image URLs failed to download ({probe_fail}/{probe_total})",
"Images are external URLs, not embedded in the dataset",
]
# Try LLM-friendly warning
friendly = None
try:
from .llm_assist import llm_generate_dataset_warning
friendly = llm_generate_dataset_warning(
issues,
dataset_name = dataset_name,
modality = "vision",
column_names = [image_column, text_column],
)
except Exception:
pass
msg = friendly or (
f"⚠️ {fail_rate:.0%} of the first {PROBE_SIZE} images failed to download "
f"({probe_fail}/{probe_total}). "
"This dataset has too many broken or unreachable image URLs. "
"Consider using a dataset with embedded images instead."
)
logger.info(msg)
_notify(msg)
raise ValueError(msg)
# Estimate total time for remaining samples
remaining = total - PROBE_SIZE
estimated_seconds = remaining / throughput if throughput > 0 else 0
eta_str = _format_eta(estimated_seconds)
info_msg = (
f"Downloading {total:,} images ({num_workers} workers, ~{throughput:.1f} img/s). "
f"Estimated time: ~{eta_str}"
)
if probe_fail > 0:
info_msg += f" | {fail_rate:.0%} broken URLs will be skipped"
logger.info(
f"✅ Probe passed: {probe_ok}/{probe_total} ok, {probe_fail} failed ({fail_rate:.0%}), {throughput:.1f} img/s"
)
logger.info(f"⏱️ Estimated time for {total:,} samples: ~{eta_str}")
_notify(info_msg)
# ── Full conversion with progress ──
from tqdm import tqdm
logger.info(f"🔄 Converting {total} samples to VLM format...")
converted_list = []
failed_count = 0
if has_urls:
# Parallel conversion for URL-based datasets
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from utils.hardware import safe_thread_num_proc
num_workers = safe_thread_num_proc()
batch_size = 500
start_time = time.time()
for batch_start in range(0, total, batch_size):
batch_end = min(batch_start + batch_size, total)
batch_samples = [dataset[i] for i in range(batch_start, batch_end)]
with ThreadPoolExecutor(max_workers = num_workers) as executor:
futures = {
executor.submit(_convert_single_sample, s): i
for i, s in enumerate(batch_samples)
}
batch_results = [None] * len(batch_samples)
for future in as_completed(futures):
idx = futures[future]
try:
batch_results[idx] = future.result()
except Exception as e:
failed_count += 1
if failed_count == 1:
print(
f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}"
)
if failed_count == 1:
logger.info(
f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}"
)
converted_list.extend(r for r in batch_results if r is not None)
# Progress update every batch
elapsed = time.time() - start_time
done = batch_end
rate = done / elapsed if elapsed > 0 else 0
remaining_time = (total - done) / rate if rate > 0 else 0
eta_str = _format_eta(remaining_time)
progress_msg = f"Downloading images: {done:,}/{total:,} ({done*100//total}%) | ~{eta_str} remaining | {failed_count} skipped"
logger.info(
f" [{done}/{total}] {rate:.1f} img/s, {failed_count} failed, ETA {eta_str}"
)
_notify(progress_msg)
else:
# Sequential conversion for local/embedded images (fast, no I/O bottleneck)
pbar = tqdm(dataset, total = total, desc = "Converting VLM samples", unit = "sample")
for sample in pbar:
try:
converted_list.append(_convert_single_sample(sample))
except Exception as e:
failed_count += 1
if failed_count == 1:
# Log the first failure to aid debugging
print(f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}")
if failed_count == 1:
# Log the first failure to aid debugging
logger.info(
f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}"
)
pbar.set_postfix(ok = len(converted_list), failed = failed_count, refresh = False)
pbar.close()
if failed_count > 0:
fail_rate = failed_count / total
logger.info(
f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images"
)
# For datasets that skipped the probe (small URL datasets), check fail rate now
if has_urls and fail_rate >= MAX_FAIL_RATE:
issues = [
f"{fail_rate:.0%} of images failed to download ({failed_count}/{total})",
"Images are external URLs, not embedded in the dataset",
]
friendly = None
try:
from .llm_assist import llm_generate_dataset_warning
friendly = llm_generate_dataset_warning(
issues,
dataset_name = dataset_name,
modality = "vision",
column_names = [image_column, text_column],
)
except Exception:
pass
msg = friendly or (
f"⚠️ {fail_rate:.0%} of images failed to download ({failed_count}/{total}). "
"This dataset has too many broken or unreachable image URLs. "
"Consider using a dataset with embedded images instead."
)
_notify(msg)
raise ValueError(msg)
if len(converted_list) == 0:
issues = [
f"All {total} samples failed during VLM conversion — no usable images found",
f"Image column '{image_column}' may contain URLs that are no longer accessible, "
"or local file paths that don't exist",
]
friendly = None
try:
from .llm_assist import llm_generate_dataset_warning
friendly = llm_generate_dataset_warning(
issues,
dataset_name = dataset_name,
modality = "vision",
column_names = [image_column, text_column],
)
except Exception:
pass
raise ValueError(
friendly
or (
f"All {total} samples failed during VLM conversion — no usable images found. "
"This dataset may contain only image URLs that are no longer accessible."
)
)
logger.info(f"✅ Converted {len(converted_list)}/{total} samples")
_notify(f"Converted {len(converted_list):,}/{total:,} images successfully")
# Return list, NOT Dataset
return converted_list
def convert_sharegpt_with_images_to_vlm_format(
dataset,
image_column = "image",
messages_column = "conversations",
dataset_name = None,
progress_callback = None,
):
"""
Converts ShareGPT/ChatML datasets that have a separate image column and
``<image>`` placeholders inside the conversation text.
Example input::
{
"image": "sam/images/sa_545504.jpg",
"conversations": [
{"from": "human", "value": "<image>\\nWhat is this photo about?"},
{"from": "gpt", "value": "The image captures..."}
]
}
Returns a list of dicts in standard VLM messages format (PIL Images inline).
"""
from PIL import Image
from tqdm import tqdm
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff")
_ROLE_MAP = {
"human": "user",
"user": "user",
"gpt": "assistant",
"assistant": "assistant",
"system": "system",
}
def _notify(msg):
if progress_callback:
progress_callback(status_message = msg)
# ── Resolve image loading strategy (same 3-tier as convert_to_vlm_format) ──
total = len(dataset)
first_image = next(iter(dataset))[image_column]
_image_lookup = None
if (
isinstance(first_image, str)
and not first_image.startswith(("http://", "https://"))
and not os.path.exists(first_image)
and dataset_name
):
try:
from huggingface_hub import HfApi
_notify("Resolving image filenames from HF repo...")
logger.info(
f"🔍 Image column contains bare filenames (e.g. '{first_image}') — building repo lookup..."
)
repo_files = HfApi().list_repo_files(dataset_name, repo_type = "dataset")
_image_lookup = {
os.path.basename(f): f
for f in repo_files
if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS)
}
# Also add the full relative paths as keys (for paths like "sam/images/sa_545504.jpg")
for f in repo_files:
if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS):
_image_lookup[f] = f
if first_image in _image_lookup:
logger.info(
f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}''{_image_lookup[first_image]}')"
)
else:
logger.info(
f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found — falling back to local open"
)
_image_lookup = None
except Exception as e:
logger.info(f"⚠️ Failed to build HF repo image lookup: {e}")
_image_lookup = None
def _resolve_image(image_data):
"""Resolve image data to a PIL Image object."""
if hasattr(image_data, "size") and hasattr(image_data, "mode"):
return image_data # Already PIL
if isinstance(image_data, str):
if image_data.startswith(("http://", "https://")):
import fsspec
from io import BytesIO
with fsspec.open(image_data, "rb", expand = True) as f:
return Image.open(BytesIO(f.read())).convert("RGB")
elif _image_lookup is not None and image_data in _image_lookup:
from huggingface_hub import hf_hub_download
local_path = hf_hub_download(
dataset_name,
_image_lookup[image_data],
repo_type = "dataset",
)
return Image.open(local_path).convert("RGB")
else:
return Image.open(image_data).convert("RGB")
if isinstance(image_data, dict) and (
"bytes" in image_data or "path" in image_data
):
if image_data.get("bytes"):
from io import BytesIO
return Image.open(BytesIO(image_data["bytes"])).convert("RGB")
if image_data.get("path"):
return Image.open(image_data["path"]).convert("RGB")
raise ValueError(f"Cannot resolve image: {type(image_data)}")
def _convert_single_sample(sample):
"""Convert a single ShareGPT+image sample to standard VLM format."""
pil_image = _resolve_image(sample[image_column])
conversation = sample[messages_column]
new_messages = []
for msg in conversation:
role_raw = msg.get("from") or msg.get("role", "user")
role = _ROLE_MAP.get(role_raw.lower(), role_raw.lower())
text = msg.get("value") or msg.get("content") or ""
# Split on <image> to interleave text and image content blocks
if "<image>" in text:
parts = text.split("<image>")
content = []
for i, part in enumerate(parts):
part = part.strip()
if part:
content.append({"type": "text", "text": part})
if i < len(parts) - 1:
content.append({"type": "image", "image": pil_image})
# If <image> was the entire text, content might just be the image
if not content:
content.append({"type": "image", "image": pil_image})
else:
content = [{"type": "text", "text": text}]
new_messages.append({"role": role, "content": content})
return {"messages": new_messages}
# ── Full conversion with progress ──
logger.info(f"🔄 Converting {total} samples from ShareGPT+image format...")
converted_list = []
failed_count = 0
pbar = tqdm(dataset, total = total, desc = "Converting ShareGPT+image", unit = "sample")
for sample in pbar:
try:
converted_list.append(_convert_single_sample(sample))
except Exception as e:
failed_count += 1
if failed_count == 1:
logger.info(f"⚠️ First conversion failure: {type(e).__name__}: {e}")
pbar.set_postfix(ok = len(converted_list), failed = failed_count, refresh = False)
pbar.close()
if failed_count > 0:
logger.info(
f"⚠️ Skipped {failed_count}/{total} ({failed_count*100//total}%) samples"
)
if len(converted_list) == 0:
raise ValueError(
f"All {total} samples failed during ShareGPT+image conversion — "
"no usable samples found."
)
logger.info(f"✅ Converted {len(converted_list)}/{total} samples")
_notify(f"Converted {len(converted_list):,}/{total:,} samples successfully")
return converted_list
def convert_llava_to_vlm_format(dataset):
"""
Converts Llava format to standard VLM format.
Llava format:
- messages: [{'content': [{'type': 'image', 'index': 0}, {'type': 'text', 'text': '...'}]}]
- images: [PIL_Image1, PIL_Image2, ...]
Standard VLM format:
- messages: [{'content': [{'type': 'image', 'image': PIL_Image}, {'type': 'text', 'text': '...'}]}]
"""
from PIL import Image
logger.info(
f"🔄 Converting {len(dataset)} samples from Llava format to standard VLM format..."
)
def _convert_single_sample(sample):
"""Convert a single llava sample to standard VLM format."""
messages = sample["messages"]
images = sample.get("images", [])
# Process each message
new_messages = []
for msg in messages:
new_content = []
for item in msg["content"]:
if item["type"] == "image":
# Replace index with actual PIL image
if "index" in item and item["index"] is not None:
img_idx = item["index"]
if img_idx < len(images):
pil_image = images[img_idx]
# Ensure it's PIL
if isinstance(pil_image, str):
pil_image = Image.open(pil_image).convert("RGB")
new_content.append(
{
"type": "image",
"image": pil_image, # Actual PIL object
}
)
else:
# No index, try to use first image
if len(images) > 0:
pil_image = images[0]
if isinstance(pil_image, str):
pil_image = Image.open(pil_image).convert("RGB")
new_content.append({"type": "image", "image": pil_image})
elif item["type"] == "text":
# Keep text as-is (only type + text)
new_content.append({"type": "text", "text": item.get("text", "")})
new_messages.append({"role": msg["role"], "content": new_content})
return {"messages": new_messages}
# Convert using list comprehension
converted_list = [_convert_single_sample(sample) for sample in dataset]
logger.info(f"✅ Converted {len(converted_list)} samples")
return converted_list