From 5c321a5e89e8abdbde0a38acdcb371f48ea1b819 Mon Sep 17 00:00:00 2001 From: Kai Christensen Date: Tue, 27 Oct 2026 11:44:20 -0600 Subject: [PATCH 01/10] Add packaging for custom OWLv2 uploads Co-authored-by: OpenAI Codex --- README.md | 2 + docs/owlv2-upload.md | 54 +++++++++++++++++++++ roboflow/util/model_processor.py | 77 +++++++++++++++++++++++++++++- tests/util/test_model_processor.py | 41 ++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 docs/owlv2-upload.md diff --git a/README.md b/README.md index 1be0577f..98d79be1 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,8 @@ Within the project object, you can retrieve metadata about the project, list ver Within the version object, you can download the dataset version in any model format, train the version on Roboflow, and deploy your own external model to Roboflow. +For custom OWLv2 checkpoints and Auto Label, see [Upload a custom OWLv2 model](docs/owlv2-upload.md). + ## 🏆 Contributing We would love your input on how we can improve the Roboflow Python package! Please see our [contributing guide](https://github.com/roboflow/roboflow-python/blob/main/CONTRIBUTING.md) to get started. Thank you 🙏 to all our contributors! diff --git a/docs/owlv2-upload.md b/docs/owlv2-upload.md new file mode 100644 index 00000000..58782352 --- /dev/null +++ b/docs/owlv2-upload.md @@ -0,0 +1,54 @@ +# Upload a custom OWLv2 model + +Use `model_type="owlv2-custom"` to upload a complete OWLv2 parameter dictionary. +This model uses saved text prompts and returns object detections with fixed labels. +It works through the custom-model Auto Label path and the Object Detection Model Workflow block. + +## Prepare the upload folder + +Save these files beside the checkpoint: + +| File | Content | +| --- | --- | +| `owlv2.pth` | The complete `Owlv2ForObjectDetection.state_dict()`, including the text encoder and detection heads | +| `config.json` | The original model configuration, saved with `model.config.save_pretrained(folder)` | +| Processor and tokenizer files | The original processor, saved with `processor.save_pretrained(folder)` | +| `class_names.txt` | Unique output labels, one per line | +| `owlv2_prompts.json` | Optional JSON array of text prompts, in label order | + +The processor files must include `tokenizer_config.json` and either `tokenizer.json` or both `vocab.json` and `merges.txt`. +Include either `processor_config.json` or `preprocessor_config.json` for image processing. + +If you omit `owlv2_prompts.json`, the model uses each label as its text prompt. +For example, labels `dent` and `scratch` can use prompts `["a dent in a metal panel", "a scratch in a metal panel"]`. +The class IDs follow the order in `class_names.txt`. + +The checkpoint alone does not identify the original preprocessing or text prompts. +Obtain those values from the training or inference code. +The exporter rejects partial checkpoints and adapter-only weights. +The SDK packages files without loading the checkpoint. + +## Upload + +Use an object-detection project that the workspace can access. +Replace the workspace, project, folder, and model name with your values. +Set `ROBOFLOW_API_KEY` in your environment before you run this example. + +```python +from roboflow import Roboflow + +workspace = Roboflow().workspace("your-workspace") +workspace.deploy_model( + model_type="owlv2-custom", + model_path="/absolute/path/to/weights-upload", + filename="owlv2.pth", + project_ids=["your-project"], + model_name="custom-owlv2", +) +``` + +After upload processing finishes, select the uploaded model under custom models in Auto Label. +In Workflows, select its model ID in an Object Detection Model block. +The saved prompts stay fixed for each model package. + +This feature requires the corresponding platform, rflm exporter, and inference runtime releases. diff --git a/roboflow/util/model_processor.py b/roboflow/util/model_processor.py index 48fc85be..a16bb605 100644 --- a/roboflow/util/model_processor.py +++ b/roboflow/util/model_processor.py @@ -68,6 +68,7 @@ "paligemma2", "florence-2", "rfdetr", + "owlv2-custom", ) SUPPORTED_HUGGINGFACE_TYPES = ( @@ -462,7 +463,11 @@ def _process_model( f"It must start with a supported family: {', '.join(SUPPORTED_MODELS)}." ) - if model_type.startswith(("paligemma", "paligemma2", "florence-2")): + if model_type == "owlv2-custom": + return _process_owlv2(model_path, filename, build_dir) + elif model_type.startswith("owlv2-custom"): + raise UnsupportedModelError("Use model_type='owlv2-custom' for OWLv2 checkpoints.") + elif model_type.startswith(("paligemma", "paligemma2", "florence-2")): return _process_huggingface(model_type, model_path, build_dir) if model_type.startswith("yolonas"): return _process_yolonas(model_type, model_path, filename, build_dir) @@ -1125,6 +1130,76 @@ def _process_rfdetr( return archive_path, model_type, warnings +def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Path, str, list[str]]: + required = {"config.json", "tokenizer_config.json", "class_names.txt"} + optional = { + "tokenizer.json", + "vocab.json", + "merges.txt", + "special_tokens_map.json", + "added_tokens.json", + "preprocessor_config.json", + "processor_config.json", + "owlv2_prompts.json", + } + files = {name: _resolve_within_source(model_path, name) for name in required | optional} + missing = sorted(name for name in required if not files[name].is_file()) + if missing: + raise MissingFileError( + f"Missing OWLv2 upload files: {', '.join(missing)}. " + "Export the original model configuration and processor, and provide class_names.txt." + ) + if not files["tokenizer.json"].is_file() and not all( + files[name].is_file() for name in ("vocab.json", "merges.txt") + ): + raise MissingFileError( + "Provide tokenizer.json or both vocab.json and merges.txt from the original OWLv2 processor." + ) + if not any(files[name].is_file() for name in ("preprocessor_config.json", "processor_config.json")): + raise MissingFileError( + "Provide preprocessor_config.json or processor_config.json from the original OWLv2 processor." + ) + try: + config = json.loads(files["config.json"].read_text()) + class_names = files["class_names.txt"].read_text().splitlines() + prompts = ( + json.loads(files["owlv2_prompts.json"].read_text()) + if files["owlv2_prompts.json"].is_file() + else class_names + ) + except (ValueError, UnicodeError) as error: + raise ModelPackagingError(f"Invalid OWLv2 metadata: {error}") from error + if not isinstance(config, dict) or config.get("model_type") != "owlv2": + raise ModelPackagingError("config.json must describe an OWLv2 model (model_type='owlv2').") + if ( + not class_names + or any(not name.strip() or name != name.strip() for name in class_names) + or len(set(class_names)) != len(class_names) + ): + raise ModelPackagingError( + "class_names.txt must contain unique, nonblank labels, one per line, without surrounding spaces." + ) + if ( + not isinstance(prompts, list) + or len(prompts) != len(class_names) + or any(not isinstance(prompt, str) or not prompt.strip() for prompt in prompts) + ): + raise ModelPackagingError( + "owlv2_prompts.json must contain one nonblank text prompt per class, in class_names.txt order." + ) + weights = _resolve_within_source(model_path, filename) + if not weights.is_file(): + raise MissingFileError( + f"OWLv2 weights file '{weights}' was not found. Set filename to the checkpoint's relative path." + ) + archive_path = build_dir / "roboflow_deploy.zip" + _write_zip( + archive_path, + [(weights, "weights.pt", True)] + [(path, name, name in required) for name, path in sorted(files.items())], + ) + return archive_path, "owlv2-custom", [] + + def _process_huggingface( model_type: str, model_path: Path, diff --git a/tests/util/test_model_processor.py b/tests/util/test_model_processor.py index da39b96e..ffbd97c7 100644 --- a/tests/util/test_model_processor.py +++ b/tests/util/test_model_processor.py @@ -728,6 +728,47 @@ def test_rfdetr_multiple_discovered_checkpoints_warns_which_used(self): bundle.cleanup() +class PackageOwlV2Test(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.source = Path(self.directory.name) + for name, contents in { + "config.json": '{"model_type": "owlv2"}', + "processor_config.json": "{}", + "tokenizer_config.json": "{}", + "tokenizer.json": "{}", + "class_names.txt": "dent\nscratch\n", + "owlv2.pth": "checkpoint", + }.items(): + (self.source / name).write_text(contents) + + def test_packages_raw_weights_without_loading_them(self): + with mock.patch.object(model_processor, "import_module", side_effect=AssertionError("No model load")): + bundle = package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + self.addCleanup(bundle.cleanup) + with zipfile.ZipFile(bundle.archive_path) as archive: + self.assertEqual(archive.read("weights.pt"), b"checkpoint") + self.assertEqual(archive.read("class_names.txt"), b"dent\nscratch\n") + self.assertIn("processor_config.json", archive.namelist()) + self.assertFalse((self.source / "weights.pt").exists()) + + def test_rejects_prompt_count_mismatch(self): + (self.source / "owlv2_prompts.json").write_text('["a dent"]') + with self.assertRaisesRegex(ModelPackagingError, "one nonblank text prompt"): + package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + + def test_rejects_duplicate_labels(self): + (self.source / "class_names.txt").write_text("dent\ndent\n") + with self.assertRaisesRegex(ModelPackagingError, "unique"): + package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + + def test_rejects_missing_processor(self): + (self.source / "processor_config.json").unlink() + with self.assertRaisesRegex(MissingFileError, "processor"): + package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + + class ProcessHuggingfaceTest(unittest.TestCase): """Packaging for HuggingFace-backed models (paligemma / florence-2).""" From 07677c9df65ff9cd1ad521829f976edf39007bfb Mon Sep 17 00:00:00 2001 From: Kai Christensen Date: Tue, 27 Oct 2026 12:00:03 -0600 Subject: [PATCH 02/10] Handle Windows newlines in the OWLv2 packaging test Co-authored-by: OpenAI Codex --- tests/util/test_model_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/util/test_model_processor.py b/tests/util/test_model_processor.py index ffbd97c7..5cf8e6f4 100644 --- a/tests/util/test_model_processor.py +++ b/tests/util/test_model_processor.py @@ -749,7 +749,7 @@ def test_packages_raw_weights_without_loading_them(self): self.addCleanup(bundle.cleanup) with zipfile.ZipFile(bundle.archive_path) as archive: self.assertEqual(archive.read("weights.pt"), b"checkpoint") - self.assertEqual(archive.read("class_names.txt"), b"dent\nscratch\n") + self.assertEqual(archive.read("class_names.txt").decode().splitlines(), ["dent", "scratch"]) self.assertIn("processor_config.json", archive.namelist()) self.assertFalse((self.source / "weights.pt").exists()) From da1db129c05f657fdf79dac47a42086ca9255558 Mon Sep 17 00:00:00 2001 From: Kai Christensen Date: Tue, 27 Oct 2026 12:01:31 -0600 Subject: [PATCH 03/10] Clarify OWLv2 label count and configuration requirements Co-authored-by: OpenAI Codex --- docs/owlv2-upload.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/owlv2-upload.md b/docs/owlv2-upload.md index 58782352..e8a9e3a6 100644 --- a/docs/owlv2-upload.md +++ b/docs/owlv2-upload.md @@ -22,9 +22,12 @@ Include either `processor_config.json` or `preprocessor_config.json` for image p If you omit `owlv2_prompts.json`, the model uses each label as its text prompt. For example, labels `dent` and `scratch` can use prompts `["a dent in a metal panel", "a scratch in a metal panel"]`. The class IDs follow the order in `class_names.txt`. +The weights do not impose a fixed class count. +Choose the labels that the model must detect, rather than a count inferred from the checkpoint. The checkpoint alone does not identify the original preprocessing or text prompts. Obtain those values from the training or inference code. +For an unchanged base model and processor, reuse the configuration and processor files from that original model. The exporter rejects partial checkpoints and adapter-only weights. The SDK packages files without loading the checkpoint. From 645da5885de267cc065845cfdd43c316f066cd7e Mon Sep 17 00:00:00 2001 From: Kai Christensen Date: Tue, 27 Oct 2026 15:43:59 -0600 Subject: [PATCH 04/10] Make the standard OWLv2 tokenizer optional on upload Co-authored-by: OpenAI Codex --- docs/owlv2-upload.md | 12 ++++++++++-- roboflow/util/model_processor.py | 17 +++++++++++----- tests/util/test_model_processor.py | 31 ++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/docs/owlv2-upload.md b/docs/owlv2-upload.md index e8a9e3a6..3a80f927 100644 --- a/docs/owlv2-upload.md +++ b/docs/owlv2-upload.md @@ -12,12 +12,20 @@ Save these files beside the checkpoint: | --- | --- | | `owlv2.pth` | The complete `Owlv2ForObjectDetection.state_dict()`, including the text encoder and detection heads | | `config.json` | The original model configuration, saved with `model.config.save_pretrained(folder)` | -| Processor and tokenizer files | The original processor, saved with `processor.save_pretrained(folder)` | +| Image processor files | The original image preprocessing configuration | +| Tokenizer files | Optional complete override of the standard OWLv2 tokenizer | | `class_names.txt` | Unique output labels, one per line | | `owlv2_prompts.json` | Optional JSON array of text prompts, in label order | -The processor files must include `tokenizer_config.json` and either `tokenizer.json` or both `vocab.json` and `merges.txt`. Include either `processor_config.json` or `preprocessor_config.json` for image processing. +If you omit all tokenizer files, the exporter uses a bundled copy of the standard OWLv2 tokenizer. +The copy comes from `google/owlv2-base-patch16-ensemble` at revision `cfd3195ba4ea9592eec887ded089f4c08eff231d`. +Export jobs use this copy offline and save it in the resulting model package. + +If you changed tokenization during training, include `tokenizer_config.json` and either `tokenizer.json` or both `vocab.json` and `merges.txt`. +Include any additional tokenizer files that your original processor saved, such as `added_tokens.json` and `special_tokens_map.json`. +Partial tokenizer overrides produce an error. The exporter does not mix uploaded tokenizer files with the default. +You can save the complete original processor with `processor.save_pretrained(folder)` to supply an override. If you omit `owlv2_prompts.json`, the model uses each label as its text prompt. For example, labels `dent` and `scratch` can use prompts `["a dent in a metal panel", "a scratch in a metal panel"]`. diff --git a/roboflow/util/model_processor.py b/roboflow/util/model_processor.py index a16bb605..f98b0d43 100644 --- a/roboflow/util/model_processor.py +++ b/roboflow/util/model_processor.py @@ -1131,13 +1131,16 @@ def _process_rfdetr( def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Path, str, list[str]]: - required = {"config.json", "tokenizer_config.json", "class_names.txt"} - optional = { + required = {"config.json", "class_names.txt"} + tokenizer_files = { + "tokenizer_config.json", "tokenizer.json", "vocab.json", "merges.txt", "special_tokens_map.json", "added_tokens.json", + } + optional = tokenizer_files | { "preprocessor_config.json", "processor_config.json", "owlv2_prompts.json", @@ -1149,11 +1152,15 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa f"Missing OWLv2 upload files: {', '.join(missing)}. " "Export the original model configuration and processor, and provide class_names.txt." ) - if not files["tokenizer.json"].is_file() and not all( - files[name].is_file() for name in ("vocab.json", "merges.txt") + if any(files[name].is_file() for name in tokenizer_files) and ( + not files["tokenizer_config.json"].is_file() + or not ( + files["tokenizer.json"].is_file() or all(files[name].is_file() for name in ("vocab.json", "merges.txt")) + ) ): raise MissingFileError( - "Provide tokenizer.json or both vocab.json and merges.txt from the original OWLv2 processor." + "Provide a complete tokenizer override: tokenizer_config.json and either tokenizer.json " + "or both vocab.json and merges.txt. Omit all tokenizer files to use the standard OWLv2 tokenizer." ) if not any(files[name].is_file() for name in ("preprocessor_config.json", "processor_config.json")): raise MissingFileError( diff --git a/tests/util/test_model_processor.py b/tests/util/test_model_processor.py index 5cf8e6f4..a223f38c 100644 --- a/tests/util/test_model_processor.py +++ b/tests/util/test_model_processor.py @@ -758,6 +758,37 @@ def test_rejects_prompt_count_mismatch(self): with self.assertRaisesRegex(ModelPackagingError, "one nonblank text prompt"): package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + def test_omits_tokenizer_for_server_default(self): + (self.source / "tokenizer_config.json").unlink() + (self.source / "tokenizer.json").unlink() + bundle = package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + self.addCleanup(bundle.cleanup) + with zipfile.ZipFile(bundle.archive_path) as archive: + self.assertNotIn("tokenizer_config.json", archive.namelist()) + self.assertNotIn("tokenizer.json", archive.namelist()) + self.assertIn("processor_config.json", archive.namelist()) + + def test_rejects_partial_tokenizer_overrides(self): + (self.source / "tokenizer_config.json").unlink() + (self.source / "tokenizer.json").unlink() + for name in ("tokenizer_config.json", "tokenizer.json", "special_tokens_map.json", "vocab.json"): + with self.subTest(name=name): + (self.source / name).write_text("{}") + with self.assertRaisesRegex(MissingFileError, "complete tokenizer override"): + package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + (self.source / name).unlink() + + def test_packages_legacy_tokenizer_override(self): + (self.source / "tokenizer.json").unlink() + (self.source / "vocab.json").write_text("{}") + (self.source / "merges.txt").write_text("#version: 0.2\n") + bundle = package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + self.addCleanup(bundle.cleanup) + with zipfile.ZipFile(bundle.archive_path) as archive: + self.assertIn("tokenizer_config.json", archive.namelist()) + self.assertIn("vocab.json", archive.namelist()) + self.assertIn("merges.txt", archive.namelist()) + def test_rejects_duplicate_labels(self): (self.source / "class_names.txt").write_text("dent\ndent\n") with self.assertRaisesRegex(ModelPackagingError, "unique"): From 8ce83c8637a027c2f40af609e5221f9410ebec25 Mon Sep 17 00:00:00 2001 From: Kai Christensen Date: Tue, 27 Oct 2026 16:09:57 -0600 Subject: [PATCH 05/10] Default OWLv2 uploads to weights and class names Co-authored-by: OpenAI Codex --- docs/owlv2-upload.md | 75 ++++++++++++++++-------------- roboflow/util/model_processor.py | 14 ++---- tests/util/test_model_processor.py | 20 ++++++-- 3 files changed, 62 insertions(+), 47 deletions(-) diff --git a/docs/owlv2-upload.md b/docs/owlv2-upload.md index 3a80f927..ac444c20 100644 --- a/docs/owlv2-upload.md +++ b/docs/owlv2-upload.md @@ -1,43 +1,23 @@ # Upload a custom OWLv2 model -Use `model_type="owlv2-custom"` to upload a complete OWLv2 parameter dictionary. -This model uses saved text prompts and returns object detections with fixed labels. -It works through the custom-model Auto Label path and the Object Detection Model Workflow block. +Use `model_type="owlv2-custom"` to upload fine-tuned OWLv2 weights for Auto Label and Workflows. +For a standard OWLv2 model, the upload folder needs only two files: -## Prepare the upload folder +```text +weights-upload/ + owlv2.pth + class_names.txt +``` -Save these files beside the checkpoint: +`owlv2.pth` must contain the complete `Owlv2ForObjectDetection.state_dict()`, including the text encoder and detection heads. +A training checkpoint that wraps the weights under another key is not accepted. +`class_names.txt` contains unique, nonblank output labels, one per line. Class IDs follow this order. +The labels also become the text prompts. The weights do not impose a fixed class count. -| File | Content | -| --- | --- | -| `owlv2.pth` | The complete `Owlv2ForObjectDetection.state_dict()`, including the text encoder and detection heads | -| `config.json` | The original model configuration, saved with `model.config.save_pretrained(folder)` | -| Image processor files | The original image preprocessing configuration | -| Tokenizer files | Optional complete override of the standard OWLv2 tokenizer | -| `class_names.txt` | Unique output labels, one per line | -| `owlv2_prompts.json` | Optional JSON array of text prompts, in label order | - -Include either `processor_config.json` or `preprocessor_config.json` for image processing. -If you omit all tokenizer files, the exporter uses a bundled copy of the standard OWLv2 tokenizer. -The copy comes from `google/owlv2-base-patch16-ensemble` at revision `cfd3195ba4ea9592eec887ded089f4c08eff231d`. -Export jobs use this copy offline and save it in the resulting model package. - -If you changed tokenization during training, include `tokenizer_config.json` and either `tokenizer.json` or both `vocab.json` and `merges.txt`. -Include any additional tokenizer files that your original processor saved, such as `added_tokens.json` and `special_tokens_map.json`. -Partial tokenizer overrides produce an error. The exporter does not mix uploaded tokenizer files with the default. -You can save the complete original processor with `processor.save_pretrained(folder)` to supply an override. - -If you omit `owlv2_prompts.json`, the model uses each label as its text prompt. -For example, labels `dent` and `scratch` can use prompts `["a dent in a metal panel", "a scratch in a metal panel"]`. -The class IDs follow the order in `class_names.txt`. -The weights do not impose a fixed class count. -Choose the labels that the model must detect, rather than a count inferred from the checkpoint. - -The checkpoint alone does not identify the original preprocessing or text prompts. -Obtain those values from the training or inference code. -For an unchanged base model and processor, reuse the configuration and processor files from that original model. -The exporter rejects partial checkpoints and adapter-only weights. -The SDK packages files without loading the checkpoint. +The exporter assumes the standard `google/owlv2-base-patch16-ensemble` model configuration, image preprocessing, and tokenizer. +It bundles these defaults at revision `cfd3195ba4ea9592eec887ded089f4c08eff231d` and uses them offline. +Fine-tuning the weights does not require additional files if those components stay unchanged. +The exporter saves the resolved configuration and processor in the inference package. ## Upload @@ -62,4 +42,29 @@ After upload processing finishes, select the uploaded model under custom models In Workflows, select its model ID in an Object Detection Model block. The saved prompts stay fixed for each model package. +## Custom model or preprocessing + +If training changed a component, place its override files beside the weights. +Each supplied component takes precedence over its default. + +| Optional files | When to supply them | +| --- | --- | +| `config.json` | A different model architecture, saved with `model.config.save_pretrained(folder)` | +| `processor_config.json` or `preprocessor_config.json` | Different image preprocessing, such as resizing or normalization | +| Tokenizer files | Different tokenization, saved with the original tokenizer or processor | +| `owlv2_prompts.json` | Text prompts that differ from the output labels | + +A tokenizer override needs `tokenizer_config.json` and either `tokenizer.json` or both `vocab.json` and `merges.txt`. +Include additional tokenizer files that the original processor saved, such as `added_tokens.json` and `special_tokens_map.json`. +Partial tokenizer overrides produce an error. They are never mixed with the default tokenizer. +You can save a complete processor override with `processor.save_pretrained(folder)`. + +`owlv2_prompts.json` is a JSON array with one nonblank prompt per label, in label order. +For example, labels `dent` and `scratch` can use `["a dent in a metal panel", "a scratch in a metal panel"]`. + +The exporter strictly matches the weights to the selected model configuration and rejects partial or adapter-only checkpoints. +If the weights do not match the default architecture, supply the original `config.json`. +Weight shapes cannot identify custom resizing or normalization. Omitted image preprocessing files mean standard preprocessing. +The SDK packages files without loading the checkpoint. + This feature requires the corresponding platform, rflm exporter, and inference runtime releases. diff --git a/roboflow/util/model_processor.py b/roboflow/util/model_processor.py index f98b0d43..39d5d344 100644 --- a/roboflow/util/model_processor.py +++ b/roboflow/util/model_processor.py @@ -1131,7 +1131,7 @@ def _process_rfdetr( def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Path, str, list[str]]: - required = {"config.json", "class_names.txt"} + required = {"class_names.txt"} tokenizer_files = { "tokenizer_config.json", "tokenizer.json", @@ -1141,6 +1141,7 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa "added_tokens.json", } optional = tokenizer_files | { + "config.json", "preprocessor_config.json", "processor_config.json", "owlv2_prompts.json", @@ -1149,8 +1150,7 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa missing = sorted(name for name in required if not files[name].is_file()) if missing: raise MissingFileError( - f"Missing OWLv2 upload files: {', '.join(missing)}. " - "Export the original model configuration and processor, and provide class_names.txt." + f"Missing OWLv2 upload files: {', '.join(missing)}. Provide class_names.txt with one output label per line." ) if any(files[name].is_file() for name in tokenizer_files) and ( not files["tokenizer_config.json"].is_file() @@ -1162,12 +1162,8 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa "Provide a complete tokenizer override: tokenizer_config.json and either tokenizer.json " "or both vocab.json and merges.txt. Omit all tokenizer files to use the standard OWLv2 tokenizer." ) - if not any(files[name].is_file() for name in ("preprocessor_config.json", "processor_config.json")): - raise MissingFileError( - "Provide preprocessor_config.json or processor_config.json from the original OWLv2 processor." - ) try: - config = json.loads(files["config.json"].read_text()) + config = json.loads(files["config.json"].read_text()) if files["config.json"].is_file() else None class_names = files["class_names.txt"].read_text().splitlines() prompts = ( json.loads(files["owlv2_prompts.json"].read_text()) @@ -1176,7 +1172,7 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa ) except (ValueError, UnicodeError) as error: raise ModelPackagingError(f"Invalid OWLv2 metadata: {error}") from error - if not isinstance(config, dict) or config.get("model_type") != "owlv2": + if files["config.json"].is_file() and (not isinstance(config, dict) or config.get("model_type") != "owlv2"): raise ModelPackagingError("config.json must describe an OWLv2 model (model_type='owlv2').") if ( not class_names diff --git a/tests/util/test_model_processor.py b/tests/util/test_model_processor.py index a223f38c..83b0054a 100644 --- a/tests/util/test_model_processor.py +++ b/tests/util/test_model_processor.py @@ -794,9 +794,23 @@ def test_rejects_duplicate_labels(self): with self.assertRaisesRegex(ModelPackagingError, "unique"): package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") - def test_rejects_missing_processor(self): - (self.source / "processor_config.json").unlink() - with self.assertRaisesRegex(MissingFileError, "processor"): + def test_packages_only_weights_and_class_names(self): + for path in self.source.iterdir(): + if path.name not in {"owlv2.pth", "class_names.txt"}: + path.unlink() + bundle = package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + self.addCleanup(bundle.cleanup) + with zipfile.ZipFile(bundle.archive_path) as archive: + self.assertEqual(set(archive.namelist()), {"weights.pt", "class_names.txt"}) + + def test_rejects_invalid_model_configuration_override(self): + (self.source / "config.json").write_text('{"model_type": "owlvit"}') + with self.assertRaisesRegex(ModelPackagingError, "config.json must describe an OWLv2 model"): + package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + + def test_rejects_missing_class_names(self): + (self.source / "class_names.txt").unlink() + with self.assertRaisesRegex(MissingFileError, "class_names.txt"): package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") From 09d5cf88366ef801c0c9f2df22a49b5b0d1ca04a Mon Sep 17 00:00:00 2001 From: Kai Christensen Date: Wed, 28 Oct 2026 19:39:51 -0600 Subject: [PATCH 06/10] Reuse the owlv2 model type for checkpoint uploads Co-authored-by: OpenAI Codex --- docs/owlv2-upload.md | 5 +++-- roboflow/util/model_processor.py | 10 +++++----- tests/util/test_model_processor.py | 18 +++++++++--------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/docs/owlv2-upload.md b/docs/owlv2-upload.md index ac444c20..75ef28a2 100644 --- a/docs/owlv2-upload.md +++ b/docs/owlv2-upload.md @@ -1,6 +1,6 @@ # Upload a custom OWLv2 model -Use `model_type="owlv2-custom"` to upload fine-tuned OWLv2 weights for Auto Label and Workflows. +Use `model_type="owlv2"` to upload fine-tuned OWLv2 weights for Auto Label and Workflows. For a standard OWLv2 model, the upload folder needs only two files: ```text @@ -30,7 +30,7 @@ from roboflow import Roboflow workspace = Roboflow().workspace("your-workspace") workspace.deploy_model( - model_type="owlv2-custom", + model_type="owlv2", model_path="/absolute/path/to/weights-upload", filename="owlv2.pth", project_ids=["your-project"], @@ -41,6 +41,7 @@ workspace.deploy_model( After upload processing finishes, select the uploaded model under custom models in Auto Label. In Workflows, select its model ID in an Object Detection Model block. The saved prompts stay fixed for each model package. +Inference reuses the existing OWLv2 implementation. Roboflow Instant embedding uploads keep their separate format and route. ## Custom model or preprocessing diff --git a/roboflow/util/model_processor.py b/roboflow/util/model_processor.py index 39d5d344..83ffadbb 100644 --- a/roboflow/util/model_processor.py +++ b/roboflow/util/model_processor.py @@ -68,7 +68,7 @@ "paligemma2", "florence-2", "rfdetr", - "owlv2-custom", + "owlv2", ) SUPPORTED_HUGGINGFACE_TYPES = ( @@ -463,10 +463,10 @@ def _process_model( f"It must start with a supported family: {', '.join(SUPPORTED_MODELS)}." ) - if model_type == "owlv2-custom": + if model_type == "owlv2": return _process_owlv2(model_path, filename, build_dir) - elif model_type.startswith("owlv2-custom"): - raise UnsupportedModelError("Use model_type='owlv2-custom' for OWLv2 checkpoints.") + elif model_type.startswith("owlv2"): + raise UnsupportedModelError("Use model_type='owlv2' for OWLv2 checkpoints.") elif model_type.startswith(("paligemma", "paligemma2", "florence-2")): return _process_huggingface(model_type, model_path, build_dir) if model_type.startswith("yolonas"): @@ -1200,7 +1200,7 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa archive_path, [(weights, "weights.pt", True)] + [(path, name, name in required) for name, path in sorted(files.items())], ) - return archive_path, "owlv2-custom", [] + return archive_path, "owlv2", [] def _process_huggingface( diff --git a/tests/util/test_model_processor.py b/tests/util/test_model_processor.py index 83b0054a..e2b39ded 100644 --- a/tests/util/test_model_processor.py +++ b/tests/util/test_model_processor.py @@ -745,7 +745,7 @@ def setUp(self): def test_packages_raw_weights_without_loading_them(self): with mock.patch.object(model_processor, "import_module", side_effect=AssertionError("No model load")): - bundle = package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + bundle = package_custom_weights("owlv2", str(self.source), "owlv2.pth") self.addCleanup(bundle.cleanup) with zipfile.ZipFile(bundle.archive_path) as archive: self.assertEqual(archive.read("weights.pt"), b"checkpoint") @@ -756,12 +756,12 @@ def test_packages_raw_weights_without_loading_them(self): def test_rejects_prompt_count_mismatch(self): (self.source / "owlv2_prompts.json").write_text('["a dent"]') with self.assertRaisesRegex(ModelPackagingError, "one nonblank text prompt"): - package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + package_custom_weights("owlv2", str(self.source), "owlv2.pth") def test_omits_tokenizer_for_server_default(self): (self.source / "tokenizer_config.json").unlink() (self.source / "tokenizer.json").unlink() - bundle = package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + bundle = package_custom_weights("owlv2", str(self.source), "owlv2.pth") self.addCleanup(bundle.cleanup) with zipfile.ZipFile(bundle.archive_path) as archive: self.assertNotIn("tokenizer_config.json", archive.namelist()) @@ -775,14 +775,14 @@ def test_rejects_partial_tokenizer_overrides(self): with self.subTest(name=name): (self.source / name).write_text("{}") with self.assertRaisesRegex(MissingFileError, "complete tokenizer override"): - package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + package_custom_weights("owlv2", str(self.source), "owlv2.pth") (self.source / name).unlink() def test_packages_legacy_tokenizer_override(self): (self.source / "tokenizer.json").unlink() (self.source / "vocab.json").write_text("{}") (self.source / "merges.txt").write_text("#version: 0.2\n") - bundle = package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + bundle = package_custom_weights("owlv2", str(self.source), "owlv2.pth") self.addCleanup(bundle.cleanup) with zipfile.ZipFile(bundle.archive_path) as archive: self.assertIn("tokenizer_config.json", archive.namelist()) @@ -792,13 +792,13 @@ def test_packages_legacy_tokenizer_override(self): def test_rejects_duplicate_labels(self): (self.source / "class_names.txt").write_text("dent\ndent\n") with self.assertRaisesRegex(ModelPackagingError, "unique"): - package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + package_custom_weights("owlv2", str(self.source), "owlv2.pth") def test_packages_only_weights_and_class_names(self): for path in self.source.iterdir(): if path.name not in {"owlv2.pth", "class_names.txt"}: path.unlink() - bundle = package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + bundle = package_custom_weights("owlv2", str(self.source), "owlv2.pth") self.addCleanup(bundle.cleanup) with zipfile.ZipFile(bundle.archive_path) as archive: self.assertEqual(set(archive.namelist()), {"weights.pt", "class_names.txt"}) @@ -806,12 +806,12 @@ def test_packages_only_weights_and_class_names(self): def test_rejects_invalid_model_configuration_override(self): (self.source / "config.json").write_text('{"model_type": "owlvit"}') with self.assertRaisesRegex(ModelPackagingError, "config.json must describe an OWLv2 model"): - package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + package_custom_weights("owlv2", str(self.source), "owlv2.pth") def test_rejects_missing_class_names(self): (self.source / "class_names.txt").unlink() with self.assertRaisesRegex(MissingFileError, "class_names.txt"): - package_custom_weights("owlv2-custom", str(self.source), "owlv2.pth") + package_custom_weights("owlv2", str(self.source), "owlv2.pth") class ProcessHuggingfaceTest(unittest.TestCase): From 733d3d37d899772bac44688a96efca42471952cd Mon Sep 17 00:00:00 2001 From: Kai Christensen Date: Wed, 28 Oct 2026 21:18:10 -0600 Subject: [PATCH 07/10] Remove internal release coordination from OWLv2 upload docs Co-authored-by: OpenAI Codex --- docs/owlv2-upload.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/owlv2-upload.md b/docs/owlv2-upload.md index 75ef28a2..d51988a6 100644 --- a/docs/owlv2-upload.md +++ b/docs/owlv2-upload.md @@ -67,5 +67,3 @@ The exporter strictly matches the weights to the selected model configuration an If the weights do not match the default architecture, supply the original `config.json`. Weight shapes cannot identify custom resizing or normalization. Omitted image preprocessing files mean standard preprocessing. The SDK packages files without loading the checkpoint. - -This feature requires the corresponding platform, rflm exporter, and inference runtime releases. From 45e7d7a9e0c21cabec33d509197e4cb79f8dfb0d Mon Sep 17 00:00:00 2001 From: Kai Christensen <80430764+mkaic@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:10:02 -0500 Subject: [PATCH 08/10] Allow OWLv2 uploads without default labels Co-authored-by: OpenAI Codex --- docs/owlv2-upload.md | 26 ++++++++++++++++++-------- roboflow/util/model_processor.py | 19 ++++++++----------- tests/util/test_model_processor.py | 12 +++++++++++- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/docs/owlv2-upload.md b/docs/owlv2-upload.md index d51988a6..b84cbe8d 100644 --- a/docs/owlv2-upload.md +++ b/docs/owlv2-upload.md @@ -1,18 +1,17 @@ # Upload a custom OWLv2 model Use `model_type="owlv2"` to upload fine-tuned OWLv2 weights for Auto Label and Workflows. -For a standard OWLv2 model, the upload folder needs only two files: +For a standard OWLv2 model, the only required file is the checkpoint: ```text weights-upload/ owlv2.pth - class_names.txt ``` `owlv2.pth` must contain the complete `Owlv2ForObjectDetection.state_dict()`, including the text encoder and detection heads. A training checkpoint that wraps the weights under another key is not accepted. -`class_names.txt` contains unique, nonblank output labels, one per line. Class IDs follow this order. -The labels also become the text prompts. The weights do not impose a fixed class count. +To use image-only requests in Auto Label or the Workflows Object Detection Model block, add `class_names.txt` beside the checkpoint. +It contains unique, nonblank output labels, one per line. Class IDs follow this order. By default, these labels are also the text queries. The exporter assumes the standard `google/owlv2-base-patch16-ensemble` model configuration, image preprocessing, and tokenizer. It bundles these defaults at revision `cfd3195ba4ea9592eec887ded089f4c08eff231d` and uses them offline. @@ -38,10 +37,21 @@ workspace.deploy_model( ) ``` -After upload processing finishes, select the uploaded model under custom models in Auto Label. -In Workflows, select its model ID in an Object Detection Model block. -The saved prompts stay fixed for each model package. -Inference reuses the existing OWLv2 implementation. Roboflow Instant embedding uploads keep their separate format and route. +## Run local inference + +On a local server that supports `/v2/models/infer`, send your model ID, image, and text queries. +Replace the model ID and image path below. The example uses the same API key as the upload. + +```bash +curl 'http://localhost:9001/v2/models/infer?model_id=your-workspace/custom-owlv2' \ + -H "Authorization: Bearer $ROBOFLOW_API_KEY" \ + -F 'image=@field.jpg' \ + -F 'inputs={"classes":["maize tassel","weed"]}' +``` + +Each class ID indexes the query list for that request. Explicit queries override any saved prompts for that call. +If you included `class_names.txt`, omit the `inputs` field to use the saved labels, or select this model in Auto Label. +If you uploaded only the weights, supply queries on every request. ## Custom model or preprocessing diff --git a/roboflow/util/model_processor.py b/roboflow/util/model_processor.py index 83ffadbb..cbee0e7a 100644 --- a/roboflow/util/model_processor.py +++ b/roboflow/util/model_processor.py @@ -1131,7 +1131,6 @@ def _process_rfdetr( def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Path, str, list[str]]: - required = {"class_names.txt"} tokenizer_files = { "tokenizer_config.json", "tokenizer.json", @@ -1141,17 +1140,15 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa "added_tokens.json", } optional = tokenizer_files | { + "class_names.txt", "config.json", "preprocessor_config.json", "processor_config.json", "owlv2_prompts.json", } - files = {name: _resolve_within_source(model_path, name) for name in required | optional} - missing = sorted(name for name in required if not files[name].is_file()) - if missing: - raise MissingFileError( - f"Missing OWLv2 upload files: {', '.join(missing)}. Provide class_names.txt with one output label per line." - ) + files = {name: _resolve_within_source(model_path, name) for name in optional} + if files["owlv2_prompts.json"].is_file() and not files["class_names.txt"].is_file(): + raise MissingFileError("owlv2_prompts.json requires class_names.txt to name the output labels.") if any(files[name].is_file() for name in tokenizer_files) and ( not files["tokenizer_config.json"].is_file() or not ( @@ -1164,7 +1161,7 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa ) try: config = json.loads(files["config.json"].read_text()) if files["config.json"].is_file() else None - class_names = files["class_names.txt"].read_text().splitlines() + class_names = files["class_names.txt"].read_text().splitlines() if files["class_names.txt"].is_file() else None prompts = ( json.loads(files["owlv2_prompts.json"].read_text()) if files["owlv2_prompts.json"].is_file() @@ -1174,7 +1171,7 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa raise ModelPackagingError(f"Invalid OWLv2 metadata: {error}") from error if files["config.json"].is_file() and (not isinstance(config, dict) or config.get("model_type") != "owlv2"): raise ModelPackagingError("config.json must describe an OWLv2 model (model_type='owlv2').") - if ( + if class_names is not None and ( not class_names or any(not name.strip() or name != name.strip() for name in class_names) or len(set(class_names)) != len(class_names) @@ -1182,7 +1179,7 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa raise ModelPackagingError( "class_names.txt must contain unique, nonblank labels, one per line, without surrounding spaces." ) - if ( + if class_names is not None and ( not isinstance(prompts, list) or len(prompts) != len(class_names) or any(not isinstance(prompt, str) or not prompt.strip() for prompt in prompts) @@ -1198,7 +1195,7 @@ def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Pa archive_path = build_dir / "roboflow_deploy.zip" _write_zip( archive_path, - [(weights, "weights.pt", True)] + [(path, name, name in required) for name, path in sorted(files.items())], + [(weights, "weights.pt", True)] + [(path, name, False) for name, path in sorted(files.items())], ) return archive_path, "owlv2", [] diff --git a/tests/util/test_model_processor.py b/tests/util/test_model_processor.py index e2b39ded..daf0c4b5 100644 --- a/tests/util/test_model_processor.py +++ b/tests/util/test_model_processor.py @@ -808,8 +808,18 @@ def test_rejects_invalid_model_configuration_override(self): with self.assertRaisesRegex(ModelPackagingError, "config.json must describe an OWLv2 model"): package_custom_weights("owlv2", str(self.source), "owlv2.pth") - def test_rejects_missing_class_names(self): + def test_packages_weights_alone_for_explicit_queries(self): + for path in self.source.iterdir(): + if path.name != "owlv2.pth": + path.unlink() + bundle = package_custom_weights("owlv2", str(self.source), "owlv2.pth") + self.addCleanup(bundle.cleanup) + with zipfile.ZipFile(bundle.archive_path) as archive: + self.assertEqual(archive.namelist(), ["weights.pt"]) + + def test_prompt_override_requires_output_labels(self): (self.source / "class_names.txt").unlink() + (self.source / "owlv2_prompts.json").write_text('["a dent"]') with self.assertRaisesRegex(MissingFileError, "class_names.txt"): package_custom_weights("owlv2", str(self.source), "owlv2.pth") From cf731352631494118f4854c44dda977c855c535f Mon Sep 17 00:00:00 2001 From: Kai Christensen Date: Mon, 9 Nov 2026 10:51:56 -0700 Subject: [PATCH 09/10] Package OWLv2 files without client-side validation The export job validates OWLv2 metadata, because uploads can bypass the SDK. Co-Authored-By: Claude Opus 5.5 --- docs/owlv2-upload.md | 1 + roboflow/util/model_processor.py | 57 +++++------------------------- tests/util/test_model_processor.py | 52 --------------------------- 3 files changed, 9 insertions(+), 101 deletions(-) diff --git a/docs/owlv2-upload.md b/docs/owlv2-upload.md index b84cbe8d..c02426ea 100644 --- a/docs/owlv2-upload.md +++ b/docs/owlv2-upload.md @@ -12,6 +12,7 @@ weights-upload/ A training checkpoint that wraps the weights under another key is not accepted. To use image-only requests in Auto Label or the Workflows Object Detection Model block, add `class_names.txt` beside the checkpoint. It contains unique, nonblank output labels, one per line. Class IDs follow this order. By default, these labels are also the text queries. +If you upload without `class_names.txt`, use `workspace.deploy_model`. An upload to a dataset version requires the labels file. The exporter assumes the standard `google/owlv2-base-patch16-ensemble` model configuration, image preprocessing, and tokenizer. It bundles these defaults at revision `cfd3195ba4ea9592eec887ded089f4c08eff231d` and uses them offline. diff --git a/roboflow/util/model_processor.py b/roboflow/util/model_processor.py index cbee0e7a..384e1634 100644 --- a/roboflow/util/model_processor.py +++ b/roboflow/util/model_processor.py @@ -1131,62 +1131,21 @@ def _process_rfdetr( def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Path, str, list[str]]: - tokenizer_files = { - "tokenizer_config.json", - "tokenizer.json", - "vocab.json", - "merges.txt", - "special_tokens_map.json", + # The export job validates these files, because uploads can bypass the SDK. + optional = { "added_tokens.json", - } - optional = tokenizer_files | { "class_names.txt", "config.json", + "merges.txt", + "owlv2_prompts.json", "preprocessor_config.json", "processor_config.json", - "owlv2_prompts.json", + "special_tokens_map.json", + "tokenizer.json", + "tokenizer_config.json", + "vocab.json", } files = {name: _resolve_within_source(model_path, name) for name in optional} - if files["owlv2_prompts.json"].is_file() and not files["class_names.txt"].is_file(): - raise MissingFileError("owlv2_prompts.json requires class_names.txt to name the output labels.") - if any(files[name].is_file() for name in tokenizer_files) and ( - not files["tokenizer_config.json"].is_file() - or not ( - files["tokenizer.json"].is_file() or all(files[name].is_file() for name in ("vocab.json", "merges.txt")) - ) - ): - raise MissingFileError( - "Provide a complete tokenizer override: tokenizer_config.json and either tokenizer.json " - "or both vocab.json and merges.txt. Omit all tokenizer files to use the standard OWLv2 tokenizer." - ) - try: - config = json.loads(files["config.json"].read_text()) if files["config.json"].is_file() else None - class_names = files["class_names.txt"].read_text().splitlines() if files["class_names.txt"].is_file() else None - prompts = ( - json.loads(files["owlv2_prompts.json"].read_text()) - if files["owlv2_prompts.json"].is_file() - else class_names - ) - except (ValueError, UnicodeError) as error: - raise ModelPackagingError(f"Invalid OWLv2 metadata: {error}") from error - if files["config.json"].is_file() and (not isinstance(config, dict) or config.get("model_type") != "owlv2"): - raise ModelPackagingError("config.json must describe an OWLv2 model (model_type='owlv2').") - if class_names is not None and ( - not class_names - or any(not name.strip() or name != name.strip() for name in class_names) - or len(set(class_names)) != len(class_names) - ): - raise ModelPackagingError( - "class_names.txt must contain unique, nonblank labels, one per line, without surrounding spaces." - ) - if class_names is not None and ( - not isinstance(prompts, list) - or len(prompts) != len(class_names) - or any(not isinstance(prompt, str) or not prompt.strip() for prompt in prompts) - ): - raise ModelPackagingError( - "owlv2_prompts.json must contain one nonblank text prompt per class, in class_names.txt order." - ) weights = _resolve_within_source(model_path, filename) if not weights.is_file(): raise MissingFileError( diff --git a/tests/util/test_model_processor.py b/tests/util/test_model_processor.py index daf0c4b5..461c5684 100644 --- a/tests/util/test_model_processor.py +++ b/tests/util/test_model_processor.py @@ -753,47 +753,6 @@ def test_packages_raw_weights_without_loading_them(self): self.assertIn("processor_config.json", archive.namelist()) self.assertFalse((self.source / "weights.pt").exists()) - def test_rejects_prompt_count_mismatch(self): - (self.source / "owlv2_prompts.json").write_text('["a dent"]') - with self.assertRaisesRegex(ModelPackagingError, "one nonblank text prompt"): - package_custom_weights("owlv2", str(self.source), "owlv2.pth") - - def test_omits_tokenizer_for_server_default(self): - (self.source / "tokenizer_config.json").unlink() - (self.source / "tokenizer.json").unlink() - bundle = package_custom_weights("owlv2", str(self.source), "owlv2.pth") - self.addCleanup(bundle.cleanup) - with zipfile.ZipFile(bundle.archive_path) as archive: - self.assertNotIn("tokenizer_config.json", archive.namelist()) - self.assertNotIn("tokenizer.json", archive.namelist()) - self.assertIn("processor_config.json", archive.namelist()) - - def test_rejects_partial_tokenizer_overrides(self): - (self.source / "tokenizer_config.json").unlink() - (self.source / "tokenizer.json").unlink() - for name in ("tokenizer_config.json", "tokenizer.json", "special_tokens_map.json", "vocab.json"): - with self.subTest(name=name): - (self.source / name).write_text("{}") - with self.assertRaisesRegex(MissingFileError, "complete tokenizer override"): - package_custom_weights("owlv2", str(self.source), "owlv2.pth") - (self.source / name).unlink() - - def test_packages_legacy_tokenizer_override(self): - (self.source / "tokenizer.json").unlink() - (self.source / "vocab.json").write_text("{}") - (self.source / "merges.txt").write_text("#version: 0.2\n") - bundle = package_custom_weights("owlv2", str(self.source), "owlv2.pth") - self.addCleanup(bundle.cleanup) - with zipfile.ZipFile(bundle.archive_path) as archive: - self.assertIn("tokenizer_config.json", archive.namelist()) - self.assertIn("vocab.json", archive.namelist()) - self.assertIn("merges.txt", archive.namelist()) - - def test_rejects_duplicate_labels(self): - (self.source / "class_names.txt").write_text("dent\ndent\n") - with self.assertRaisesRegex(ModelPackagingError, "unique"): - package_custom_weights("owlv2", str(self.source), "owlv2.pth") - def test_packages_only_weights_and_class_names(self): for path in self.source.iterdir(): if path.name not in {"owlv2.pth", "class_names.txt"}: @@ -803,11 +762,6 @@ def test_packages_only_weights_and_class_names(self): with zipfile.ZipFile(bundle.archive_path) as archive: self.assertEqual(set(archive.namelist()), {"weights.pt", "class_names.txt"}) - def test_rejects_invalid_model_configuration_override(self): - (self.source / "config.json").write_text('{"model_type": "owlvit"}') - with self.assertRaisesRegex(ModelPackagingError, "config.json must describe an OWLv2 model"): - package_custom_weights("owlv2", str(self.source), "owlv2.pth") - def test_packages_weights_alone_for_explicit_queries(self): for path in self.source.iterdir(): if path.name != "owlv2.pth": @@ -817,12 +771,6 @@ def test_packages_weights_alone_for_explicit_queries(self): with zipfile.ZipFile(bundle.archive_path) as archive: self.assertEqual(archive.namelist(), ["weights.pt"]) - def test_prompt_override_requires_output_labels(self): - (self.source / "class_names.txt").unlink() - (self.source / "owlv2_prompts.json").write_text('["a dent"]') - with self.assertRaisesRegex(MissingFileError, "class_names.txt"): - package_custom_weights("owlv2", str(self.source), "owlv2.pth") - class ProcessHuggingfaceTest(unittest.TestCase): """Packaging for HuggingFace-backed models (paligemma / florence-2).""" From d61cebde4b89d78bf77148e12ccf854011197e09 Mon Sep 17 00:00:00 2001 From: Kai Christensen Date: Mon, 9 Nov 2026 13:47:24 -0700 Subject: [PATCH 10/10] Reject inconsistent OWLv2 file sets before the upload The export job stays the authority on OWLv2 uploads. But it reports a failure only on the model document, which the SDK does not poll. A doomed upload therefore looked successful after a multi-gigabyte transfer. The SDK now repeats the file-set rules only: prompts need labels, a tokenizer override must be complete, and an upload to a dataset version needs class_names.txt. File contents are still validated by the export job alone. Co-Authored-By: Claude --- docs/owlv2-upload.md | 3 ++- roboflow/core/version.py | 12 +++++++++++- roboflow/util/model_processor.py | 29 ++++++++++++++++++++++------- tests/test_version.py | 12 ++++++++++++ tests/util/test_model_processor.py | 25 +++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 9 deletions(-) diff --git a/docs/owlv2-upload.md b/docs/owlv2-upload.md index c02426ea..2f0195a7 100644 --- a/docs/owlv2-upload.md +++ b/docs/owlv2-upload.md @@ -77,4 +77,5 @@ For example, labels `dent` and `scratch` can use `["a dent in a metal panel", "a The exporter strictly matches the weights to the selected model configuration and rejects partial or adapter-only checkpoints. If the weights do not match the default architecture, supply the original `config.json`. Weight shapes cannot identify custom resizing or normalization. Omitted image preprocessing files mean standard preprocessing. -The SDK packages files without loading the checkpoint. +The SDK packages files without loading the checkpoint. Before the upload, it checks only that the set of files is consistent. +The exporter validates the contents of the files. diff --git a/roboflow/core/version.py b/roboflow/core/version.py index e78c5b2f..c82827ef 100644 --- a/roboflow/core/version.py +++ b/roboflow/core/version.py @@ -6,6 +6,7 @@ import sys import time import warnings +from pathlib import Path from typing import TYPE_CHECKING, Optional, Union from dotenv import load_dotenv @@ -36,7 +37,11 @@ from roboflow.util import http from roboflow.util.annotations import amend_data_yaml from roboflow.util.general import extract_zip, write_line -from roboflow.util.model_processor import package_custom_weights_interactive, validate_model_type_for_project +from roboflow.util.model_processor import ( + MissingFileError, + package_custom_weights_interactive, + validate_model_type_for_project, +) from roboflow.util.train_recipe import fold_epochs_into_recipe from roboflow.util.versions import get_model_format, get_wrong_dependencies_versions @@ -663,6 +668,11 @@ def deploy(self, model_type: str, model_path: str, filename: str = "weights/best model_path (str): File path to the model weights to be uploaded. filename (str, optional): The name of the weights file. Defaults to "weights/best.pt". """ + if model_type == "owlv2" and not (Path(model_path) / "class_names.txt").is_file(): + raise MissingFileError( + "An OWLv2 upload to a dataset version needs class_names.txt. " + "Use workspace.deploy_model to upload weights without labels." + ) bundle = package_custom_weights_interactive(model_type, model_path, filename, build_dir=model_path) self._validate_against_project_type(bundle.model_type) diff --git a/roboflow/util/model_processor.py b/roboflow/util/model_processor.py index 384e1634..04be15d2 100644 --- a/roboflow/util/model_processor.py +++ b/roboflow/util/model_processor.py @@ -1131,21 +1131,36 @@ def _process_rfdetr( def _process_owlv2(model_path: Path, filename: str, build_dir: Path) -> tuple[Path, str, list[str]]: - # The export job validates these files, because uploads can bypass the SDK. - optional = { + tokenizer_files = { "added_tokens.json", - "class_names.txt", - "config.json", "merges.txt", - "owlv2_prompts.json", - "preprocessor_config.json", - "processor_config.json", "special_tokens_map.json", "tokenizer.json", "tokenizer_config.json", "vocab.json", } + optional = tokenizer_files | { + "class_names.txt", + "config.json", + "owlv2_prompts.json", + "preprocessor_config.json", + "processor_config.json", + } files = {name: _resolve_within_source(model_path, name) for name in optional} + present = {name for name, path in files.items() if path.is_file()} + # The export job is the authority on these files, because uploads can bypass the SDK. The SDK + # repeats only the file-set rules, so that a doomed upload fails here and not in a remote job + # after a multi-gigabyte transfer. File contents are validated by the export job alone. + if "owlv2_prompts.json" in present and "class_names.txt" not in present: + raise MissingFileError("owlv2_prompts.json requires class_names.txt to name the output labels.") + if present & tokenizer_files and ( + "tokenizer_config.json" not in present + or not ("tokenizer.json" in present or {"vocab.json", "merges.txt"} <= present) + ): + raise MissingFileError( + "Provide a complete tokenizer override: tokenizer_config.json and either tokenizer.json " + "or both vocab.json and merges.txt. Omit all tokenizer files to use the standard OWLv2 tokenizer." + ) weights = _resolve_within_source(model_path, filename) if not weights.is_file(): raise MissingFileError( diff --git a/tests/test_version.py b/tests/test_version.py index d00ae4cc..0afe46f6 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,6 +1,8 @@ import json import os +import tempfile import unittest +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -20,6 +22,7 @@ from roboflow.core.training import Training from roboflow.core.version import Version, unwrap_version_id from roboflow.models.object_detection import ObjectDetectionModel +from roboflow.util.model_processor import MissingFileError from tests.helpers import get_version @@ -275,6 +278,15 @@ def test_classification_project_rejects_detection(self): self._version(TYPE_CLASSICATION)._validate_against_project_type("yolov11") +class TestDeployOwlV2(unittest.TestCase): + def test_weights_without_labels_are_rejected_before_packaging(self): + with tempfile.TemporaryDirectory() as model_path: + (Path(model_path) / "owlv2.pth").write_text("checkpoint") + with self.assertRaisesRegex(MissingFileError, "workspace.deploy_model"): + get_version().deploy("owlv2", model_path, "owlv2.pth") + self.assertEqual([path.name for path in Path(model_path).iterdir()], ["owlv2.pth"]) + + class TestConstructionDoesNotProbeNetwork(unittest.TestCase): @patch("roboflow.adapters.rfapi.get_version", side_effect=AssertionError("get_version should not be called")) def test_construction_makes_no_request_when_payload_has_no_model(self, _mock_get_version: MagicMock): diff --git a/tests/util/test_model_processor.py b/tests/util/test_model_processor.py index 461c5684..9f0539d3 100644 --- a/tests/util/test_model_processor.py +++ b/tests/util/test_model_processor.py @@ -771,6 +771,31 @@ def test_packages_weights_alone_for_explicit_queries(self): with zipfile.ZipFile(bundle.archive_path) as archive: self.assertEqual(archive.namelist(), ["weights.pt"]) + def test_prompt_override_requires_output_labels(self): + (self.source / "class_names.txt").unlink() + (self.source / "owlv2_prompts.json").write_text('["a dent"]') + with self.assertRaisesRegex(MissingFileError, "class_names.txt"): + package_custom_weights("owlv2", str(self.source), "owlv2.pth") + + def test_rejects_partial_tokenizer_overrides(self): + (self.source / "tokenizer_config.json").unlink() + (self.source / "tokenizer.json").unlink() + for name in ("tokenizer_config.json", "tokenizer.json", "special_tokens_map.json", "vocab.json"): + with self.subTest(name=name): + (self.source / name).write_text("{}") + with self.assertRaisesRegex(MissingFileError, "complete tokenizer override"): + package_custom_weights("owlv2", str(self.source), "owlv2.pth") + (self.source / name).unlink() + + def test_packages_legacy_tokenizer_override(self): + (self.source / "tokenizer.json").unlink() + (self.source / "vocab.json").write_text("{}") + (self.source / "merges.txt").write_text("#version: 0.2\n") + bundle = package_custom_weights("owlv2", str(self.source), "owlv2.pth") + self.addCleanup(bundle.cleanup) + with zipfile.ZipFile(bundle.archive_path) as archive: + self.assertLessEqual({"tokenizer_config.json", "vocab.json", "merges.txt"}, set(archive.namelist())) + class ProcessHuggingfaceTest(unittest.TestCase): """Packaging for HuggingFace-backed models (paligemma / florence-2)."""