3939from __future__ import annotations
4040
4141import argparse
42+ import hashlib
4243import io
4344import json
4445import os
@@ -112,7 +113,9 @@ def _validate_split_name(split: str) -> str:
112113 *split*, unchanged, once validated.
113114
114115 Raises:
115- ValueError: If *split* is empty, or contains a path separator or a ``.``/``..`` segment.
116+ ValueError: If *split* is empty, contains a path separator or a ``.``/``..`` segment, or carries a
117+ glob metacharacter. The last one matters because the name is also matched against existing shard
118+ files: a split called ``*`` would match — and then delete — the shards of every other split.
116119
117120 Examples:
118121 >>> _validate_split_name("train")
@@ -124,15 +127,23 @@ def _validate_split_name(split: str) -> str:
124127 """
125128 if not split or "/" in split or "\\ " in split or split in ("." , ".." ):
126129 raise ValueError (f"split { split !r} must not contain a path separator or '..'." )
130+ forbidden = sorted (set ("*?[]" ) & set (split ))
131+ if forbidden :
132+ raise ValueError (
133+ f"split { split !r} must not contain { '' .join (forbidden )} : the name is matched against existing shard "
134+ "files, so a glob metacharacter would reach other splits' shards."
135+ )
127136 return split
128137
129138
130- def _shard_name (split : str , index : int ) -> str :
139+ def _shard_name (split : str , index : int , generation : str | None = None ) -> str :
131140 """Return the file name of shard *index* of *split*.
132141
133142 Args:
134143 split: Split name the shard belongs to.
135144 index: Zero-based shard number.
145+ generation: Token identifying one pack run. Included in the name so that re-packing a split writes new
146+ files instead of overwriting the ones the published index still points at.
136147
137148 Returns:
138149 Shard file name.
@@ -143,9 +154,13 @@ def _shard_name(split: str, index: int) -> str:
143154 Examples:
144155 >>> _shard_name("train", 7)
145156 'train-000007.tar'
157+ >>> _shard_name("train", 7, "a1b2c3d4")
158+ 'train-a1b2c3d4-000007.tar'
146159 """
147160 _validate_split_name (split )
148- return f"{ split } -{ index :06d} .tar"
161+ if generation is None :
162+ return f"{ split } -{ index :06d} .tar"
163+ return f"{ split } -{ generation } -{ index :06d} .tar"
149164
150165
151166def index_name (split : str ) -> str :
@@ -357,6 +372,56 @@ def _annotations_by_image(coco_data: dict[str, Any]) -> dict[Any, list[dict[str,
357372 return grouped
358373
359374
375+ def _pack_generation (payload : dict [str , Any ], shard_sizes : list [int ]) -> str :
376+ """Derive a short generation token from what was packed.
377+
378+ Deterministic on purpose: the same split packed twice produces the same token, so re-packing unchanged data
379+ reproduces the same file names rather than churning the directory. Any change to the sample count, the
380+ category set, the per-shard sample counts or the shard byte sizes changes the token, which is what keeps a
381+ re-pack from writing over the shards a currently published index still references.
382+
383+ Args:
384+ payload: The index mapping for this pack, excluding the shard names it is about to name.
385+ shard_sizes: Byte size of each staged shard, in order.
386+
387+ Returns:
388+ Eight hex characters.
389+
390+ Examples:
391+ >>> a = _pack_generation({"split": "train", "num_samples": 2}, [10, 20])
392+ >>> a == _pack_generation({"split": "train", "num_samples": 2}, [10, 20])
393+ True
394+ >>> a == _pack_generation({"split": "train", "num_samples": 3}, [10, 20])
395+ False
396+ """
397+ material = json .dumps ({"index" : payload , "sizes" : shard_sizes }, sort_keys = True ).encode ("utf-8" )
398+ return hashlib .sha256 (material ).hexdigest ()[:8 ]
399+
400+
401+ def _published_shard_names (destination : Path , split : str ) -> set [str ]:
402+ """Return the shard names the currently published index of *split* references.
403+
404+ Reading the index rather than globbing keeps cleanup scoped to what this split actually published: the
405+ directory legitimately holds other splits, and a half-written generation from a crashed run must not be
406+ mistaken for a live one.
407+
408+ Args:
409+ destination: Directory holding the pack.
410+ split: Split whose published index to read.
411+
412+ Returns:
413+ Shard file names from the published index, or an empty set when the split has no readable index yet.
414+
415+ Examples:
416+ >>> _published_shard_names(Path("/nonexistent"), "train")
417+ set()
418+ """
419+ try :
420+ return set (read_shard_index (destination , split ).shards )
421+ except (WebDatasetSplitUnavailableError , ValueError , KeyError , json .JSONDecodeError ):
422+ return set ()
423+
424+
360425def pack_coco_to_shards (
361426 image_dir : str | Path ,
362427 annotations_file : str | Path ,
@@ -399,6 +464,10 @@ def pack_coco_to_shards(
399464 """
400465 if max_shard_bytes <= 0 :
401466 raise ValueError (f"max_shard_bytes must be > 0, got { max_shard_bytes } ." )
467+ if category_ids not in ("remap" , "raw" ):
468+ # The CLI constrains this with `choices`, but a library caller can pass anything. Without this the pack
469+ # succeeds and only fails later, at read time, when ShardIndex.from_json rejects the policy it wrote.
470+ raise ValueError (f"category_ids must be 'remap' or 'raw', got { category_ids !r} ." )
402471 _validate_split_name (split )
403472
404473 image_root = Path (image_dir )
@@ -494,6 +563,20 @@ def pack_coco_to_shards(
494563 tar = None
495564 shard_sample_counts .append (shard_samples )
496565
566+ provisional = list (shard_names )
567+ shard_sizes = [(work_dir / name ).stat ().st_size for name in provisional ]
568+ generation = _pack_generation (
569+ {
570+ "split" : split ,
571+ "num_samples" : written ,
572+ "categories" : list (coco_data .get ("categories" , ())),
573+ "annotated_category_ids" : sorted (annotated_ids ),
574+ "category_ids" : category_ids ,
575+ "samples_per_shard" : shard_sample_counts ,
576+ },
577+ shard_sizes ,
578+ )
579+ shard_names = [_shard_name (split , position , generation ) for position in range (len (provisional ))]
497580 index = ShardIndex (
498581 split = split ,
499582 shards = tuple (shard_names ),
@@ -509,12 +592,20 @@ def pack_coco_to_shards(
509592 # this run does not reproduce (e.g. it produced fewer of them) are removed too, so no stale, unindexed
510593 # shard is left behind. The index is written last, so a reader can never observe an index whose shard
511594 # list is only partially on disk.
512- stale_shards = {path .name for path in destination .glob (f"{ split } -*.tar" )} - set (shard_names )
513- for name in shard_names :
514- (work_dir / name ).replace (destination / name )
515- for name in stale_shards :
516- (destination / name ).unlink ()
517- (destination / index_name (split )).write_bytes (index_bytes )
595+ # Shard names carry this run's generation token, so moving them in cannot overwrite a shard the
596+ # published index still points at. Publication is therefore: move this generation's shards in, swap the
597+ # index with os.replace (atomic on POSIX), then drop the shards only the previous index referenced. A
598+ # reader that opened the old index keeps a complete pack until that last step; one that opens the new
599+ # index sees this generation in full. The window where a reader loses files it is mid-way through is
600+ # narrowed to the cleanup, not the whole re-pack.
601+ previous = _published_shard_names (destination , split )
602+ for staged , published in zip (provisional , shard_names ):
603+ (work_dir / staged ).replace (destination / published )
604+ staged_index = destination / f".{ index_name (split )} .{ generation } "
605+ staged_index .write_bytes (index_bytes )
606+ staged_index .replace (destination / index_name (split ))
607+ for name in previous - set (shard_names ):
608+ (destination / name ).unlink (missing_ok = True )
518609 finally :
519610 if tar is not None :
520611 tar .close ()
@@ -894,12 +985,24 @@ def build_webdataset_loader(
894985 A ``DataLoader`` over *dataset*.
895986
896987 Raises:
897- ValueError: If a planned epoch would leave a worker with no shard to read.
988+ ValueError: If the split has fewer shards than distributed ranks, or if a planned epoch would leave a
989+ worker with no shard to read.
898990 """
899991 _require_webdataset ()
900992 ranks = _distributed_world_size () if world_size is None else world_size
993+ shard_count = len (dataset .index .shards )
994+ if ranks > shard_count :
995+ # An empty DataLoader *worker* is harmless; an entirely empty *rank* is not. split_by_node would leave
996+ # that rank with no batches, so it never enters validation_step/test_step while the populated ranks do —
997+ # and their DDP forward and sync_dist=True logging then wait on a rank that never arrives. This applies
998+ # to evaluation as much as to training, so it is checked before the fixed_epoch branch below.
999+ raise ValueError (
1000+ f"Split { dataset .index .split !r} has { shard_count } shard(s) for { ranks } rank(s): a rank with no shard "
1001+ "never reaches the step function while the others do, which deadlocks the process group. Re-pack "
1002+ "with a smaller --max-shard-mb so the split has at least one shard per rank."
1003+ )
9011004 if fixed_epoch :
902- shards = len ( dataset . index . shards )
1005+ shards = shard_count
9031006 slots = ranks * max (1 , num_workers )
9041007 if slots > shards :
9051008 raise ValueError (
@@ -1019,7 +1122,23 @@ def build_webdataset(image_set: str, args: Any, resolution: int) -> WebDatasetDe
10191122 keypoint_flip_pairs = None ,
10201123 )
10211124
1022- cat2label = None if is_train else read_shard_index (root , "train" ).cat2label ()
1125+ if is_train :
1126+ cat2label = None
1127+ else :
1128+ # Both indexes are read so a policy mismatch is refused rather than silently honoured. Passing None for a
1129+ # "raw" train split would let this split derive its own mapping from its own index, so a train split
1130+ # packed "raw" beside a val split packed "remap" would evaluate remapped labels against raw-trained
1131+ # predictions — wrong numbers, no error.
1132+ train_index = read_shard_index (root , "train" )
1133+ split_index = read_shard_index (root , split )
1134+ if split_index .category_ids != train_index .category_ids :
1135+ raise ValueError (
1136+ f"Split { split !r} was packed with category_ids={ split_index .category_ids !r} but the train split "
1137+ f"was packed with { train_index .category_ids !r} . The two label spaces do not match, so evaluation "
1138+ "would score predictions against different class indices than training used. Re-pack both splits "
1139+ "with the same --category-ids."
1140+ )
1141+ cat2label = train_index .cat2label ()
10231142 logger .info ("Building WebDataset %s dataset at resolution %d from %s" , image_set , resolution , root )
10241143 return WebDatasetDetection (
10251144 root ,
0 commit comments