Merge pull request #350 from unslothai/fix/vision-datasets-fix
Fix VLM dataset detection and conversion
This commit is contained in:
commit
9edadaf21f
5 changed files with 415 additions and 61 deletions
|
|
@ -83,16 +83,12 @@ def _serialize_preview_rows(rows):
|
|||
# --- Endpoints ---
|
||||
|
||||
# Recognized data-file extensions for the single-file fallback approach.
|
||||
DATA_EXTS = (
|
||||
'.parquet',
|
||||
'.json', '.jsonl',
|
||||
'.csv', '.tsv',
|
||||
'.txt',
|
||||
'.arrow',
|
||||
'.tar', '.tar.gz', '.tgz',
|
||||
'.gz', '.zst',
|
||||
'.zip',
|
||||
)
|
||||
# Tabular formats are preferred over archives for Tier 1 preview because
|
||||
# archives (e.g. images.zip) may be loaded as ImageFolder datasets with
|
||||
# synthetic columns (image/label) that don't match the real dataset schema.
|
||||
_TABULAR_EXTS = ('.parquet', '.json', '.jsonl', '.csv', '.tsv', '.arrow')
|
||||
_ARCHIVE_EXTS = ('.tar', '.tar.gz', '.tgz', '.gz', '.zst', '.zip', '.txt')
|
||||
DATA_EXTS = _TABULAR_EXTS + _ARCHIVE_EXTS
|
||||
LOCAL_FILE_EXTS = ('.json', '.jsonl', '.csv', '.parquet')
|
||||
LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl", ".parquet"}
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
|
@ -363,8 +359,20 @@ def check_format(
|
|||
)
|
||||
data_files = [f for f in repo_files if any(f.endswith(ext) for ext in DATA_EXTS)]
|
||||
|
||||
if data_files:
|
||||
first_file = data_files[0]
|
||||
# Prefer tabular formats over archives (e.g. images.zip → ImageFolder
|
||||
# with synthetic image/label columns that don't match the real schema).
|
||||
tabular_files = [f for f in data_files if any(f.endswith(ext) for ext in _TABULAR_EXTS)]
|
||||
candidates = tabular_files or data_files
|
||||
|
||||
# When a subset is specified, narrow to files whose name matches
|
||||
# (e.g. subset="testmini" → prefer "testmini.parquet").
|
||||
if request.subset and candidates:
|
||||
subset_matches = [f for f in candidates if request.subset in Path(f).stem]
|
||||
if subset_matches:
|
||||
candidates = subset_matches
|
||||
|
||||
if candidates:
|
||||
first_file = candidates[0]
|
||||
logger.info(f"Tier 1: loading single file {first_file}")
|
||||
load_kwargs = {
|
||||
"path": request.dataset_name,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from .format_conversion import (
|
|||
convert_alpaca_to_chatml,
|
||||
convert_to_vlm_format,
|
||||
convert_llava_to_vlm_format,
|
||||
convert_sharegpt_with_images_to_vlm_format,
|
||||
)
|
||||
|
||||
# Chat templates
|
||||
|
|
@ -81,6 +82,7 @@ __all__ = [
|
|||
"convert_alpaca_to_chatml",
|
||||
"convert_to_vlm_format",
|
||||
"convert_llava_to_vlm_format",
|
||||
"convert_sharegpt_with_images_to_vlm_format",
|
||||
# Templates
|
||||
"apply_chat_template_to_dataset",
|
||||
"get_dataset_info_summary",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from .format_conversion import (
|
|||
convert_alpaca_to_chatml,
|
||||
convert_to_vlm_format,
|
||||
convert_llava_to_vlm_format,
|
||||
convert_sharegpt_with_images_to_vlm_format,
|
||||
)
|
||||
from .chat_templates import (
|
||||
apply_chat_template_to_dataset,
|
||||
|
|
@ -688,17 +689,15 @@ def format_and_template_dataset(
|
|||
"errors": [],
|
||||
}
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to apply user VLM mapping: {e}")
|
||||
return {
|
||||
"dataset": dataset,
|
||||
"detected_format": "user_mapped",
|
||||
"final_format": "vlm_conversion_failed",
|
||||
"is_vlm": True,
|
||||
"success": False,
|
||||
"requires_manual_mapping": True,
|
||||
"warnings": warnings,
|
||||
"errors": errors,
|
||||
}
|
||||
# User mapping failed — fall back to auto-detection instead
|
||||
# of giving up (handles stale cached mappings gracefully)
|
||||
warnings.append(
|
||||
f"User VLM mapping (image='{user_vlm_image_column}', "
|
||||
f"text='{user_vlm_text_column}') failed: {e} — "
|
||||
f"falling back to auto-detection"
|
||||
)
|
||||
print(f"⚠️ User VLM mapping failed, falling back to auto-detection...")
|
||||
custom_format_mapping = None # clear so auto-detection runs below
|
||||
else:
|
||||
errors.append(
|
||||
f"Invalid VLM mapping: need 'image' and 'text' roles. Got: {custom_format_mapping}"
|
||||
|
|
@ -738,6 +737,33 @@ def format_and_template_dataset(
|
|||
"errors": errors,
|
||||
}
|
||||
|
||||
# Handle ShareGPT/ChatML + image column (e.g. ShareGPT4V, LLaVA-style)
|
||||
elif vlm_structure["format"] == "sharegpt_with_images":
|
||||
try:
|
||||
dataset = convert_sharegpt_with_images_to_vlm_format(
|
||||
dataset,
|
||||
image_column=vlm_structure["image_column"],
|
||||
messages_column=vlm_structure["messages_column"],
|
||||
dataset_name=dataset_name,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
warnings.append("Converted from ShareGPT+image format to standard VLM format")
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to convert ShareGPT+image format: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return {
|
||||
"dataset": dataset,
|
||||
"detected_format": "sharegpt_with_images",
|
||||
"final_format": "vlm_conversion_failed",
|
||||
"is_vlm": True,
|
||||
"success": False,
|
||||
"requires_manual_mapping": True,
|
||||
"warnings": warnings,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
# Handle simple format
|
||||
elif vlm_structure["needs_conversion"]:
|
||||
if vlm_text_column is None:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ This module contains functions for converting between dataset formats
|
|||
(Alpaca, ShareGPT, ChatML) and standardizing chat formats.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from datasets import IterableDataset
|
||||
|
||||
|
||||
|
|
@ -311,7 +313,7 @@ def convert_to_vlm_format(
|
|||
|
||||
def _convert_single_sample(sample):
|
||||
"""Convert a single sample to VLM format."""
|
||||
# Get image (might be PIL Image, local path, or URL)
|
||||
# Get image (might be PIL Image, local path, URL, or bare filename)
|
||||
image_data = sample[image_column]
|
||||
|
||||
if isinstance(image_data, str):
|
||||
|
|
@ -320,11 +322,21 @@ def convert_to_vlm_format(
|
|||
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
|
||||
# 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:
|
||||
|
|
@ -356,6 +368,36 @@ def convert_to_vlm_format(
|
|||
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...")
|
||||
print(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:
|
||||
print(f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' → '{_image_lookup[first_image]}')")
|
||||
else:
|
||||
print(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:
|
||||
print(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
|
||||
|
|
@ -443,8 +485,10 @@ def convert_to_vlm_format(
|
|||
idx = futures[future]
|
||||
try:
|
||||
batch_results[idx] = future.result()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
if failed_count == 1:
|
||||
print(f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}")
|
||||
|
||||
converted_list.extend(r for r in batch_results if r is not None)
|
||||
|
||||
|
|
@ -463,8 +507,11 @@ def convert_to_vlm_format(
|
|||
for sample in pbar:
|
||||
try:
|
||||
converted_list.append(_convert_single_sample(sample))
|
||||
except Exception:
|
||||
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}")
|
||||
pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False)
|
||||
pbar.close()
|
||||
|
||||
|
|
@ -494,6 +541,164 @@ def convert_to_vlm_format(
|
|||
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...")
|
||||
print(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:
|
||||
print(f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' → '{_image_lookup[first_image]}')")
|
||||
else:
|
||||
print(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:
|
||||
print(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 ──
|
||||
print(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:
|
||||
print(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:
|
||||
print(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."
|
||||
)
|
||||
|
||||
print(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.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ This module contains functions for detecting dataset formats (Alpaca, ShareGPT,
|
|||
detecting multimodal/VLM dataset structures, and heuristic-based 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
|
||||
|
||||
|
||||
def detect_dataset_format(dataset):
|
||||
"""
|
||||
|
|
@ -354,6 +361,7 @@ def detect_multimodal_dataset(dataset):
|
|||
'image', 'img', 'pixel',
|
||||
'jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'tiff', 'svg',
|
||||
'photo', 'pic', 'picture', 'visual',
|
||||
'file_name', 'filename',
|
||||
]
|
||||
|
||||
# Keywords that indicate audio data
|
||||
|
|
@ -364,11 +372,11 @@ def detect_multimodal_dataset(dataset):
|
|||
modality_types = set()
|
||||
|
||||
# ── Image detection ─────────────────────────────────────
|
||||
# Pass 1: column-name heuristic
|
||||
# Pass 1: column-name heuristic (word-boundary match to avoid
|
||||
# false positives like 'pic' in 'topic')
|
||||
for col_name in column_names:
|
||||
col_lower = col_name.lower()
|
||||
for keyword in image_keywords:
|
||||
if keyword in col_lower:
|
||||
if _keyword_in_column(keyword, col_name):
|
||||
multimodal_columns.append(col_name)
|
||||
modality_types.add(keyword)
|
||||
break
|
||||
|
|
@ -384,11 +392,10 @@ def detect_multimodal_dataset(dataset):
|
|||
modality_types.add("image")
|
||||
|
||||
# ── Audio detection ─────────────────────────────────────
|
||||
# Pass 1: column-name heuristic
|
||||
# Pass 1: column-name heuristic (word-boundary match)
|
||||
for col_name in column_names:
|
||||
col_lower = col_name.lower()
|
||||
for keyword in audio_keywords:
|
||||
if keyword in col_lower:
|
||||
if _keyword_in_column(keyword, col_name):
|
||||
audio_columns.append(col_name)
|
||||
modality_types.add("audio")
|
||||
break
|
||||
|
|
@ -471,6 +478,17 @@ def _is_image_value(value) -> bool:
|
|||
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()
|
||||
# Image URL (http://... ending in image extension)
|
||||
if lower.startswith(("http://", "https://")) and any(lower.split("?")[0].endswith(ext) for ext in _IMAGE_EXTS):
|
||||
return True
|
||||
# Image file path (relative or absolute path ending in image extension)
|
||||
if any(lower.endswith(ext) for ext in _IMAGE_EXTS):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -575,6 +593,46 @@ def detect_vlm_dataset_structure(dataset):
|
|||
"text_column": None,
|
||||
}
|
||||
|
||||
# Check for 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
|
||||
# Detect ShareGPT (from/value) or ChatML (role/content) keys
|
||||
msg_text = first_msg.get("value") or first_msg.get("content")
|
||||
if not isinstance(msg_text, str):
|
||||
continue
|
||||
# Check for <image> placeholder anywhere in the conversation
|
||||
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 using metadata filtering
|
||||
|
||||
# Define metadata patterns to EXCLUDE
|
||||
|
|
@ -584,10 +642,10 @@ def detect_vlm_dataset_structure(dataset):
|
|||
}
|
||||
|
||||
# Image-related keywords
|
||||
image_keywords = ['image', 'img', 'photo', 'picture', 'pic', 'visual', 'scan']
|
||||
image_keywords = ['image', 'img', 'photo', 'picture', 'pic', 'visual', 'scan', 'file_name', 'filename']
|
||||
|
||||
# Text-related keywords
|
||||
text_keywords = ['text', 'caption', 'description', 'answer', 'output', 'response', 'label']
|
||||
text_keywords = ['text', 'caption', 'captions', 'description', 'answer', 'output', 'response', 'label']
|
||||
|
||||
def is_metadata_column(col_name):
|
||||
"""Check if column name looks like metadata."""
|
||||
|
|
@ -603,39 +661,92 @@ def detect_vlm_dataset_structure(dataset):
|
|||
|
||||
return False
|
||||
|
||||
def _score_image_candidate(col, sample_value):
|
||||
"""Score a candidate image column by how resolvable its value is."""
|
||||
# PIL Image object (highest priority - already loaded)
|
||||
if hasattr(sample_value, 'size') and hasattr(sample_value, 'mode'):
|
||||
return 100
|
||||
|
||||
# Dict with image data (bytes/path from HF Image feature)
|
||||
if isinstance(sample_value, dict) and ('bytes' in sample_value or 'path' in sample_value):
|
||||
return 75
|
||||
|
||||
if isinstance(sample_value, str):
|
||||
# URL strings
|
||||
if sample_value.startswith(("http://", "https://")):
|
||||
return 70 if not is_metadata_column(col) else 55
|
||||
# Bare file path
|
||||
if is_metadata_column(col):
|
||||
return 30
|
||||
return 50
|
||||
|
||||
return 0
|
||||
|
||||
def _probe_image_candidate(col, sample_value):
|
||||
"""Quick probe to check if an image candidate is actually reachable.
|
||||
Returns True if likely valid, False if definitely broken."""
|
||||
import os
|
||||
|
||||
# PIL / dict — already loaded, always valid
|
||||
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 here, that's OK
|
||||
|
||||
# URL — quick HEAD request 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 filtering out metadata and checking keywords."""
|
||||
"""Find image column by keyword match + value-based fallback.
|
||||
When multiple candidates exist, probes them to find one that works."""
|
||||
candidates = []
|
||||
|
||||
# Pass 1: keyword-matched columns
|
||||
for col in column_names:
|
||||
col_lower = col.lower()
|
||||
|
||||
# Check if contains image keywords
|
||||
if any(keyword in col_lower for keyword in image_keywords):
|
||||
# Verify it actually contains image data
|
||||
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))
|
||||
|
||||
# PIL Image object (highest priority - even if name suggests metadata)
|
||||
if hasattr(sample_value, 'size') and hasattr(sample_value, 'mode'):
|
||||
candidates.append((col, 100)) # High priority - actual PIL Image
|
||||
# Pass 2: value-based fallback — find columns with image URLs/paths
|
||||
# even if the column name doesn't match image 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)
|
||||
# Slightly penalise non-keyword columns so keyword matches win on ties
|
||||
candidates.append((col, max(score - 5, 1)))
|
||||
|
||||
# String (could be path) - but lower priority if name is metadata-like
|
||||
elif isinstance(sample_value, str):
|
||||
if is_metadata_column(col):
|
||||
candidates.append((col, 30)) # Lower priority for metadata names
|
||||
else:
|
||||
candidates.append((col, 50)) # Medium priority
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Dict with image data
|
||||
elif isinstance(sample_value, dict) and ('bytes' in sample_value or 'path' in sample_value):
|
||||
candidates.append((col, 75)) # High-medium priority
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Return highest priority candidate
|
||||
if candidates:
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
# Single candidate or top candidate is PIL/dict — no probing needed
|
||||
if len(candidates) == 1 or candidates[0][1] >= 75:
|
||||
return candidates[0][0]
|
||||
|
||||
return None
|
||||
# Multiple string-based candidates — probe to find one that actually works
|
||||
for col, score in candidates:
|
||||
sample_value = sample[col]
|
||||
if _probe_image_candidate(col, sample_value):
|
||||
return col
|
||||
|
||||
# Nothing probed successfully — return highest-scored anyway and let
|
||||
# conversion handle the error (it may still resolve via hf_hub_download)
|
||||
return candidates[0][0]
|
||||
|
||||
def find_text_column():
|
||||
"""Find text column by filtering out metadata and checking keywords."""
|
||||
|
|
@ -646,10 +757,8 @@ def detect_vlm_dataset_structure(dataset):
|
|||
if is_metadata_column(col):
|
||||
continue
|
||||
|
||||
col_lower = col.lower()
|
||||
|
||||
# Check if contains text keywords
|
||||
if any(keyword in col_lower for keyword in text_keywords):
|
||||
# Check if contains text keywords (word-boundary match)
|
||||
if any(_keyword_in_column(keyword, col) for keyword in text_keywords):
|
||||
# Verify it's actually text
|
||||
sample_value = sample[col]
|
||||
|
||||
|
|
@ -657,6 +766,10 @@ def detect_vlm_dataset_structure(dataset):
|
|||
# Longer text = higher priority (likely content, not just a label)
|
||||
priority = min(len(sample_value), 1000) # Cap at 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 list) — lower priority than plain strings
|
||||
priority = min(len(sample_value[0]), 1000) // 2
|
||||
candidates.append((col, priority))
|
||||
|
||||
# Return highest priority candidate
|
||||
if candidates:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue