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
922 lines
29 KiB
Python
922 lines
29 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 detection: Alpaca/ShareGPT/ChatML, multimodal/VLM structures, heuristic column mapping."""
|
|
|
|
import re
|
|
|
|
|
|
def _keyword_in_column(keyword: str, col_name: str) -> bool:
|
|
"""Word-boundary keyword match to avoid false positives like 'pic' in 'topic'."""
|
|
return re.search(r"\b" + re.escape(keyword) + r"\b", col_name, re.IGNORECASE) is not None
|
|
|
|
|
|
CONVERSATION_COLUMNS = ("messages", "conversations", "texts")
|
|
_CHATML_KEYS = frozenset({"role", "content"})
|
|
_SHAREGPT_KEYS = frozenset({"from", "value"})
|
|
_TRACE_SUFFIXES = ("__trace", "_trace")
|
|
|
|
|
|
def _sample_dataset_rows(dataset, limit: int = 100) -> list[dict]:
|
|
try:
|
|
total = min(len(dataset), limit)
|
|
return [dataset[index] for index in range(total)]
|
|
except Exception:
|
|
rows = []
|
|
try:
|
|
for index, row in enumerate(dataset):
|
|
if index >= limit:
|
|
break
|
|
rows.append(row)
|
|
except Exception:
|
|
return []
|
|
return rows
|
|
|
|
|
|
def _get_dataset_column_names(dataset, sample: dict) -> list[str]:
|
|
column_names = getattr(dataset, "column_names", None)
|
|
if isinstance(column_names, list):
|
|
return [str(column) for column in column_names]
|
|
return [str(column) for column in sample.keys()]
|
|
|
|
|
|
def _is_trace_conversation_name(column_name: str) -> bool:
|
|
return column_name.lower().endswith(_TRACE_SUFFIXES)
|
|
|
|
|
|
def _inspect_conversation_column(rows: list[dict], column_name: str) -> dict | None:
|
|
turn_keys: set[str] = set()
|
|
has_chatml = False
|
|
has_sharegpt = False
|
|
|
|
for row in rows:
|
|
if not isinstance(row, dict) or column_name not in row:
|
|
continue
|
|
chat_data = row[column_name]
|
|
if not isinstance(chat_data, list) or len(chat_data) == 0:
|
|
continue
|
|
for turn in chat_data:
|
|
if not isinstance(turn, dict):
|
|
continue
|
|
keys = {str(key) for key in turn.keys()}
|
|
turn_keys.update(keys)
|
|
if _SHAREGPT_KEYS.issubset(keys):
|
|
has_sharegpt = True
|
|
if _CHATML_KEYS.issubset(keys):
|
|
has_chatml = True
|
|
|
|
if has_sharegpt:
|
|
return {
|
|
"format": "sharegpt",
|
|
"chat_column": column_name,
|
|
"needs_standardization": True,
|
|
"sample_keys": sorted(turn_keys),
|
|
}
|
|
if has_chatml:
|
|
return {
|
|
"format": "chatml",
|
|
"chat_column": column_name,
|
|
"needs_standardization": False,
|
|
"sample_keys": sorted(turn_keys),
|
|
}
|
|
if turn_keys:
|
|
return {
|
|
"format": "unknown",
|
|
"chat_column": column_name,
|
|
"needs_standardization": None,
|
|
"sample_keys": sorted(turn_keys),
|
|
}
|
|
return None
|
|
|
|
|
|
def _detect_conversation_column(rows: list[dict], column_names: list[str]) -> dict | None:
|
|
column_name_set = set(column_names)
|
|
unknown_exact = None
|
|
for column_name in CONVERSATION_COLUMNS:
|
|
if column_name not in column_name_set:
|
|
continue
|
|
inspected = _inspect_conversation_column(rows, column_name)
|
|
if inspected and inspected["format"] in {"sharegpt", "chatml"}:
|
|
return inspected
|
|
if inspected and unknown_exact is None:
|
|
unknown_exact = inspected
|
|
|
|
structural_candidates = []
|
|
for column_name in column_names:
|
|
if column_name in CONVERSATION_COLUMNS:
|
|
continue
|
|
inspected = _inspect_conversation_column(rows, column_name)
|
|
if inspected and inspected["format"] in {"sharegpt", "chatml"}:
|
|
structural_candidates.append(inspected)
|
|
|
|
trace_candidates = [
|
|
candidate
|
|
for candidate in structural_candidates
|
|
if _is_trace_conversation_name(candidate["chat_column"])
|
|
]
|
|
if len(trace_candidates) == 1:
|
|
return trace_candidates[0]
|
|
if len(trace_candidates) > 1:
|
|
return unknown_exact
|
|
if len(structural_candidates) == 1:
|
|
return structural_candidates[0]
|
|
if unknown_exact is not None:
|
|
return unknown_exact
|
|
return None
|
|
|
|
|
|
def detect_dataset_format(dataset):
|
|
"""Detect dataset format by inspecting structure.
|
|
|
|
Returns:
|
|
dict: {
|
|
"format": "alpaca" | "sharegpt" | "chatml" | "unknown",
|
|
"chat_column": str | None,
|
|
"needs_standardization": bool,
|
|
"sample_keys": list of keys found in messages (for debugging)
|
|
}
|
|
"""
|
|
sample_rows = _sample_dataset_rows(dataset)
|
|
if not sample_rows:
|
|
return {
|
|
"format": "unknown",
|
|
"chat_column": None,
|
|
"needs_standardization": None,
|
|
"sample_keys": [],
|
|
}
|
|
|
|
column_names = _get_dataset_column_names(dataset, sample_rows[0])
|
|
column_name_set = set(column_names)
|
|
|
|
# Alpaca
|
|
alpaca_columns = {"instruction", "output"}
|
|
if alpaca_columns.issubset(column_name_set):
|
|
return {
|
|
"format": "alpaca",
|
|
"chat_column": None,
|
|
"needs_standardization": False,
|
|
"sample_keys": [],
|
|
}
|
|
|
|
conversation = _detect_conversation_column(sample_rows, column_names)
|
|
if conversation:
|
|
return conversation
|
|
|
|
return {
|
|
"format": "unknown",
|
|
"chat_column": None,
|
|
"needs_standardization": None,
|
|
"sample_keys": [],
|
|
}
|
|
|
|
|
|
def detect_custom_format_heuristic(dataset):
|
|
"""Detection with priority scoring.
|
|
|
|
Strategy for ambiguous keywords like 'task':
|
|
1. Detect assistant first (unambiguous)
|
|
2. Detect user using high-priority keywords first
|
|
3. Check REMAINING columns for system keywords (including 'task')
|
|
4. Only if no system match, use 'task' as fallback user
|
|
"""
|
|
sample = next(iter(dataset))
|
|
all_columns = list(sample.keys())
|
|
|
|
mapping = {}
|
|
|
|
assistant_words = [
|
|
"output",
|
|
"answer",
|
|
"response",
|
|
"assistant",
|
|
"completion",
|
|
"expected",
|
|
"recommendation",
|
|
"reply",
|
|
"result",
|
|
"target",
|
|
"solution",
|
|
"explanation",
|
|
"solve",
|
|
]
|
|
|
|
user_words_high_priority = [
|
|
"input",
|
|
"question",
|
|
"query",
|
|
"prompt",
|
|
"instruction",
|
|
"request",
|
|
"snippet",
|
|
"user",
|
|
"text",
|
|
"problem",
|
|
"exercise",
|
|
]
|
|
user_words_low_priority = ["task"] # Ambiguous - can be user OR system
|
|
user_words = user_words_high_priority + user_words_low_priority
|
|
|
|
system_words = [
|
|
"system",
|
|
"context",
|
|
"description",
|
|
"persona",
|
|
"role",
|
|
"template",
|
|
"task", # also a system keyword
|
|
]
|
|
|
|
# Metadata columns to ignore.
|
|
metadata_exact_match = {
|
|
"id",
|
|
"idx",
|
|
"index",
|
|
"key",
|
|
"timestamp",
|
|
"date",
|
|
"metadata",
|
|
"source",
|
|
"kind",
|
|
"type",
|
|
"category",
|
|
"score",
|
|
"label",
|
|
"tag",
|
|
"inference_mode",
|
|
}
|
|
|
|
metadata_prefix_patterns = [
|
|
"problem_type",
|
|
"problem_source",
|
|
"generation_model",
|
|
"pass_rate",
|
|
]
|
|
|
|
priority_patterns = {
|
|
"generated": 100,
|
|
"gen_": 90,
|
|
"model_": 80,
|
|
"predicted": 70,
|
|
"completion": 60,
|
|
}
|
|
|
|
def has_keyword(col_name, keywords):
|
|
"""True if any keyword appears in the column name."""
|
|
col_lower = col_name.lower()
|
|
col_normalized = col_lower.replace("_", "").replace("-", "").replace(" ", "")
|
|
|
|
for keyword in keywords:
|
|
if keyword in col_lower or keyword in col_normalized:
|
|
return True
|
|
return False
|
|
|
|
def is_metadata(col_name):
|
|
"""True if the column is likely metadata."""
|
|
col_lower = col_name.lower()
|
|
|
|
if col_lower in metadata_exact_match:
|
|
return True
|
|
|
|
if col_lower in metadata_prefix_patterns:
|
|
return True
|
|
|
|
for pattern in metadata_prefix_patterns:
|
|
if col_lower.startswith(pattern.split("_")[0] + "_") and col_lower != pattern:
|
|
if "_" in col_lower:
|
|
prefix = col_lower.split("_")[0]
|
|
if prefix in ["generation", "pass", "inference"]:
|
|
return True
|
|
|
|
if len(col_lower) <= 2 and not col_lower in ["qa", "q", "a"]:
|
|
return True
|
|
|
|
return False
|
|
|
|
def get_priority_score(col_name):
|
|
"""Priority score from column-name patterns."""
|
|
col_lower = col_name.lower()
|
|
score = 0
|
|
|
|
for pattern, pattern_score in priority_patterns.items():
|
|
if pattern in col_lower:
|
|
score += pattern_score
|
|
|
|
return score
|
|
|
|
def get_content_length(col_name):
|
|
"""Average content length for this column."""
|
|
try:
|
|
if col_name in sample and sample[col_name]:
|
|
content = str(sample[col_name])
|
|
return len(content)
|
|
return 0
|
|
except:
|
|
return 0
|
|
|
|
def score_column(col_name, keywords, role_type, num_candidates):
|
|
"""Score how likely a column is to be a given role."""
|
|
if not has_keyword(col_name, keywords):
|
|
return 0
|
|
|
|
score = 0
|
|
score += 10
|
|
|
|
# Penalize ambiguous "task" so other user columns win.
|
|
if role_type == "user":
|
|
col_lower = col_name.lower()
|
|
if "task" in col_lower and not any(kw in col_lower for kw in user_words_high_priority):
|
|
score -= 15
|
|
|
|
priority_bonus = get_priority_score(col_name)
|
|
score += priority_bonus
|
|
|
|
if role_type in ["assistant", "user"]:
|
|
avg_length = get_content_length(col_name)
|
|
|
|
if num_candidates > 1:
|
|
if avg_length > 1000:
|
|
score += 50
|
|
elif avg_length > 200:
|
|
score += 30
|
|
elif avg_length > 50:
|
|
score += 10
|
|
elif avg_length < 50:
|
|
score -= 20
|
|
else:
|
|
if avg_length > 1000:
|
|
score += 50
|
|
elif avg_length > 200:
|
|
score += 30
|
|
elif avg_length > 50:
|
|
score += 10
|
|
|
|
return score
|
|
|
|
content_columns = [col for col in all_columns if not is_metadata(col)]
|
|
|
|
assistant_potential = [col for col in content_columns if has_keyword(col, assistant_words)]
|
|
user_potential = [col for col in content_columns if has_keyword(col, user_words)]
|
|
|
|
# STEP 1: best ASSISTANT column
|
|
assistant_candidates = []
|
|
for col in assistant_potential:
|
|
score = score_column(col, assistant_words, "assistant", len(assistant_potential))
|
|
if score > 0:
|
|
assistant_candidates.append((col, score))
|
|
|
|
if assistant_candidates:
|
|
assistant_candidates.sort(key = lambda x: x[1], reverse = True)
|
|
assistant_col = assistant_candidates[0][0]
|
|
mapping[assistant_col] = "assistant"
|
|
else:
|
|
assistant_col = None
|
|
|
|
# STEP 2: best USER column (penalizing ambiguous keywords)
|
|
user_candidates = []
|
|
for col in user_potential:
|
|
if col == assistant_col:
|
|
continue
|
|
score = score_column(col, user_words, "user", len(user_potential))
|
|
if score > 0:
|
|
user_candidates.append((col, score))
|
|
|
|
if user_candidates:
|
|
user_candidates.sort(key = lambda x: x[1], reverse = True)
|
|
user_col = user_candidates[0][0]
|
|
mapping[user_col] = "user"
|
|
else:
|
|
user_col = None
|
|
|
|
# STEP 3: check remaining columns for SYSTEM matches
|
|
remaining_columns = [col for col in content_columns if col not in mapping]
|
|
|
|
system_col = None
|
|
for col in remaining_columns:
|
|
if has_keyword(col, system_words):
|
|
mapping[col] = "system"
|
|
system_col = col
|
|
break
|
|
|
|
# STEP 4: handle any additional remaining columns
|
|
if system_col:
|
|
remaining_columns = [col for col in remaining_columns if col != system_col]
|
|
|
|
if len(remaining_columns) >= 1:
|
|
remaining_col = remaining_columns[0]
|
|
|
|
# No strong keyword match: decide by what's missing.
|
|
if not has_keyword(remaining_col, user_words + assistant_words):
|
|
mapping[remaining_col] = "system"
|
|
elif user_col is None:
|
|
mapping[remaining_col] = "user"
|
|
else:
|
|
mapping[remaining_col] = "system"
|
|
|
|
# Ensure at least user + assistant.
|
|
has_user = any(role == "user" for role in mapping.values())
|
|
has_assistant = any(role == "assistant" for role in mapping.values())
|
|
|
|
if not has_user and len(remaining_columns) > 0:
|
|
for col in remaining_columns:
|
|
if col not in mapping:
|
|
mapping[col] = "user"
|
|
has_user = True
|
|
break
|
|
|
|
if has_user and has_assistant:
|
|
return mapping
|
|
|
|
return None
|
|
|
|
|
|
def detect_multimodal_dataset(dataset):
|
|
"""Detect multimodal data (images and/or audio) in a dataset.
|
|
|
|
Two passes per modality: column-name keyword heuristic, then value-type
|
|
inspection. Returns a dict with is_image/is_audio flags, detected columns,
|
|
modality types, and detected audio/text/speaker columns.
|
|
"""
|
|
sample = next(iter(dataset))
|
|
column_names = list(sample.keys())
|
|
|
|
image_keywords = [
|
|
"image",
|
|
"img",
|
|
"pixel",
|
|
"jpg",
|
|
"jpeg",
|
|
"png",
|
|
"webp",
|
|
"bmp",
|
|
"gif",
|
|
"tiff",
|
|
"svg",
|
|
"photo",
|
|
"pic",
|
|
"picture",
|
|
"visual",
|
|
"file_name",
|
|
"filename",
|
|
]
|
|
|
|
audio_keywords = ["audio", "speech", "wav", "waveform", "sound"]
|
|
|
|
multimodal_columns = []
|
|
audio_columns = []
|
|
modality_types = set()
|
|
|
|
# ── Image detection ─────────────────────────────────────
|
|
# Pass 1: column-name heuristic (word-boundary match)
|
|
for col_name in column_names:
|
|
for keyword in image_keywords:
|
|
if _keyword_in_column(keyword, col_name):
|
|
multimodal_columns.append(col_name)
|
|
modality_types.add(keyword)
|
|
break
|
|
|
|
# Pass 2: inspect actual values
|
|
already_detected = set(multimodal_columns)
|
|
for col_name in column_names:
|
|
if col_name in already_detected:
|
|
continue
|
|
value = sample[col_name]
|
|
if _is_image_value(value):
|
|
multimodal_columns.append(col_name)
|
|
modality_types.add("image")
|
|
|
|
# ── Audio detection ─────────────────────────────────────
|
|
# Pass 1: column-name heuristic (word-boundary match)
|
|
for col_name in column_names:
|
|
for keyword in audio_keywords:
|
|
if _keyword_in_column(keyword, col_name):
|
|
audio_columns.append(col_name)
|
|
modality_types.add("audio")
|
|
break
|
|
|
|
# Pass 2: inspect actual values (catches non-obvious column names)
|
|
already_audio = set(audio_columns)
|
|
for col_name in column_names:
|
|
if col_name in already_audio:
|
|
continue
|
|
value = sample[col_name]
|
|
if _is_audio_value(value):
|
|
audio_columns.append(col_name)
|
|
modality_types.add("audio")
|
|
|
|
# Drop audio columns from the image list (a {"bytes","path"} audio column
|
|
# can match _is_image_value).
|
|
if audio_columns:
|
|
audio_set = set(audio_columns)
|
|
multimodal_columns = [c for c in multimodal_columns if c not in audio_set]
|
|
|
|
# Text column for audio datasets.
|
|
detected_text_col = None
|
|
if audio_columns:
|
|
text_keywords = ["text", "sentence", "transcript", "transcription", "label"]
|
|
for col_name in column_names:
|
|
if col_name.lower() in text_keywords:
|
|
detected_text_col = col_name
|
|
break
|
|
|
|
is_audio = len(audio_columns) > 0
|
|
|
|
# speaker_id column for TTS datasets (CSM, Orpheus, Spark)
|
|
detected_speaker_col = None
|
|
if audio_columns:
|
|
speaker_keywords = ["source", "speaker", "speaker_id"]
|
|
for col_name in column_names:
|
|
if col_name.lower() in speaker_keywords:
|
|
detected_speaker_col = col_name
|
|
break
|
|
|
|
return {
|
|
"is_image": len(multimodal_columns) > 0,
|
|
"multimodal_columns": multimodal_columns,
|
|
"modality_types": list(modality_types),
|
|
"is_audio": is_audio,
|
|
"audio_columns": audio_columns,
|
|
"detected_audio_column": audio_columns[0] if audio_columns else None,
|
|
"detected_text_column": detected_text_col,
|
|
"detected_speaker_column": detected_speaker_col,
|
|
}
|
|
|
|
|
|
def _is_image_value(value) -> bool:
|
|
"""Check if a single sample value looks like image data."""
|
|
if value is None:
|
|
return False
|
|
|
|
try:
|
|
from PIL.Image import Image as PILImage
|
|
if isinstance(value, PILImage):
|
|
return True
|
|
except ImportError:
|
|
pass
|
|
|
|
# HF Image feature: decoded as PIL, or {"bytes", "path"} when undecoded.
|
|
# Exclude audio dicts (decoded audio has "array" + "sampling_rate").
|
|
if isinstance(value, dict):
|
|
if "array" in value and "sampling_rate" in value:
|
|
return False # audio, not image
|
|
if "bytes" in value and "path" in value:
|
|
# Use path extension to exclude audio files.
|
|
path = value.get("path") or ""
|
|
if isinstance(path, str) and any(
|
|
path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS
|
|
):
|
|
return False
|
|
return True
|
|
|
|
if isinstance(value, (bytes, bytearray)):
|
|
return _has_image_header(value)
|
|
|
|
# String that looks like an image file path or URL.
|
|
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".svg")
|
|
if isinstance(value, str) and len(value) < 1000:
|
|
lower = value.strip().lower()
|
|
if lower.startswith(("http://", "https://")) and any(
|
|
lower.split("?")[0].endswith(ext) for ext in _IMAGE_EXTS
|
|
):
|
|
return True
|
|
if any(lower.endswith(ext) for ext in _IMAGE_EXTS):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
_AUDIO_EXTENSIONS = (
|
|
".wav",
|
|
".mp3",
|
|
".flac",
|
|
".ogg",
|
|
".opus",
|
|
".m4a",
|
|
".aac",
|
|
".wma",
|
|
".webm",
|
|
)
|
|
|
|
|
|
def _is_audio_value(value) -> bool:
|
|
"""Check if a single sample value looks like audio data."""
|
|
if value is None:
|
|
return False
|
|
|
|
# HF Audio feature: decoded -> {"array", "sampling_rate"}; undecoded -> {"bytes", "path"}.
|
|
if isinstance(value, dict):
|
|
if "array" in value and "sampling_rate" in value:
|
|
return True
|
|
if "bytes" in value or "path" in value:
|
|
path = value.get("path") or ""
|
|
if isinstance(path, str) and any(
|
|
path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS
|
|
):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _has_image_header(data: bytes) -> bool:
|
|
"""Quick magic-byte check for common image formats."""
|
|
if len(data) < 4:
|
|
return False
|
|
if data[:2] == b"\xff\xd8": # JPEG
|
|
return True
|
|
if data[:4] == b"\x89PNG": # PNG
|
|
return True
|
|
if data[:3] == b"GIF": # GIF
|
|
return True
|
|
if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP": # WebP
|
|
return True
|
|
if data[:2] == b"BM": # BMP
|
|
return True
|
|
return False
|
|
|
|
|
|
def detect_vlm_dataset_structure(dataset):
|
|
"""Detect which VLM dataset shape this is:
|
|
- Standard VLM messages (image objects in content)
|
|
- Llava format (image indices + separate images column)
|
|
- Simple format needing conversion (image + text columns)
|
|
"""
|
|
try:
|
|
sample = next(iter(dataset))
|
|
except StopIteration:
|
|
return {
|
|
"format": "unknown",
|
|
"needs_conversion": None,
|
|
"image_column": None,
|
|
"text_column": None,
|
|
"messages_column": None,
|
|
}
|
|
|
|
column_names = set(sample.keys())
|
|
|
|
if "messages" in column_names:
|
|
messages = sample["messages"]
|
|
|
|
if messages and len(messages) > 0:
|
|
first_msg = messages[0]
|
|
if "content" in first_msg:
|
|
content = first_msg["content"]
|
|
|
|
if isinstance(content, list) and len(content) > 0:
|
|
if isinstance(content[0], dict) and "type" in content[0]:
|
|
# Llava format?
|
|
has_index = any(
|
|
"index" in item for item in content if isinstance(item, dict)
|
|
)
|
|
has_images_column = "images" in column_names
|
|
|
|
if has_index and has_images_column:
|
|
return {
|
|
"format": "vlm_messages_llava",
|
|
"needs_conversion": True,
|
|
"messages_column": "messages",
|
|
"image_column": "images",
|
|
"text_column": None,
|
|
}
|
|
|
|
# Standard VLM format
|
|
has_image = any(
|
|
"image" in item for item in content if isinstance(item, dict)
|
|
)
|
|
if has_image:
|
|
return {
|
|
"format": "vlm_messages",
|
|
"needs_conversion": False,
|
|
"messages_column": "messages",
|
|
"image_column": None,
|
|
"text_column": None,
|
|
}
|
|
|
|
# ShareGPT/ChatML conversations with <image> placeholder + companion
|
|
# image column (e.g. Lin-Chen/ShareGPT4V, LLaVA-style datasets)
|
|
for chat_col in ("conversations", "messages"):
|
|
if chat_col not in column_names:
|
|
continue
|
|
chat_data = sample[chat_col]
|
|
if not isinstance(chat_data, list) or len(chat_data) == 0:
|
|
continue
|
|
first_msg = chat_data[0]
|
|
if not isinstance(first_msg, dict):
|
|
continue
|
|
# ShareGPT (from/value) or ChatML (role/content).
|
|
msg_text = first_msg.get("value") or first_msg.get("content")
|
|
if not isinstance(msg_text, str):
|
|
continue
|
|
has_image_placeholder = any(
|
|
"<image>" in str(m.get("value", "") or m.get("content", ""))
|
|
for m in chat_data
|
|
if isinstance(m, dict)
|
|
)
|
|
if not has_image_placeholder:
|
|
continue
|
|
# Find companion image column.
|
|
image_col = None
|
|
for col in column_names:
|
|
if col == chat_col:
|
|
continue
|
|
if _keyword_in_column("image", col) or _keyword_in_column("img", col):
|
|
image_col = col
|
|
break
|
|
if image_col:
|
|
return {
|
|
"format": "sharegpt_with_images",
|
|
"needs_conversion": True,
|
|
"image_column": image_col,
|
|
"text_column": None,
|
|
"messages_column": chat_col,
|
|
}
|
|
|
|
# Find image and text columns, filtering out metadata patterns
|
|
metadata_patterns = {
|
|
"suffixes": [
|
|
"_id",
|
|
"_url",
|
|
"_name",
|
|
"_filename",
|
|
"_uri",
|
|
"_link",
|
|
"_key",
|
|
"_index",
|
|
],
|
|
"prefixes": [
|
|
"id_",
|
|
"url_",
|
|
"name_",
|
|
"filename_",
|
|
"uri_",
|
|
"link_",
|
|
"key_",
|
|
"index_",
|
|
],
|
|
}
|
|
|
|
image_keywords = [
|
|
"image",
|
|
"img",
|
|
"photo",
|
|
"picture",
|
|
"pic",
|
|
"visual",
|
|
"scan",
|
|
"file_name",
|
|
"filename",
|
|
]
|
|
|
|
text_keywords = [
|
|
"text",
|
|
"caption",
|
|
"captions",
|
|
"description",
|
|
"answer",
|
|
"output",
|
|
"response",
|
|
"label",
|
|
]
|
|
|
|
def is_metadata_column(col_name):
|
|
"""True if the column name looks like metadata."""
|
|
col_lower = col_name.lower()
|
|
|
|
if any(col_lower.endswith(suffix) for suffix in metadata_patterns["suffixes"]):
|
|
return True
|
|
if any(col_lower.startswith(prefix) for prefix in metadata_patterns["prefixes"]):
|
|
return True
|
|
|
|
return False
|
|
|
|
def _score_image_candidate(col, sample_value):
|
|
"""Score a candidate image column by how resolvable its value is."""
|
|
# PIL Image (already loaded) -> highest.
|
|
if hasattr(sample_value, "size") and hasattr(sample_value, "mode"):
|
|
return 100
|
|
|
|
# HF Image feature dict.
|
|
if isinstance(sample_value, dict) and ("bytes" in sample_value or "path" in sample_value):
|
|
return 75
|
|
|
|
if isinstance(sample_value, str):
|
|
if sample_value.startswith(("http://", "https://")): # URL
|
|
return 70 if not is_metadata_column(col) else 55
|
|
if is_metadata_column(col): # bare file path
|
|
return 30
|
|
return 50
|
|
|
|
return 0
|
|
|
|
def _probe_image_candidate(col, sample_value):
|
|
"""Probe whether an image candidate is reachable (True unless definitely broken)."""
|
|
import os
|
|
|
|
# PIL / dict — already loaded.
|
|
if not isinstance(sample_value, str):
|
|
return True
|
|
|
|
# Local file — check it exists.
|
|
if not sample_value.startswith(("http://", "https://")):
|
|
return os.path.exists(sample_value) # bare filenames return False, that's OK
|
|
|
|
# URL — quick HEAD with short timeout.
|
|
try:
|
|
import urllib.request
|
|
|
|
req = urllib.request.Request(sample_value, method = "HEAD")
|
|
resp = urllib.request.urlopen(req, timeout = 3)
|
|
return resp.status < 400
|
|
except Exception:
|
|
return False
|
|
|
|
def find_image_column():
|
|
"""Find image column by keyword match + value-based fallback, probing for one that works."""
|
|
candidates = []
|
|
|
|
# Pass 1: keyword-matched columns.
|
|
for col in column_names:
|
|
if any(_keyword_in_column(keyword, col) for keyword in image_keywords):
|
|
sample_value = sample[col]
|
|
score = _score_image_candidate(col, sample_value)
|
|
if score > 0:
|
|
candidates.append((col, score))
|
|
|
|
# Pass 2: value-based fallback for image URLs/paths even when the name
|
|
# doesn't match keywords.
|
|
already = {c[0] for c in candidates}
|
|
for col in column_names:
|
|
if col in already:
|
|
continue
|
|
sample_value = sample[col]
|
|
if _is_image_value(sample_value):
|
|
score = _score_image_candidate(col, sample_value)
|
|
# Penalise non-keyword columns so keyword matches win on ties.
|
|
candidates.append((col, max(score - 5, 1)))
|
|
|
|
if not candidates:
|
|
return None
|
|
|
|
candidates.sort(key = lambda x: x[1], reverse = True)
|
|
|
|
# Single candidate or top is PIL/dict — no probing needed.
|
|
if len(candidates) == 1 or candidates[0][1] >= 75:
|
|
return candidates[0][0]
|
|
|
|
# Multiple string candidates — probe for one that works.
|
|
for col, score in candidates:
|
|
sample_value = sample[col]
|
|
if _probe_image_candidate(col, sample_value):
|
|
return col
|
|
|
|
# None probed OK — return highest-scored; conversion may still resolve it.
|
|
return candidates[0][0]
|
|
|
|
def find_text_column():
|
|
"""Find text column: skip metadata, match keywords."""
|
|
candidates = []
|
|
|
|
for col in column_names:
|
|
if is_metadata_column(col):
|
|
continue
|
|
|
|
if any(_keyword_in_column(keyword, col) for keyword in text_keywords):
|
|
sample_value = sample[col]
|
|
|
|
if isinstance(sample_value, str) and len(sample_value) > 0:
|
|
# Longer text = higher priority (content, not a label).
|
|
priority = min(len(sample_value), 1000)
|
|
candidates.append((col, priority))
|
|
elif (
|
|
isinstance(sample_value, list)
|
|
and len(sample_value) > 0
|
|
and isinstance(sample_value[0], str)
|
|
):
|
|
# List of strings (e.g. captions) — lower priority than plain str.
|
|
priority = min(len(sample_value[0]), 1000) // 2
|
|
candidates.append((col, priority))
|
|
|
|
if candidates:
|
|
candidates.sort(key = lambda x: x[1], reverse = True)
|
|
return candidates[0][0]
|
|
|
|
return None
|
|
|
|
found_image = find_image_column()
|
|
found_text = find_text_column()
|
|
|
|
if found_image and found_text:
|
|
return {
|
|
"format": "simple_image_text",
|
|
"needs_conversion": True,
|
|
"image_column": found_image,
|
|
"text_column": found_text,
|
|
"messages_column": None,
|
|
}
|
|
|
|
return {
|
|
"format": "unknown",
|
|
"needs_conversion": None,
|
|
"image_column": found_image,
|
|
"text_column": found_text,
|
|
"messages_column": None,
|
|
}
|