Skip to content

Commit b1463e9

Browse files
authored
Merge pull request #3001 from AL3708/perf/image-metadata-only-scan
Extend metadata-only bucketing to still images in the discovery backend
2 parents bc3d59d + 6e6c894 commit b1463e9

2 files changed

Lines changed: 128 additions & 7 deletions

File tree

simpletuner/helpers/metadata/backends/discovery.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from io import BytesIO
77
from typing import Optional
88

9+
from PIL import Image
10+
911
from simpletuner.helpers.data_backend.base import BaseDataBackend
1012
from simpletuner.helpers.data_backend.dataset_types import DatasetType
1113
from simpletuner.helpers.image_manipulation.load import load_image, load_video
@@ -94,16 +96,58 @@ def __init__(
9496
max_num_samples=max_num_samples,
9597
)
9698

97-
def _should_use_metadata_only_for_video(self) -> bool:
98-
if not _is_ffprobe_available():
99-
return False
100-
if self.dataset_type is not DatasetType.VIDEO:
101-
return False
99+
def _should_use_metadata_only(self, is_video_file: bool) -> bool:
100+
"""Whether this sample can be bucketed from dimensions alone.
101+
102+
Bucketing needs only the sample dimensions: calculate_target_size() and
103+
the crop coordinate maths derive everything from original_size, and no
104+
pixel-derived metadata is stored. Videos already take this path via
105+
ffprobe; still images can do the same from the file header.
106+
107+
Face cropping is the one crop style whose coordinates depend on pixel
108+
content, so it always requires a full decode.
109+
"""
102110
crop_enabled = bool(self.dataset_config.get("crop", False))
103111
crop_style = str(self.dataset_config.get("crop_style") or "random").lower()
104112
if crop_enabled and crop_style == "face":
105113
return False
106-
return True
114+
115+
if is_video_file:
116+
return self.dataset_type is DatasetType.VIDEO and _is_ffprobe_available()
117+
if self.dataset_type in (DatasetType.IMAGE, DatasetType.CONDITIONING):
118+
# Header reads need a real path; remote backends stay on full reads.
119+
return getattr(self.data_backend, "type", None) == "local"
120+
return False
121+
122+
def _probe_image_dimensions(self, image_path_str: str) -> Optional[dict]:
123+
"""Read image dimensions from the file header, without decoding pixels.
124+
125+
PIL parses only the header on Image.open(); pixel data is loaded lazily
126+
and never requested here. Returns None to signal that the caller should
127+
fall back to a full decode.
128+
129+
EXIF orientations 5-8 transpose the image, so the stored dimensions are
130+
swapped relative to what exif_transpose() produces on the full-decode
131+
path. The orientation tag lives in the header too, so it is applied here
132+
to keep both paths in agreement.
133+
"""
134+
try:
135+
with Image.open(image_path_str) as image:
136+
width, height = image.size
137+
orientation = image.getexif().get(0x0112)
138+
if orientation in (5, 6, 7, 8):
139+
width, height = height, width
140+
except Exception as e:
141+
logger.debug(
142+
"(id=%s) Could not read image header for %s (%s); falling back to full decode.",
143+
self.id,
144+
image_path_str,
145+
e,
146+
)
147+
return None
148+
if not width or not height:
149+
return None
150+
return {"original_size": (width, height)}
107151

108152
def _needs_video_frame_count(self) -> bool:
109153
if self.bucket_strategy == "resolution_frames":
@@ -382,7 +426,7 @@ def _process_for_bucket(
382426
is_video_file = file_extension.strip(".") in video_file_extensions
383427

384428
use_metadata_only = False
385-
if is_video_file and self._should_use_metadata_only_for_video():
429+
if is_video_file and self._should_use_metadata_only(is_video_file):
386430
if getattr(self.data_backend, "type", None) == "local":
387431
video_metadata = self._probe_video_metadata(image_path_str, None)
388432
else:
@@ -429,6 +473,13 @@ def _process_for_bucket(
429473

430474
logger.debug("(id=%s) Using ffprobe metadata-only scan for %s", self.id, image_path_str)
431475

476+
elif not is_video_file and self._should_use_metadata_only(is_video_file):
477+
image_dimensions = self._probe_image_dimensions(image_path_str)
478+
if image_dimensions:
479+
use_metadata_only = True
480+
image_metadata.update(image_dimensions)
481+
logger.debug("(id=%s) Using header-only metadata scan for %s", self.id, image_path_str)
482+
432483
if use_metadata_only:
433484
if not self.meets_resolution_requirements(image_metadata=image_metadata):
434485
if not self.delete_unwanted_images:

tests/test_metadata_backend.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,76 @@ def test_len(self):
6363
}
6464
self.assertEqual(len(self.metadata_backend), 3)
6565

66+
def test_probe_image_dimensions_applies_exif_orientation(self):
67+
with tempfile.TemporaryDirectory() as tmpdir:
68+
image_path = Path(tmpdir) / "oriented.jpg"
69+
exif = Image.Exif()
70+
exif[0x0112] = 6
71+
Image.new("RGB", (64, 32)).save(image_path, exif=exif)
72+
73+
metadata = self.metadata_backend._probe_image_dimensions(str(image_path))
74+
75+
self.assertEqual(metadata, {"original_size": (32, 64)})
76+
77+
def test_local_image_metadata_scan_skips_backend_read(self):
78+
self.data_backend.type = "local"
79+
self.metadata_backend.dataset_type = DatasetType.IMAGE
80+
self.metadata_backend.dataset_config = {"dataset_type": "image", "crop": False}
81+
self.data_backend.read.reset_mock()
82+
prepared = SimpleNamespace(
83+
crop_coordinates=(0, 0),
84+
target_size=(512, 256),
85+
intermediary_size=(512, 256),
86+
aspect_ratio=2.0,
87+
)
88+
89+
with (
90+
patch.object(
91+
self.metadata_backend,
92+
"_probe_image_dimensions",
93+
return_value={"original_size": (512, 256)},
94+
),
95+
patch("simpletuner.helpers.metadata.backends.discovery.TrainingSample") as training_sample,
96+
):
97+
training_sample.return_value.prepare.return_value = prepared
98+
metadata_updates = {}
99+
buckets = self.metadata_backend._process_for_bucket(
100+
"image.png",
101+
{},
102+
metadata_updates=metadata_updates,
103+
)
104+
105+
self.data_backend.read.assert_not_called()
106+
self.assertEqual(buckets, {"2.0": ["image.png"]})
107+
self.assertEqual(metadata_updates["image.png"]["original_size"], (512, 256))
108+
109+
def test_face_crop_forces_full_image_decode(self):
110+
self.data_backend.type = "local"
111+
self.metadata_backend.dataset_type = DatasetType.IMAGE
112+
self.metadata_backend.dataset_config = {
113+
"dataset_type": "image",
114+
"crop": True,
115+
"crop_style": "face",
116+
}
117+
self.data_backend.read = Mock(return_value=b"image payload")
118+
prepared = SimpleNamespace(
119+
crop_coordinates=(0, 0),
120+
target_size=(512, 256),
121+
intermediary_size=(512, 256),
122+
aspect_ratio=2.0,
123+
)
124+
125+
with (
126+
patch.object(self.metadata_backend, "_probe_image_dimensions") as probe_dimensions,
127+
patch("simpletuner.helpers.metadata.backends.discovery.load_image", return_value=self.test_image),
128+
patch("simpletuner.helpers.metadata.backends.discovery.TrainingSample") as training_sample,
129+
):
130+
training_sample.return_value.prepare.return_value = prepared
131+
self.metadata_backend._process_for_bucket("image.png", {})
132+
133+
probe_dimensions.assert_not_called()
134+
self.data_backend.read.assert_called_once_with("image.png")
135+
66136
def test_discover_new_files(self):
67137
# Assuming that StateTracker.get_image_files returns known files
68138
# and list_files should return both known and potentially new files

0 commit comments

Comments
 (0)