Skip to content

Commit 2551252

Browse files
committed
Add flexible group parameter with stem, sibling, and callable support (closes #39, #51, #41)
Fix group_by_prefix collision bug where startswith matching caused image_130 to match image_1300. Replace with Path.stem-based grouping in a new splitfolders/grouping.py module. Add group= parameter to ratio(), fixed(), and kfold() supporting: - group="stem": auto-discovers group size by filename stem - group="sibling": splits parallel directories in lockstep - group=callable: custom grouping function (enables manifest splits) Add --group CLI argument, mutually exclusive with --group_prefix. Update README with grouping documentation and examples.
1 parent 25ce6a9 commit 2551252

26 files changed

Lines changed: 736 additions & 71 deletions

File tree

README.md

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ This should get you started to do some serious deep learning on your data. [Read
5050
- The files get shuffled.
5151
- A [seed](https://docs.python.org/3/library/random.html#random.seed) makes splits reproducible.
5252
- Allows randomized [oversampling](https://en.wikipedia.org/wiki/Oversampling_and_undersampling_in_data_analysis) for imbalanced datasets.
53-
- Optionally group files by prefix.
53+
- Optionally group files by prefix or by stem.
5454
- Optionally split files by file format(s).
55+
- Split parallel directories (e.g. `images/` + `annotations/`) in lockstep.
56+
- Custom grouping via callable.
5557
- (Should) work on all operating systems.
5658

5759
## Install
@@ -84,13 +86,15 @@ import splitfolders
8486
# Split with a ratio.
8587
# To only split into training and validation set, set a tuple to `ratio`, i.e, `(.8, .2)`.
8688
splitfolders.ratio("input_folder", output="output",
87-
seed=1337, ratio=(.8, .1, .1), group_prefix=None, formats=None, move=False) # default values
89+
seed=1337, ratio=(.8, .1, .1), group_prefix=None, group=None,
90+
formats=None, move=False) # default values
8891

8992
# Split val/test with a fixed number of items, e.g. `(100, 100)`, for each set.
9093
# To only split into training and validation set, use a single number to `fixed`, i.e., `10`.
9194
# Set 3 values, e.g. `(300, 100, 100)`, to limit the number of training values.
9295
splitfolders.fixed("input_folder", output="output",
93-
seed=1337, fixed=(100, 100), oversample=False, group_prefix=None, formats=None, move=False) # default values
96+
seed=1337, fixed=(100, 100), oversample=False, group_prefix=None, group=None,
97+
formats=None, move=False) # default values
9498

9599
# Use `fixed="auto"` with oversampling to auto-compute the val size from the smallest class.
96100
# Allocates ~20% of the smallest class to validation, rest to training.
@@ -101,15 +105,106 @@ splitfolders.fixed("input_folder", output="output",
101105
# Each fold directory contains train/ and val/ subdirectories.
102106
# Uses symlinks by default to avoid k× disk usage.
103107
splitfolders.kfold("input_folder", output="output",
104-
seed=1337, k=5, group_prefix=None, formats=None, move="symlink") # default values
108+
seed=1337, k=5, group_prefix=None, group=None,
109+
formats=None, move="symlink") # default values
105110
```
106111

107-
Occasionally, you may have things that comprise more than a single file (e.g. picture (.png) + annotation (.txt)).
108-
`splitfolders` lets you split files into equally-sized groups based on their prefix.
109-
Set `group_prefix` to the length of the group (e.g. `2`).
110-
But now _all_ files should be part of groups.
112+
### Grouping files
111113

112-
Also, there might be some instances when you have multiple file formats in these folders. Provide one or multiple extension(s) to `formats` for splitting the files in a list (e.g. `formats = ['.jpeg','.png']`).
114+
When your data has multiple files per sample (e.g. an image and its annotation), you need to keep them together during the split. There are several ways to do this.
115+
116+
#### Group by prefix (`group_prefix`)
117+
118+
The legacy approach. Set `group_prefix` to the number of files per group (e.g. `2` for image + annotation pairs). Files are grouped by their filename stem (the part before the extension). All stems must have exactly `group_prefix` files.
119+
120+
```
121+
input/cats/
122+
img1.jpg img1.txt
123+
img2.jpg img2.txt
124+
```
125+
126+
```python
127+
splitfolders.ratio("input", output="output", group_prefix=2)
128+
```
129+
130+
#### Group by stem (`group="stem"`)
131+
132+
A simpler alternative to `group_prefix`. Automatically groups files that share the same stem and discovers the group size. No need to specify how many files per group — it just requires every stem to have the same count.
133+
134+
```python
135+
splitfolders.ratio("input", output="output", group="stem")
136+
```
137+
138+
If every stem has only one file (e.g. a folder of just `.jpg` files), `group="stem"` behaves identically to no grouping.
139+
140+
#### Group by sibling directories (`group="sibling"`)
141+
142+
For datasets where file types live in **parallel directories** rather than alongside each other:
143+
144+
```
145+
data/
146+
images/
147+
im_1.jpg
148+
im_2.jpg
149+
annotations/
150+
im_1.xml
151+
im_2.xml
152+
```
153+
154+
Use `group="sibling"` to split all directories in lockstep, matching files across directories by stem:
155+
156+
```python
157+
splitfolders.ratio("data", output="output", group="sibling")
158+
```
159+
160+
This produces:
161+
162+
```
163+
output/
164+
train/
165+
images/im_1.jpg
166+
annotations/im_1.xml
167+
val/
168+
images/im_2.jpg
169+
annotations/im_2.xml
170+
```
171+
172+
Requirements:
173+
- The input must have at least 2 subdirectories.
174+
- Every stem must exist in every subdirectory.
175+
- Cannot be combined with `oversample=True`.
176+
177+
#### Custom grouping (`group=callable`)
178+
179+
For advanced use cases, pass any callable that takes a list of `Path` objects and returns a list of tuples:
180+
181+
```python
182+
def my_grouping(files):
183+
# Custom logic to group files
184+
# Return: list of tuples of Path objects
185+
...
186+
187+
splitfolders.ratio("input", output="output", group=my_grouping)
188+
```
189+
190+
This also covers **manifest-based splitting** (#41). For example, if you have a CSV that defines train/test assignments:
191+
192+
```python
193+
def group_from_manifest(files):
194+
manifest = load_my_csv("split_manifest.csv")
195+
# return list of tuples grouped according to manifest
196+
...
197+
198+
splitfolders.ratio("input", output="output", group=group_from_manifest)
199+
```
200+
201+
> **Note:** `group_prefix` and `group` are mutually exclusive — setting both raises a `ValueError`.
202+
203+
### File formats
204+
205+
There might be some instances when you have multiple file formats in these folders. Provide one or multiple extension(s) to `formats` for splitting only certain files (e.g. `formats=['.jpeg', '.png']`).
206+
207+
### Move options
113208

114209
Set
115210
- `move=True` or `move='move'` if you want to move the files instead of copying.
@@ -120,7 +215,7 @@ Set
120215

121216
```
122217
Usage:
123-
splitfolders [--output] [--ratio] [--fixed] [--kfold] [--seed] [--oversample] [--group_prefix] [--formats] [--move] folder_with_images
218+
splitfolders [--output] [--ratio] [--fixed] [--kfold] [--seed] [--oversample] [--group_prefix] [--group] [--formats] [--move] folder_with_images
124219
Options:
125220
--output path to the output folder. defaults to `output`. Get created if non-existent.
126221
--ratio the ratio to split. e.g. for train/val/test `.8 .1 .1 --` or for train/val `.8 .2 --`.
@@ -132,12 +227,15 @@ Options:
132227
--seed set seed value for shuffling the items. defaults to 1337.
133228
--oversample enable oversampling of imbalanced datasets, works only with --fixed.
134229
--group_prefix split files into equally-sized groups based on their prefix
230+
--group grouping strategy: 'stem' or 'sibling' (mutually exclusive with --group_prefix)
135231
--formats split the files based on specified extension(s)
136232
--move move the files instead of copying
137233
--symlink symlink(create shortcut) the files instead of copying
138234
Example:
139235
splitfolders --ratio .8 .1 .1 -- folder_with_images
140236
splitfolders --kfold 5 folder_with_images
237+
splitfolders --group stem --ratio .8 .1 .1 -- folder_with_images
238+
splitfolders --group sibling --ratio .8 .1 .1 -- data_with_parallel_dirs
141239
```
142240

143241
Because of some [Python quirks](https://github.com/jfilter/split-folders/issues/19) you have to prepend ` --` after using `--ratio`.

splitfolders/cli.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,19 @@ def run():
5656
default=None,
5757
help="split files into equally-sized groups based on their prefix",
5858
)
59-
group = parser.add_mutually_exclusive_group()
60-
group.add_argument(
59+
parser.add_argument(
60+
"--group",
61+
type=str,
62+
default=None,
63+
help="grouping strategy: 'stem' or 'sibling'",
64+
)
65+
move_group = parser.add_mutually_exclusive_group()
66+
move_group.add_argument(
6167
"--move",
6268
action="store_true",
6369
help="move the files instead of copying",
6470
)
65-
group.add_argument(
71+
move_group.add_argument(
6672
"--symlink",
6773
action="store_true",
6874
help="symlink(create shortcut) the files instead of copying",
@@ -85,11 +91,14 @@ def run():
8591

8692
args = parser.parse_args()
8793

94+
if args.group_prefix is not None and args.group is not None:
95+
parser.error("--group_prefix and --group are mutually exclusive.")
96+
8897
if args.symlink:
8998
args.move = "symlink"
9099

91100
if args.ratio:
92-
ratio(args.input, args.output, args.seed, args.ratio, args.group_prefix, args.move, args.formats)
101+
ratio(args.input, args.output, args.seed, args.ratio, args.group_prefix, args.group, args.move, args.formats)
93102
elif args.fixed:
94103
if args.fixed == ["auto"]:
95104
fixed_value = "auto"
@@ -102,6 +111,7 @@ def run():
102111
fixed_value,
103112
args.oversample,
104113
args.group_prefix,
114+
args.group,
105115
args.move,
106116
args.formats,
107117
)
@@ -112,6 +122,7 @@ def run():
112122
args.seed,
113123
args.kfold,
114124
args.group_prefix,
125+
args.group,
115126
args.move if args.move else "symlink",
116127
args.formats,
117128
)

splitfolders/grouping.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from collections import defaultdict
2+
3+
4+
def group_by_prefix(files, len_pairs):
5+
"""Groups files by Path.stem, validates each group has len_pairs members."""
6+
stem_groups = defaultdict(list)
7+
for f in files:
8+
stem_groups[f.stem].append(f)
9+
for stem, group in stem_groups.items():
10+
if len(group) != len_pairs:
11+
raise ValueError(
12+
f"Expected {len_pairs} files with stem '{stem}', found {len(group)}"
13+
)
14+
return [tuple(sorted(g)) for g in sorted(stem_groups.values(), key=lambda g: g[0])]
15+
16+
17+
def resolve_grouping(files, group_prefix=None, group=None):
18+
"""Central dispatcher. Validates mutual exclusivity, routes to the right strategy."""
19+
if group_prefix is not None and group is not None:
20+
raise ValueError("Cannot use both `group_prefix` and `group`.")
21+
if group_prefix is not None:
22+
return group_by_prefix(files, group_prefix)
23+
if group is None:
24+
return files
25+
if group == "stem":
26+
return group_by_stem(files)
27+
if callable(group):
28+
return group(files)
29+
if group == "sibling":
30+
return files # sibling handled at orchestration level, not here
31+
raise ValueError(f"Unknown group value: {group!r}.")
32+
33+
34+
def group_by_stem(files):
35+
"""Groups files by stem. Auto-discovers group size. Validates all groups same size.
36+
If group size is 1 (no actual grouping), returns flat list of Paths."""
37+
stem_groups = defaultdict(list)
38+
for f in files:
39+
stem_groups[f.stem].append(f)
40+
41+
sizes = {len(g) for g in stem_groups.values()}
42+
if len(sizes) != 1:
43+
size_details = {stem: len(g) for stem, g in stem_groups.items()}
44+
raise ValueError(
45+
f"All stems must have the same number of files. Found sizes: {size_details}"
46+
)
47+
48+
group_size = sizes.pop()
49+
if group_size == 1:
50+
return sorted(files)
51+
52+
return [tuple(sorted(g)) for g in sorted(stem_groups.values(), key=lambda g: g[0])]
53+
54+
55+
def setup_sibling_files(input_dir, seed, formats=None):
56+
"""Lists type dirs, groups files by stem across all dirs.
57+
Validates every stem exists in every dir. Returns (type_dir_names, groups)."""
58+
from .utils import list_dirs, list_files
59+
60+
type_dirs = sorted(list_dirs(input_dir))
61+
if len(type_dirs) < 2:
62+
raise ValueError(
63+
f"group='sibling' requires at least 2 subdirectories, found {len(type_dirs)}."
64+
)
65+
66+
type_dir_names = [d.name for d in type_dirs]
67+
68+
# Collect stems per type dir
69+
stems_per_dir = {}
70+
files_per_dir = {}
71+
for td in type_dirs:
72+
dir_files = list_files(td, formats)
73+
files_per_dir[td.name] = {f.stem: f for f in dir_files}
74+
stems_per_dir[td.name] = set(files_per_dir[td.name].keys())
75+
76+
# Validate: every stem must exist in every dir
77+
all_stems = set()
78+
for s in stems_per_dir.values():
79+
all_stems |= s
80+
81+
for stem in sorted(all_stems):
82+
for dir_name, stems in stems_per_dir.items():
83+
if stem not in stems:
84+
raise ValueError(
85+
f"Stem '{stem}' missing in directory '{dir_name}'. "
86+
"All stems must exist in every subdirectory for group='sibling'."
87+
)
88+
89+
# Build groups: each group is a dict mapping type_dir_name -> Path
90+
import random
91+
92+
random.seed(seed)
93+
sorted_stems = sorted(all_stems)
94+
random.shuffle(sorted_stems)
95+
96+
groups = []
97+
for stem in sorted_stems:
98+
group = tuple(files_per_dir[dn][stem] for dn in type_dir_names)
99+
groups.append(group)
100+
101+
return type_dir_names, groups

0 commit comments

Comments
 (0)