feat: parallel URL image probe with time estimate and progress reporting
- Add 200-sample parallel probe using ThreadPoolExecutor + safe_num_proc to estimate download speed and failure rate before full conversion - Abort with clear error if >=30% of probe images fail to download - Show estimated download time in the training overlay modal - Parallel batch conversion for URL-based datasets (vs sequential for local) - Add warning field to /check-format response for URL-based image datasets - Display URL warning in dataset preview dialog (amber banner) - Thread progress_callback from trainer through format_and_template_dataset to convert_to_vlm_format for real-time status updates
This commit is contained in:
parent
195c1a3ce3
commit
9ca45826d4
7 changed files with 171 additions and 29 deletions
|
|
@ -475,6 +475,7 @@ class UnslothTrainer:
|
|||
format_type=format_type,
|
||||
dataset_name=dataset_source,
|
||||
custom_format_mapping=custom_format_mapping,
|
||||
progress_callback=self._update_progress,
|
||||
)
|
||||
|
||||
# Check if stopped during formatting
|
||||
|
|
|
|||
|
|
@ -34,3 +34,4 @@ class CheckFormatResponse(BaseModel):
|
|||
detected_text_column: Optional[str] = None
|
||||
preview_samples: Optional[List[Dict]] = None
|
||||
total_rows: Optional[int] = None
|
||||
warning: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -211,6 +211,21 @@ def check_format(
|
|||
else:
|
||||
preview_samples = _serialize_preview_rows(preview_slice)
|
||||
|
||||
# Lightweight URL-based image detection for VLM datasets
|
||||
warning = None
|
||||
image_col = result.get("detected_image_column")
|
||||
if image_col and image_col in (result.get("columns") or []):
|
||||
try:
|
||||
sample_val = preview_slice[0][image_col]
|
||||
if isinstance(sample_val, str) and sample_val.startswith(("http://", "https://")):
|
||||
warning = (
|
||||
"This dataset contains image URLs instead of embedded images. "
|
||||
"Images will be downloaded during training, which may be slow for large datasets."
|
||||
)
|
||||
logger.info(f"URL-based image column detected: {image_col}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return CheckFormatResponse(
|
||||
requires_manual_mapping=result["requires_manual_mapping"],
|
||||
detected_format=result["detected_format"],
|
||||
|
|
@ -222,6 +237,7 @@ def check_format(
|
|||
detected_text_column=result.get("detected_text_column"),
|
||||
preview_samples=preview_samples,
|
||||
total_rows=total_rows,
|
||||
warning=warning,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -593,6 +593,7 @@ def format_and_template_dataset(
|
|||
aliases_for_assistant=["gpt", "assistant", "output",],
|
||||
batch_size=1000,
|
||||
num_proc=None,
|
||||
progress_callback=None,
|
||||
):
|
||||
"""
|
||||
Convenience function that combines format_dataset and apply_chat_template_to_dataset.
|
||||
|
|
@ -638,6 +639,7 @@ def format_and_template_dataset(
|
|||
text_column=user_vlm_text_column,
|
||||
image_column=user_vlm_image_column,
|
||||
dataset_name=dataset_name,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
warnings.append(f"Applied user VLM mapping: image='{user_vlm_image_column}', text='{user_vlm_text_column}'")
|
||||
|
||||
|
|
@ -734,6 +736,7 @@ def format_and_template_dataset(
|
|||
text_column=vlm_text_column,
|
||||
image_column=vlm_image_column,
|
||||
dataset_name=dataset_name,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
if vlm_instruction:
|
||||
|
|
|
|||
|
|
@ -238,24 +238,51 @@ def convert_alpaca_to_chatml(dataset, batch_size=1000, num_proc=None):
|
|||
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(
|
||||
|
|
@ -322,48 +349,133 @@ def convert_to_vlm_format(
|
|||
# Return dict with messages
|
||||
return {"messages": messages}
|
||||
|
||||
# Convert samples, skipping any with broken/unreachable images.
|
||||
# For URL-based datasets, check the first PROBE_SIZE samples early to
|
||||
# fail fast if too many images are broken, before downloading millions.
|
||||
PROBE_SIZE = 5000
|
||||
MAX_FAIL_RATE = 0.3
|
||||
|
||||
total = len(dataset)
|
||||
has_urls = isinstance(next(iter(dataset))[image_column], str)
|
||||
probe_needed = has_urls and total > PROBE_SIZE
|
||||
|
||||
# ── 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_num_proc
|
||||
|
||||
num_workers = safe_num_proc()
|
||||
_notify(f"Probing {PROBE_SIZE} image URLs with {num_workers} workers...")
|
||||
print(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:
|
||||
msg = (
|
||||
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."
|
||||
)
|
||||
print(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"
|
||||
|
||||
print(f"✅ Probe passed: {probe_ok}/{probe_total} ok, {probe_fail} failed ({fail_rate:.0%}), {throughput:.1f} img/s")
|
||||
print(f"⏱️ Estimated time for {total:,} samples: ~{eta_str}")
|
||||
_notify(info_msg)
|
||||
|
||||
# ── Full conversion with progress ──
|
||||
from tqdm import tqdm
|
||||
|
||||
print(f"🔄 Converting {total} samples to VLM format...")
|
||||
converted_list = []
|
||||
failed_count = 0
|
||||
|
||||
pbar = tqdm(dataset, total=total, desc="Converting VLM samples", unit="sample")
|
||||
for i, sample in enumerate(pbar):
|
||||
try:
|
||||
converted_list.append(_convert_single_sample(sample))
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
if has_urls:
|
||||
# Parallel conversion for URL-based datasets
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from utils.hardware import safe_num_proc
|
||||
|
||||
pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False)
|
||||
num_workers = safe_num_proc()
|
||||
batch_size = 500
|
||||
start_time = time.time()
|
||||
|
||||
# Early exit check after probing the first batch
|
||||
if probe_needed and (i + 1) == PROBE_SIZE:
|
||||
fail_rate = failed_count / PROBE_SIZE
|
||||
if fail_rate >= MAX_FAIL_RATE:
|
||||
pbar.close()
|
||||
raise ValueError(
|
||||
f"{fail_rate:.0%} of the first {PROBE_SIZE} images failed to download "
|
||||
f"({failed_count}/{PROBE_SIZE}). "
|
||||
"This dataset has too many broken or unreachable image URLs. "
|
||||
"Consider using a dataset with embedded images instead."
|
||||
)
|
||||
print(f"✅ Probe passed: {failed_count}/{PROBE_SIZE} ({fail_rate:.0%}) failures in first batch, continuing...")
|
||||
pbar.close()
|
||||
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:
|
||||
failed_count += 1
|
||||
|
||||
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"
|
||||
print(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:
|
||||
failed_count += 1
|
||||
pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False)
|
||||
pbar.close()
|
||||
|
||||
if failed_count > 0:
|
||||
fail_rate = failed_count / total
|
||||
print(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:
|
||||
msg = (
|
||||
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:
|
||||
raise ValueError(
|
||||
|
|
@ -372,6 +484,7 @@ def convert_to_vlm_format(
|
|||
)
|
||||
|
||||
print(f"✅ Converted {len(converted_list)}/{total} samples")
|
||||
_notify(f"Converted {len(converted_list):,}/{total:,} images successfully")
|
||||
|
||||
# Return list, NOT Dataset
|
||||
return converted_list
|
||||
|
|
|
|||
|
|
@ -340,6 +340,13 @@ export function DatasetPreviewDialog({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{data.warning && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400 mb-4 flex items-start gap-2.5">
|
||||
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 shrink-0 mt-0.5" />
|
||||
<span>{data.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mappingEnabled && (
|
||||
<DatasetMappingCard
|
||||
mapping={manualMapping}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ export type CheckFormatResponse = {
|
|||
total_rows?: number | null;
|
||||
is_multimodal?: boolean;
|
||||
multimodal_columns?: string[] | null;
|
||||
warning?: string | null;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue