From 7db826a66c3d65dfceafef2e2c0775d5c9e9dbcd Mon Sep 17 00:00:00 2001 From: bghira Date: Sun, 6 Sep 2026 12:06:54 -0600 Subject: [PATCH] Keep metadata bucket keys consistent across cache refreshes --- simpletuner/helpers/data_backend/factory.py | 24 +----- simpletuner/helpers/metadata/backends/base.py | 7 +- .../helpers/metadata/backends/discovery.py | 17 +--- .../helpers/metadata/backends/huggingface.py | 16 +--- .../helpers/metadata/backends/parquet.py | 17 +--- .../helpers/metadata/backends/webshart.py | 21 +---- simpletuner/helpers/multiaspect/sampler.py | 37 +-------- tests/test_dataset.py | 14 +--- tests/test_metadata_backend.py | 80 ++++++++++++++++++- tests/test_sampler.py | 45 +++++++++++ tests/test_webshart_backend.py | 20 +++++ 11 files changed, 161 insertions(+), 137 deletions(-) diff --git a/simpletuner/helpers/data_backend/factory.py b/simpletuner/helpers/data_backend/factory.py index 606eb9c33..8632d7291 100644 --- a/simpletuner/helpers/data_backend/factory.py +++ b/simpletuner/helpers/data_backend/factory.py @@ -10,7 +10,7 @@ from math import sqrt from pathlib import Path from types import SimpleNamespace -from typing import Any, Dict, Iterable, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union from .runtime import BatchFetcher @@ -66,26 +66,6 @@ def _normalise_vae_cache_config(config: Dict[str, Any]) -> Tuple[bool, bool]: return vae_cache_disable, vae_cache_ondemand -def _coerce_bucket_keys(indices: Dict[Any, Iterable]) -> Dict[Any, list]: - """Return a copy of aspect ratio bucket indices with numeric keys coerced to float.""" - coerced: Dict[Any, list] = {} - for key, values in (indices or {}).items(): - try: - coerced_key: Any = float(key) - except (TypeError, ValueError): - coerced_key = key - if isinstance(values, dict): - iterable_values = [values] - elif isinstance(values, str): - iterable_values = [values] - elif isinstance(values, Iterable): - iterable_values = list(values) - else: - iterable_values = [values] - coerced.setdefault(coerced_key, []).extend(iterable_values) - return coerced - - import numpy as np import pandas as pd import torch @@ -3179,8 +3159,6 @@ def _configure_metadata_backend(self, backend: Dict[str, Any], init_backend: Dic # Restore the live-authoritative runtime config after metadata cache loading. StateTracker.set_data_backend_config(init_backend["id"], init_backend["config"]) metadata_backend = init_backend["metadata_backend"] - if isinstance(getattr(metadata_backend, "aspect_ratio_bucket_indices", None), dict): - metadata_backend.aspect_ratio_bucket_indices = _coerce_bucket_keys(metadata_backend.aspect_ratio_bucket_indices) if hasattr(metadata_backend, "attach_bucket_report"): metadata_backend.attach_bucket_report(init_backend.get("bucket_report")) if hasattr(metadata_backend, "_mock_children"): diff --git a/simpletuner/helpers/metadata/backends/base.py b/simpletuner/helpers/metadata/backends/base.py index 0cc2dc347..35a8ab953 100644 --- a/simpletuner/helpers/metadata/backends/base.py +++ b/simpletuner/helpers/metadata/backends/base.py @@ -235,7 +235,10 @@ def aspect_ratio_bucket_indices(self): @aspect_ratio_bucket_indices.setter def aspect_ratio_bucket_indices(self, value): - """Set aspect ratio bucket indices with debug tracking.""" + """Keep bucket keys identical during discovery, cache loading, and resume.""" + normalized = {} + for key, samples in value.items(): + normalized.setdefault(str(key), []).extend(samples) if hasattr(self, "_aspect_ratio_bucket_indices"): old_count = sum(len(v) for v in self._aspect_ratio_bucket_indices.values()) new_count = sum(len(v) for v in value.values()) if value else 0 @@ -246,7 +249,7 @@ def aspect_ratio_bucket_indices(self, value): f"Old buckets: {list(self._aspect_ratio_bucket_indices.keys())}, " f"New buckets: {list(value.keys()) if value else []}" ) - self._aspect_ratio_bucket_indices = value + self._aspect_ratio_bucket_indices = normalized def _extract_audio_config(self) -> Dict[str, Any]: if self.dataset_config is None: diff --git a/simpletuner/helpers/metadata/backends/discovery.py b/simpletuner/helpers/metadata/backends/discovery.py index 996ab6d59..4fdac4127 100644 --- a/simpletuner/helpers/metadata/backends/discovery.py +++ b/simpletuner/helpers/metadata/backends/discovery.py @@ -18,19 +18,6 @@ from simpletuner.helpers.training.multi_process import should_log from simpletuner.helpers.training.state_tracker import StateTracker - -def _coerce_bucket_keys_to_float(indices: dict) -> dict: - """Coerce bucket keys from strings to floats (fixes JSON serialization issue).""" - coerced = {} - for key, values in (indices or {}).items(): - try: - coerced_key = float(key) - except (TypeError, ValueError): - coerced_key = key - coerced[coerced_key] = list(values) if not isinstance(values, list) else values - return coerced - - logger = logging.getLogger("DiscoveryMetadataBackend") if should_log(): target_level = os.environ.get("SIMPLETUNER_LOG_LEVEL", "INFO") @@ -340,9 +327,7 @@ def reload_cache(self, set_config: bool = True): except Exception as e: logger.warning(f"Error loading aspect bucket cache, creating new one: {e}") cache_data = {} - # Coerce bucket keys from strings to floats (JSON serialization converts float keys to strings) - loaded_indices = cache_data.get("aspect_ratio_bucket_indices", {}) - self.aspect_ratio_bucket_indices = _coerce_bucket_keys_to_float(loaded_indices) + self.aspect_ratio_bucket_indices = cache_data.get("aspect_ratio_bucket_indices", {}) if set_config: self.config = cache_data.get("config", {}) if self.config != {}: diff --git a/simpletuner/helpers/metadata/backends/huggingface.py b/simpletuner/helpers/metadata/backends/huggingface.py index 302d57f32..2f5fac5d3 100644 --- a/simpletuner/helpers/metadata/backends/huggingface.py +++ b/simpletuner/helpers/metadata/backends/huggingface.py @@ -21,18 +21,6 @@ from simpletuner.helpers.training.state_tracker import StateTracker -def _coerce_bucket_keys_to_float(indices: dict) -> dict: - """Coerce bucket keys from strings to floats (fixes JSON serialization issue).""" - coerced = {} - for key, values in (indices or {}).items(): - try: - coerced_key = float(key) - except (TypeError, ValueError): - coerced_key = key - coerced[coerced_key] = list(values) if not isinstance(values, list) else values - return coerced - - def _dataset_type_value(dataset_type: Any) -> str: return str(getattr(dataset_type, "value", dataset_type)).lower() @@ -385,9 +373,7 @@ def reload_cache(self, set_config: bool = True): except Exception as e: logger.warning(f"Error loading aspect ratio bucket cache, creating new one: {e}") cache_data = {} - # Coerce bucket keys from strings to floats (JSON serialization converts float keys to strings) - loaded_indices = cache_data.get("aspect_ratio_bucket_indices", {}) - self.aspect_ratio_bucket_indices = _coerce_bucket_keys_to_float(loaded_indices) + self.aspect_ratio_bucket_indices = cache_data.get("aspect_ratio_bucket_indices", {}) if set_config: self.config = cache_data.get("config", {}) if self.config != {}: diff --git a/simpletuner/helpers/metadata/backends/parquet.py b/simpletuner/helpers/metadata/backends/parquet.py index 91064d4c1..171762b2f 100644 --- a/simpletuner/helpers/metadata/backends/parquet.py +++ b/simpletuner/helpers/metadata/backends/parquet.py @@ -18,19 +18,6 @@ from simpletuner.helpers.training import audio_file_extensions, image_file_extensions, video_file_extensions from simpletuner.helpers.training.state_tracker import StateTracker - -def _coerce_bucket_keys_to_float(indices: dict) -> dict: - """Coerce bucket keys from strings to floats (fixes JSON serialization issue).""" - coerced = {} - for key, values in (indices or {}).items(): - try: - coerced_key = float(key) - except (TypeError, ValueError): - coerced_key = key - coerced[coerced_key] = list(values) if not isinstance(values, list) else values - return coerced - - logger = logging.getLogger("ParquetMetadataBackend") from simpletuner.helpers.training.multi_process import should_log @@ -278,9 +265,7 @@ def reload_cache(self, set_config: bool = True): except Exception as e: logger.warning(f"Error loading aspect ratio bucket cache, creating new one: {e}") cache_data = {} - # Coerce bucket keys from strings to floats (JSON serialization converts float keys to strings) - loaded_indices = cache_data.get("aspect_ratio_bucket_indices", {}) - self.aspect_ratio_bucket_indices = _coerce_bucket_keys_to_float(loaded_indices) + self.aspect_ratio_bucket_indices = cache_data.get("aspect_ratio_bucket_indices", {}) if set_config: self.config = cache_data.get("config", {}) if self.config != {}: diff --git a/simpletuner/helpers/metadata/backends/webshart.py b/simpletuner/helpers/metadata/backends/webshart.py index 8471a2207..1e68ae311 100644 --- a/simpletuner/helpers/metadata/backends/webshart.py +++ b/simpletuner/helpers/metadata/backends/webshart.py @@ -27,17 +27,6 @@ logger.setLevel("ERROR") -def _coerce_bucket_keys_to_float(indices: dict) -> dict: - coerced = {} - for key, values in (indices or {}).items(): - try: - coerced_key = float(key) - except (TypeError, ValueError): - coerced_key = key - coerced[coerced_key] = list(values) if not isinstance(values, list) else values - return coerced - - class WebshartMetadataBackend(MetadataBackend): def __init__( self, @@ -162,9 +151,7 @@ def reload_cache(self, set_config: bool = True): except Exception as exc: logger.warning("Error loading webshart aspect bucket cache, creating new one: %s", exc) cache_data = {} - self.aspect_ratio_bucket_indices = _coerce_bucket_keys_to_float( - cache_data.get("aspect_ratio_bucket_indices", {}) - ) + self.aspect_ratio_bucket_indices = cache_data.get("aspect_ratio_bucket_indices", {}) self._sync_image_files_with_buckets() if set_config: self.config = cache_data.get("config", {}) @@ -354,7 +341,7 @@ def _prepare_bucket_entry( shard_metadata: dict, entry: dict, sample_path: str, - ) -> tuple[dict, Optional[tuple[float, dict]], Optional[Exception]]: + ) -> tuple[dict, Optional[tuple[str, dict]], Optional[Exception]]: try: filename = str(entry["filename"]) sample_metadata = self._metadata_for_entry(shard_metadata, filename, entry, sample_path) @@ -362,7 +349,7 @@ def _prepare_bucket_entry( except Exception as exc: return {}, None, exc - def _prepare_metadata(self, sample_path: str, sample_metadata: dict) -> Optional[tuple[float, dict]]: + def _prepare_metadata(self, sample_path: str, sample_metadata: dict) -> Optional[tuple[str, dict]]: if not sample_metadata or "original_size" not in sample_metadata: return None if not self.meets_resolution_requirements(image_metadata=sample_metadata): @@ -392,7 +379,7 @@ def _prepare_metadata(self, sample_path: str, sample_metadata: dict) -> Optional ) sample_metadata["bucket_frames"] = rounded_frames else: - bucket_key = round(aspect_ratio, 2) + bucket_key = str(round(aspect_ratio, 2)) return bucket_key, sample_metadata def _entries_for_shard(self, shard_idx: int) -> list[dict]: diff --git a/simpletuner/helpers/multiaspect/sampler.py b/simpletuner/helpers/multiaspect/sampler.py index 5d1a20475..254b8e330 100644 --- a/simpletuner/helpers/multiaspect/sampler.py +++ b/simpletuner/helpers/multiaspect/sampler.py @@ -203,14 +203,14 @@ def load_states(self, state_path: str): if isinstance(saved_schedule, dict) and self._saved_schedule_is_restorable(previous_state, state_path): self.metadata_backend.aspect_ratio_bucket_indices = saved_schedule self._val_master_list = sorted(sum(saved_schedule.values(), [])) - self.buckets = previous_state.get("buckets", self.load_buckets()) + self.buckets = [str(bucket) for bucket in previous_state.get("buckets", self.load_buckets())] if "current_bucket" in previous_state: self.current_bucket = previous_state["current_bucket"] self.exhausted_buckets = [] if "exhausted_buckets" in previous_state: self.logger.info(f"Previous checkpoint had {len(previous_state['exhausted_buckets'])} exhausted buckets.") - self.exhausted_buckets = previous_state["exhausted_buckets"] + self.exhausted_buckets = [str(bucket) for bucket in previous_state["exhausted_buckets"]] self.current_epoch = 1 if "current_epoch" in previous_state: self.logger.info(f"Previous checkpoint was on epoch {previous_state['current_epoch']}.") @@ -230,7 +230,7 @@ def load_states(self, state_path: str): self.metadata_backend.seen_images.update(normalized_seen) def load_buckets(self): - return list(self.metadata_backend.aspect_ratio_bucket_indices.keys()) # These keys are a float value, eg. 1.78. + return list(self.metadata_backend.aspect_ratio_bucket_indices.keys()) def retrieve_validation_set(self, batch_size: int): """ @@ -436,36 +436,7 @@ def _reset_buckets(self, raise_exhaustion_signal: bool = True): raise MultiDatasetExhausted() def _get_bucket_images(self, bucket): - """ - Safely retrieve bucket images, trying both original type and type conversion. - - Args: - bucket: The bucket key (could be float or str) - - Returns: - list: List of images in the bucket, or empty list if bucket not found - """ - # Try the original bucket key first - if bucket in self.metadata_backend.aspect_ratio_bucket_indices: - return self.metadata_backend.aspect_ratio_bucket_indices[bucket] - - # Try converting between str and float - try: - if isinstance(bucket, str): - # Try converting str to float - bucket_as_float = float(bucket) - if bucket_as_float in self.metadata_backend.aspect_ratio_bucket_indices: - return self.metadata_backend.aspect_ratio_bucket_indices[bucket_as_float] - elif isinstance(bucket, (float, int)): - # Try converting float/int to str - bucket_as_str = str(bucket) - if bucket_as_str in self.metadata_backend.aspect_ratio_bucket_indices: - return self.metadata_backend.aspect_ratio_bucket_indices[bucket_as_str] - except (ValueError, TypeError): - pass - - # Bucket not found with either type - return [] + return self.metadata_backend.aspect_ratio_bucket_indices.get(str(bucket), []) def _filter_unseen_occurrences(self, images): """Filter consumed positions without collapsing duplicate filepaths.""" diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 6ea569223..f3ec1ae3c 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -12,7 +12,7 @@ from PIL import Image from simpletuner.helpers.data_backend.base import BaseDataBackend -from simpletuner.helpers.data_backend.factory import _coerce_bucket_keys, check_column_values +from simpletuner.helpers.data_backend.factory import check_column_values from simpletuner.helpers.metadata.backends.discovery import DiscoveryMetadataBackend from simpletuner.helpers.multiaspect.dataset import MultiAspectDataset @@ -169,18 +169,6 @@ def test_invalid_data_type(self): check_column_values(column_data, "test_column", "test_file.parquet") self.assertIn("Unsupported data type in column", str(context.exception)) - def test_coerce_bucket_keys(self): - indices = {"1.0": ["foo"], 1.5: ["bar"], "invalid": ["baz"], "single": "path"} - coerced = _coerce_bucket_keys(indices) - self.assertIn(1.0, coerced) - self.assertEqual(coerced[1.0], ["foo"]) - self.assertIn(1.5, coerced) - self.assertEqual(coerced[1.5], ["bar"]) - self.assertIn("invalid", coerced) - self.assertEqual(coerced["invalid"], ["baz"]) - self.assertIn("single", coerced) - self.assertEqual(coerced["single"], ["path"]) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_metadata_backend.py b/tests/test_metadata_backend.py index 21725e23f..9b32461fd 100644 --- a/tests/test_metadata_backend.py +++ b/tests/test_metadata_backend.py @@ -16,6 +16,9 @@ from simpletuner.helpers.data_backend.filters import build_dataset_filter from simpletuner.helpers.metadata.backends.base import MetadataBackend from simpletuner.helpers.metadata.backends.discovery import DiscoveryMetadataBackend +from simpletuner.helpers.metadata.backends.huggingface import HuggingfaceMetadataBackend +from simpletuner.helpers.metadata.backends.parquet import ParquetMetadataBackend +from simpletuner.helpers.metadata.backends.webshart import WebshartMetadataBackend from simpletuner.helpers.training.state_tracker import StateTracker @@ -186,11 +189,84 @@ def test_load_cache_valid(self): } with patch.object(self.data_backend, "read", return_value=json.dumps(valid_cache_data)): self.metadata_backend.reload_cache() - # JSON string keys are coerced back to floats when loading self.assertEqual( self.metadata_backend.aspect_ratio_bucket_indices, - {1.0: ["image1", "image2"]}, + {"1.0": ["image1", "image2"]}, + ) + + def test_cache_loaders_preserve_string_bucket_keys(self): + indices = { + "1.0": ["square.jpg", "square.jpg"], + "1.5": ["wide.jpg"], + "1920x1080@125": ["video.mp4"], + "3s": ["audio.wav"], + "captions": ["caption.txt"], + } + for backend_class in ( + DiscoveryMetadataBackend, + HuggingfaceMetadataBackend, + ParquetMetadataBackend, + WebshartMetadataBackend, + ): + with self.subTest(backend=backend_class.__name__): + backend = backend_class.__new__(backend_class) + backend.id = "cache-keys" + backend.cache_file = "buckets.json" + backend.data_backend = self.data_backend + backend.load_image_metadata = Mock() + backend._sync_image_files_with_buckets = Mock() + with patch.object( + self.data_backend, "read", return_value=json.dumps({"aspect_ratio_bucket_indices": indices}) + ): + backend.reload_cache(set_config=False) + self.assertEqual(backend.aspect_ratio_bucket_indices, indices) + + def test_discovery_after_cache_reload_keeps_one_bucket(self): + cache = {"aspect_ratio_bucket_indices": {"1.0": ["cached.jpg", "cached.jpg"]}} + with patch.object(self.data_backend, "read", return_value=json.dumps(cache)): + self.metadata_backend.reload_cache() + + self.data_backend.type = "local" + self.metadata_backend.dataset_type = DatasetType.IMAGE + self.metadata_backend.dataset_config = {"dataset_type": "image", "crop": False} + prepared = SimpleNamespace( + crop_coordinates=(0, 0), + target_size=(512, 512), + intermediary_size=(512, 512), + aspect_ratio=1.0, ) + with ( + patch.object(self.metadata_backend, "_probe_image_dimensions", return_value={"original_size": (1024, 1024)}), + patch("simpletuner.helpers.metadata.backends.discovery.TrainingSample") as training_sample, + ): + training_sample.return_value.prepare.return_value = prepared + self.metadata_backend._process_for_bucket("new.jpg", self.metadata_backend.aspect_ratio_bucket_indices) + + self.assertEqual( + self.metadata_backend.aspect_ratio_bucket_indices, + {"1.0": ["cached.jpg", "cached.jpg", "new.jpg"]}, + ) + + def test_bucket_assignment_merges_key_types_without_changing_occurrences(self): + indices = { + 1.0: ["first.jpg", "repeat.jpg"], + "1.0": ["repeat.jpg", "last.jpg"], + "1920x1080@125": ["video.mp4"], + "3s": ["audio.wav"], + "captions": ["caption.txt"], + } + self.metadata_backend.aspect_ratio_bucket_indices = indices + + expected = { + "1.0": ["first.jpg", "repeat.jpg", "repeat.jpg", "last.jpg"], + "1920x1080@125": ["video.mp4"], + "3s": ["audio.wav"], + "captions": ["caption.txt"], + } + self.assertEqual(self.metadata_backend.aspect_ratio_bucket_indices, expected) + self.metadata_backend.aspect_ratio_bucket_indices = self.metadata_backend.aspect_ratio_bucket_indices + self.assertEqual(self.metadata_backend.aspect_ratio_bucket_indices, expected) + self.assertEqual(indices[1.0], ["first.jpg", "repeat.jpg"]) def test_load_cache_invalid(self): invalid_cache_data = "this is not valid json" diff --git a/tests/test_sampler.py b/tests/test_sampler.py index d51447abb..aa1644e1e 100644 --- a/tests/test_sampler.py +++ b/tests/test_sampler.py @@ -549,6 +549,51 @@ def _state_path(directory, rank): filename = "training_state.json" if rank == 0 else f"training_state-rank{rank}.json" return os.path.join(directory, filename) + def test_resume_normalizes_numeric_bucket_names_and_preserves_remaining_occurrences(self): + backend = DiscoveryMetadataBackend.__new__(DiscoveryMetadataBackend) + backend.id = "resume" + backend.instance_data_dir = "" + backend.accelerator = SimpleNamespace(num_processes=1, process_index=0) + backend.aspect_ratio_bucket_indices = {"1.0": ["fresh.jpg"]} + backend.seen_images = {} + sampler = self._sampler(backend) + sampler.rank_info = "[RANK 0]" + saved_schedule = { + "1.0": ["repeat.jpg", "repeat.jpg", "other.jpg"], + "1.5": ["wide.jpg"], + "1920x1080@125": ["video.mp4"], + } + with tempfile.TemporaryDirectory() as checkpoint_dir: + state_path = self._state_path(checkpoint_dir, 0) + sampler.state_manager.save_state( + { + "aspect_ratio_bucket_indices": saved_schedule, + "buckets": [1.0, "1920x1080@125"], + "exhausted_buckets": [1.5], + "current_bucket": 0, + "batch_size": 1, + "seen_images": {"repeat.jpg": 1, "wide.jpg": 1}, + "dp_size": 1, + "dp_rank": 0, + }, + state_path, + ) + sampler.load_states(state_path) + self.assertEqual(sampler.buckets, ["1.0", "1920x1080@125"]) + self.assertEqual(sampler.exhausted_buckets, ["1.5"]) + self.assertEqual(sampler.current_bucket, 0) + self.assertEqual(backend.aspect_ratio_bucket_indices, saved_schedule) + self.assertEqual(sampler._get_unseen_images("1.0"), ["repeat.jpg", "other.jpg"]) + backend.mark_batch_as_seen(["repeat.jpg", "other.jpg"]) + self.assertEqual(sampler._get_unseen_images("1.0"), []) + + sampler.save_state(state_path) + sampler.load_states(state_path) + self.assertEqual(backend.aspect_ratio_bucket_indices, saved_schedule) + self.assertEqual(sampler._get_unseen_images("1.0"), []) + self.assertEqual(sampler._get_unseen_images("1920x1080@125"), ["video.mp4"]) + sampler.logger.warning.assert_not_called() + def _resume_shards(self, *, images, save_world_size, saving_ranks, resume_world_size): with tempfile.TemporaryDirectory() as checkpoint_dir: for rank in range(save_world_size): diff --git a/tests/test_webshart_backend.py b/tests/test_webshart_backend.py index d5495402b..2cb09154f 100644 --- a/tests/test_webshart_backend.py +++ b/tests/test_webshart_backend.py @@ -181,6 +181,26 @@ def test_filtered_aspect_bucket_call_requires_webshart_api(self): with self.assertRaisesRegex(ImportError, "list_shard_sample_aspect_buckets_filtered"): backend.list_shard_sample_aspect_buckets([0], dataset_filter=dataset_filter) + @patch("simpletuner.helpers.metadata.backends.webshart.TrainingSample") + def test_prepared_image_bucket_matches_cached_string_key(self, training_sample): + backend = WebshartMetadataBackend.__new__(WebshartMetadataBackend) + backend.id = "test-backend" + backend.dataset_type = DatasetType.IMAGE + backend.meets_resolution_requirements = Mock(return_value=True) + backend.aspect_ratio_bucket_indices = {"1.0": ["cached.webp"]} + training_sample.return_value.prepare.return_value = SimpleNamespace( + aspect_ratio=1.0, + intermediary_size=(512, 512), + crop_coordinates=(0, 0), + target_size=(512, 512), + ) + + key, metadata = backend._prepare_metadata("new.webp", {"original_size": (1024, 1024)}) + backend.aspect_ratio_bucket_indices.setdefault(key, []).append("new.webp") + + self.assertEqual(backend.aspect_ratio_bucket_indices, {"1.0": ["cached.webp", "new.webp"]}) + self.assertEqual(metadata["aspect_ratio"], 1.0) + def test_video_metadata_uses_indexed_frame_fields_and_probe_geometry(self): backend = WebshartMetadataBackend.__new__(WebshartMetadataBackend) backend.dataset_type = DatasetType.VIDEO