Files
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

915 lines
33 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
"""Dataset format conversion between Alpaca, ShareGPT, and ChatML."""
import os
from .iterable import is_streaming_dataset
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,
chat_column: str | None = None,
):
"""
Standardize BOTH messages and conversations: map non-standard role
names and keys to the standard format.
"""
import collections
import itertools
# 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())
if chat_column:
if chat_column not in column_names:
return dataset
elif "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
def _iter_probe_rows():
try:
total = min(len(dataset), 100)
for index in range(total):
yield dataset[index]
return
except Exception:
pass
for example in itertools.islice(dataset, 100):
yield example
uniques = collections.defaultdict(list)
for example in _iter_probe_rows():
chat_data = example.get(chat_column)
if not isinstance(chat_data, list) or len(chat_data) == 0:
continue
for message in chat_data:
if not isinstance(message, dict):
continue
for key, value in message.items():
if type(value) is not str:
continue # Skip non-strings
uniques[key].append(value)
if "from" in uniques and "value" in uniques:
role_key = "from"
content_key = "value"
elif "role" in uniques and "content" in uniques:
role_key = "role"
content_key = "content"
elif len(uniques.keys()) == 2:
keys = list(uniques.keys())
length_first = len(set(uniques[keys[0]]))
length_second = len(set(uniques[keys[1]]))
if length_first < length_second:
role_key = keys[0]
content_key = keys[1]
else:
role_key = keys[1]
content_key = keys[0]
else:
raise ValueError(f"Could not infer role/content keys for chat column '{chat_column}'")
# 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:
if not isinstance(convo, list):
all_convos.append([])
continue
new_convo = []
for message in convo:
if not isinstance(message, dict):
continue
# Use the inferred keys first; fall back per-message so mixed
# ShareGPT/ChatML rows keep valid turns.
original_role = message.get(role_key)
original_content = message.get(content_key)
if original_role is None:
original_role = message.get("role") or message.get("from") or ""
if original_content is None:
original_content = message.get("content") or message.get("value") or ""
standard_role = aliases_mapping.get(original_role, original_role)
if is_vlm:
original_content = [{"type": "text", "text": original_content}]
# Keep EXPLICIT key 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 is_streaming_dataset(dataset):
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"
result = dataset.map(_standardize_dataset, **dataset_map_kwargs)
# For streaming, force the first mapped row through now so any
# column/format errors surface before training begins (not mid-iteration).
# IterableDataset re-iterates from the generator source, so this is safe.
if is_streaming_dataset(dataset):
try:
next(iter(result))
except Exception as exc:
raise ValueError(
f"Streaming chat-format standardization failed on the first row: {exc}"
) from exc
return result
def convert_chatml_to_alpaca(
dataset,
batch_size = 1000,
num_proc = None,
chat_column: str | None = None,
):
"""
Convert ChatML (messages OR conversations) to Alpaca format.
Supports:
- "messages" or "conversations" column
- "role"/"content" (standard) or "from"/"value" (ShareGPT)
"""
is_iterable = is_streaming_dataset(dataset)
def _convert(examples):
chatml_data = examples.get(chat_column) if chat_column else None
if chatml_data is None:
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:
# Standard and ShareGPT key names
role = msg.get("role") or msg.get("from")
content = msg.get("content") or msg.get("value")
# First user message -> instruction
if role in ["user", "human", "input"] and not instruction:
instruction = content
# First assistant message -> output
elif role in ["assistant", "gpt", "output"] and not output:
output = content
break # Stop after first assistant response
instructions.append(instruction)
inputs.append("") # Alpaca input usually empty
outputs.append(output)
return {"instruction": instructions, "input": inputs, "output": outputs}
dataset_map_kwargs = {
"batched": True,
"batch_size": batch_size,
}
if not is_iterable:
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"
result = dataset.map(_convert, **dataset_map_kwargs)
# For streaming, force the first mapped row through now so any
# column/format errors surface before training begins (not mid-iteration).
# IterableDataset re-iterates from the generator source, so this is safe.
if is_iterable:
try:
next(iter(result))
except Exception as exc:
raise ValueError(
f"Streaming ChatML-to-Alpaca conversion failed on the first row: {exc}"
) from exc
return result
def convert_alpaca_to_chatml(
dataset,
batch_size = 1000,
num_proc = None,
):
"""
Convert Alpaca format to ChatML format.
Output: 'conversations' column with standard 'role'/'content' dicts.
"""
is_iterable = is_streaming_dataset(dataset)
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]
# User message = instruction + input (if any)
if input_text and input_text.strip():
user_content = f"{instruction}\n\n{input_text}".strip()
else:
user_content = instruction
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 is_iterable:
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"
result = dataset.map(_convert, **dataset_map_kwargs)
# For streaming, force the first mapped row through now so any
# column/format errors surface before training begins (not mid-iteration).
# IterableDataset re-iterates from the generator source, so this is safe.
if is_iterable:
try:
next(iter(result))
except Exception as exc:
raise ValueError(
f"Streaming Alpaca-to-ChatML conversion failed on the first row: {exc}"
) from exc
return result
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,
):
"""
Convert simple {image, text} format to VLM messages format.
Returns a LIST, not a HuggingFace Dataset (to preserve PIL Images).
For URL-based datasets, runs a 200-sample parallel probe first to
estimate speed/failure rate via progress_callback.
Args:
progress_callback: Optional callable(status_message=str) for progress.
Returns:
list: List of dicts with 'messages' field
"""
from PIL import Image
from .vlm_processing import generate_smart_vlm_instruction
def _notify(msg):
"""Send a status update to the training overlay if callback set."""
if progress_callback:
progress_callback(status_message = msg)
# Generate a smart instruction if none 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."""
# Image may be a 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")
# Text: if a list (e.g. multiple captions), pick one at random
text_data = sample[text_column]
if isinstance(text_data, list) and len(text_data) > 0:
import random
text_data = random.choice(text_data)
# Instruction: static or dynamic
if uses_dynamic and instruction_column:
current_instruction = sample[instruction_column]
else:
current_instruction = instruction
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 {"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: build a basename→repo_path lookup so
# filename-only images resolve 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 parallel samples 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",
]
# 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 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:
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)
# Per-batch progress update
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 (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
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"
)
# Small URL datasets skip the probe; check fail rate here
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 a 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,
):
"""
Convert ShareGPT/ChatML datasets with a separate image column and
``<image>`` placeholders in 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 (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 key by full relative path (e.g. "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."""
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 one 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 ""
# Interleave text and image blocks around <image>
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 text was only <image>, content is just 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):
"""
Convert 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 one llava sample to standard VLM format."""
messages = sample["messages"]
images = sample.get("images", [])
new_messages = []
for msg in messages:
new_content = []
for item in msg["content"]:
if item["type"] == "image":
# Replace index with the 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 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: use the 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":
new_content.append({"type": "text", "text": item.get("text", "")})
new_messages.append({"role": msg["role"], "content": new_content})
return {"messages": new_messages}
converted_list = [_convert_single_sample(sample) for sample in dataset]
logger.info(f"✅ Converted {len(converted_list)} samples")
return converted_list