Buckets:
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| from pathlib import Path | |
| from dagster import ( | |
| AssetCheckResult, | |
| AssetCheckSeverity, | |
| AssetExecutionContext, | |
| MaterializeResult, | |
| asset, | |
| asset_check, | |
| ) | |
| from dagster_hf_datasets import hf_dataset_asset | |
| from datasets import Dataset | |
| # ── Helpers ─────────────────────────────────────────────────────────────────── | |
| def _image_hash(img) -> str: | |
| """Perceptual-adjacent hash: MD5 of raw pixel bytes at 32×32 thumbnail.""" | |
| thumb = img.copy() | |
| thumb = thumb.convert("RGB") | |
| thumb = thumb.resize((32, 32)) | |
| return hashlib.md5(thumb.tobytes()).hexdigest() | |
| def _is_corrupt(img) -> bool: | |
| """Attempt a full decode to catch truncated/corrupt JPEG and PNG files.""" | |
| try: | |
| img.verify() # catches truncated files for some formats | |
| return False | |
| except Exception: | |
| return True | |
| def _check_corrupt_via_load(img) -> bool: | |
| """Secondary corrupt check: try loading pixel data.""" | |
| try: | |
| img.load() | |
| return False | |
| except Exception: | |
| return True | |
| def _aspect_ratio(img) -> float: | |
| w, h = img.size | |
| return round(w / h, 4) if h > 0 else 0.0 | |
| # ── Step 1: Ingest ──────────────────────────────────────────────────────────── | |
| def tiny_imagenet_raw( | |
| context: AssetExecutionContext, | |
| dataset: Dataset, | |
| ) -> MaterializeResult: | |
| """Ingest Tiny ImageNet training split from the Hub. | |
| Tiny ImageNet contains 100,000 training images across 200 classes | |
| at 64×64 resolution. Small enough to run locally while exhibiting | |
| realistic curation challenges: class imbalance, occasional corrupt | |
| entries, and mixed aspect ratios near boundaries. | |
| """ | |
| context.log.info("Loaded Tiny ImageNet train: %s rows", len(dataset)) | |
| context.log.info("Columns: %s", dataset.column_names) | |
| return MaterializeResult( | |
| value=dataset, | |
| metadata={ | |
| "rows": len(dataset), | |
| "columns": dataset.column_names, | |
| "source_dataset": "zh-plus/tiny-imagenet", | |
| "split": "train", | |
| "fingerprint": dataset._fingerprint, | |
| }, | |
| ) | |
| # ── Step 2: Resolution filter ───────────────────────────────────────────────── | |
| MIN_WIDTH = 32 | |
| MIN_HEIGHT = 32 | |
| MAX_WIDTH = 4096 | |
| MAX_HEIGHT = 4096 | |
| def resolution_filtered( | |
| context: AssetExecutionContext, | |
| tiny_imagenet_raw: Dataset, | |
| ) -> MaterializeResult: | |
| """Remove images outside the acceptable resolution range. | |
| Drops images smaller than 32×32 (likely placeholder/corrupt) and | |
| larger than 4096×4096 (pathological outliers that would blow up | |
| training batch memory). Tiny ImageNet is nominally 64×64, so this | |
| primarily catches edge cases and validates the assumption. | |
| """ | |
| before = len(tiny_imagenet_raw) | |
| def within_bounds(example: dict) -> bool: | |
| img = example["image"] | |
| w, h = img.size | |
| return MIN_WIDTH <= w <= MAX_WIDTH and MIN_HEIGHT <= h <= MAX_HEIGHT | |
| filtered = tiny_imagenet_raw.filter(within_bounds, desc="Resolution filter") | |
| after = len(filtered) | |
| context.log.info( | |
| "Resolution filter: %s → %s rows (%s dropped)", | |
| before, after, before - after, | |
| ) | |
| context.add_output_metadata( | |
| { | |
| "rows_in": before, | |
| "rows_out": after, | |
| "dropped": before - after, | |
| "min_width": MIN_WIDTH, | |
| "min_height": MIN_HEIGHT, | |
| } | |
| ) | |
| return MaterializeResult( | |
| value=filtered, | |
| metadata={ | |
| "rows": after, | |
| "rows_in": before, | |
| "rows_out": after, | |
| "dropped": before - after, | |
| "min_width": MIN_WIDTH, | |
| "min_height": MIN_HEIGHT, | |
| }, | |
| ) | |
| # ── Step 3: Corrupt image detection ────────────────────────────────────────── | |
| def corrupt_removed( | |
| context: AssetExecutionContext, | |
| resolution_filtered: Dataset, | |
| ) -> MaterializeResult: | |
| """Remove corrupt or unloadable images. | |
| Uses a two-pass strategy: | |
| 1. img.verify() — catches truncated streams for JPEG/PNG | |
| 2. img.load() — forces full pixel decode, catches partial corruption | |
| Note: PIL's verify() consumes the image object; a fresh copy is | |
| needed for subsequent operations, so both checks use .copy(). | |
| """ | |
| before = len(resolution_filtered) | |
| corrupt_indices: list[int] = [] | |
| for i, example in enumerate(resolution_filtered): | |
| img = example["image"] | |
| if _check_corrupt_via_load(img.copy()): | |
| corrupt_indices.append(i) | |
| if i % 5000 == 0: | |
| context.log.info("Corruption scan: %s / %s", i, before) | |
| if corrupt_indices: | |
| context.log.warning("Found %s corrupt images at indices: %s", len(corrupt_indices), corrupt_indices[:10]) | |
| keep_indices = [i for i in range(before) if i not in set(corrupt_indices)] | |
| cleaned = resolution_filtered.select(keep_indices) | |
| else: | |
| context.log.info("No corrupt images found") | |
| cleaned = resolution_filtered | |
| after = len(cleaned) | |
| context.add_output_metadata( | |
| { | |
| "rows_in": before, | |
| "rows_out": after, | |
| "corrupt_removed": len(corrupt_indices), | |
| "corrupt_indices_sample": str(corrupt_indices[:5]), | |
| } | |
| ) | |
| return MaterializeResult( | |
| value=cleaned, | |
| metadata={ | |
| "rows": after, | |
| "rows_in": before, | |
| "rows_out": after, | |
| "corrupt_removed": len(corrupt_indices), | |
| "corrupt_indices_sample": str(corrupt_indices[:5]), | |
| }, | |
| ) | |
| # ── Step 4: Perceptual deduplication ───────────────────────────────────────── | |
| def deduplicated_images( | |
| context: AssetExecutionContext, | |
| corrupt_removed: Dataset, | |
| ) -> MaterializeResult: | |
| """Remove near-duplicate images using 32×32 RGB thumbnail hashing. | |
| Downsamples each image to 32×32 RGB and hashes the raw pixel bytes. | |
| Images sharing a hash are considered perceptual duplicates; only the | |
| first occurrence is retained. This catches exact duplicates and | |
| near-identical rescaled copies. | |
| """ | |
| before = len(corrupt_removed) | |
| seen: set[str] = set() | |
| def is_unique(example: dict) -> bool: | |
| h = _image_hash(example["image"]) | |
| if h in seen: | |
| return False | |
| seen.add(h) | |
| return True | |
| deduped = corrupt_removed.filter(is_unique, desc="Deduplication") | |
| after = len(deduped) | |
| context.log.info( | |
| "Deduplication: %s → %s rows (%s duplicates removed)", | |
| before, after, before - after, | |
| ) | |
| context.add_output_metadata( | |
| { | |
| "rows_in": before, | |
| "rows_out": after, | |
| "duplicates_removed": before - after, | |
| "dedup_method": "32x32 RGB pixel hash (MD5)", | |
| } | |
| ) | |
| return MaterializeResult( | |
| value=deduped, | |
| metadata={ | |
| "rows": after, | |
| "rows_in": before, | |
| "rows_out": after, | |
| "duplicates_removed": before - after, | |
| "dedup_method": "32x32 RGB pixel hash (MD5)", | |
| }, | |
| ) | |
| # ── Step 5: Aspect ratio validation ────────────────────────────────────────── | |
| MIN_ASPECT = 0.25 # 1:4 portrait | |
| MAX_ASPECT = 4.0 # 4:1 landscape | |
| def aspect_ratio_validated( | |
| context: AssetExecutionContext, | |
| deduplicated_images: Dataset, | |
| ) -> MaterializeResult: | |
| """Split images into curated (accepted) and rejected sets by aspect ratio. | |
| Images with aspect ratio outside [0.25, 4.0] are atypical for | |
| classification and likely to harm training. Accepted images are | |
| returned; rejected indices are logged for audit. | |
| Returns only the curated (accepted) dataset. | |
| """ | |
| before = len(deduplicated_images) | |
| rejected_indices: list[int] = [] | |
| aspect_ratios: list[float] = [] | |
| for i, example in enumerate(deduplicated_images): | |
| ar = _aspect_ratio(example["image"]) | |
| aspect_ratios.append(ar) | |
| if ar < MIN_ASPECT or ar > MAX_ASPECT: | |
| rejected_indices.append(i) | |
| keep_indices = [i for i in range(before) if i not in set(rejected_indices)] | |
| curated = deduplicated_images.select(keep_indices) | |
| after = len(curated) | |
| import statistics | |
| context.log.info( | |
| "Aspect ratio validation: %s → %s accepted, %s rejected", | |
| before, after, len(rejected_indices), | |
| ) | |
| context.add_output_metadata( | |
| { | |
| "rows_in": before, | |
| "rows_out": after, | |
| "rejected_count": len(rejected_indices), | |
| "aspect_ratio_mean": round(statistics.mean(aspect_ratios), 4), | |
| "aspect_ratio_min": round(min(aspect_ratios), 4), | |
| "aspect_ratio_max": round(max(aspect_ratios), 4), | |
| "min_aspect_threshold": MIN_ASPECT, | |
| "max_aspect_threshold": MAX_ASPECT, | |
| } | |
| ) | |
| return MaterializeResult( | |
| value=curated, | |
| metadata={ | |
| "rows": after, | |
| "rows_in": before, | |
| "rows_out": after, | |
| "rejected_count": len(rejected_indices), | |
| "aspect_ratio_mean": round(statistics.mean(aspect_ratios), 4), | |
| "aspect_ratio_min": round(min(aspect_ratios), 4), | |
| "aspect_ratio_max": round(max(aspect_ratios), 4), | |
| "min_aspect_threshold": MIN_ASPECT, | |
| "max_aspect_threshold": MAX_ASPECT, | |
| }, | |
| ) | |
| # ── Step 6: Multi-format export ─────────────────────────────────────────────── | |
| def curated_export( | |
| context: AssetExecutionContext, | |
| aspect_ratio_validated: Dataset, | |
| ) -> MaterializeResult: | |
| """Export the curated dataset in Parquet and Arrow formats with a manifest. | |
| Writes to `.dagster_hf_storage/curated_images/` in two formats: | |
| - Arrow (save_to_disk): fastest for subsequent datasets library usage | |
| - Parquet (export_to_parquet): portable, readable by pandas/spark/duckdb | |
| A JSON manifest records row counts, export paths, and class distribution. | |
| """ | |
| export_dir = Path(".dagster_hf_storage/curated_images") | |
| export_dir.mkdir(parents=True, exist_ok=True) | |
| arrow_path = export_dir / "arrow" | |
| parquet_path = export_dir / "curated.parquet" | |
| context.log.info("Saving Arrow format to %s", arrow_path) | |
| aspect_ratio_validated.save_to_disk(str(arrow_path)) | |
| context.log.info("Saving Parquet format to %s", parquet_path) | |
| aspect_ratio_validated.to_parquet(str(parquet_path)) | |
| # Class distribution | |
| label_counts: dict[int, int] = {} | |
| for ex in aspect_ratio_validated: | |
| lbl = ex.get("label", -1) | |
| label_counts[lbl] = label_counts.get(lbl, 0) + 1 | |
| manifest = { | |
| "total_rows": len(aspect_ratio_validated), | |
| "num_classes": len(label_counts), | |
| "arrow_path": str(arrow_path), | |
| "parquet_path": str(parquet_path), | |
| "class_distribution_sample": dict(sorted(label_counts.items())[:10]), | |
| } | |
| manifest_path = export_dir / "manifest.json" | |
| manifest_path.write_text(json.dumps(manifest, indent=2)) | |
| context.log.info("Export complete: %s rows, %s classes", manifest["total_rows"], manifest["num_classes"]) | |
| context.add_output_metadata( | |
| { | |
| "total_rows": manifest["total_rows"], | |
| "num_classes": manifest["num_classes"], | |
| "arrow_path": str(arrow_path), | |
| "parquet_path": str(parquet_path), | |
| } | |
| ) | |
| return MaterializeResult( | |
| value=manifest, | |
| metadata={ | |
| "total_rows": manifest["total_rows"], | |
| "num_classes": manifest["num_classes"], | |
| "arrow_path": str(arrow_path), | |
| "parquet_path": str(parquet_path), | |
| }, | |
| ) | |
| # ── Step 7: Curation report ─────────────────────────────────────────────────── | |
| def curation_report( | |
| context: AssetExecutionContext, | |
| tiny_imagenet_raw: Dataset, | |
| resolution_filtered: Dataset, | |
| corrupt_removed: Dataset, | |
| deduplicated_images: Dataset, | |
| aspect_ratio_validated: Dataset, | |
| ) -> MaterializeResult: | |
| """Produce a full funnel report tracking row counts at each curation stage. | |
| Shows exactly how many images were dropped at each step and the | |
| cumulative retention rate through the pipeline. | |
| """ | |
| stages = { | |
| "raw": len(tiny_imagenet_raw), | |
| "after_resolution_filter": len(resolution_filtered), | |
| "after_corrupt_removal": len(corrupt_removed), | |
| "after_deduplication": len(deduplicated_images), | |
| "after_aspect_ratio_validation": len(aspect_ratio_validated), | |
| } | |
| report = stages | |
| raw = stages["raw"] | |
| report = { | |
| "stages": stages, | |
| "dropped_per_stage": { | |
| "resolution_filter": stages["raw"] - stages["after_resolution_filter"], | |
| "corrupt_removal": stages["after_resolution_filter"] - stages["after_corrupt_removal"], | |
| "deduplication": stages["after_corrupt_removal"] - stages["after_deduplication"], | |
| "aspect_ratio": stages["after_deduplication"] - stages["after_aspect_ratio_validation"], | |
| }, | |
| "total_dropped": raw - stages["after_aspect_ratio_validation"], | |
| "final_retention_pct": round(stages["after_aspect_ratio_validation"] / raw * 100, 2), | |
| } | |
| context.log.info("Curation funnel: %s", report["stages"]) | |
| context.log.info("Final retention: %.1f%%", report["final_retention_pct"]) | |
| return MaterializeResult( | |
| value=report, | |
| metadata={ | |
| **{f"stage_{k}": v for k, v in stages.items()}, | |
| "total_dropped": report["total_dropped"], | |
| "final_retention_pct": report["final_retention_pct"], | |
| }, | |
| ) | |
| # ── Asset checks ────────────────────────────────────────────────────────────── | |
| def check_curation_retention( | |
| deduplicated_images: Dataset, | |
| aspect_ratio_validated: Dataset, | |
| ) -> AssetCheckResult: | |
| dedup_count = len(deduplicated_images) | |
| curated_count = len(aspect_ratio_validated) | |
| retention = (curated_count / dedup_count * 100) if dedup_count > 0 else 0.0 | |
| return AssetCheckResult( | |
| passed=retention >= 90.0, | |
| severity=AssetCheckSeverity.WARN, | |
| metadata={ | |
| "dedup_rows": dedup_count, | |
| "curated_rows": curated_count, | |
| "retention_pct": round(retention, 2), | |
| }, | |
| ) | |
| def check_aspect_bounds(aspect_ratio_validated: Dataset) -> AssetCheckResult: | |
| violations = [ | |
| i for i, ex in enumerate(aspect_ratio_validated) | |
| if not (MIN_ASPECT <= _aspect_ratio(ex["image"]) <= MAX_ASPECT) | |
| ] | |
| return AssetCheckResult( | |
| passed=len(violations) == 0, | |
| severity=AssetCheckSeverity.ERROR, | |
| metadata={"violation_count": len(violations), "sample_indices": str(violations[:5])}, | |
| ) | |
Xet Storage Details
- Size:
- 16.5 kB
- Xet hash:
- 480ab2ab0c1117a86e47c173b32c9e37cf646d39280802efe9120e2a37fec80c
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.