Skip to content

Extend metadata-only bucketing to still images in the discovery backend - #3001

Open
AL3708 wants to merge 1 commit into
bghira:mainfrom
AL3708:perf/image-metadata-only-scan
Open

Extend metadata-only bucketing to still images in the discovery backend#3001
AL3708 wants to merge 1 commit into
bghira:mainfrom
AL3708:perf/image-metadata-only-scan

Conversation

@AL3708

@AL3708 AL3708 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Context

Metadata-only bucketing already exists in SimpleTuner — but not for still
images scanned from a local directory.

Where it does exist:

  • discovery.py skips decoding videos via ffprobe
    (_should_use_metadata_only_for_videouse_metadata_only = True)
  • the parquet backend reads width_column / height_column
  • the huggingface backend reads width/height dataset columns
  • the webshart backend reads dimensions from shard metadata

The gap: with the discovery backend on a plain local image folder — the most
common setup — every image is fully read and decoded just to reach
image.size:

image_data = self.data_backend.read(image_path_str)
image = load_image(BytesIO(image_data))
...
image_metadata["original_size"] = image.size

Bucketing never needs those pixels. calculate_target_size() and the
crop-coordinate maths in TrainingSample derive everything from
original_size, and PreparedSample stores no pixel-derived metadata. On a
network filesystem this turns discovery into a multi-megabyte read per image
for data that is immediately discarded — and the only way to avoid it today is
to build and maintain an external parquet/JSONL of dimensions the file headers
already contain.

Change

Still images now take the same metadata-only path as videos, reading
dimensions from the file header.

use_metadata_only already supports this downstream — TrainingSample
tolerates image=None:

  • crop() guards its pixel work on
    self.image is not None and isinstance(self.image, Image.Image)
  • the RGB/EXIF transform helper guards on self.image is not None
  • meets_resolution_requirements() has an image_metadata branch reading
    original_size

So this only adds the entry point, plus a header probe.

The two per-media guards are folded into one
_should_use_metadata_only(is_video_file), so the face-crop exclusion lives in
a single place rather than being duplicated. Videos keep the ffprobe route;
images take the header route. is_video_file is passed explicitly so a
video-extension file inside an IMAGE dataset still falls back to a full
decode, exactly as before.

_probe_image_dimensions() opens the file with PIL, which parses only the
header — pixel data is loaded lazily and never requested.

Guards, following the existing video precedent:

  • local data backends only (a header read needs a real path)
  • IMAGE and CONDITIONING dataset types
  • not when crop is enabled with crop_style="face" — the one crop style
    whose coordinates depend on pixel content
  • any header read failure returns None and falls back to a full decode, so
    truncated, corrupt or unsupported files behave as before

EXIF correctness

EXIF orientations 5–8 transpose the image, so header dimensions are swapped
relative to what exif_transpose() yields on the decode path. The orientation
tag is in the header too, so it is applied in the probe.

Verified equal to the full-decode result across:

Case probe full decode
no EXIF (1200, 800) (1200, 800)
orientations 1–4 (1200, 800) (1200, 800)
orientations 5–8 (800, 1200) (800, 1200)
PNG / WebP / 1×1 JPEG match match
corrupt header None → fallback

Metadata-only bucketing already exists, but not for still images scanned
from a local directory. Videos skip decoding via ffprobe, and the parquet,
huggingface and webshart backends read dimensions from metadata columns.
With the discovery backend on a plain image folder, every file is fully
read and decoded just to reach image.size.

Bucketing never needs those pixels: calculate_target_size() and the crop
coordinate maths derive everything from original_size, and PreparedSample
stores no pixel-derived metadata. On a network filesystem this means a
multi-megabyte read per image for data that is discarded immediately, and
the only way to avoid it today is to maintain an external parquet of
dimensions the file headers already contain.

Still images now take the same metadata-only path as videos.
use_metadata_only is already supported downstream -- TrainingSample
tolerates image=None, since crop() and the transform helper both guard on
`self.image is not None`, and meets_resolution_requirements() has an
image_metadata branch reading original_size. So this adds the entry point
plus a header probe.

_probe_image_dimensions() opens the file with PIL, which parses only the
header; pixel data is loaded lazily and never requested.

The two per-media guards are folded into one
_should_use_metadata_only(is_video_file), so the face-crop exclusion lives
in one place instead of being duplicated. is_video_file is passed
explicitly so a video-extension file inside an IMAGE dataset still falls
back to a full decode. Verified equivalent to the previous logic across
all 480 combinations of file kind, dataset type, ffprobe availability,
backend type, crop flag and crop style.

Guards follow the existing video precedent: local backends only, IMAGE and
CONDITIONING dataset types, never with crop_style="face" (the one crop
style whose coordinates depend on pixel content), and any header read
failure falls back to a full decode.

EXIF orientations 5-8 transpose the image, so header dimensions are
swapped relative to exif_transpose() on the decode path. The orientation
tag is in the header, so it is applied here. Verified equal to the
full-decode result for all eight orientations plus JPEG/PNG/WebP without
EXIF, with corrupt headers falling back as intended.
@bghira

bghira commented Aug 5, 2026

Copy link
Copy Markdown
Owner

unfortunately, PIL is really slow for decode versus TrainingSample which uses OpenCV under the hood. can you place this path through tsr instead? might need to open a PR to the TrainingSample repo to add a probe helper.

@AL3708

AL3708 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Hi,
Avoiding any decode at all is the goal here — bucketing doesn't need the pixels, only original_size.

Image.open() is lazy: it parses the container header to populate size/mode/format and returns. Pixel data is only read on .load(), .convert(), or array conversion. _probe_image_dimensions() touches .size and EXIF tag 0x0112 — both header fields — and never triggers a load. On a 4 MB JPEG that's a ~200 byte read, against reading and decompressing the whole file on the path it replaces. No decoder runs, so decode speed doesn't enter into it.

Where a decode genuinely is needed, nothing changes: the probe returns None on any header read failure or unsupported format, and the existing full-decode path handles it exactly as before. Same for face cropping and non-local backends, which are excluded from the metadata-only path.

That makes this one-directional — it can only speed things up, never slow them down. On the hit path a full read+decode becomes a header read. On the fallback path the only added cost is that header read itself before the original code runs, which is negligible next to the decode that follows. There's no configuration where this does more work than before.

@bghira

bghira commented Aug 6, 2026

Copy link
Copy Markdown
Owner

well, one thing that the full decode would achieve is to allow us to delete corrupted samples if eg. --delete_problematic_images is provided and enabled. i guess this is caught later on during read_image, and in VAE Cache as read_image_batch and the delete call is executed by each databackend. however, i'd request that this be checked before merge. the change here would still allow deletion if we fall through to the full read path, but in case it doesn't fall through (eg. headers show size but the file is truncated and not able to be decoded) i'd like to know it won't cause further problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants