Skip to content

fix(folderparser): take the split from directory names before substrings - #576

Open
BarsamErshadi wants to merge 2 commits into
roboflow:mainfrom
BarsamErshadi:fix/upload-dataset-split-detection
Open

BarsamErshadi wants to merge 2 commits into
roboflow:mainfrom
BarsamErshadi:fix/upload-dataset-split-detection

Conversation

@BarsamErshadi

@BarsamErshadi BarsamErshadi commented Nov 4, 2026 •

Copy link
Copy Markdown

What does this PR do?

When upload_dataset uploads a directory image by image, it infers each image's split in _decide_split, which looks for valid, train and test anywhere in the lowercased relative path, including class folders and the filename. So a class folder or filename can silently override the real split directory: with a classification class called train, images under test/train/ are uploaded to train. The split= argument forces one split for the whole upload, so it can't repair a mixed dataset. Zip uploads (a .zip path or use_zip_upload=True) return before this code runs, so this PR doesn't affect them.

With this change, _decide_split works in two steps:

  1. It takes the topmost directory whose whole name is a split, ignoring case: train, training, valid, validation, test or testing, optionally followed by set, data or images (valid_set, train-data). So test/train/ is test and validation/train/ is valid.
  2. If there is no such directory, it looks for a split name at the start of a word anywhere in the path, in the same valid, train, test order as before. A change of case also starts a word, so valid_001.jpg, my_test_set/, myTest/ and VOCtest/ keep working, while latest/ and invalid/ stop matching. The default is still train.

A folder that only contains a split word, like test-project-1/ or test_dataset/, doesn't count in step 1. That keeps test-project-1/train/ and test-project-1/valid/ apart instead of sending both to test.

Minimal repro:

import os, tempfile
from PIL import Image
from roboflow.util.folderparser import parsefolder

with tempfile.TemporaryDirectory() as d:
    for path in ["test/train/1.jpg", "test/bus/1.jpg", "train/invalid_ticket.jpg", "unsplit/latest.jpg"]:
        p = os.path.join(d, path)
        os.makedirs(os.path.dirname(p), exist_ok=True)
        Image.new("RGB", (4, 4)).save(p)
    for i in parsefolder(d, is_classification=True)["images"]:
        print(i["file"], "split=" + i["split"], "label=" + i["annotationfile"]["classification_label"])

Before:

/test/bus/1.jpg split=test label=bus
/test/train/1.jpg split=train label=train
/train/invalid_ticket.jpg split=valid label=train
/unsplit/latest.jpg split=test label=unsplit

After:

/test/bus/1.jpg split=test label=bus
/test/train/1.jpg split=test label=train
/train/invalid_ticket.jpg split=train label=train
/unsplit/latest.jpg split=train label=unsplit

I also called Workspace.upload_dataset with a stubbed project, on a folder with test/train/1.jpg, test/bus/1.jpg, validation/train/1.jpg and training/test/1.jpg. The split passed to upload_image for /test/train/1.jpg was train before and test after. The other three kept the splits they had before: test, valid and train.

Related Issue(s): None that I found. Open PRs #575, #490 and #421 also touch folderparser.py, but none of their hunks touch _decide_split.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

The split of some images does change, which is the point of the fix. Additional Context has a table of what changed in a sample of paths, including the changes that could be regressions.

Testing

  • I have tested this change locally
  • I have added/updated tests for this change

Test details:

I added seven tests to tests/util/test_folderparser.py. They cover:

  • a split directory winning over later path text
  • an inner split name not overriding an outer directory like validation/
  • wrapper folders like test-project-1/ not flattening the splits
  • case-insensitive directory matching
  • split names inside other words (latest, invalid, contest)
  • a case change counting as a word start (myTest, VOCtest_06-Nov-2007, but not LATEST)
  • the fallback for flat names and folders such as validation/

Six of them fail on the unfixed code. The fallback test passes both before and after, because it guards behaviour that has to stay the same.

  • Fixtures: I dumped {file: split} for all 8 datasets in tests/datasets (72 images) before and after. The two dumps are byte-identical.
  • tests/util: 158 tests, OK.
  • Full suite (unittest discovery over tests/, with non-loopback connections blocked): 1285 tests, 1 skipped, 1 error. The error is tests.cli.test_train_handler.TestTrainStart.test_start_with_all_options, which tries a real outbound HTTPS connection. It errors the same way with this fix removed, so it is caused by my network block rather than this change. I did not run it with network access.
  • Hooks: pre-commit passed on the changed files, including bandit, ruff format and ruff. mypy on folderparser.py also passed.
  • Limits: pytest isn't installed in my environment, so I used unittest, which is what CONTRIBUTING.md describes. I ran everything on macOS only and did not test against the real Roboflow API.

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code where necessary, particularly in hard-to-understand areas
  • My changes generate no new warnings or errors
  • I have updated the documentation accordingly (if applicable) (no docs describe this inference, so nothing needed changing)

Additional Context

These are the behaviour changes for you to judge. I computed the old and new splits for 76 sample paths, and 32 changed. All 32 are in this table. It's a sample, so other paths can change too.

Path Before After Note
test/train/a.jpg, test/valid/a.jpg, dataset/test/train/a.jpg train, valid, train test the bug
train/valid/a.jpg, train/valid_001.jpg, train/invalid_ticket.jpg valid train the bug
test/train_001.jpg train test the bug
test_set/train/a.jpg, test_set/valid/a.jpg train, valid test the bug
testing/train/a.jpg, testing/train_001.jpg train test the bug
train_test_split/test/a.jpg train test the bug
invalid/a.jpg, INVALID/a.jpg, Invalid/a.jpg valid train the bug
latest/a.jpg, LATEST/a.jpg, Latest/a.jpg, contest/a.jpg test train the bug
backtest/a.jpg test train word-boundary trade-off
mytest/a.jpg, xtest/a.jpg, MYTEST/a.jpg test train trade-off: possible regression
myvalid/a.jpg valid train trade-off: possible regression
trainvalid/a.jpg valid train trade-off: possible regression
testtrain/a.jpg train test trade-off: possible regression
testing/valid/a.jpg valid test trade-off: outer directory wins
training/valid/a.jpg valid train trade-off: outer directory wins
test/fixtures/train/a.jpg train test trade-off: topmost wins
training_stuff/test/a.jpg, train_v2/test/a.jpg, train2017/test/a.jpg train test trade-off: possible regression
  • Run-together names: a split name that follows a letter of the same case is no longer recognised, so mytest/, xtest/ and MYTEST/ now land in train. That rule is what stops latest and invalid from matching. Separated and mixed-case forms still work: my_test, my-test, myTest, imagesValid and VOCtest_06-Nov-2007. A dataset that relies on all-lowercase run-together names would change silently.
  • Case changes: because a case change starts a word, laTest/ and inValid/ still match, as they did before.
  • Unrecognised outer folders: the list of split directory names is short on purpose, and an outer folder that isn't on it loses to an exact name inside it. So train_v2/test/ and train2017/test/ go to test, which is wrong if test is a class. The same rule is what keeps test-project-1/train/ in train, and a per-image rule can't tell those two apart.
  • Wrapper directories: if a folder named like a split sits above the real split directory, the outer one now wins, so testing/valid/ is test and training/valid/ is train. The path is relative to the uploaded folder, so this only matters when that wrapper is inside the upload.
  • Still unchanged:
    • validation/train/a.jpg and validation/test/a.jpg stay valid, and training/test/a.jpg stays train.
    • my_test_set/train/a.jpg and test-project-1/train/a.jpg stay train.
    • val/images/b.jpg still goes to train, and so does val2017/train/a.jpg. I left treating val as a split name for you to decide.

BarsamErshadi and others added 2 commits November 4, 2026 05:51
_decide_split looked for "valid", "train" and "test" anywhere in the
lowercased relative path, so a class folder or filename could override
the split directory. In a classification dataset, test/train/1.jpg was
uploaded to train and train/invalid_ticket.jpg to valid, and
unsplit/latest.jpg went to test because of "latest". Nothing warned about
it, and the split= argument can't repair a mixed dataset.

Take the topmost directory whose whole name is a split (test/,
validation/, train_set/), so validation/train/ stays valid while a wrapper
like test-project-1/train/ stays train. Otherwise fall back to a match at
the start of a word, in the old valid, train, test order, treating a
change of case as a word start, so valid_001.jpg, my_test_set/, myTest/
and VOCtest/ keep working and latest/ and invalid/ stop matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant