diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4ca239a8..616dce1d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,6 +3,7 @@ "dockerComposeFile": "./docker-compose.yaml", "service": "devcontainer-roboflow-python", "workspaceFolder": "/roboflow-python", + "initializeCommand": "sh -lc 'mkdir -p .devcontainer/certs; if command -v mkcert >/dev/null 2>&1; then CAROOT=\"$(mkcert -CAROOT)\"; if [ -f \"$CAROOT/rootCA.pem\" ]; then cp \"$CAROOT/rootCA.pem\" .devcontainer/certs/mkcert-rootCA.crt; else echo \"[devcontainer] mkcert CA not found at $CAROOT/rootCA.pem; skipping\"; fi; else echo \"[devcontainer] mkcert not installed; skipping CA copy\"; fi'", "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", "customizations": { "vscode": { diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml index 2b384ece..231cf405 100644 --- a/.devcontainer/docker-compose.yaml +++ b/.devcontainer/docker-compose.yaml @@ -5,6 +5,8 @@ services: context: .. dockerfile: Dockerfile.dev image: devcontainer-roboflow-python + extra_hosts: + - "localhost.roboflow.one:host-gateway" volumes: - ..:/roboflow-python command: sleep infinity diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 48cb08fd..8c73f451 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,31 +2,86 @@ name: Publish WorkFlow on: release: - types: [created] + types: [published] jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.8] + environment: pypi + permissions: + id-token: write steps: - name: πŸ›ŽοΈ Checkout uses: actions/checkout@v4 with: ref: ${{ github.head_ref }} - - name: 🐍 Set up Python ${{ matrix.python-version }} + - name: 🐍 Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: '3.10' - name: 🦾 Install dependencies run: | python -m pip install --upgrade pip pip install ".[dev]" - - name: πŸš€ Publish to PyPi - env: - PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} - PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - PYPI_TEST_PASSWORD: ${{ secrets.PYPI_TEST_PASSWORD }} + - name: πŸ“¦ Build package + run: python setup.py sdist bdist_wheel + - name: πŸš€ Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + build-slim: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - name: πŸ›ŽοΈ Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + - name: 🐍 Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: 🦾 Install dependencies + run: | + python -m pip install --upgrade pip + pip install ".[dev]" + - name: πŸ“¦ Build slim package + run: | + rm -rf dist/ build/ *.egg-info + python setup_slim.py sdist bdist_wheel + - name: πŸš€ Publish roboflow-slim to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + deploy-docs: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: πŸ›ŽοΈ Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🐍 Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: πŸ“š Install MkDocs and dependencies run: | - make publish -e PYPI_USERNAME=$PYPI_USERNAME -e PYPI_PASSWORD=$PYPI_PASSWORD -e PYPI_TEST_PASSWORD=$PYPI_TEST_PASSWORD + python -m pip install --upgrade pip + pip install mkdocs-material mkdocstrings mkdocstrings[python] + pip install ".[dev]" + + - name: πŸ—οΈ Build documentation + run: | + mkdocs build + + - name: πŸš€ Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70f0f2a7..d8af7f47 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,12 +6,18 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: build: - runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + os: ["ubuntu-latest", "windows-latest"] + python-version: ["3.10", "3.11", "3.12", "3.13"] + runs-on: ${{ matrix.os }} + env: + PYTHONUTF8: 1 steps: - name: πŸ›ŽοΈ Checkout @@ -32,3 +38,24 @@ jobs: make check_code_quality - name: πŸ§ͺ Run tests run: "python -m unittest" + + test-slim: + runs-on: ubuntu-latest + steps: + - name: πŸ›ŽοΈ Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + - name: 🐍 Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: 🦾 Install slim dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-slim.txt + pip install -e . --no-deps + pip install responses + - name: πŸ§ͺ Run slim-compatible tests + run: "python -m unittest tests.test_slim_compat tests.test_vision_events" diff --git a/.gitignore b/.gitignore index 40868fda..00ea342a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ .idea # C extensions *.so +.devcontainer/certs/ # Distribution / packaging .Python @@ -154,3 +155,4 @@ tests/manual/data README.roboflow.txt *.zip .DS_Store +.claude diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7946a926..7d8cb188 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ ci: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict @@ -24,14 +24,14 @@ repos: - id: trailing-whitespace - repo: https://github.com/PyCQA/bandit - rev: 1.7.9 + rev: 1.9.4 hooks: - id: bandit args: ["-c", "pyproject.toml"] additional_dependencies: ["bandit[toml]"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.4 + rev: v0.15.10 hooks: - id: ruff-format - id: ruff diff --git a/CHANGELOG.md b/CHANGELOG.md index 48403b2b..bb35f49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,213 @@ All notable changes to this project will be documented in this file. -## 1.1.5 +## 1.4.1 -[stub] +### Added + +- Custom train recipes on v2 trainings + ([#510](https://github.com/roboflow/roboflow-python/pull/510)): + - `Version.describe_train_recipe(model_type)` β€” fetch the tunable + hyperparameter schema, allowed online augmentation/preprocessing steps, + and a ready-to-edit recipe `template` for a model type. + - `train_recipe` on `Version.create_training(...)` β€” + pass an edited `describe_train_recipe` template for custom + hyperparameters/online augmentation (the server dense-fills omitted + defaults). A top-level `epochs` is folded into the recipe's + hyperparameters (the server resolves recipe epochs ahead of the body + value). `train_recipe` requires `model_type` β€” recipes are minted per + model type, and without one the platform would train the project's + default architecture. + - `roboflow train recipe -p -v -m ` β€” print the + recipe schema and template as JSON. + - `roboflow train start --train-recipe ''` β€” create the training + through the v2 API and print the new `trainingId`. Accepts inline JSON + or a curl-style file reference (`--train-recipe @train_recipe.json`). + +## 1.4.0 + +### Added β€” Support for multiple models per version + +A dataset version can now own many trainings, and a training can produce many +models (e.g. a NAS sweep). New object types expose this: + +**SDK (`roboflow/core/training.py`, `roboflow/core/version.py`):** +- `Version.trainings()` β€” list the version's training runs as `Training` objects. +- `Version.models()` β€” every trained model for the version (the union across its + trainings), as `TrainedModel` objects. This is now the canonical way to get a + version's models. +- `Version.create_training(speed=, model_type=, checkpoint=, epochs=)` β€” launch a + run without blocking, returning a `Training`. +- `Training` β€” `.models`, `.refresh()`, `.cancel()`, `.stop()`, plus + `.training_id` / `.status` / `.model_type`. +- `TrainedModel` β€” `.predict()`, `.predict_video()`, `.download()`, plus + `.model_id` / `.model_type` / `.metrics`. A `TrainedModel` does everything the + old `version.model` could; you just reach it through `version.models()`. + +**Adapters (`roboflow/adapters/rfapi.py`):** v2 trainings endpoints β€” +`list_trainings_for_version`, `get_training`, `create_training_v2`, +`cancel_training_v2`, `stop_training_v2`, `get_model_weights_url`. + +### Added + +- `workspace.update_image_metadata()` and `workspace.batch_update_image_metadata()` + (plus a `project.update_image_metadata()` convenience alias) β€” public SDK + wrappers for updating metadata and tags on existing images, previously only + reachable via the internal `rfapi` adapter or the CLI. The batch method + accepts `wait=True` to poll the async task until completion and return + per-image results. +- Upload raw rf-detr PyTorch-Lightning checkpoints (e.g. `checkpoint_best_ema.pth`): + `upload_model` detects them and rebuilds a deploy-ready bundle via rf-detr's + `export_for_roboflow` (requires `rfdetr>=1.8.0`) + ([#488](https://github.com/roboflow/roboflow-python/pull/488)) + +### Changed + +- Keypoint detection inference now reports its prediction type correctly + (previously mislabeled as classification), fixing rendering/plotting of + keypoint predictions. + +### Deprecated + +- `version.model` (the singular attribute) is deprecated and emits a + `DeprecationWarning`. It cannot represent a version with multiple models; + use `version.models()` instead. + +## 1.3.11 + +### Added + +- `roboflow api-key` CLI command group and SDK methods to create, list, get, + update, protect, and revoke workspace API keys β€” including scoped keys, folder + restrictions, and custom metadata (scoping/metadata require the Advanced API + Keys plan feature). + +## 1.3.10 + +### Added + +- Weight upload support for yolo26-sem semantic segmentation models via + `version.deploy()` and `workspace.deploy_model()` + +## 1.3.9 + +### Added β€” Model evaluations SDK & CLI + +Wraps the public `/{workspace}/model-evals` REST surface +([roboflow/roboflow#11636](https://github.com/roboflow/roboflow/pull/11636)) +so users can read evaluation results β€” mAP, confidence sweep, per-class +performance, confusion matrix, vector clusters, per-image stats, +recommendations β€” from Python and from the CLI without hitting the API +directly. Companion docs: +[roboflow-dev-reference#18](https://github.com/roboflow/roboflow-dev-reference/pull/18). + +**SDK (`roboflow/core/model_eval.py`):** +- `Workspace.evals(project=None, version=None, model=None, status=None, limit=None)` β€” list evals as `ModelEval` instances pre-populated with metadata from the list response. +- `Workspace.eval(eval_id)` β€” fetch a single eval (returns a `ModelEval` with `.summary` populated when status is `done`). +- `ModelEval.refresh()` β€” re-fetch the eval header. +- `ModelEval.map_results()`, `.confidence_sweep()`, `.performance_by_class(split=None)`, `.confusion_matrix(split=None, confidence=None)`, `.vector_analysis(confidence=None)`, `.image_predictions(split=None, confidence=None, limit=None, offset=None)`, `.recommendations()` β€” one method per panel; each returns the raw JSON dict. + +**CLI (`roboflow/cli/handlers/eval.py`):** +- `roboflow eval list [--project P] [--version V] [--model M] [--status S] [--limit N]` +- `roboflow eval get ` +- `roboflow eval map-results ` +- `roboflow eval confidence-sweep ` +- `roboflow eval performance-by-class [--split S]` +- `roboflow eval confusion-matrix [--split S] [--confidence N]` +- `roboflow eval vector-analysis [--confidence N]` +- `roboflow eval image-predictions [--split S] [--confidence N] [--limit N] [--offset N]` +- `roboflow eval recommendations ` + +Exit codes are stable per error class so shell scripts and AI agents can +react without parsing message strings: `3` for `model_eval_not_found` +(404), `4` for `model_eval_not_done` (409), `5` for `invalid_split` / +`invalid_confidence` (400). Every command supports `--json` for +structured output. + +**Low-level (`roboflow.adapters.rfapi`):** +- `list_model_evals`, `get_model_eval`, `get_model_eval_map_results`, `get_model_eval_confidence_sweep`, `get_model_eval_performance_by_class`, `get_model_eval_confusion_matrix`, `get_model_eval_vector_analysis`, `get_model_eval_image_predictions`, `get_model_eval_recommendations`. +- New typed exceptions `ModelEvalNotFoundError`, `ModelEvalNotDoneError`, `InvalidSplitError`, `InvalidConfidenceError` (all subclasses of `RoboflowError`) so callers can distinguish "eval doesn't exist" from "eval still running" from "bad argument" without parsing strings. + +The endpoints require the `model-eval:read` scope. The base URL is +configurable via `API_URL` (set to `https://localapi.roboflow.one` to +test against a local API server). + +### Fixed +- rf-detr model upload: accept checkpoints whose `args` is a plain dict (e.g. EMA checkpoints) when extracting class names, instead of raising `TypeError` from `vars()`. + +### Changed +- Pin `typer<0.26` and declare `click` explicitly: typer 0.26 vendors its own click and drops the external dependency, which broke the CLI and its type checks. + +## 1.3.7 + +### Added β€” Soft-delete / Trash support + +Mirrors the soft-delete and Trash features added to the Roboflow web app +([roboflow/roboflow#11131](https://github.com/roboflow/roboflow/pull/11131)). +Deleting a project, version, or workflow now moves it to Trash with a +30-day retention window (and cancels any in-flight training jobs); items +can be restored within that window. Companion docs: +[roboflow-dev-reference#5](https://github.com/roboflow/roboflow-dev-reference/pull/5). + +**SDK (`roboflow/`):** +- `Project.delete()` / `Project.restore()` β€” soft-delete and restore by slug. +- `Version.delete()` / `Version.restore()` β€” same shape on a version handle. +- `Workspace.trash()` β€” list everything currently in a workspace's Trash, grouped by `projects` / `versions` / `workflows`. +- `Workspace.restore_from_trash(item_type, item_id, parent_id=None)` β€” restore an item by id when you don't have a live SDK handle (or for workflows, which don't have a first-class object yet). + +**CLI (`roboflow/cli/`):** +- `roboflow project delete` / `roboflow project restore` +- `roboflow version delete` / `roboflow version restore` +- `roboflow workflow delete` / `roboflow workflow restore` +- `roboflow trash list` + +Destructive commands prompt for confirmation interactively and accept +`--yes` / `-y` for scripted use. Every command supports `--json` for +structured output and emits actionable error hints with stable exit codes. + +**Low-level (`roboflow.adapters.rfapi`):** +- `delete_project`, `delete_version`, `delete_workflow`, `list_trash`, `restore_trash_item`. +- `RoboflowError` messages now extract the `error` field from JSON response bodies (e.g. "Not authorized to view trash") instead of the raw response text. + +**Permanent deletion is intentionally web-UI-only.** Emptying Trash or +immediately deleting a single Trash item destroys data irrecoverably, so +those actions are not exposed on the SDK or CLI β€” they live only in the +Roboflow app's Trash view, which has an explicit confirmation dialog. +Items left in Trash are cleaned up automatically after 30 days. + +### Fixed β€” Workflows created via SDK/CLI now execute successfully + +`Workspace.create_workflow()` and `roboflow workflow create --definition` +auto-wrap bare workflow definitions in `{"specification": ...}` before +POSTing to the backend, matching what the web app does +([#460](https://github.com/roboflow/roboflow-python/pull/460)). Previously, +the user-facing flat shape (`{version, inputs, steps, outputs}`) was sent +verbatim, so `POST /infer/workflows/...` against the resulting workflow +returned `HTTP 502` with `MalformedWorkflowResponseError: Workflow +specification not found in Roboflow API response`. + +Workflows already wrapped (top-level `specification` key) are passed +through unchanged. Non-workflow dicts and non-JSON strings are also +passed through verbatim so custom payloads aren't second-guessed. + +> **Note:** workflows that were stored with the bare shape *before* this +> fix will still 502 until re-saved. Run `roboflow workflow update +> --definition ` once per affected workflow to migrate. + +### Changed β€” Image upload no longer re-encodes images + +`upload_image` now uploads original image bytes instead of re-encoding to +JPEG client-side ([#464](https://github.com/roboflow/roboflow-python/pull/464)). + +### Backward compatibility + +Purely additive on the public API surface. The new endpoints require +`project:update`, `version:update`, or `workflow:update` scopes β€” most +existing keys already have these. + +## 1.1.50 + +- Added support for Palligema2 model uploads via `upload_model` command with the following model types: + - `paligemma2-3b-pt-224` + - `paligemma2-3b-pt-448` + - `paligemma2-3b-pt-896` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..30b5e522 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Running Tests +```bash +python -m unittest +``` + +### Linting and Code Quality +```bash +# Format code with ruff +make style + +# Check code quality (includes ruff and mypy) +make check_code_quality + +# Individual commands +ruff format roboflow +ruff check roboflow --fix +mypy roboflow +``` + +### Building Documentation +```bash +# Install documentation dependencies +python -m pip install mkdocs mkdocs-material mkdocstrings mkdocstrings[python] + +# Serve documentation locally +mkdocs serve +``` + +### Installing Development Environment +```bash +# Create virtual environment +python3 -m venv env +source env/bin/activate + +# Install in editable mode with dev dependencies +pip install -e ".[dev]" + +# Install pre-commit hooks +pip install pre-commit +pre-commit install +``` + +## Architecture Overview + +The Roboflow Python SDK follows a hierarchical object model that mirrors the Roboflow platform structure: + +### Core Components + +1. **Roboflow** (`roboflow/__init__.py`) - Entry point and authentication + - Handles API key management and workspace initialization + - Provides `login()` for CLI authentication + - Creates workspace connections + +2. **Workspace** (`roboflow/core/workspace.py`) - Manages Roboflow workspaces + - Lists and accesses projects + - Handles dataset uploads and model deployments + - Manages workspace-level operations + +3. **Project** (`roboflow/core/project.py`) - Represents a computer vision project + - Manages project metadata and versions + - Handles image/annotation uploads + - Supports different project types (object-detection, classification, etc.) + +4. **Version** (`roboflow/core/version.py`) - Dataset version management + - Downloads datasets in various formats + - Deploys models + - Provides access to trained models for inference + +5. **Model Classes** (`roboflow/models/`) - Type-specific inference models + - `ObjectDetectionModel` - Bounding box predictions + - `ClassificationModel` - Image classification + - `InstanceSegmentationModel` - Pixel-level segmentation + - `SemanticSegmentationModel` - Class-based segmentation + - `KeypointDetectionModel` - Keypoint predictions + +### API Adapters + +- **rfapi** (`roboflow/adapters/rfapi.py`) - Low-level API communication +- **deploymentapi** (`roboflow/adapters/deploymentapi.py`) - Model deployment operations + +### CLI Package (`roboflow/cli/`) + +The CLI is built on [typer](https://typer.tiangolo.com/) (which uses Click under the hood). `roboflow/roboflowpy.py` is a backwards-compatibility shim that delegates to `roboflow.cli.main`. + +**Package structure:** +- `__init__.py` β€” Root `typer.Typer()` app with global `@app.callback()` for `--json`, `--workspace`, `--api-key`, `--quiet`. Explicitly registers all handler apps via `app.add_typer()`. +- `_output.py` β€” `output(args, data, text)` for JSON/text output, `output_error(args, msg, hint, exit_code)` for structured errors, `suppress_sdk_output()` to silence SDK noise, `stub()` for unimplemented commands +- `_compat.py` β€” `ctx_to_args(ctx, **kwargs)` bridge that converts `typer.Context` to the `SimpleNamespace` that output helpers expect +- `_table.py` β€” `format_table(rows, columns)` for columnar list output +- `_resolver.py` β€” `resolve_resource(shorthand)` for parsing `project`, `ws/project`, `ws/project/3` +- `handlers/` β€” One file per command group, each exporting a `typer.Typer()` app. `_aliases.py` registers backwards-compat top-level commands via `register_aliases(app)`. + +**Adding a new command:** +1. Create `roboflow/cli/handlers/mycommand.py` +2. Create a module-level `mycommand_app = typer.Typer(help="...", no_args_is_help=True)` +3. Add commands with `@mycommand_app.command("verb")` decorators +4. Each command takes `ctx: typer.Context` + typed params, calls `ctx_to_args(ctx, **params)` to create args namespace +5. Use `output()` for all output, `output_error()` for all errors +6. Wrap SDK calls in `with suppress_sdk_output():` to prevent "loading..." noise +7. Register in `roboflow/cli/__init__.py`: `app.add_typer(mycommand_app, name="mycommand")` +8. Add tests using `typer.testing.CliRunner` in `tests/cli/test_mycommand_handler.py` + +**Agent experience requirements for all CLI commands:** +- Support `--json` for structured output (stable schema) +- No interactive prompts when all required flags are provided +- Structured error output: `{"error": {"message": "...", "hint": "..."}}` on stderr +- Exit codes: 0 = success, 1 = error, 2 = auth error, 3 = not found +- Actionable error messages: always tell the user what went wrong AND what to do + +**Documentation policy:** `CLI-COMMANDS.md` in this repo is a quickstart only. The full command reference lives in `roboflow-product-docs` (published to docs.roboflow.com). When adding commands, update both. + +### Key Design Patterns + +1. **Hierarchical Access**: Always access objects through their parent (Workspace β†’ Project β†’ Version β†’ Model) +2. **API Key Flow**: API key is passed down through the object hierarchy +3. **Format Flexibility**: Supports multiple dataset formats (YOLO, COCO, Pascal VOC, etc.) +4. **Batch Operations**: Upload and download operations support concurrent processing +5. **CLI Noun-Verb Pattern**: Commands follow `roboflow ` (e.g. `roboflow project list`). Common operations have top-level aliases (`login`, `upload`, `download`) +6. **CLI Explicit Registration**: Handler apps are explicitly imported and registered via `app.add_typer()` in `__init__.py` β€” clear dependency chain, no runtime discovery +7. **Backwards Compatibility**: Legacy command names and flag signatures are preserved as hidden aliases + +## Project Configuration + +- **Python Version**: 3.10+ +- **Main Dependencies**: See `requirements.txt` (includes `typer>=0.12.0`) +- **Entry Point**: `roboflow=roboflow.roboflowpy:main` (shim delegates to `roboflow.cli.main`) +- **Code Style**: Enforced by ruff with Google docstring convention +- **Type Checking**: mypy configured for Python 3.10 + +## Important Notes + +- API keys are stored in `~/.config/roboflow/config.json` (Unix) or `~/roboflow/config.json` (Windows) +- The SDK supports both hosted inference (Roboflow platform) and local inference (via Roboflow Inference) +- Pre-commit hooks automatically run formatting and linting checks +- Test files intentionally excluded from linting: `tests/manual/debugme.py` diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index 1e2372c2..4593b802 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -1,247 +1,419 @@ -# The roboflow-python command line -This has the same capabilities of the [roboflow node cli](https://www.npmjs.com/package/roboflow-cli) so that our users don't need to install two different tools. +# Roboflow CLI -## See available commands +The `roboflow` command line tool provides access to the Roboflow platform for managing computer vision projects, datasets, models, and deployments. It's designed for both human developers and AI coding agents. + +> **Full reference:** [docs.roboflow.com/deploy/sdks/python-cli](https://docs.roboflow.com/deploy/sdks/python-cli) + +## Install & authenticate ```bash -$ roboflow --help +pip install roboflow +export ROBOFLOW_API_KEY=rf_xxxxx # recommended for scripts and agents +roboflow auth login # or interactive login ``` -``` -usage: roboflow [-h] {login,download,upload,import,infer,project,workspace} ... +## Global flags -Welcome to the roboflow CLI: computer vision at your fingertips πŸͺ„ +| Flag | Short | Description | +|------|-------|-------------| +| `--json` | `-j` | Structured JSON output (for agents and piping) | +| `--api-key` | `-k` | API key override | +| `--workspace` | `-w` | Workspace override | +| `--quiet` | `-q` | Suppress progress bars and status messages | +| `--version` | | Show version | -options: - -h, --help show this help message and exit +Flags work in any position: `roboflow project list --json` and `roboflow --json project list` are equivalent. -subcommands: - {login,download,upload,import,infer,project,workspace} - login Log in to Roboflow - download Download a dataset version from your workspace or Roboflow Universe. - upload Upload a single image to a dataset - import Import a dataset from a local folder - infer perform inference on an image - project project related commands. type 'roboflow project' to see detailed command help - workspace workspace related commands. type 'roboflow workspace' to see detailed command help -``` +## Quick examples + +### Create a project and upload images -## Authentication +```bash +roboflow project create my-project --type object-detection +roboflow image upload photo.jpg -p my-project +roboflow image upload ./dataset-folder/ -p my-project # smart: detects directory +``` -You need to authenticate first +### Download a dataset ```bash -$ roboflow login +roboflow version download my-workspace/my-project/3 -f yolov8 +roboflow download my-workspace/my-project/3 -f coco # alias ``` +### Run inference + +```bash +roboflow infer photo.jpg -m my-project/3 ``` -visit https://app.roboflow.com/auth-cli to get your authentication token. -Paste the authentication token here: + +### Train, monitor, cancel, stop + +```bash +# Start training (any architecture). For NAS sweeps, use a NAS parent modelType: +roboflow train start -p my-project -v 3 --type rfdetr-base +roboflow train start -p my-project -v 3 --type rfdetr-nas-parent # NAS sweep +roboflow train start -p my-project -v 3 --type rfdetr-nas-base-parent # NAS Base sweep +roboflow train start -p my-project -v 3 --type rfdetr-nas-seg-parent # NAS instance-segmentation + +# Cancel an in-flight training (any architecture; NAS-aware): +roboflow train cancel my-project/3 +# Pass --continue-if-no-refund to cancel even past the refund window: +roboflow train cancel my-project/3 --continue-if-no-refund + +# Graceful early-stop: +roboflow train stop my-project/3 + +# Run-level training results bundle (NAS leaderboard for NAS runs, +# minimal bundle for non-NAS): +roboflow train results my-project/3 ``` -Open that link on your browser, get the token, paste it on the terminal. -The credentials get saved to `~/.config/roboflow/config.json` -## Display help usage for other commands +NAS sweeps require the version's validation split to have at least 15 images; +the server returns `code: "insufficient_validation_images_for_nas"` otherwise. -"How do I download stuff?" +### Train recipes β€” custom hyperparameters & augmentation (v2) ```bash -$ roboflow download --help -``` +# Inspect a model type's tunable hyperparameter schema, allowed online +# augmentation/preprocessing steps, and a ready-to-edit recipe template: +roboflow train recipe -p my-project -v 3 -m rfdetr-medium + +# Start a training from an edited recipe: take the `template` field, tweak +# it (hyperparameters, online augmentation), and submit it. The server +# dense-fills any defaults the recipe leaves out: +roboflow --json train recipe -p my-project -v 3 -m rfdetr-medium | jq .template > recipe.json +# ... edit recipe.json (e.g. set .hyperparameters.lr) ... +roboflow train start -p my-project -v 3 -t rfdetr-medium --train-recipe @recipe.json ``` -usage: roboflow download [-h] [-f FORMAT] [-l LOCATION] datasetUrl -positional arguments: - datasetUrl Dataset URL (e.g., `roboflow-100/cells-uyemf/2`) +--train-recipe accepts inline JSON or a curl-style @file reference; it creates +the training through the v2 trainings API and prints +the new `trainingId` instead of blocking β€” handy for launching sweeps and +polling status separately. --epochs is folded into the recipe's +hyperparameters unless the recipe already sets epochs. -options: - -h, --help show this help message and exit - -f FORMAT Specify the format to download the version. Available options: [coco, yolov5pytorch, yolov7pytorch, my-yolov6, darknet, - voc, tfrecord, createml, clip, multiclass, coco-segmentation, yolo5-obb, png-mask-semantic, yolov8, yolov9] - -l LOCATION Location to download the dataset +### NAS models β€” list, star, deploy + +```bash +# Get a NAS run's modelGroup from training results: +roboflow --json train results my-project/3 | jq -r .modelGroup +# β†’ rfdetrNasGroup-3 + +# List every model from one NAS run, with hardware/latency/mAP columns: +roboflow model list -p my-project --group rfdetrNasGroup-3 + +# Star a NAS-trained model (triggers TRT compile for its recommended hardware): +# --json train results … gives you the modelId per row. +roboflow model star +roboflow model star --unstar ``` -"How do I import a dataset into my workspace?" +`model star` is NAS-only by server-side design; non-NAS modelTypes return +`code: "MODEL_NOT_NAS"`. + +### Update image metadata and tags ```bash -$ roboflow import --help -``` +# Single image: set metadata + add tags +roboflow image metadata -m '{"camera": "cam1"}' --tags "review,v2" -``` -usage: roboflow import [-h] [-w WORKSPACE] [-p PROJECT] [-c CONCURRENCY] [-f FORMAT] folder +# Remove metadata keys +roboflow image metadata --remove-metadata "old_key" -positional arguments: - folder filesystem path to a folder that contains your dataset +# Remove tags +roboflow image metadata --remove-tags "draft" -options: - -h, --help show this help message and exit - -w WORKSPACE specify a workspace url or id (will use default workspace if not specified) - -p PROJECT project will be created if it does not exist - -c CONCURRENCY how many image uploads to perform concurrently (default: 10) - -n BATCH_NAME name of batch to upload to within project +# Batch: update multiple images (async), poll for completion +roboflow image metadata img1,img2,img3 --tags "processed" --poll + +# Batch with timeout +roboflow image metadata img1,img2 -m '{"status": "done"}' --poll --timeout 600 + +# Tag alias works identically (hidden command) +roboflow image tag --tags "review" --remove-tags "draft" ``` -## Example: download dataset +Single image ID updates synchronously. Multiple comma-separated IDs use the +batch async endpoint (up to 1000 images). Use `--poll` to block until +completion; without it the command returns the `taskId` immediately. -Download [Joseph's chess dataset](https://universe.roboflow.com/joseph-nelson/chess-pieces-new/dataset/25) from Roboflow Universe in VOC format: +### Search and export ```bash -$ roboflow download -f voc -l ~/tmp/chess joseph-nelson/chess-pieces-new/25 +roboflow search "tag:reviewed" --limit 100 +roboflow search "class:person" --export -f coco -l ./export/ ``` + +### Browse resources + +```bash +roboflow workspace list +roboflow project list +roboflow project get my-project +roboflow version list -p my-project +roboflow model list -p my-project ``` -loading Roboflow workspace... -loading Roboflow project... -Downloading Dataset Version Zip in /Users/tony/tmp/chess to voc:: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 19178/19178 [00:01<00:00, 10424.62it/s] -Extracting Dataset Version Zip to /Users/tony/tmp/chess in voc:: 100%|β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ| 1391/1391 [00:00<00:00, 8992.30it/s] +### Manage folders + +```bash +roboflow folder list +roboflow folder create "Training Data" --projects proj1,proj2 +roboflow folder get +roboflow folder update --name "New Name" +roboflow folder delete ``` + +### Annotation batches and jobs + ```bash -$ ls -lh ~/tmp/chess -total 16 --rw-r--r--@ 1 tony staff 1.8K Jan 5 10:32 README.dataset.txt --rw-r--r--@ 1 tony staff 562B Jan 5 10:32 README.roboflow.txt -drwxr-xr-x@ 60 tony staff 1.9K Jan 5 10:32 test -drwxr-xr-x@ 1214 tony staff 38K Jan 5 10:32 train -drwxr-xr-x@ 118 tony staff 3.7K Jan 5 10:32 valid +roboflow annotation batch list -p my-project +roboflow annotation batch get -p my-project +roboflow annotation job list -p my-project +roboflow annotation job create -p my-project --name "Label round 1" \ + --batch --num-images 100 --labeler a@co.com --reviewer b@co.com ``` -## Example: import a dataset +### RFDM devices (v2 deployments) -Upload a dataset from a folder to a project in your workspace +Workspace-scoped device management β€” backed by the external Deployments API +(`/:workspace/devices/v2/*`). Read commands need the `device:read` scope on +your api_key; `create` needs `device:update`. ```bash -roboflow import -w my-workspace -p my-chess ~/tmp/chess -``` +roboflow device list +roboflow device get +roboflow device create "Factory floor cam" --type edge --tags floor-1,vision -``` -loading Roboflow workspace... -loading Roboflow project... -Uploading to existing project my-workspace/my-chess -[UPLOADED] /home/jonny/tmp/chess/102_jpg.rf.205e2a0cb0fabbbf32b4a936e2d6f1e4.jpg (sFpTfnyLpLA8QcqPwdvf) / annotations = OK -[UPLOADED] /home/jonny/tmp/chess/2_jpg.rf.c1a4ed4e0c3947743b22ede09f7e1212.jpg (wDA2yxnLJWY5YwYwO7dP) / annotations = OK -[UPLOADED] /home/jonny/tmp/chess/221_jpg.rf.e841c9bbb31a135b8f6274643f522686.jpg (UCv7MeuvEqo7PYElatEn) / annotations = OK -[UPLOADED] /home/jonny/tmp/chess/10_jpg.rf.841f3ccdfc4b93ee68566e602025c03f.jpg (HnkCpUcYzxStvQF49VQW) / annotations = OK -[UPLOADED] /home/jonny/tmp/chess/130_jpg.rf.29f756d510d2e488eb5e12769c7707ff.jpg (WxrFIhfaJ9H1JvaXMgfF) / annotations = OK -[UPLOADED] /home/jonny/tmp/chess/112_jpg.rf.1a6e7b87410fa3f787f10e82bd02b54e.jpg (7tWtAn573cKrefeg5pIO) / annotations = OK +# Observe β€” config is sensitive (may include credentials). +roboflow device config +roboflow device config-history --limit 20 + +# Streams the device runs. +roboflow device streams +roboflow device stream + +# Logs (5 req/min/IP) and aggregated telemetry (60 req/min). +roboflow device logs --severity ERROR --limit 200 +roboflow device telemetry --time-period 7d + +# Lifecycle events (stream start/stop, errors, config changes…). +roboflow device events --entity-type stream --direction backward ``` -## Example: list workspaces -List the workspaces you have access to +### Workflows ```bash -$ roboflow workspace list +roboflow workflow list +roboflow workflow get my-workflow +roboflow workflow create --name "My Workflow" --definition workflow.json +roboflow workflow update my-workflow --definition updated.json +roboflow workflow version list my-workflow +roboflow workflow fork other-ws/their-workflow ``` -``` -tonyprivate - link: https://app.roboflow.com/tonyprivate - id: tonyprivate +### Fork a Universe project (async) -wolfodorpythontests - link: https://app.roboflow.com/wolfodorpythontests - id: wolfodorpythontests +```bash +# Fork a public Universe project into the default (or --workspace) workspace. +# By default this blocks until the async task completes (up to --timeout seconds). +roboflow project fork https://universe.roboflow.com/leo-ueno-uduc7/license-plate-recognition +roboflow project fork leo-ueno-uduc7/license-plate-recognition --workspace my-ws -test minimize - link: https://app.roboflow.com/test-minimize - id: test-minimize +# Return immediately with a {taskId, url} payload instead of waiting. +roboflow project fork leo-ueno-uduc7/license-plate-recognition --no-wait + +# Poll the resulting task later (works for any async task that returns a taskId). +roboflow asynctasks get +roboflow asynctasks wait --timeout 600 ``` -## Example: get workspace details +### Create a dataset version ```bash -$ roboflow workspace get tonyprivate +roboflow version create -p my-project --settings settings.json ``` +### Delete and restore (soft delete / Trash) + +```bash +# Move a project to Trash β€” any in-flight training jobs are cancelled automatically. +# Items stay in Trash for 30 days, then are permanently cleaned up. +roboflow project delete my-workspace/my-project +roboflow project restore my-workspace/my-project + +# Same flow for versions (also cancels in-flight training on the version). +roboflow version delete my-workspace/my-project/3 +roboflow version restore my-workspace/my-project/3 + +# Same flow for workflows. +roboflow workflow delete my-workflow +roboflow workflow restore my-workflow + +# Inspect what's currently in Trash. +roboflow trash list + +# Skip the confirmation prompt for scripts. +roboflow project delete my-workspace/my-project --yes ``` -{ - "workspace": { - "name": "tonyprivate", - "url": "tonyprivate", - "members": 4, - "projects": [ - { - "id": "tonyprivate/annotation-upload", - "type": "object-detection", - "name": "annotation-upload", - "created": 1685199749.708, - "updated": 1695910515.48, - "images": 1, - (...) - } - ] - } -} -``` -## Example: list projects +Permanent deletion (emptying Trash or skipping the retention window for a +single item) is intentionally not available from the SDK or CLI β€” those +actions destroy data irrecoverably and live only in the web UI's Trash +view. Items left in Trash are cleaned up automatically after 30 days. + +### Inspect model evaluations ```bash -roboflow project list -w tonyprivate +# List evals in the workspace; filter by project, version, model, or status. +roboflow eval list --status done --limit 10 + +# Read a single eval's metadata + summary metrics. +roboflow eval get + +# Pull each panel β€” pipe to jq for structured access. +roboflow eval map-results --json | jq '.splits.test.map50' +roboflow eval performance-by-class --split test +roboflow eval confusion-matrix --split test --confidence 30 +roboflow eval confidence-sweep --json +roboflow eval vector-analysis --confidence 20 --json +roboflow eval image-predictions --split test --limit 200 +roboflow eval recommendations --json ``` + +Exit codes are stable per error class so scripts and agents can react +without parsing message strings: `3` for `model_eval_not_found` (404), +`4` for `model_eval_not_done` (409 β€” eval still running), `5` for +`invalid_split` / `invalid_confidence` (400). Requires the +`model-eval:read` scope on the api key. + +### Workspace stats and billing + +```bash +roboflow workspace usage +roboflow workspace plan +roboflow workspace stats --start-date 2026-01-01 --end-date 2026-03-31 ``` -annotation-upload - link: https://app.roboflow.com/tonyprivate/annotation-upload - id: tonyprivate/annotation-upload - type: object-detection - versions: 0 - images: 1 - classes: dict_keys(['0', 'Rabbits1', 'Rabbits2', 'minion1', 'minion0', '5075E']) -hand-gestures - link: https://app.roboflow.com/tonyprivate/hand-gestures-fsph8 - id: tonyprivate/hand-gestures-fsph8 - type: object-detection - versions: 5 - images: 387 - classes: dict_keys(['zero', 'four', 'one', 'two', 'five', 'three', 'Guard']) +### Search Roboflow Universe + +```bash +roboflow universe search "hard hats" --type dataset --limit 5 ``` -## Example: get project details +### Video inference ```bash -roboflow project get -w tonyprivate annotation-upload +roboflow video infer -p my-project -v 3 -f video.mp4 --fps 10 +roboflow video status ``` + +### Shell completion + +The fastest path: let the CLI install completion for you. Auto-detects your shell from `$SHELL`. + +```bash +roboflow completion install ``` -{ - "workspace": { - "name": "tonyprivate", - "url": "tonyprivate", - "members": 4 - }, - "project": { - "id": "tonyprivate/annotation-upload", - "type": "object-detection", - "name": "annotation-upload", - "created": 1685199749.708, - "updated": 1695910515.48, - "images": 1, - (...) - }, - "versions": [] -} + +This writes the completion script to a per-user location and updates your shell rc file (`~/.bashrc` or `~/.zshrc`) so completion works in new shells. Idempotent β€” safe to re-run. Delegates to `typer.completion.install` under the hood. + +Supported shells: `bash`, `zsh`, `fish`. Windows / PowerShell is not supported. + +Override detection or scope to one shell: + +```bash +roboflow completion install --shell zsh +roboflow completion install --shell bash +roboflow completion install --shell fish ``` -## Example: run inference +Hidden commands (legacy aliases, snake_case shims, not-yet-implemented stubs) are filtered from completion automatically. -If your project has a trained model (or you are using a dataset from Roboflow Universe that has a trained model), you can run inference from the command line. +To uninstall, delete the completion script (location depends on your shell β€” typer writes to `~/.bash_completions/roboflow.sh`, `~/.zfunc/_roboflow`, or `~/.config/fish/completions/roboflow.fish`) and remove any `source ...` line typer added to your `~/.bashrc`. -Let's use [Rock-Paper-Scissors sample public dataset]([url](https://universe.roboflow.com/roboflow-58fyf/rock-paper-scissors-sxsw/model/11)) from Roboflow universe +#### Advanced: print the script yourself -(In my case, `~/scissors.png` is me holding two fingers to the camera, you can use your own image file ;-)) +If you want full control, generate the raw script and source it however you like: ```bash -roboflow infer -w roboflow-58fyf -m rock-paper-scissors-sxsw/11 ~/scissors.png -``` -``` -{ - "x": 1230.0, - "y": 814.5, - "width": 840.0, - "height": 1273.0, - "confidence": 0.8817358016967773, - "class": "Scissors", - "class_id": 2, - "image_path": "/Users/tony/scissors.png", - "prediction_type": "ObjectDetectionModel" -} +# Zsh +eval "$(roboflow completion zsh)" + +# Bash (requires bash >= 4.4) +eval "$(roboflow completion bash)" + +# Fish +roboflow completion fish | source ``` + +## JSON output for agents + +Every command supports `--json` for structured output that's safe to pipe: + +```bash +# stdout: JSON data, stderr: JSON errors, exit codes: 0/1/2/3 +roboflow --json project list | python3 -c "import sys,json; print(json.load(sys.stdin))" +roboflow --json project get nonexistent 2>/dev/null # stderr gets the error JSON +``` + +Error schema is consistent: `{"error": {"message": "...", "hint": "..."}}` + +## Resource shorthand + +Resources can be addressed with compact identifiers: + +| Shorthand | Resolves to | +|-----------|-------------| +| `my-project` | default workspace + project | +| `my-ws/my-project` | explicit workspace + project | +| `my-project/3` | default workspace + project + version 3 | +| `my-ws/my-project/3` | explicit workspace + project + version 3 | + +Version numbers are always numeric β€” that's how `x/y` is disambiguated between `workspace/project` and `project/version`. + +## All command groups + +| Command | Description | +|---------|-------------| +| `auth` | Login, logout, status, set default workspace | +| `api-key` | List, create, update, protect, disable, revoke workspace API keys | +| `workspace` | List and inspect workspaces | +| `project` | List, get, create projects | +| `version` | List, get, download, export dataset versions | +| `image` | Upload, get, search, metadata, tag, delete, annotate images | +| `model` | List, get, upload trained models | +| `train` | Start model training | +| `infer` | Run inference on images | +| `search` | Search workspace images (RoboQL), export results | +| `deployment` | Manage dedicated deployments | +| `device` | List, get, create, and observe RFDM devices (v2 deployment API) | +| `eval` | Inspect model evaluation runs (mAP, confusion matrix, recommendations, ...) | +| `workflow` | Manage workflows | +| `folder` | Manage workspace folders | +| `annotation` | Annotation batches and jobs | +| `asynctasks` | Inspect async background tasks (e.g. project forks) | +| `trash` | List items in Trash | +| `universe` | Search Roboflow Universe | +| `video` | Video inference | +| `batch` | Batch processing jobs *(coming soon)* | +| `completion` | Install or generate shell completion scripts (bash, zsh, fish) | + +Run `roboflow --help` for details on any command. + +## Backwards compatibility + +All legacy command names still work: + +| Legacy | Current | +|--------|---------| +| `roboflow login` | `roboflow auth login` | +| `roboflow whoami` | `roboflow auth status` | +| `roboflow upload ` | `roboflow image upload ` | +| `roboflow import ` | `roboflow image upload ` | +| `roboflow download ` | `roboflow version download ` | +| `roboflow search-export` | `roboflow search --export` | +| `roboflow train` | `roboflow train start` | +| `roboflow deployment add` | `roboflow deployment create` | +| `roboflow deployment machine_type` | `roboflow deployment machine-type` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da367956..9567a84a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,62 @@ Before that, install the dependencies: python -m pip install mkdocs mkdocs-material mkdocstrings mkdocstrings[python] ``` +### CLI Development + +The CLI is built on [typer](https://typer.tiangolo.com/). Each command group is a separate `typer.Typer()` app registered in `roboflow/cli/__init__.py`. To add a new command: + +1. Create `roboflow/cli/handlers/mycommand.py`: + +```python +"""My command description.""" +from __future__ import annotations +from typing import Annotated, Optional +import typer +from roboflow.cli._compat import ctx_to_args + +mycommand_app = typer.Typer(help="Do something", no_args_is_help=True) + +@mycommand_app.command("list") +def list_things( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """List things in a project.""" + args = ctx_to_args(ctx, project=project) + _list(args) + +def _list(args) -> None: + from roboflow.cli._output import output, output_error, suppress_sdk_output + + with suppress_sdk_output(): + try: + # ... your logic here ... + data = [{"id": "example"}] + except Exception as exc: + output_error(args, str(exc), hint="Check your project ID.", exit_code=3) + return + + output(args, data, text="Found 1 result.") +``` + +2. Register in `roboflow/cli/__init__.py`: +```python +from roboflow.cli.handlers.mycommand import mycommand_app +app.add_typer(mycommand_app, name="mycommand") +``` + +3. Add tests using `typer.testing.CliRunner` in `tests/cli/test_mycommand_handler.py` +4. Run `make check_code_quality` and `python -m unittest` + +**Agent experience checklist** (every command must satisfy): +- [ ] Supports `--json` via `output()` helper +- [ ] No interactive prompts when all required flags are provided +- [ ] Errors use `output_error(args, message, hint=..., exit_code=N)` +- [ ] SDK calls wrapped in `with suppress_sdk_output():` +- [ ] Exit codes: 0=success, 1=error, 2=auth, 3=not found + +**Documentation policy:** `CLI-COMMANDS.md` in this repo is a quickstart only. The comprehensive command reference lives in [`roboflow-dev-reference`](https://github.com/roboflow/roboflow-dev-reference) and is published to docs.roboflow.com/developer/command-line-interface. When adding a new command, update both: add a quick example to `CLI-COMMANDS.md` and the full reference to the dev-reference CLI page. + ### Pre-commit Hooks To ensure code quality and consistency, we use pre-commit hooks. Follow these steps to set up pre-commit in your development environment: diff --git a/Dockerfile.dev b/Dockerfile.dev index 1536f7c5..1f604c88 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,9 +1,25 @@ -FROM python:3.8 -RUN apt-get update && apt-get install -y make libgl1-mesa-glx && rm -rf /var/lib/apt/lists/* +FROM python:3.10 +RUN apt-get update && apt-get install -y make curl libgl1-mesa-glx ca-certificates && rm -rf /var/lib/apt/lists/* +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.local/bin:${PATH}" + + WORKDIR /roboflow-python COPY .devcontainer/bashrc_ext /root/bashrc_ext RUN echo "source /root/bashrc_ext" >> ~/.bashrc -COPY ./setup.py ./pyproject.toml ./README.md ./requirements.txt ./ + +# Trust any custom CAs provided in build context (e.g., mkcert) +COPY .devcontainer/certs/ /usr/local/share/ca-certificates/ +RUN update-ca-certificates || true +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + + +COPY ./requirements.txt ./ +RUN uv pip install --system -r requirements.txt + +COPY ./setup.py ./pyproject.toml ./README.md ./ COPY roboflow/__init__.py ./roboflow/__init__.py -RUN pip install -e ".[dev]" +RUN uv pip install --system -e ".[dev]" + COPY . . diff --git a/Makefile b/Makefile index 1d59d41f..91f294b9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: style check_code_quality publish +.PHONY: style check_code_quality export PYTHONPATH = . check_dirs := roboflow @@ -11,8 +11,3 @@ check_code_quality: ruff format $(check_dirs) --check ruff check $(check_dirs) mypy $(check_dirs) - -publish: - python setup.py sdist bdist_wheel - twine check dist/* - twine upload dist/* -u ${PYPI_USERNAME} -p ${PYPI_PASSWORD} --verbose diff --git a/README.md b/README.md index 31518ca9..63b78ead 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,20 @@ pip install "roboflow[desktop]" ``` +
+ Lightweight install (roboflow-slim) + + If you only need vision events, workspace management, and the CLI (no image processing, inference, or training), install the lightweight package: + + ```bash + pip install roboflow-slim + ``` + + This skips heavy dependencies like OpenCV, NumPy, Matplotlib, and Pillow, reducing install size from ~400MB to ~50MB. Useful for embedded devices, CI pipelines, and serverless environments. + + Both packages share the same codebase and version. `pip install roboflow` includes everything. +
+
Install from source @@ -104,21 +118,19 @@ Below are some common methods used with the Roboflow Python package, presented c ```python import roboflow -roboflow.login() +# Pass API key or use roboflow.login() +rf = roboflow.Roboflow(api_key="MY_API_KEY") -rf = roboflow.Roboflow() +workspace = rf.workspace() -# create a project -rf.create_project( - project_name="project name", - project_type="project-type", - license="project-license" # "private" for private projects +# creating object detection model that will detect flowers +project = workspace.create_project( + project_name="Flower detector", + project_type="object-detection", # Or "classification", "instance-segmentation", "semantic-segmentation" + project_license="MIT", # "private" for private projects, only available for paid customers + annotation="flowers" # If you plan to annotate lillys, sunflowers, etc. ) -workspace = rf.workspace("WORKSPACE_URL") -project = workspace.project("PROJECT_URL") -version = project.version("VERSION_NUMBER") - # upload a dataset workspace.upload_dataset( dataset_path="./dataset/", @@ -128,16 +140,13 @@ workspace.upload_dataset( project_type="object-detection" ) -# upload model weights -version.deploy(model_type="yolov8", model_path=f”{HOME}/runs/detect/train/”) +version = project.version("VERSION_NUMBER") # upload model weights - yolov10 -# Before attempting to upload YOLOv10 models install ultralytics like this: -# pip install git+https://github.com/THU-MIG/yolov10.git version.deploy(model_type="yolov10", model_path=f”{HOME}/runs/detect/train/”, filename="weights.pt") -# run inference -model = version.model +# run inference (a version may own several trained models; models() returns all of them) +model = version.models()[0] img_url = "https://media.roboflow.com/quickstart/aerial_drone.jpeg" @@ -146,6 +155,28 @@ predictions = model.predict(img_url, hosted=True).json() print(predictions) ``` +### Search and Export + +Search for images across your workspace and export matching results as a ready-to-use dataset: + +```python +workspace = rf.workspace() + +# Export images matching a search query +workspace.search_export( + query="class:person", # search query (e.g. "tag:review", "class:dog", "*") + format="coco", # annotation format: coco, yolov8, yolov5, voc, etc. + dataset="my-project", # optional: limit to a specific project + location="./my-export", # optional: output directory +) +``` + +Or from the CLI: + +```bash +roboflow search-export "class:person" -f coco -d my-project -l ./my-export +``` + ## Library Structure The Roboflow Python library is structured using the same Workspace, Project, and Version ontology that you will see in the Roboflow application. diff --git a/docs/core/training.md b/docs/core/training.md new file mode 100644 index 00000000..56f51fc4 --- /dev/null +++ b/docs/core/training.md @@ -0,0 +1 @@ +:::roboflow.core.training diff --git a/docs/index.md b/docs/index.md index 431d8ab2..decfe174 100644 --- a/docs/index.md +++ b/docs/index.md @@ -68,21 +68,18 @@ Below are some common methods used with the Roboflow Python package, presented c ```python import roboflow -roboflow.login() - -rf = roboflow.Roboflow() - -# create a project -rf.create_project( - project_name="project name", - project_type="project-type", - license="project-license" # "private" for private projects +# Pass API key or use roboflow.login() +rf = roboflow.Roboflow(api_key="MY_API_KEY") + +workspace = rf.workspace() +# creating object detection model that will detect flowers +project = workspace.create_project( + project_name="Flower detector", + project_type="object-detection", # Or "classification", "instance-segmentation", "semantic-segmentation" + project_license="MIT", # "private" for private projects, only available for paid customers + annotation="flowers" # If you plan to annotate lillys, sunflowers, etc. ) -workspace = rf.workspace("WORKSPACE_URL") -project = workspace.project("PROJECT_URL") -version = project.version("VERSION_NUMBER") - # upload a dataset workspace.upload_dataset( dataset_path="./dataset/", @@ -92,11 +89,13 @@ workspace.upload_dataset( project_type="object-detection" ) -# upload model weights -version.deploy(model_type="yolov8", model_path=f”{HOME}/runs/detect/train/”) +version = project.version("VERSION_NUMBER") + +# upload model weights - yolov10 +version.deploy(model_type="yolov10", model_path=f”{HOME}/runs/detect/train/”, filename="weights.pt") -# run inference -model = version.model +# run inference (a version may own several trained models; models() returns all of them) +model = version.models()[0] img_url = "https://media.roboflow.com/quickstart/aerial_drone.jpeg" @@ -105,6 +104,104 @@ predictions = model.predict(img_url, hosted=True).json() print(predictions) ``` +### Search and Export + +Search for images across your workspace and export matching results as a ready-to-use dataset: + +```python +workspace = rf.workspace() + +# Export images matching a search query +workspace.search_export( + query="class:person", # search query (e.g. "tag:review", "class:dog", "*") + format="coco", # annotation format: coco, yolov8, yolov5, voc, etc. + dataset="my-project", # optional: limit to a specific project + location="./my-export", # optional: output directory +) +``` + +Or from the CLI: + +```bash +roboflow search-export "class:person" -f coco -d my-project -l ./my-export +``` + +### Delete Workspace Images + +Delete orphan images (not in any project) from your workspace: + +```python +workspace = rf.workspace() + +# Delete orphan images by ID +result = workspace.delete_images(["image_id_1", "image_id_2"]) +print(f"Deleted: {result['deletedSources']}, Skipped: {result['skippedSources']}") +``` + +### Upload with Metadata + +Attach custom key-value metadata to images during upload: + +```python +project = workspace.project("my-project") + +# Upload a local image with metadata +project.upload( + image_path="./image.jpg", + metadata={"camera_id": "cam001", "location": "warehouse-3"}, +) + +# Upload a hosted image with metadata +project.upload( + image_path="https://example.com/image.jpg", + metadata={"camera_id": "cam002", "shift": "night"}, +) +``` + +Or from the CLI: + +```bash +roboflow upload image.jpg -p my-project -M '{"camera_id":"cam001","location":"warehouse-3"}' +``` + +### Update Metadata on Existing Images + +Update metadata and tags on images already in your workspace. Values in +`metadata` are upserted: new keys are added, existing keys are overwritten. + +```python +workspace = rf.workspace("my-workspace") + +# Single image (synchronous) +workspace.update_image_metadata( + "IMAGE_ID", + metadata={"quality_score": 95, "reviewed": True}, + remove_metadata=["old_key"], + add_tags=["reviewed"], + remove_tags=["pending"], +) + +# Also available from a project object +project.update_image_metadata("IMAGE_ID", metadata={"reviewed": True}) + +# Batch update up to 1,000 images (asynchronous); wait=True polls until done +final = workspace.batch_update_image_metadata( + [ + {"imageId": "img1", "metadata": {"batch": "june"}, "addTags": ["processed"]}, + {"imageId": "img2", "metadata": {"batch": "june"}, "addTags": ["processed"]}, + ], + wait=True, +) +print(final["result"]["succeeded"], final["result"]["failedItems"]) +``` + +Or from the CLI: + +```bash +roboflow image metadata IMAGE_ID -m '{"quality_score": 95}' --tags "reviewed" +roboflow image metadata img1,img2 --tags "processed" --poll +``` + ## Library Structure The Roboflow Python library is structured using the same Workspace, Project, and Version ontology that you will see in the Roboflow application. diff --git a/mkdocs.yml b/mkdocs.yml index e6b4a5df..11543b97 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,6 +34,7 @@ nav: - Projects: core/project.md - Workspaces: core/workspace.md - Versions: core/version.md + - Trainings: core/training.md - Models: - Object Detection: models/object-detection.md - Classification: models/classification.md diff --git a/pyproject.toml b/pyproject.toml index 61cfa870..8847bc57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ target = ["test", "roboflow"] tests = ["B201", "B301"] [tool.ruff] -target-version = "py38" +target-version = "py310" line-length = 120 [tool.ruff.lint] @@ -20,6 +20,7 @@ ignore = [ "BLE", "C", "COM", + "CPY", "D", "DTZ", "EM", @@ -32,6 +33,7 @@ ignore = [ "N", "PERF", "PIE", + "PLC0415", # `import` should be at the top-level of a file "PLR", "PLW", "PT", @@ -44,6 +46,7 @@ ignore = [ "T", "TD", "TRY", + "UP", ] # Exclude a variety of commonly ignored directories. @@ -86,6 +89,9 @@ convention = "google" "E402", # Module level import not at top of file "F401", # Imported but unused ] +"tests/manual/*.py" = [ + "INP001", # Manual scripts don't need __init__.py +] [tool.ruff.lint.pyupgrade] # Preserve types, even if a file imports `from __future__ import annotations`. @@ -99,9 +105,17 @@ banned-module-level-imports = [ ] [tool.mypy] -python_version = "3.8" +python_version = "3.10" exclude = ["^build/"] +# numpy's bundled stubs use PEP 695 `type` statements, which mypy rejects when +# checking against python_version 3.10. Skip following them so the type checker +# doesn't choke on numpy's own stub syntax. +[[tool.mypy.overrides]] +module = ["numpy", "numpy.*"] +follow_imports = "skip" +follow_imports_for_stubs = true + [[tool.mypy.overrides]] module = [ "_datetime.*", @@ -110,10 +124,8 @@ module = [ "IPython.display.*", # ipywidgets is an optional dependency "ipywidgets.*", - # matplotlib typing is not available for Python 3.8 - # remove this when we stop supporting Python 3.8 - "matplotlib.*", "requests_toolbelt.*", + "rfdetr.*", "torch.*", "ultralytics.*", ] diff --git a/requirements-slim.txt b/requirements-slim.txt new file mode 100644 index 00000000..9c4d021e --- /dev/null +++ b/requirements-slim.txt @@ -0,0 +1,13 @@ +certifi +idna +requests +urllib3>=1.26.6 +tqdm>=4.41.0 +PyYAML>=5.3.1 +requests_toolbelt +filetype +typer>=0.12.0,<0.26 # 0.26 vendors click, dropping the external dep the CLI imports +click>=8.0 +python-dateutil +python-dotenv +six diff --git a/requirements.txt b/requirements.txt index 0a70f16b..c6f09308 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,18 @@ certifi -idna==3.7 +idna>=3.7 cycler kiwisolver>=1.3.1 matplotlib -numpy>=1.18.5 -opencv-python-headless==4.10.0.84 +# numpy 2.4 ships PEP 695 `type` statements in its stubs, which mypy rejects +# under python_version=3.10 (see [tool.mypy] in pyproject.toml). Cap below 2.4, +# matching rf-detr's typing constraint. +numpy>=1.18.5,<2.4 +opencv-python-headless>=4.10.0 # relax exact pin to avoid downstream conflicts (#349) Pillow>=7.1.2 +# https://github.com/roboflow/roboflow-python/issues/390 +# pi-heif 1.x requires Python 3.10+ +pi-heif<2; python_version >= "3.10" +pillow-avif-plugin<2 python-dateutil python-dotenv requests @@ -15,3 +22,5 @@ tqdm>=4.41.0 PyYAML>=5.3.1 requests_toolbelt filetype +typer>=0.12.0,<0.26 # 0.26 vendors click, dropping the external dep the CLI imports +click>=8.0 diff --git a/roboflow/__init__.py b/roboflow/__init__.py index bf1fca79..c248fa03 100644 --- a/roboflow/__init__.py +++ b/roboflow/__init__.py @@ -10,12 +10,18 @@ from roboflow.adapters import rfapi from roboflow.config import API_URL, APP_URL, DEMO_KEYS, load_roboflow_api_key -from roboflow.core.project import Project from roboflow.core.workspace import Workspace -from roboflow.models import CLIPModel, GazeModel # noqa: F401 from roboflow.util.general import write_line -__version__ = "1.1.45" +try: + from roboflow.core.project import Project + from roboflow.models import CLIPModel, GazeModel # noqa: F401 +except ImportError: + Project = None # type: ignore[assignment,misc] + CLIPModel = None # type: ignore[assignment,misc] + GazeModel = None # type: ignore[assignment,misc] + +__version__ = "1.4.0" def check_key(api_key, model, notebook, num_retries=0): @@ -43,7 +49,7 @@ def check_key(api_key, model, notebook, num_retries=0): num_retries += 1 return check_key(api_key, model, notebook, num_retries) else: - raise RuntimeError("There was an error validating the api key with Roboflow" " server.") + raise RuntimeError("There was an error validating the api key with Roboflow server.") else: r = response.json() return r @@ -71,7 +77,7 @@ def login(workspace=None, force=False): # default configuration location conf_location = os.getenv("ROBOFLOW_CONFIG_DIR", default=default_path) if os.path.isfile(conf_location) and not force: - write_line("You are already logged into Roboflow. To make a different login," "run roboflow.login(force=True).") + write_line("You are already logged into Roboflow. To make a different login,run roboflow.login(force=True).") return None # we could eventually return the workspace object here # return Roboflow().workspace() @@ -131,17 +137,12 @@ def initialize_roboflow(the_workspace=None): global active_workspace - conf_location = os.getenv("ROBOFLOW_CONFIG_DIR", default=str(Path.home() / ".config" / "roboflow" / "config.json")) - - if not os.path.isfile(conf_location): - raise RuntimeError("To use this method, you must first login - run roboflow.login()") + if the_workspace is None: + active_workspace = Roboflow().workspace() else: - if the_workspace is None: - active_workspace = Roboflow().workspace() - else: - active_workspace = Roboflow().workspace(the_workspace) + active_workspace = Roboflow().workspace(the_workspace) - return active_workspace + return active_workspace def load_model(model_url): @@ -167,7 +168,9 @@ def load_model(model_url): project = operate_workspace.project(project) version = project.version(version) - model = version.model + # version.model is deprecated; read the underlying legacy model directly so + # load_model keeps its single-model return contract without emitting the warning. + model = getattr(version, "_model", None) return model @@ -255,6 +258,10 @@ def project(self, project_name, the_workspace=None): :param the_workspace workspace name :return project object """ + if Project is None: + raise ImportError( + "Project requires additional dependencies. Install the full package: pip install roboflow" + ) if the_workspace is None: if "/" in project_name: diff --git a/roboflow/adapters/deploymentapi.py b/roboflow/adapters/deploymentapi.py index cfe88885..fa86c8af 100644 --- a/roboflow/adapters/deploymentapi.py +++ b/roboflow/adapters/deploymentapi.py @@ -1,3 +1,5 @@ +import urllib + import requests from roboflow.config import DEDICATED_DEPLOYMENT_URL @@ -7,10 +9,13 @@ class DeploymentApiError(Exception): pass -def add_deployment(api_key, machine_type, duration, delete_on_expiration, deployment_name, inference_version): +def add_deployment( + api_key, creator_email, machine_type, duration, delete_on_expiration, deployment_name, inference_version +): url = f"{DEDICATED_DEPLOYMENT_URL}/add" params = { "api_key": api_key, + "creator_email": creator_email, # "security_level": security_level, "duration": duration, "delete_on_expiration": delete_on_expiration, @@ -41,6 +46,48 @@ def list_deployment(api_key): return response.status_code, response.json() +def get_workspace_usage(api_key, from_timestamp, to_timestamp): + params = {"api_key": api_key} + if from_timestamp is not None: + params["from_timestamp"] = from_timestamp.isoformat() # may contain + sign + if to_timestamp is not None: + params["to_timestamp"] = to_timestamp.isoformat() # may contain + sign + url = f"{DEDICATED_DEPLOYMENT_URL}/usage_workspace?{urllib.parse.urlencode(params)}" + response = requests.get(url) + if response.status_code != 200: + return response.status_code, response.text + return response.status_code, response.json() + + +def get_deployment_usage(api_key, deployment_name, from_timestamp, to_timestamp): + params = {"api_key": api_key, "deployment_name": deployment_name} + if from_timestamp is not None: + params["from_timestamp"] = from_timestamp.isoformat() # may contain + sign + if to_timestamp is not None: + params["to_timestamp"] = to_timestamp.isoformat() # may contain + sign + url = f"{DEDICATED_DEPLOYMENT_URL}/usage_deployment?{urllib.parse.urlencode(params)}" + response = requests.get(url) + if response.status_code != 200: + return response.status_code, response.text + return response.status_code, response.json() + + +def pause_deployment(api_key, deployment_name): + url = f"{DEDICATED_DEPLOYMENT_URL}/pause" + response = requests.post(url, json={"api_key": api_key, "deployment_name": deployment_name}) + if response.status_code != 200: + return response.status_code, response.text + return response.status_code, response.json() + + +def resume_deployment(api_key, deployment_name): + url = f"{DEDICATED_DEPLOYMENT_URL}/resume" + response = requests.post(url, json={"api_key": api_key, "deployment_name": deployment_name}) + if response.status_code != 200: + return response.status_code, response.text + return response.status_code, response.json() + + def delete_deployment(api_key, deployment_name): url = f"{DEDICATED_DEPLOYMENT_URL}/delete" response = requests.post(url, json={"api_key": api_key, "deployment_name": deployment_name}) @@ -55,3 +102,18 @@ def list_machine_types(api_key): if response.status_code != 200: return response.status_code, response.text return response.status_code, response.json() + + +def get_deployment_log(api_key, deployment_name, from_timestamp=None, to_timestamp=None, max_entries=-1): + params = {"api_key": api_key, "deployment_name": deployment_name} + if from_timestamp is not None: + params["from_timestamp"] = from_timestamp.isoformat() # may contain + sign + if to_timestamp is not None: + params["to_timestamp"] = to_timestamp.isoformat() # may contain + sign + if max_entries > 0: + params["max_entries"] = max_entries + url = f"{DEDICATED_DEPLOYMENT_URL}/get_log?{urllib.parse.urlencode(params)}" + response = requests.get(url) + if response.status_code != 200: + return response.status_code, response.text + return response.status_code, response.json() diff --git a/roboflow/adapters/devicesapi.py b/roboflow/adapters/devicesapi.py new file mode 100644 index 00000000..92534542 --- /dev/null +++ b/roboflow/adapters/devicesapi.py @@ -0,0 +1,298 @@ +"""Adapter for the workspace-scoped device management API. + +Wraps the read-only external observability endpoints plus device create +served by the ``light.v2.device`` Cloud Function. Routes are documented in +``docs/api/deployments/overview.md`` of the ``roboflow/roboflow`` repo. + +Read endpoints require the ``device:read`` scope; create requires +``device:update``. Authentication is via the workspace api_key. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from urllib.parse import urlencode + +import requests + +from roboflow.adapters.rfapi import RoboflowError +from roboflow.config import API_URL + +DEFAULT_TIMEOUT = (10, 60) + +# Cap on raw error-body bytes surfaced through exception messages. Server-side +# 500s sometimes return a multi-KB HTML stack trace; without a cap the whole +# blob would land in `str(exc)` and wreck terminals/log lines. +_MAX_ERROR_BODY_CHARS = 1024 + + +def _truncate(text: str) -> str: + if not text: + return text + if len(text) <= _MAX_ERROR_BODY_CHARS: + return text + return text[:_MAX_ERROR_BODY_CHARS] + "…[truncated]" + + +class DeviceApiError(RoboflowError): + """Raised when a device API call returns a non-success status.""" + + def __init__(self, message: str, status_code: Optional[int] = None) -> None: + super().__init__(message, status_code=status_code) + + +class DeviceNotFoundError(DeviceApiError): + """404 β€” device or stream does not exist or is owned by a different workspace.""" + + +class DeviceAuthError(DeviceApiError): + """401/403 β€” missing key, wrong scope, or device-bound key targeting a sibling.""" + + +class DeviceRateLimitedError(DeviceApiError): + """429 β€” logs (5/min/IP) or telemetry (60/min) limit hit.""" + + +class DeviceBadRequestError(DeviceApiError): + """400 β€” malformed cursor, unparseable date, unknown ``time_period``.""" + + +def _build_url(workspace: str, path: str, api_key: str, query: Optional[Dict[str, Any]] = None) -> str: + base = f"{API_URL}/{workspace}/devices/v2{path}" + params: Dict[str, Any] = {"api_key": api_key} + if query: + for key, value in query.items(): + if value is None: + continue + if isinstance(value, list): + if not value: + continue + params[key] = ",".join(str(v) for v in value) + else: + params[key] = value + return f"{base}?{urlencode(params, doseq=False)}" + + +def _raise_for_status(response: requests.Response) -> None: + if response.status_code < 400: + return + error_type: Optional[str] = None + try: + payload = response.json() + err = payload.get("error") if isinstance(payload, dict) else None + if isinstance(err, dict): + message = err.get("message") or response.text + raw_type = err.get("type") + error_type = raw_type if isinstance(raw_type, str) else None + elif isinstance(err, str): + message = err + else: + message = response.text + except Exception: # noqa: BLE001 + message = response.text + message = _truncate(message) + code = response.status_code + if code == 400: + raise DeviceBadRequestError(message or "Bad request", status_code=code) + if code in (401, 403): + raise DeviceAuthError(message or "Unauthorized", status_code=code) + if code == 404: + # validateToken.js returns 404 + GraphMethodException when an api_key + # is valid for this workspace but lacks the required scope + # (device:read / device:update). Surface that as auth so the CLI + # exits 2 with the scope hint instead of 3 ("not found"). + if error_type == "GraphMethodException": + raise DeviceAuthError(message or "Forbidden", status_code=code) + raise DeviceNotFoundError(message or "Not found", status_code=code) + if code == 429: + raise DeviceRateLimitedError(message or "Rate limited", status_code=code) + raise DeviceApiError(message or f"HTTP {code}", status_code=code) + + +def list_devices(api_key: str, workspace: str) -> Dict[str, Any]: + """``GET /:workspace/devices/v2`` β€” returns the parsed JSON response.""" + response = requests.get(_build_url(workspace, "", api_key), timeout=DEFAULT_TIMEOUT) + _raise_for_status(response) + return response.json() + + +def create_device( + api_key: str, + workspace: str, + *, + device_name: str, + device_type: Optional[str] = None, + workflow_id: Optional[str] = None, + tags: Optional[List[str]] = None, + offline_mode: Optional[bool] = None, + source_device_id: Optional[str] = None, +) -> Dict[str, Any]: + """``POST /:workspace/devices/v2`` β€” returns ``{ deviceId, installId }``.""" + body: Dict[str, Any] = {"device_name": device_name} + if device_type is not None: + body["device_type"] = device_type + if workflow_id is not None: + body["workflow_id"] = workflow_id + if tags is not None: + body["tags"] = tags + if offline_mode is not None: + body["offline_mode"] = offline_mode + if source_device_id is not None: + # Body field is camelCase per docs/api/deployments/overview.md + body["sourceDeviceId"] = source_device_id + response = requests.post( + _build_url(workspace, "", api_key), + json=body, + timeout=DEFAULT_TIMEOUT, + ) + _raise_for_status(response) + return response.json() + + +def get_device(api_key: str, workspace: str, device_id: str) -> Dict[str, Any]: + """``GET /:workspace/devices/v2/:deviceId``.""" + response = requests.get(_build_url(workspace, f"/{device_id}", api_key), timeout=DEFAULT_TIMEOUT) + _raise_for_status(response) + return response.json() + + +def get_device_config(api_key: str, workspace: str, device_id: str) -> Dict[str, Any]: + """``GET /:workspace/devices/v2/:deviceId/config``. + + Note: + The response can include ``environment_variables`` and integration + credentials. Treat the returned dict as sensitive. + """ + response = requests.get(_build_url(workspace, f"/{device_id}/config", api_key), timeout=DEFAULT_TIMEOUT) + _raise_for_status(response) + return response.json() + + +def get_device_config_history( + api_key: str, + workspace: str, + device_id: str, + *, + limit: Optional[int] = None, + cursor: Optional[str] = None, +) -> Dict[str, Any]: + """``GET /:workspace/devices/v2/:deviceId/config/history``.""" + response = requests.get( + _build_url( + workspace, + f"/{device_id}/config/history", + api_key, + query={"limit": limit, "cursor": cursor}, + ), + timeout=DEFAULT_TIMEOUT, + ) + _raise_for_status(response) + return response.json() + + +def list_device_streams(api_key: str, workspace: str, device_id: str) -> Dict[str, Any]: + """``GET /:workspace/devices/v2/:deviceId/streams``.""" + response = requests.get(_build_url(workspace, f"/{device_id}/streams", api_key), timeout=DEFAULT_TIMEOUT) + _raise_for_status(response) + return response.json() + + +def get_device_stream(api_key: str, workspace: str, device_id: str, stream_id: str) -> Dict[str, Any]: + """``GET /:workspace/devices/v2/:deviceId/streams/:streamId``.""" + response = requests.get( + _build_url(workspace, f"/{device_id}/streams/{stream_id}", api_key), + timeout=DEFAULT_TIMEOUT, + ) + _raise_for_status(response) + return response.json() + + +def get_device_logs( + api_key: str, + workspace: str, + device_id: str, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + service: Optional[List[str]] = None, + severity: Optional[List[str]] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, +) -> Dict[str, Any]: + """``GET /:workspace/devices/v2/:deviceId/logs``. Rate limited 5/min/IP.""" + response = requests.get( + _build_url( + workspace, + f"/{device_id}/logs", + api_key, + query={ + "start_time": start_time, + "end_time": end_time, + "service": service, + "severity": severity, + "limit": limit, + "cursor": cursor, + }, + ), + timeout=DEFAULT_TIMEOUT, + ) + _raise_for_status(response) + return response.json() + + +def get_device_telemetry( + api_key: str, + workspace: str, + device_id: str, + *, + time_period: Optional[str] = None, +) -> Dict[str, Any]: + """``GET /:workspace/devices/v2/:deviceId/telemetry``. Rate limited 60/min.""" + response = requests.get( + _build_url( + workspace, + f"/{device_id}/telemetry", + api_key, + query={"time_period": time_period}, + ), + timeout=DEFAULT_TIMEOUT, + ) + _raise_for_status(response) + return response.json() + + +def get_device_events( + api_key: str, + workspace: str, + device_id: str, + *, + entity_type: Optional[str] = None, + entity_id: Optional[str] = None, + event: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + direction: Optional[str] = None, +) -> Dict[str, Any]: + """``GET /:workspace/devices/v2/:deviceId/events``.""" + response = requests.get( + _build_url( + workspace, + f"/{device_id}/events", + api_key, + query={ + "entity_type": entity_type, + "entity_id": entity_id, + "event": event, + "start_time": start_time, + "end_time": end_time, + "limit": limit, + "cursor": cursor, + "direction": direction, + }, + ), + timeout=DEFAULT_TIMEOUT, + ) + _raise_for_status(response) + return response.json() diff --git a/roboflow/adapters/rfapi.py b/roboflow/adapters/rfapi.py index 3e1d1982..e2631122 100644 --- a/roboflow/adapters/rfapi.py +++ b/roboflow/adapters/rfapi.py @@ -1,32 +1,44 @@ import json +import mimetypes import os import urllib -from typing import Optional +from typing import Any, Dict, List, Optional, Union +from urllib.parse import quote import requests +from requests.exceptions import RequestException from requests_toolbelt.multipart.encoder import MultipartEncoder from roboflow.config import API_URL, DEFAULT_BATCH_NAME, DEFAULT_JOB_NAME -from roboflow.util import image_utils class RoboflowError(Exception): - pass + """Generic API error. + + Optional `status_code` is the HTTP status from the upstream response + when available β€” set by helpers like `_raise_for_trash_response` so + callers can branch on auth (401) vs not-found (404) without string + matching the message. Existing call sites that pass only a message + still work; the attribute defaults to `None`. + """ + + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code class ImageUploadError(RoboflowError): def __init__(self, message, status_code=None): self.message = message - self.status_code = status_code self.retries = 0 - super().__init__(self.message) + super().__init__(self.message, status_code=status_code) class AnnotationSaveError(RoboflowError): def __init__(self, message, status_code=None): self.message = message - self.status_code = status_code - super().__init__(self.message) + self.retries = 0 + super().__init__(self.message, status_code=status_code) def get_workspace(api_key, workspace_url): @@ -47,6 +59,620 @@ def get_project(api_key, workspace_url, project_url): return result +def get_project_health(api_key, workspace_url, project_url, regenerate=False): + """GET /{workspace}/{project}/health β€” dataset health check statistics.""" + url = f"{API_URL}/{workspace_url}/{project_url}/health?api_key={api_key}" + if regenerate: + url += "®enerate=true" + response = requests.get(url) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def start_version_training( + api_key: str, + workspace_url: str, + project_url: str, + version: str, + *, + speed: Optional[str] = None, + checkpoint: Optional[str] = None, + model_type: Optional[str] = None, + epochs: Optional[int] = None, +): + """ + Start a training job for a specific version. + + This is a thin plumbing wrapper around the backend endpoint. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/train?api_key={api_key}&nocache=true" + + data: Dict[str, Union[str, int]] = {} + if speed is not None: + data["speed"] = speed + if checkpoint is not None: + data["checkpoint"] = checkpoint + if model_type is not None: + # API expects camelCase + data["modelType"] = model_type + if epochs is not None: + data["epochs"] = epochs + + response = requests.post(url, json=data) + if not response.ok: + raise RoboflowError(response.text) + return True + + +def cancel_version_training( + api_key: str, + workspace_url: str, + project_url: str, + version: str, + *, + continue_if_no_refund: bool = False, +): + """Cancel an in-flight training run. + + Backend handler is canonical for both vanilla and NAS trainings β€” it + accepts ``mining`` status, so this works for NAS sweeps too. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/train/cancel?api_key={api_key}" + body: Dict[str, Union[str, int, bool]] = {} + if continue_if_no_refund: + body["continueIfNoRefund"] = True + response = requests.post(url, json=body) + if not response.ok: + raise RoboflowError(response.text) + return response.json() if response.content else {"success": True} + + +def stop_version_training(api_key: str, workspace_url: str, project_url: str, version: str): + """Request an early stop on an in-flight training run. + + The backend flips ``train.requestedStop``; the run finishes the current + phase gracefully (mining or training). + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/train/stop?api_key={api_key}" + response = requests.post(url, json={}) + if not response.ok: + raise RoboflowError(response.text) + return response.json() if response.content else {"success": True} + + +def resolve_version_training_id( + api_key: str, + workspace_url: str, + project_url: str, + version: str, + training_id: Optional[str] = None, +) -> str: + """Resolve the training run a version-scoped call targets. + + A supplied id is returned as-is (blank β†’ ``ValueError``). When omitted, + the version's sole run is resolved via ``list_trainings_for_version``; + zero or multiple runs raise with the run ids so the caller can pick one. + """ + if training_id is not None: + if not str(training_id).strip(): + raise ValueError("training_id must be a non-empty string when provided") + return training_id + trainings = list_trainings_for_version(api_key, workspace_url, project_url, version) + if not trainings: + raise RoboflowError(f"No training runs found for {project_url}/{version}.") + if len(trainings) > 1: + ids = ", ".join(str(t.get("id")) for t in trainings) + raise RoboflowError( + f"MULTIPLE_TRAININGS: version {project_url}/{version} owns several runs ({ids}); pass training_id." + ) + return str(trainings[0].get("id")) + + +def delete_version_training( + api_key: str, + workspace_url: str, + project_url: str, + version: str, + *, + training_id: str, +): + """Move a terminal training run to the workspace Trash (soft delete). + + DELETE /{workspace}/{project}/{version}/v2/trainings/{training_id} β€” the + same resource-DELETE pattern as project/version/workflow deletion, and the + same ``{deleted, type, ..., trash: true}`` response shape. The run and + every model it produced disappear from listings but stay restorable for + 30 days via ``restore_trash_item(..., "training", ...)`` or the Trash UI, + after which they are permanently deleted. The server refuses in-flight + runs (stop or cancel first) and the run backing the version's registered + model. There is no permanent-delete option on the public API. + + ``training_id`` is required (it is the resource path). Use + ``resolve_version_training_id`` to target a version's sole run. + """ + if not training_id or not str(training_id).strip(): + raise ValueError("training_id is required") + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/{training_id}?api_key={api_key}" + response = requests.delete(url) + if not response.ok: + raise RoboflowError(response.text) + return response.json() if response.content else {"deleted": True} + + +def get_training_results(api_key: str, workspace_url: str, project_url: str, version: str): + """Run-level training results bundle. + + For NAS runs returns ``{ trainingId, status, modelGroup, modelCount, + recommendedByHardware, mining?, models: [...] }``. For non-NAS runs + returns a minimal bundle with the produced model(s). + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/training/results?api_key={api_key}" + response = requests.get(url) + if not response.ok: + raise RoboflowError(response.text) + return response.json() + + +# --------------------------------------------------------------------------- +# DNA v2 trainings surface (MMPV-aware). Mirrors the MCP's rf_api.py 1:1: a +# version owns many trainings, each owning one or more models (a NAS run owns +# many). trainingId rides in the query/body, never the path, because legacy ids +# contain slashes. The legacy-vs-MMPV branch lives entirely on the backend. +# --------------------------------------------------------------------------- + + +def list_trainings_for_version(api_key: str, workspace_url: str, project_url: str, version: str): + """List a version's trainings (DNA ``trainings.list``). + + GET /{ws}/{proj}/{version}/v2/trainings. MMPV versions return every run; + SMPV versions return a single entry synthesized from ``version.train``. + Returns the raw ``trainings`` array β€” each entry carries + ``{id, versionId, status, start, end, jobType, modelType, modelGroup, modelIds}``. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings?api_key={api_key}" + response = requests.get(url) + if not response.ok: + raise RoboflowError(response.text) + data = response.json() + return data.get("trainings", []) or [] + + +def get_training(api_key: str, workspace_url: str, project_url: str, version: str, training_id=None): + """A single run's results bundle (DNA ``trainings.get``). + + GET /{ws}/{proj}/{version}/v2/trainings/get[?trainingId=]. Omitting + ``training_id`` targets the version's sole run; a version that owns several + runs responds 409 (list them and pass a specific id). Returns + ``{trainingId, status, modelType, modelGroup, modelCount, models: [...]}``, + each model carrying an inference-style ``modelId`` (``/``). + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/get?api_key={api_key}" + if training_id: + url += f"&trainingId={quote(str(training_id), safe='')}" + response = requests.get(url) + if not response.ok: + raise RoboflowError(response.text) + return response.json() + + +def get_train_recipe( + api_key: str, + workspace_url: str, + project_url: str, + version: str, + *, + model_type: str, +): + """GET /{ws}/{proj}/{version}/v2/trainings/recipe β€” training schema for a model type. + + Returns the tunable-hyperparameter schema, the allowed online + augmentation/preprocessing steps, and a ready-to-submit ``template`` + that can be edited and passed to ``create_training_v2`` as ``train_recipe``. + """ + encoded_model_type = quote(model_type, safe="") + url = ( + f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/recipe" + f"?api_key={api_key}&modelType={encoded_model_type}" + ) + response = requests.get(url) + if not response.ok: + raise RoboflowError(response.text) + return response.json() + + +def create_training_v2( + api_key: str, + workspace_url: str, + project_url: str, + version: str, + *, + speed: Optional[str] = None, + checkpoint: Optional[str] = None, + model_type: Optional[str] = None, + epochs: Optional[int] = None, + train_recipe: Optional[Dict] = None, +): + """Create a training on a version (DNA ``trainings.create``). + + POST /{ws}/{proj}/{version}/v2/trainings. A version may own many trainings, + so repeated/concurrent runs are allowed; the backend rejects a second run on + a legacy (SMPV) version. Returns ``{trainingId, status, jobId}``. + + ``train_recipe`` submits a full recipe (camelCase ``trainRecipe`` body + key) β€” typically the ``template`` from ``get_train_recipe`` with edited + hyperparameters/online augmentation; the server dense-fills omitted + defaults. Only non-None arguments are sent. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings?api_key={api_key}" + data: Dict[str, Union[str, int, Dict]] = {} + if speed is not None: + data["speed"] = speed + if checkpoint is not None: + data["checkpoint"] = checkpoint + if model_type is not None: + data["modelType"] = model_type + if epochs is not None: + data["epochs"] = epochs + if train_recipe is not None: + data["trainRecipe"] = train_recipe + response = requests.post(url, json=data) + if not response.ok: + raise RoboflowError(response.text) + return response.json() if response.content else {"status": "training_started"} + + +def cancel_training_v2( + api_key: str, + workspace_url: str, + project_url: str, + version: str, + training_id=None, + continue_if_no_refund: bool = False, +): + """Cancel an in-flight run (DNA ``trainings.cancel``). + + POST /{ws}/{proj}/{version}/v2/trainings/cancel. ``training_id`` selects a + specific run; omit it to target the version's sole run. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/cancel?api_key={api_key}" + body: Dict[str, Union[str, bool]] = {} + if training_id: + body["trainingId"] = training_id + if continue_if_no_refund: + body["continueIfNoRefund"] = True + response = requests.post(url, json=body) + if not response.ok: + raise RoboflowError(response.text) + return response.json() if response.content else {"success": True} + + +def stop_training_v2(api_key: str, workspace_url: str, project_url: str, version: str, training_id=None): + """Request an early stop on an in-flight run (DNA ``trainings.stop``). + + POST /{ws}/{proj}/{version}/v2/trainings/stop. ``training_id`` selects a + specific run; omit it to target the version's sole run. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/v2/trainings/stop?api_key={api_key}" + body: Dict[str, str] = {} + if training_id: + body["trainingId"] = training_id + response = requests.post(url, json=body) + if not response.ok: + raise RoboflowError(response.text) + return response.json() if response.content else {"success": True} + + +def get_model_weights_url(api_key: str, workspace_url: str, project_url: str, model_id: str, model_format: str = "pt"): + """Resolve a signed PyTorch weights URL for a single trained model. + + GET /{ws}/{proj}/{model_id}/ptFile, where ``model_id`` is the addressable + segment of an inference-style id β€” a model slug (MMPV) or a version number + (SMPV). Returns the signed ``weightsUrl``. + """ + if model_format != "pt": + raise RoboflowError(f"Unsupported weights format '{model_format}'. Only 'pt' is supported.") + encoded = quote(str(model_id), safe="") + url = f"{API_URL}/{workspace_url}/{project_url}/{encoded}/ptFile?api_key={api_key}" + response = requests.get(url) + if not response.ok: + raise RoboflowError(response.text) + return response.json()["weightsUrl"] + + +def list_project_models( + api_key: str, + workspace_url: str, + project_url: str, + *, + group: Optional[str] = None, +): + """List models for a project; pass ``group`` to scope to one NAS run.""" + url = f"{API_URL}/{workspace_url}/{project_url}/models?api_key={api_key}" + if group: + url += f"&group={urllib.parse.quote(group, safe='')}" + response = requests.get(url) + if not response.ok: + raise RoboflowError(response.text) + return response.json() + + +def get_model_by_url(api_key: str, workspace_url: str, model_url: str): + """Fetch a single model by its URL slug.""" + encoded = urllib.parse.quote(model_url, safe="/") + url = f"{API_URL}/models/{workspace_url}/{encoded}?api_key={api_key}" + response = requests.get(url) + if not response.ok: + raise RoboflowError(response.text) + return response.json() + + +def favorite_nas_model(api_key: str, workspace_url: str, model_id: str, *, starred: bool = True): + """Star or unstar a NAS-trained model. + + ``model_id`` is the opaque public model id (e.g. ``my-project-3-nas-gpu-b``), + the same value the public API returns as ``models[].modelId`` on + ``GET /:workspace/:project/:version/training/results``. NAS-only on the + server side. + """ + encoded = urllib.parse.quote(model_id, safe="") + url = f"{API_URL}/{workspace_url}/models/{encoded}/favorite?api_key={api_key}" + response = requests.post(url, json={"starred": bool(starred)}) + if not response.ok: + raise RoboflowError(response.text) + return response.json() + + +def get_version(api_key: str, workspace_url: str, project_url: str, version: str, nocache: bool = False): + """ + Fetch detailed information about a specific dataset version. + + Args: + api_key: Roboflow API key + workspace_url: Workspace slug/url + project_url: Project slug/url + version: Version identifier (number or slug) + nocache: If True, bypass server-side cache + + Returns: + Parsed JSON response from the API. + + Raises: + RoboflowError: On non-200 response status codes. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}?api_key={api_key}" + if nocache: + url += "&nocache=true" + + response = requests.get(url) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def get_version_export( + api_key: str, + workspace_url: str, + project_url: str, + version: str, + format: str, +): + """ + Fetch export status or finalized link for a specific version/format. + + Returns either: + - {"ready": False, "progress": float} when the export is in progress (HTTP 202) + - The raw JSON payload (dict) from the server when the export is ready (HTTP 200) + + Raises RoboflowError on non-200/202 statuses or invalid/missing JSON when 200/202. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}/{format}?api_key={api_key}&nocache=true" + response = requests.get(url) + + # Non-success codes other than 202 are errors + if response.status_code not in (200, 202): + raise RoboflowError(response.text) + + try: + payload = response.json() + except Exception: + # If server returns a 200/202 without JSON, treat as error for consumers + raise RoboflowError(str(response)) + + if response.status_code == 202: + progress = payload.get("progress") + try: + progress_val = float(progress) if progress is not None else 0.0 + except Exception: + progress_val = 0.0 + return {"ready": False, "progress": progress_val} + + # 200 OK: export is ready; return payload unchanged + return payload + + +def start_search_export( + api_key: str, + workspace_url: str, + query: str, + format: str, + session: requests.Session, + dataset: Optional[str] = None, + annotation_group: Optional[str] = None, + name: Optional[str] = None, +) -> str: + """Start a search export job. + + Returns the export_id string used to poll for completion. + + Raises RoboflowError on non-202 responses. + """ + url = f"{API_URL}/{workspace_url}/search/export?api_key={api_key}" + body: Dict[str, str] = {"query": query, "format": format} + if dataset is not None: + body["dataset"] = dataset + if annotation_group is not None: + body["annotationGroup"] = annotation_group + if name is not None: + body["name"] = name + + response = session.post(url, json=body) + if response.status_code != 202: + raise RoboflowError(response.text) + + payload = response.json() + return payload["link"] + + +def get_search_export(api_key: str, workspace_url: str, export_id: str, session: requests.Session) -> dict: + """Poll the status of a search export job. + + Returns dict with ``ready`` (bool) and ``link`` (str, present when ready). + + Raises RoboflowError on non-200 responses. + """ + url = f"{API_URL}/{workspace_url}/search/export/{export_id}?api_key={api_key}" + response = session.get(url) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def workspace_search( + api_key: str, + workspace_url: str, + query: str, + page_size: int = 50, + fields: Optional[List[str]] = None, + continuation_token: Optional[str] = None, +) -> dict: + """Search across all images in a workspace using RoboQL syntax. + + Args: + api_key: Roboflow API key. + workspace_url: Workspace slug/url. + query: RoboQL search query (e.g. ``"tag:review"``, ``"project:false"``). + page_size: Number of results per page (default 50). + fields: Fields to include in each result. + continuation_token: Token for fetching the next page. + + Returns: + Parsed JSON response with ``results``, ``total``, and ``continuationToken``. + + Raises: + RoboflowError: On non-200 response status codes. + """ + url = f"{API_URL}/{workspace_url}/search/v1?api_key={api_key}" + payload: Dict[str, Union[str, int, List[str]]] = { + "query": query, + "pageSize": page_size, + } + if fields is not None: + payload["fields"] = fields + if continuation_token is not None: + payload["continuationToken"] = continuation_token + + response = requests.post(url, json=payload) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def workspace_delete_images( + api_key: str, + workspace_url: str, + image_ids: List[str], +) -> dict: + """Delete orphan images from a workspace. + + Args: + api_key: Roboflow API key. + workspace_url: Workspace slug/url. + image_ids: List of image IDs to delete. + + Returns: + Parsed JSON response with deletion counts. + + Raises: + RoboflowError: On non-200 response status codes. + """ + url = f"{API_URL}/{workspace_url}/images?api_key={api_key}" + response = requests.delete(url, json={"images": image_ids}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def update_image_metadata( + api_key: str, + workspace_url: str, + image_id: str, + *, + metadata: Optional[Dict] = None, + remove_metadata: Optional[List[str]] = None, + add_tags: Optional[List[str]] = None, + remove_tags: Optional[List[str]] = None, +) -> dict: + """Update metadata and tags on a single image (synchronous). + + Args: + api_key: Roboflow API key. + workspace_url: Workspace slug/url. + image_id: Image/source ID. + metadata: Key-value pairs to set on the image. + remove_metadata: Metadata keys to delete. + add_tags: Tags to append. + remove_tags: Tags to remove. + + Returns: + Parsed JSON response (``{"success": true}``). + + Raises: + RoboflowError: On non-200 response. + """ + url = f"{API_URL}/{workspace_url}/images/{quote(image_id, safe='')}/metadata" + body: Dict[str, Any] = {} + if metadata is not None: + body["metadata"] = metadata + if remove_metadata is not None: + body["removeMetadata"] = remove_metadata + if add_tags is not None: + body["addTags"] = add_tags + if remove_tags is not None: + body["removeTags"] = remove_tags + + response = requests.post(url, params={"api_key": api_key}, json=body) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def batch_update_image_metadata( + api_key: str, + workspace_url: str, + updates: List[Dict], +) -> dict: + """Batch-update metadata and tags on multiple images (asynchronous). + + Args: + api_key: Roboflow API key. + workspace_url: Workspace slug/url. + updates: List of update dicts, each containing ``imageId`` and optionally + ``metadata``, ``removeMetadata``, ``addTags``, ``removeTags``. + + Returns: + Parsed JSON with ``taskId`` and ``url`` for polling. + + Raises: + RoboflowError: On non-202 response. + """ + url = f"{API_URL}/{workspace_url}/images/metadata" + response = requests.post(url, params={"api_key": api_key}, json={"updates": updates}) + if response.status_code != 202: + raise RoboflowError(response.text) + return response.json() + + def upload_image( api_key, project_url, @@ -54,9 +680,10 @@ def upload_image( hosted_image: bool = False, split: str = "train", batch_name: str = DEFAULT_BATCH_NAME, - tag_names: list = [], + tag_names: Optional[List[str]] = None, sequence_number: Optional[int] = None, sequence_size: Optional[int] = None, + metadata: Optional[Dict] = None, **kwargs, ): """ @@ -66,33 +693,52 @@ def upload_image( image_path (str): path to image you'd like to upload hosted_image (bool): whether the image is hosted on Roboflow split (str): the dataset split the image to + metadata (dict, optional): custom key-value metadata to attach to the image. + Example: {"camera_id": "cam001", "location": "warehouse"} """ coalesced_batch_name = batch_name or DEFAULT_BATCH_NAME + if tag_names is None: + tag_names = [] # If image is not a hosted image if not hosted_image: image_name = os.path.basename(image_path) - imgjpeg = image_utils.file2jpeg(image_path) + with open(image_path, "rb") as fh: + image_bytes = fh.read() + content_type = mimetypes.guess_type(image_path)[0] or "application/octet-stream" upload_url = _local_upload_url( api_key, project_url, coalesced_batch_name, tag_names, sequence_number, sequence_size, kwargs ) - m = MultipartEncoder( - fields={ - "name": image_name, - "split": split, - "file": ("imageToUpload", imgjpeg, "image/jpeg"), - } - ) - response = requests.post(upload_url, data=m, headers={"Content-Type": m.content_type}, timeout=(300, 300)) + fields = { + "name": image_name, + "split": split, + "file": (image_name, image_bytes, content_type), + } + if metadata is not None: + fields["metadata"] = json.dumps(metadata) + m = MultipartEncoder(fields=fields) + + try: + response = requests.post(upload_url, data=m, headers={"Content-Type": m.content_type}, timeout=(300, 300)) + except RequestException as e: + raise ImageUploadError(str(e)) from e else: # Hosted image upload url - upload_url = _hosted_upload_url(api_key, project_url, image_path, split, coalesced_batch_name, tag_names) + hosted_kwargs = dict(kwargs) + if metadata is not None: + hosted_kwargs["metadata"] = json.dumps(metadata) + upload_url = _hosted_upload_url( + api_key, project_url, image_path, split, coalesced_batch_name, tag_names, hosted_kwargs + ) - # Get response - response = requests.post(upload_url, timeout=(300, 300)) + try: + # Get response + response = requests.post(upload_url, timeout=(300, 300)) + except RequestException as e: + raise ImageUploadError(str(e)) from e responsejson = None try: @@ -101,7 +747,7 @@ def upload_image( pass if response.status_code != 200: - if responsejson: + if responsejson and isinstance(responsejson, dict): err_msg = responsejson if err_msg.get("error"): @@ -147,12 +793,15 @@ def save_annotation( api_key, project_url, annotation_name, image_id, job_name, is_prediction, overwrite ) - response = requests.post( - upload_url, - data=json.dumps({"annotationFile": annotation_string, "labelmap": annotation_labelmap}), - headers={"Content-Type": "application/json"}, - timeout=(60, 60), - ) + try: + response = requests.post( + upload_url, + data=json.dumps({"annotationFile": annotation_string, "labelmap": annotation_labelmap}), + headers={"Content-Type": "application/json"}, + timeout=(60, 60), + ) + except RequestException as e: + raise AnnotationSaveError(str(e)) from e # Handle response responsejson = None @@ -166,7 +815,9 @@ def save_annotation( if response.status_code not in (200, 409): raise _save_annotation_error(response) if response.status_code == 409: - if "already annotated" in responsejson.get("error", {}).get("message"): + err_obj = responsejson.get("error", {}) + err_message = err_obj.get("message", "") if isinstance(err_obj, dict) else str(err_obj) + if "already annotated" in err_message: return {"warn": "already annotated"} else: raise _save_annotation_error(response) @@ -179,7 +830,7 @@ def save_annotation( def _save_annotation_url(api_key, project_url, name, image_id, job_name, is_prediction, overwrite=False): - url = f"{API_URL}/dataset/{project_url}/annotate/{image_id}?api_key={api_key}" f"&name={name}" + url = f"{API_URL}/dataset/{project_url}/annotate/{image_id}?api_key={api_key}&name={name}" if job_name: url += f"&jobName={job_name}" if is_prediction: @@ -199,7 +850,8 @@ def _upload_url(api_key, project_url, **kwargs): return url -def _hosted_upload_url(api_key, project_url, image_path, split, batch_name, tag_names): +def _hosted_upload_url(api_key, project_url, image_path, split, batch_name, tag_names, kwargs=None): + extra = kwargs or {} return _upload_url( api_key, project_url, @@ -208,6 +860,7 @@ def _hosted_upload_url(api_key, project_url, image_path, split, batch_name, tag_ image=image_path, batch=batch_name, tag=tag_names, + **extra, ) @@ -232,8 +885,945 @@ def _save_annotation_error(response): if responsejson.get("error"): err_msg = responsejson["error"] - if err_msg.get("message"): - err_msg = err_msg["message"] - return AnnotationSaveError(err_msg, status_code=response.status_code) + if isinstance(err_msg, dict): + err_msg = err_msg.get("message", str(err_msg)) + return AnnotationSaveError(str(err_msg), status_code=response.status_code) return AnnotationSaveError(str(responsejson), status_code=response.status_code) + + +# --------------------------------------------------------------------------- +# Zip upload endpoints +# --------------------------------------------------------------------------- + + +def init_zip_upload(api_key, workspace_url, project_url, split=None, tags=None, batch_name=None) -> dict: + """POST /{ws}/{proj}/upload/zip β€” initialize a zip upload and get a signed URL.""" + url = f"{API_URL}/{workspace_url}/{project_url}/upload/zip" + body: Dict[str, Union[str, List[str]]] = {} + if split is not None: + body["split"] = split + if tags is not None: + body["tags"] = tags + if batch_name is not None: + body["batchName"] = batch_name + response = requests.post(url, params={"api_key": api_key}, json=body) + if response.status_code not in (200, 201): + raise RoboflowError(response.text) + return response.json() + + +def upload_zip_to_signed_url(signed_url, zip_path) -> None: + """PUT the zip file to the GCS signed URL returned by init_zip_upload.""" + with open(zip_path, "rb") as fh: + response = requests.put( + signed_url, + data=fh, + headers={"Content-Type": "application/zip"}, + timeout=(60, 3600), + ) + if not response.ok: + raise RoboflowError(f"Zip upload to signed URL failed ({response.status_code}): {response.text}") + + +def get_zip_upload_status(api_key, workspace_url, task_id) -> dict: + """GET /{ws}/upload/zip/{task_id} β€” poll status of an async zip upload.""" + url = f"{API_URL}/{workspace_url}/upload/zip/{task_id}" + response = requests.get(url, params={"api_key": api_key}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +# --------------------------------------------------------------------------- +# Phase 2: Annotation batch & job endpoints +# --------------------------------------------------------------------------- + + +def list_batches(api_key, workspace_url, project_url): + """GET /{ws}/{proj}/batches β€” list annotation batches.""" + response = requests.get(f"{API_URL}/{workspace_url}/{project_url}/batches", params={"api_key": api_key}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def get_batch(api_key, workspace_url, project_url, batch_id): + """GET /{ws}/{proj}/batches/{batch_id} β€” get batch details.""" + response = requests.get(f"{API_URL}/{workspace_url}/{project_url}/batches/{batch_id}", params={"api_key": api_key}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def list_annotation_jobs(api_key, workspace_url, project_url): + """GET /{ws}/{proj}/jobs β€” list annotation jobs.""" + response = requests.get(f"{API_URL}/{workspace_url}/{project_url}/jobs", params={"api_key": api_key}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def get_annotation_job(api_key, workspace_url, project_url, job_id): + """GET /{ws}/{proj}/jobs/{job_id} β€” get annotation job details.""" + response = requests.get(f"{API_URL}/{workspace_url}/{project_url}/jobs/{job_id}", params={"api_key": api_key}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def create_annotation_job(api_key, workspace_url, project_url, *, name, batch_id=None, assignees=None): + """POST /{ws}/{proj}/jobs β€” create an annotation job.""" + payload = {"name": name} + if batch_id: + payload["batchId"] = batch_id + if assignees: + payload["assignees"] = assignees + response = requests.post( + f"{API_URL}/{workspace_url}/{project_url}/jobs", + params={"api_key": api_key}, + json=payload, + ) + if response.status_code not in (200, 201): + raise RoboflowError(response.text) + return response.json() + + +# --------------------------------------------------------------------------- +# Phase 2: Folder (project group) endpoints +# --------------------------------------------------------------------------- + + +def list_folders(api_key, workspace_url): + """GET /{ws}/groups β€” list project folders.""" + response = requests.get(f"{API_URL}/{workspace_url}/groups", params={"api_key": api_key}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def get_folder(api_key, workspace_url, group_id): + """GET /{ws}/groups?groupId={id} β€” get folder details.""" + response = requests.get( + f"{API_URL}/{workspace_url}/groups", + params={"api_key": api_key, "groupId": group_id}, + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def create_folder(api_key, workspace_url, name, *, parent_id=None, project_ids=None): + """POST /{ws}/groups β€” create a project folder.""" + payload: Dict[str, Union[str, List[str], None]] = {"name": name} + if parent_id: + payload["parent_id"] = parent_id + if project_ids: + payload["projects"] = project_ids + response = requests.post( + f"{API_URL}/{workspace_url}/groups", + params={"api_key": api_key}, + json=payload, + ) + if response.status_code not in (200, 201): + raise RoboflowError(response.text) + return response.json() + + +def update_folder(api_key, workspace_url, group_id, *, name=None): + """POST /{ws}/groups/{id} β€” update a project folder.""" + payload: Dict[str, Optional[str]] = {} + if name: + payload["name"] = name + response = requests.post( + f"{API_URL}/{workspace_url}/groups/{group_id}", + params={"api_key": api_key}, + json=payload, + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def delete_folder(api_key, workspace_url, group_id): + """DELETE /{ws}/groups/{id} β€” delete a project folder.""" + response = requests.delete( + f"{API_URL}/{workspace_url}/groups/{group_id}", + params={"api_key": api_key}, + ) + if response.status_code not in (200, 204): + raise RoboflowError(response.text) + if response.status_code == 204 or not response.text.strip(): + return {} + return response.json() + + +def add_projects_to_folder(api_key, workspace_url, group_id, project_ids): + """PATCH /{ws}/groups/{id}/projects β€” add projects to a folder.""" + response = requests.patch( + f"{API_URL}/{workspace_url}/groups/{group_id}/projects", + params={"api_key": api_key}, + json={"projects": project_ids}, + ) + if response.status_code not in (200, 204): + raise RoboflowError(response.text) + + +def remove_projects_from_folder(api_key, workspace_url, group_id, project_ids): + """DELETE /{ws}/groups/{id}/projects β€” remove projects from a folder.""" + response = requests.delete( + f"{API_URL}/{workspace_url}/groups/{group_id}/projects", + params={"api_key": api_key}, + json={"projects": project_ids}, + ) + if response.status_code not in (200, 204): + raise RoboflowError(response.text) + + +# --------------------------------------------------------------------------- +# Phase 2: Workflow endpoints +# --------------------------------------------------------------------------- + + +_WORKFLOW_SPEC_KEYS = frozenset({"version", "inputs", "steps", "outputs"}) + + +def _normalize_workflow_config(config): + """Return a JSON string suitable for the backend's ``config`` field. + + The backend stores the ``config`` value verbatim, and the Roboflow inference + server expects to parse it to ``{"specification": {...}}`` (see + ``inference.core.roboflow_api.get_workflow_specification``). User-facing + Workflows JSON β€” as published in docs.roboflow.com/workflows, + ``inference/development/workflows_examples/*``, and the web UI's "View JSON" + export β€” is the flat shape ``{"version", "inputs", "steps", "outputs"}``. + The web app silently wraps it in ``{"specification": ...}`` before POSTing; + this helper does the same for SDK/CLI callers so that users don't need to + know the backend's storage convention. + + Behavior: + - ``None`` -> ``"{}"`` (preserves legacy "empty workflow" default). + - Anything already wrapped (``{"specification": ...}``) is passed through. + - Dicts or JSON strings that look like a bare workflow spec β€” i.e., contain + any of ``version``/``inputs``/``steps``/``outputs`` at the top level β€” + get wrapped. + - A leading UTF-8 BOM on string input is stripped before parsing AND on + the returned value, so files saved from Windows editors don't ship a + BOM to the backend (the inference server's ``json.loads`` rejects it). + - When a wrap happens, the result is serialized with compact separators + (``","``, ``":"``) to match the shape the web app writes, so audit / + diff tools don't see SDK-written and UI-written rows as different. + - Any other input is preserved as-is (stringified if needed) so callers who + intentionally send custom payloads aren't second-guessed. + """ + if config is None: + return "{}" + if isinstance(config, str): + stripped = config.lstrip("\ufeff") + try: + parsed = json.loads(stripped) + except (ValueError, TypeError): + return stripped + if isinstance(parsed, dict) and "specification" not in parsed and _WORKFLOW_SPEC_KEYS & parsed.keys(): + return json.dumps({"specification": parsed}, separators=(",", ":")) + return stripped # preserve user-supplied string when no wrap needed (BOM stripped) + if isinstance(config, dict) and "specification" not in config and _WORKFLOW_SPEC_KEYS & config.keys(): + return json.dumps({"specification": config}, separators=(",", ":")) + return json.dumps(config) + + +def list_workflows(api_key, workspace_url): + """GET /{ws}/workflows β€” list workflows.""" + response = requests.get(f"{API_URL}/{workspace_url}/workflows", params={"api_key": api_key}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def get_workflow(api_key, workspace_url, workflow_url): + """GET /{ws}/workflows/{url} β€” get workflow details.""" + response = requests.get( + f"{API_URL}/{workspace_url}/workflows/{workflow_url}", + params={"api_key": api_key}, + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def create_workflow(api_key, workspace_url, *, name, url=None, config=None, template=None): + """POST /{ws}/createWorkflow β€” create a workflow. + + The API validates ``name``, ``url``, ``template``, and ``config`` as + query-string parameters (all required strings). + + Args: + name: Display name for the workflow. + url: URL slug. Auto-generated from *name* when ``None``. + config: JSON string of the workflow config. Defaults to ``"{}"``. + template: JSON string of the workflow template. Defaults to ``"{}"``. + """ + if url is None: + import re + + url = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + if template is None: + template = "{}" + # config must be the backend's stored shape (`{"specification": ...}`); + # auto-wrap bare workflow definitions so docs-shaped JSON works unchanged. + config = _normalize_workflow_config(config) + if not isinstance(template, str): + template = json.dumps(template) + params: Dict[str, str] = { + "api_key": api_key, + "name": name, + "url": url, + "template": template, + "config": config, + } + response = requests.post( + f"{API_URL}/{workspace_url}/createWorkflow", + params=params, + ) + if response.status_code not in (200, 201): + raise RoboflowError(response.text) + return response.json() + + +def update_workflow(api_key, workspace_url, *, workflow_id, workflow_name, workflow_url, config): + """POST /{ws}/updateWorkflow β€” update a workflow definition. + + The API validates ``id``, ``name``, ``url``, and ``config`` in the + request body (all required strings). + + Args: + workflow_id: The workflow's internal ID. + workflow_name: The workflow's display name. + workflow_url: The workflow's URL slug. + config: JSON string (or dict) of the workflow config. Bare workflow + definitions (``{"version", "inputs", "steps", "outputs"}``) are + auto-wrapped in ``{"specification": ...}`` to match the backend's + stored shape; see ``_normalize_workflow_config``. + """ + config = _normalize_workflow_config(config) + payload: Dict[str, str] = { + "id": workflow_id, + "name": workflow_name, + "url": workflow_url, + "config": config, + } + response = requests.post( + f"{API_URL}/{workspace_url}/updateWorkflow", + params={"api_key": api_key}, + json=payload, + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def list_workflow_versions(api_key, workspace_url, workflow_url): + """GET /{ws}/workflows/{url}/versions β€” list workflow versions.""" + response = requests.get( + f"{API_URL}/{workspace_url}/workflows/{workflow_url}/versions", + params={"api_key": api_key}, + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def fork_project( + api_key, + dest_workspace, + *, + url=None, + source_project_slug=None, +): + """POST /{ws}/projects/fork β€” enqueue an async fork of a public Universe project. + + Pass ``url`` (a Universe URL) or an explicit ``source_project_slug``. The + API owns parsing/validation. Returns the server's response, e.g. + ``{"taskId": "...", "url": ""}``. + """ + payload: Dict[str, str] = {} + if url: + payload["url"] = url + if source_project_slug: + payload["source_project"] = source_project_slug + response = requests.post( + f"{API_URL}/{dest_workspace}/projects/fork", + params={"api_key": api_key}, + json=payload, + ) + if not response.ok: + raise RoboflowError(response.text) + return response.json() + + +def get_async_task(api_key, workspace_url, task_id): + """GET /{ws}/asynctasks/{id} β€” fetch the current status of an async task. + + Returns the server's status payload, e.g. + ``{"taskId": "...", "status": "running", "progress": {...}}`` or + ``{"taskId": "...", "status": "completed", "result": {...}}`` once + terminal. Raises ``RoboflowError`` for any non-2xx response (including + 404 for unknown ids or cross-workspace probes). + """ + # ``task_id`` comes from arbitrary external input; encode so a stray + # ``/``, ``?`` or ``#`` cannot mutate the request path (and still send + # the api_key with it). + encoded_task_id = quote(task_id, safe="") + response = requests.get( + f"{API_URL}/{workspace_url}/asynctasks/{encoded_task_id}", + params={"api_key": api_key}, + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def get_async_task_at(api_key, polling_url): + """GET an async-task polling URL returned verbatim by the server. + + Enqueue endpoints (e.g. ``/{ws}/projects/fork``) return a fully-qualified + ``url`` alongside ``taskId``. The host may differ from ``API_URL`` (e.g. + local dev against ``localapi.roboflow.one``), so hit it directly and + only attach the api_key. Falls back to ``get_async_task`` callers when + no server-supplied URL is available. + """ + response = requests.get(polling_url, params={"api_key": api_key}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def fork_workflow(api_key, workspace_url, *, source_workspace, source_workflow, name=None, url=None): + """POST /{ws}/forkWorkflow β€” fork a workflow into this workspace. + + Args: + workspace_url: Target workspace that will own the fork. + source_workspace: URL slug of the workspace that owns the source. + source_workflow: URL slug of the source workflow. + name: Optional display name for the fork. + url: Optional URL slug for the fork. + """ + payload: Dict[str, str] = { + "source_workspace": source_workspace, + "source_workflow": source_workflow, + } + if name: + payload["name"] = name + if url: + payload["url"] = url + response = requests.post( + f"{API_URL}/{workspace_url}/forkWorkflow", + params={"api_key": api_key}, + json=payload, + ) + if response.status_code not in (200, 201): + raise RoboflowError(response.text) + return response.json() + + +# --------------------------------------------------------------------------- +# Phase 2: Workspace statistics endpoints +# --------------------------------------------------------------------------- + + +def get_billing_usage(api_key, workspace_url): + """POST /{ws}/billing-usage-report β€” get billing usage report.""" + response = requests.post( + f"{API_URL}/{workspace_url}/billing-usage-report", + params={"api_key": api_key}, + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def get_plan_info(api_key): + """GET /usage/plan β€” get workspace plan info and limits.""" + response = requests.get(f"{API_URL}/usage/plan", params={"api_key": api_key}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def get_labeling_stats(api_key, workspace_url, *, start_date=None, end_date=None): + """GET /{ws}/stats β€” get annotation/labeling statistics.""" + params: Dict[str, str] = {"api_key": api_key} + if start_date: + params["startDate"] = start_date + if end_date: + params["endDate"] = end_date + response = requests.get(f"{API_URL}/{workspace_url}/stats", params=params) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +# --------------------------------------------------------------------------- +# Phase 2: Video inference status +# --------------------------------------------------------------------------- + + +def get_video_job_status(api_key, job_id): + """GET /videoinfer?jobId={id} β€” check video inference job status.""" + response = requests.get(f"{API_URL}/videoinfer", params={"api_key": api_key, "job_id": job_id}) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +# --------------------------------------------------------------------------- +# Phase 2: Universe search +# --------------------------------------------------------------------------- + + +def search_universe(query, *, api_key=None, project_type=None, limit=12, page=1): + """GET /universe/search β€” search Roboflow Universe.""" + params: Dict[str, Union[str, int]] = {"q": query, "limit": limit, "page": page} + if api_key: + params["api_key"] = api_key + if project_type: + params["type"] = project_type + response = requests.get(f"{API_URL}/universe/search", params=params) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +# --------------------------------------------------------------------------- +# Soft-delete / Trash operations +# --------------------------------------------------------------------------- + + +def _raise_for_trash_response(response): + """Raise RoboflowError with the cleanest message available. + + Backend trash endpoints return `{"error": "..."}` JSON on non-2xx. + Surface that string to the caller instead of the raw response body so + error messages are agent-friendly. Falls back to the raw text if the + body isn't JSON or doesn't contain an `error` field. + + Carries the HTTP status code on the raised exception so callers (e.g. + the CLI) can map auth failures (401) to the right exit code without + string-matching. The single `raise` at the end means we can't + accidentally swallow the intended error if a future refactor widens + the except clause. + """ + msg = None + try: + body = response.json() + if isinstance(body, dict): + msg = body.get("error") + except ValueError: + # Body wasn't JSON β€” fall through to response.text. + pass + raise RoboflowError(msg or response.text, status_code=response.status_code) + + +def delete_project(api_key, workspace_url, project_url): + """DELETE /{workspace}/{project} β€” move a project to Trash (30-day retention). + + Any in-flight training jobs for the project will be cancelled automatically. + The project can be restored via `restore_trash_item` within the retention + window; after 30 days the cleanup cron permanently removes it. + """ + url = f"{API_URL}/{workspace_url}/{project_url}?api_key={api_key}" + response = requests.delete(url) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +def delete_version(api_key, workspace_url, project_url, version): + """DELETE /{workspace}/{project}/{version} β€” move a version to Trash. + + Any in-flight training on the version will be cancelled automatically. + """ + url = f"{API_URL}/{workspace_url}/{project_url}/{version}?api_key={api_key}" + response = requests.delete(url) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +def delete_workflow(api_key, workspace_url, workflow_url): + """DELETE /{workspace}/workflows/{workflowUrl} β€” move a workflow to Trash + (30-day retention). Restore via `restore_trash_item(..., "workflow", ...)`. + """ + url = f"{API_URL}/{workspace_url}/workflows/{workflow_url}?api_key={api_key}" + response = requests.delete(url) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +def list_trash(api_key, workspace_url): + """GET /{workspace}/trash β€” list items currently in Trash. + + Returns a dict with `items` (flat list) and `sections` (grouped by type: + `datasets`, `versions`, `workflows`). Each item includes `id`, `type`, + `name`, `deletedAt`, `scheduledCleanupAt`, and (for versions) `parentId`. + """ + url = f"{API_URL}/{workspace_url}/trash?api_key={api_key}" + response = requests.get(url) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +def restore_trash_item(api_key, workspace_url, item_type, item_id, parent_id=None): + """POST /{workspace}/trash/restore β€” restore an item from Trash. + + `item_type` must be one of "project", "version", "workflow", "training". + `parent_id` is required when restoring a version (the parent project id). + """ + if not item_id or not str(item_id).strip(): + raise ValueError("item_id is required") + url = f"{API_URL}/{workspace_url}/trash/restore?api_key={api_key}" + payload = {"type": item_type, "id": item_id} + if parent_id is not None: + payload["parentId"] = parent_id + response = requests.post(url, json=payload) + if response.status_code != 200: + _raise_for_trash_response(response) + return response.json() + + +# Note: permanent-delete from Trash (deleteImmediately / empty) is +# intentionally not exposed on the public API β€” those actions destroy data +# irrecoverably and are only available through the web UI's Trash view. + + +# --------------------------------------------------------------------------- +# Model evaluations +# --------------------------------------------------------------------------- + + +class ModelEvalNotFoundError(RoboflowError): + """Raised when an eval id (or workspace) does not exist (HTTP 404).""" + + +class ModelEvalNotDoneError(RoboflowError): + """Raised when reading panel data for an eval whose status is not ``done`` (HTTP 409).""" + + +class InvalidSplitError(RoboflowError): + """Raised when ``split`` is not one of the accepted values (HTTP 400).""" + + +class InvalidConfidenceError(RoboflowError): + """Raised when ``confidence`` is non-integer or out of range 0-100 (HTTP 400).""" + + +def _model_eval_error_for(response): + """Translate a model-eval error response into the right RoboflowError subclass. + + The model-eval REST surface returns errors as a flat envelope:: + + {"error": "", "message": ""} + + Falls back to plain :class:`RoboflowError` when the body isn't JSON or + the code is unrecognised, so new error codes don't crash older SDK + callers. Status-code fallbacks for 404/409 keep typed exceptions + available even if the server omits the ``error`` field. + """ + code = None + message = response.text + try: + body = response.json() + if isinstance(body, dict): + code = body.get("error") + if not isinstance(code, str): + code = None + message = body.get("message") or code or message + except (ValueError, TypeError): + pass + + cls_by_code = { + "model_eval_not_found": ModelEvalNotFoundError, + "model_eval_not_done": ModelEvalNotDoneError, + "invalid_split": InvalidSplitError, + "invalid_confidence": InvalidConfidenceError, + } + cls = cls_by_code.get(code or "") + if cls is not None: + return cls(message) + if response.status_code == 404: + return ModelEvalNotFoundError(message) + if response.status_code == 409: + return ModelEvalNotDoneError(message) + return RoboflowError(message) + + +def _eval_get(api_key, workspace_url, path, params=None): + """GET helper for model-eval endpoints with typed error mapping.""" + query: Dict[str, Union[str, int]] = {"api_key": api_key} + if params: + for key, value in params.items(): + if value is not None: + query[key] = value + url = f"{API_URL}/{workspace_url}/model-evals{path}" + response = requests.get(url, params=query) + if response.status_code != 200: + raise _model_eval_error_for(response) + return response.json() + + +def list_model_evals( + api_key: str, + workspace_url: str, + *, + project: Optional[str] = None, + version: Optional[Union[str, int]] = None, + model: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, +) -> dict: + """GET /{workspace}/model-evals β€” list evals in the workspace.""" + return _eval_get( + api_key, + workspace_url, + "", + params={"project": project, "version": version, "model": model, "status": status, "limit": limit}, + ) + + +def get_model_eval(api_key: str, workspace_url: str, eval_id: str) -> dict: + """GET /{workspace}/model-evals/{evalId} β€” fetch a single eval (with summary if done).""" + return _eval_get(api_key, workspace_url, f"/{eval_id}") + + +def get_model_eval_map_results(api_key: str, workspace_url: str, eval_id: str) -> dict: + """GET /{workspace}/model-evals/{evalId}/map-results β€” per-split mAP breakdown.""" + return _eval_get(api_key, workspace_url, f"/{eval_id}/map-results") + + +def get_model_eval_confidence_sweep(api_key: str, workspace_url: str, eval_id: str) -> dict: + """GET /{workspace}/model-evals/{evalId}/confidence-sweep β€” F1/precision/recall sweep.""" + return _eval_get(api_key, workspace_url, f"/{eval_id}/confidence-sweep") + + +def get_model_eval_performance_by_class( + api_key: str, + workspace_url: str, + eval_id: str, + *, + split: Optional[str] = None, +) -> dict: + """GET /{workspace}/model-evals/{evalId}/performance-by-class β€” per-class metrics. + + Server rejects ``split=all`` for this panel; pass one of train/valid/test + or omit to use the server default (test). + """ + return _eval_get(api_key, workspace_url, f"/{eval_id}/performance-by-class", params={"split": split}) + + +def get_model_eval_confusion_matrix( + api_key: str, + workspace_url: str, + eval_id: str, + *, + split: Optional[str] = None, + confidence: Optional[int] = None, +) -> dict: + """GET /{workspace}/model-evals/{evalId}/confusion-matrix β€” confusion matrix for split.""" + return _eval_get( + api_key, + workspace_url, + f"/{eval_id}/confusion-matrix", + params={"split": split, "confidence": confidence}, + ) + + +def get_model_eval_vector_analysis( + api_key: str, + workspace_url: str, + eval_id: str, + *, + confidence: Optional[int] = None, +) -> dict: + """GET /{workspace}/model-evals/{evalId}/vector-analysis β€” embedding clusters & metrics.""" + return _eval_get( + api_key, + workspace_url, + f"/{eval_id}/vector-analysis", + params={"confidence": confidence}, + ) + + +def get_model_eval_image_predictions( + api_key: str, + workspace_url: str, + eval_id: str, + *, + split: Optional[str] = None, + confidence: Optional[int] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, +) -> dict: + """GET /{workspace}/model-evals/{evalId}/image-predictions β€” paginated per-image stats.""" + return _eval_get( + api_key, + workspace_url, + f"/{eval_id}/image-predictions", + params={"split": split, "confidence": confidence, "limit": limit, "offset": offset}, + ) + + +def get_model_eval_recommendations(api_key: str, workspace_url: str, eval_id: str) -> dict: + """GET /{workspace}/model-evals/{evalId}/recommendations β€” improvement suggestions.""" + return _eval_get(api_key, workspace_url, f"/{eval_id}/recommendations") + + +# --------------------------------------------------------------------------- +# API key management endpoints +# --------------------------------------------------------------------------- + + +class _FullAccess: + """Sentinel distinguishing "unscoped/full access" from "omit scopes". + + The API treats three ``scopes`` states differently: omitted inherits the + caller's own scopes, an explicit ``null`` grants full (unscoped) access, and + an empty ``[]`` grants no abilities. Passing ``None`` from Python means + "omit", so ``FULL_ACCESS`` is used to force an explicit ``"scopes": null`` + into the request body. + """ + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "FULL_ACCESS" + + +FULL_ACCESS = _FullAccess() + + +def list_api_keys( + api_key: str, + workspace_url: str, + include_disabled: bool = False, + include_folders: bool = False, +) -> dict: + """GET /{workspace}/api-keys β€” list API keys for a workspace.""" + params: Dict[str, Union[str, bool]] = {"api_key": api_key} + if include_disabled: + params["includeDisabled"] = "true" + if include_folders: + params["includeFolders"] = "true" + response = requests.get(f"{API_URL}/{workspace_url}/api-keys", params=params) + if not response.ok: + raise RoboflowError(response.text, status_code=response.status_code) + return response.json() + + +def get_api_key(api_key: str, workspace_url: str, key_id: str) -> dict: + """GET /{workspace}/api-keys/{keyId} β€” get a single API key by ID.""" + encoded = quote(key_id, safe="") + response = requests.get(f"{API_URL}/{workspace_url}/api-keys/{encoded}", params={"api_key": api_key}) + if not response.ok: + raise RoboflowError(response.text, status_code=response.status_code) + return response.json() + + +def get_publishable_key(api_key: str, workspace_url: str) -> dict: + """GET /{workspace}/api-keys/publishable β€” get the workspace publishable key.""" + response = requests.get(f"{API_URL}/{workspace_url}/api-keys/publishable", params={"api_key": api_key}) + if not response.ok: + raise RoboflowError(response.text, status_code=response.status_code) + return response.json() + + +def create_api_key( + api_key: str, + workspace_url: str, + name: Optional[str] = None, + scopes: Union[List[str], _FullAccess, None] = None, + folder_ids: Optional[List[str]] = None, + custom_metadata: Optional[Dict] = None, + protected: bool = False, +) -> dict: + """POST /{workspace}/api-keys β€” create a new API key. + + The secret ``key`` value is returned only on creation (shown once). + Omitting ``scopes`` (or passing ``None``) inherits the calling credential's + own scopes, so a full-access credential creates a full-access key. Pass a list + to scope the key (``role:`` presets are accepted), ``[]`` for a key with + no abilities, or ``FULL_ACCESS`` to send an explicit ``null`` (unscoped/full + access). ``scopes``, ``folder_ids``, and ``custom_metadata`` require the + Advanced API Keys plan feature β€” the backend returns 403 if unavailable. + """ + body: Dict[str, Any] = {} + if name is not None: + body["name"] = name + if scopes is FULL_ACCESS: + body["scopes"] = None + elif scopes is not None: + body["scopes"] = scopes + if folder_ids is not None: + body["folderIds"] = folder_ids + if custom_metadata is not None: + # Canonical wire field is camelCase `customMetadata` (consistent with `folderIds`). + body["customMetadata"] = custom_metadata + if protected: + body["protected"] = True + response = requests.post(f"{API_URL}/{workspace_url}/api-keys", params={"api_key": api_key}, json=body) + if not response.ok: + raise RoboflowError(response.text, status_code=response.status_code) + return response.json() + + +def update_api_key(api_key: str, workspace_url: str, key_id: str, **fields: Any) -> dict: + """PATCH /{workspace}/api-keys/{keyId} β€” update an existing API key. + + Pass only the fields you want to change as keyword arguments: + ``name``, ``scopes``, ``custom_metadata``, ``protected``, ``disabled``. + ``None`` values are omitted (left unchanged). To send explicit values, + pass ``scopes=[]`` (no abilities), ``scopes=FULL_ACCESS`` (unscoped/full + access, serialized as ``null``), or ``custom_metadata={}`` (clear metadata). + The API cannot unprotect a key (``protected=False`` β†’ 403). + Disabling a protected key returns 409. + """ + encoded = quote(key_id, safe="") + # Canonical wire field is camelCase `customMetadata` (consistent with `folderIds`); callers may + # pass the Pythonic `custom_metadata` kwarg, which is normalized here. + wire_aliases = {"custom_metadata": "customMetadata"} + body: Dict[str, Any] = {} + for k, v in fields.items(): + wire_key = wire_aliases.get(k, k) + if v is FULL_ACCESS: + body[wire_key] = None + elif v is not None: + body[wire_key] = v + response = requests.patch( + f"{API_URL}/{workspace_url}/api-keys/{encoded}", + params={"api_key": api_key}, + json=body, + ) + if not response.ok: + raise RoboflowError(response.text, status_code=response.status_code) + return response.json() + + +def revoke_api_key(api_key: str, workspace_url: str, key_id: str) -> dict: + """DELETE /{workspace}/api-keys/{keyId} β€” revoke (permanently delete) an API key. + + Revoking a protected key returns 409. This action is irreversible. + """ + encoded = quote(key_id, safe="") + response = requests.delete( + f"{API_URL}/{workspace_url}/api-keys/{encoded}", + params={"api_key": api_key}, + ) + if not response.ok: + raise RoboflowError(response.text, status_code=response.status_code) + return response.json() diff --git a/roboflow/adapters/vision_events_api.py b/roboflow/adapters/vision_events_api.py new file mode 100644 index 00000000..358ac5be --- /dev/null +++ b/roboflow/adapters/vision_events_api.py @@ -0,0 +1,260 @@ +import json +import os +from typing import Any, Dict, List, Optional + +import requests +from requests_toolbelt.multipart.encoder import MultipartEncoder + +from roboflow.adapters.rfapi import RoboflowError +from roboflow.config import API_URL + +_BASE = f"{API_URL}/vision-events" + + +def _auth_headers(api_key: str) -> Dict[str, str]: + return {"Authorization": f"Bearer {api_key}"} + + +def write_event(api_key: str, event: Dict[str, Any]) -> dict: + """Create a single vision event. + + Args: + api_key: Roboflow API key. + event: Event payload dict (eventId, eventType, useCaseId, timestamp, etc.). + + Returns: + Parsed JSON response with ``eventId`` and ``created``. + + Raises: + RoboflowError: On non-201 response status codes. + """ + response = requests.post(_BASE, json=event, headers=_auth_headers(api_key)) + if response.status_code != 201: + raise RoboflowError(response.text) + return response.json() + + +def write_batch(api_key: str, events: List[Dict[str, Any]]) -> dict: + """Create multiple vision events in a single request. + + Args: + api_key: Roboflow API key. + events: List of event payload dicts (max 100 per the server). + + Returns: + Parsed JSON response with ``created`` count and ``eventIds``. + + Raises: + RoboflowError: On non-201 response status codes. + """ + response = requests.post( + f"{_BASE}/batch", + json={"events": events}, + headers=_auth_headers(api_key), + ) + if response.status_code != 201: + raise RoboflowError(response.text) + return response.json() + + +def query(api_key: str, query_params: Dict[str, Any]) -> dict: + """Query vision events with filters and pagination. + + Args: + api_key: Roboflow API key. + query_params: Query payload (useCaseId, eventType, startTime, endTime, + cursor, limit, customMetadataFilters, etc.). + + Returns: + Parsed JSON response with ``events``, ``nextCursor``, ``hasMore``, + and ``lookbackDays``. + + Raises: + RoboflowError: On non-200 response status codes. + """ + response = requests.post( + f"{_BASE}/query", + json=query_params, + headers=_auth_headers(api_key), + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def list_use_cases(api_key: str, status: Optional[str] = None) -> dict: + """List all use cases for a workspace. + + Args: + api_key: Roboflow API key. + status: Optional status filter (default server-side: "active"). + + Returns: + Parsed JSON response with ``useCases`` list and ``lookbackDays``. + + Raises: + RoboflowError: On non-200 response status codes. + """ + params: Dict[str, str] = {} + if status is not None: + params["status"] = status + response = requests.get( + f"{_BASE}/use-cases", + params=params, + headers=_auth_headers(api_key), + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def get_custom_metadata_schema(api_key: str, use_case_id: str) -> dict: + """Get the custom metadata schema for a use case. + + Args: + api_key: Roboflow API key. + use_case_id: Use case identifier. + + Returns: + Parsed JSON response with ``fields`` mapping field names to their types. + + Raises: + RoboflowError: On non-200 response status codes. + """ + response = requests.get( + f"{_BASE}/custom-metadata-schema/{use_case_id}", + headers=_auth_headers(api_key), + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def create_use_case(api_key: str, name: str) -> dict: + """Create a new vision event use case. + + Args: + api_key: Roboflow API key. + name: Human-readable name for the use case. + + Returns: + Parsed JSON response with ``id`` and ``name``. + + Raises: + RoboflowError: On non-201 response status codes. + """ + response = requests.post( + f"{_BASE}/use-cases", + json={"name": name}, + headers=_auth_headers(api_key), + ) + if response.status_code != 201: + raise RoboflowError(response.text) + return response.json() + + +def rename_use_case(api_key: str, use_case_id: str, name: str) -> dict: + """Rename an existing vision event use case. + + Args: + api_key: Roboflow API key. + use_case_id: Use case identifier. + name: New name for the use case. + + Returns: + Parsed JSON response with ``id`` and ``name``. + + Raises: + RoboflowError: On non-200 response status codes. + """ + response = requests.put( + f"{_BASE}/use-cases/{use_case_id}", + json={"name": name}, + headers=_auth_headers(api_key), + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def archive_use_case(api_key: str, use_case_id: str) -> dict: + """Archive a vision event use case. + + Args: + api_key: Roboflow API key. + use_case_id: Use case identifier. + + Returns: + Parsed JSON response with ``success``. + + Raises: + RoboflowError: On non-200 response status codes. + """ + response = requests.post( + f"{_BASE}/use-cases/{use_case_id}/archive", + headers=_auth_headers(api_key), + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def unarchive_use_case(api_key: str, use_case_id: str) -> dict: + """Unarchive a vision event use case. + + Args: + api_key: Roboflow API key. + use_case_id: Use case identifier. + + Returns: + Parsed JSON response with ``success``. + + Raises: + RoboflowError: On non-200 response status codes. + """ + response = requests.post( + f"{_BASE}/use-cases/{use_case_id}/unarchive", + headers=_auth_headers(api_key), + ) + if response.status_code != 200: + raise RoboflowError(response.text) + return response.json() + + +def upload_image( + api_key: str, + image_path: str, + name: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> dict: + """Upload an image for use in vision events. + + Args: + api_key: Roboflow API key. + image_path: Local filesystem path to the image file. + name: Optional custom image name. + metadata: Optional flat dict of metadata to attach. + + Returns: + Parsed JSON response with ``sourceId`` (and optionally ``url``). + + Raises: + RoboflowError: On non-201 response status codes. + """ + filename = name or os.path.basename(image_path) + with open(image_path, "rb") as f: + fields: Dict[str, Any] = { + "file": (filename, f, "application/octet-stream"), + } + if name is not None: + fields["name"] = name + if metadata is not None: + fields["metadata"] = json.dumps(metadata) + m = MultipartEncoder(fields=fields) + headers = _auth_headers(api_key) + headers["Content-Type"] = m.content_type + response = requests.post(f"{_BASE}/upload", data=m, headers=headers) + + if response.status_code != 201: + raise RoboflowError(response.text) + return response.json() diff --git a/roboflow/cli/__init__.py b/roboflow/cli/__init__.py new file mode 100644 index 00000000..54754a08 --- /dev/null +++ b/roboflow/cli/__init__.py @@ -0,0 +1,432 @@ +"""Roboflow CLI β€” computer vision at your fingertips. + +Built on typer. Each command group is a separate Typer app in the +``handlers`` sub-package, registered via ``app.add_typer()``. +""" + +from __future__ import annotations + +import json +import os +from typing import Annotated, Any, Optional + +import click +import typer + +import roboflow +from roboflow.cli._compat import SortedGroup + +# --------------------------------------------------------------------------- +# Root application +# --------------------------------------------------------------------------- + +_DESCRIPTION = ( + "Build and deploy computer vision models with Roboflow. " + "Manage datasets, train models, run inference, and deploy " + "workflows \u2014 from the command line or via structured JSON for AI agents." +) + +app = typer.Typer( + name="roboflow", + help=_DESCRIPTION, + cls=SortedGroup, + pretty_exceptions_enable=False, + rich_markup_mode="rich", + # We expose shell completion through our own `completion` command group + # (see roboflow/cli/handlers/completion.py) so that there is exactly one + # documented entry-point. + add_completion=False, + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +def _version_callback(value: bool) -> None: + if value: + import sys + + if "--json" in sys.argv or "-j" in sys.argv: + print(json.dumps({"version": roboflow.__version__})) + else: + print(roboflow.__version__) + raise typer.Exit + + +@app.callback(invoke_without_command=True) +def _root_callback( + ctx: typer.Context, + api_key: Annotated[ + Optional[str], + typer.Option("--api-key", "-k", help="API key override (default: $ROBOFLOW_API_KEY or config file)"), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", "-j", help="Output results as JSON (stable schema, for agents and piping)"), + ] = False, + quiet: Annotated[ + bool, + typer.Option("--quiet", "-q", help="Suppress non-essential output (progress bars, status messages)"), + ] = False, + version: Annotated[ + Optional[bool], + typer.Option( + "--version", + "-v", + help="Show package version and exit", + callback=_version_callback, + is_eager=True, + ), + ] = None, + workspace: Annotated[ + Optional[str], + typer.Option("--workspace", "-w", help="Workspace URL or ID override (default: configured default)"), + ] = None, +) -> None: + """Build and deploy computer vision models with Roboflow.""" + ctx.ensure_object(dict) + ctx.obj["json"] = json_output + ctx.obj["api_key"] = api_key + ctx.obj["workspace"] = workspace + ctx.obj["quiet"] = quiet + + if ctx.invoked_subcommand is None: + _print_flattened_help() + raise typer.Exit(code=0) + + +def _print_flattened_help() -> None: + """Print a Rich-formatted help screen with all commands flattened and alphabetized.""" + from rich.console import Console + from rich.panel import Panel + from rich.table import Table + from rich.text import Text + + console = Console() + + click_app = typer.main.get_command(app) + + # Collect all visible commands, flattened + commands: list[tuple[str, str]] = [] + + def _walk(group: Any, prefix: str = "") -> None: + for name in sorted(group.list_commands(None) or []): # type: ignore[arg-type] + cmd = group.get_command(None, name) # type: ignore[arg-type] + if cmd is None or getattr(cmd, "hidden", False): + continue + full = f"{prefix} {name}".strip() if prefix else name + if hasattr(cmd, "list_commands") and cmd.list_commands(None): + _walk(cmd, full) + else: + # Use the full help text: try help attr, then short_help, then docstring + help_text = getattr(cmd, "help", None) or getattr(cmd, "short_help", None) or "" + # Take only the first line/sentence + help_text = help_text.split("\n")[0].strip() + commands.append((full, help_text)) + + _walk(click_app) + commands.sort(key=lambda x: x[0]) + + # Usage line + console.print() + console.print(" Usage: roboflow [OPTIONS] COMMAND [ARGS]...", highlight=False) + console.print() + console.print(f" {_DESCRIPTION}", highlight=False) + console.print() + + # Options panel β€” match typer's color scheme + options_data = [ + ("--api-key", "-k", "TEXT", "API key override (default: $ROBOFLOW_API_KEY or config file)"), + ("--json", "-j", "", "Output results as JSON (stable schema, for agents and piping)"), + ("--quiet", "-q", "", "Suppress non-essential output (progress bars, status messages)"), + ("--version", "-v", "", "Show package version and exit"), + ("--workspace", "-w", "TEXT", "Workspace URL or ID override (default: configured default)"), + ("--help", "-h", "", "Show this message and exit."), + ] + opt_table = Table(show_header=False, box=None, padding=(0, 1)) + opt_table.add_column(no_wrap=True, style="bold cyan") # long flag + opt_table.add_column(no_wrap=True, style="bold green") # short flag + opt_table.add_column(no_wrap=True, style="bold yellow") # metavar + opt_table.add_column() # description + for long_flag, short_flag, metavar, desc in options_data: + opt_table.add_row(long_flag, short_flag, metavar, desc) + console.print(Panel(opt_table, title="Options", title_align="left", border_style="dim")) + + # Commands panel β€” group name in dim cyan, verb in bold + cmd_table = Table(show_header=False, box=None, padding=(0, 1)) + cmd_table.add_column(no_wrap=True) # command name + cmd_table.add_column() # description + for cmd_name, help_text in commands: + parts = cmd_name.split(" ", 1) + styled_name = Text() + if len(parts) == 1: + # Top-level command (no group): just bold + styled_name.append(parts[0], style="bold") + else: + # Group + verb: group in dim cyan, verb in bold + styled_name.append(parts[0], style="cyan") + styled_name.append(" ") + styled_name.append(parts[1], style="bold") + cmd_table.add_row(styled_name, help_text) + console.print(Panel(cmd_table, title="Commands", title_align="left", border_style="dim")) + # Footer tip: nudge users to enable shell completion. Suppressed under + # --quiet (explicit opt-out of non-essential output). --json doesn't apply + # here because the flattened help only renders in non-JSON mode anyway. + import sys as _sys + + if "--quiet" not in _sys.argv and "-q" not in _sys.argv: + console.print( + " Tip: enable shell completion with [bold]roboflow completion install[/bold]", + highlight=False, + ) + console.print() + + +# --------------------------------------------------------------------------- +# Register command groups (explicit imports β€” no auto-discovery needed) +# --------------------------------------------------------------------------- + +from roboflow.cli.handlers.annotation import annotation_app # noqa: E402 +from roboflow.cli.handlers.api_key import api_key_app # noqa: E402 +from roboflow.cli.handlers.asynctasks import asynctasks_app # noqa: E402 +from roboflow.cli.handlers.auth import auth_app # noqa: E402 +from roboflow.cli.handlers.batch import batch_app # noqa: E402 +from roboflow.cli.handlers.completion import completion_app # noqa: E402 +from roboflow.cli.handlers.deployment import deployment_app # noqa: E402 +from roboflow.cli.handlers.device import device_app # noqa: E402 +from roboflow.cli.handlers.eval import eval_app # noqa: E402 +from roboflow.cli.handlers.folder import folder_app # noqa: E402 +from roboflow.cli.handlers.image import image_app # noqa: E402 +from roboflow.cli.handlers.infer import infer_command # noqa: E402 +from roboflow.cli.handlers.model import model_app # noqa: E402 +from roboflow.cli.handlers.project import project_app # noqa: E402 +from roboflow.cli.handlers.search import search_command # noqa: E402 +from roboflow.cli.handlers.train import train_app # noqa: E402 +from roboflow.cli.handlers.trash import trash_app # noqa: E402 +from roboflow.cli.handlers.universe import universe_app # noqa: E402 +from roboflow.cli.handlers.version import version_app # noqa: E402 +from roboflow.cli.handlers.video import video_app # noqa: E402 +from roboflow.cli.handlers.vision_events import vision_events_app # noqa: E402 +from roboflow.cli.handlers.workflow import workflow_app # noqa: E402 +from roboflow.cli.handlers.workspace import workspace_app # noqa: E402 + +# Register ALL commands in alphabetical order for clean --help output +app.add_typer(annotation_app, name="annotation") +app.add_typer(api_key_app, name="api-key") +app.add_typer(asynctasks_app, name="asynctasks") +app.add_typer(auth_app, name="auth") +app.add_typer(batch_app, name="batch", hidden=True) # All stubs β€” hidden until implemented +app.add_typer(completion_app, name="completion") +app.add_typer(deployment_app, name="deployment") +app.add_typer(device_app, name="device") +app.add_typer(eval_app, name="eval") +app.add_typer(folder_app, name="folder") +app.add_typer(image_app, name="image") + +# "infer" β€” top-level command, registered alphabetically +infer_command(app) + +app.add_typer(model_app, name="model") +app.add_typer(project_app, name="project") + +# "search" β€” top-level command, registered alphabetically +search_command(app) + +app.add_typer(train_app, name="train") +app.add_typer(trash_app, name="trash") +app.add_typer(universe_app, name="universe") +app.add_typer(version_app, name="version") +app.add_typer(video_app, name="video") +app.add_typer(vision_events_app, name="vision-events") +app.add_typer(workflow_app, name="workflow") +app.add_typer(workspace_app, name="workspace") + +# Hidden aliases (loaded last β€” still functional but not in --help) +from roboflow.cli.handlers._aliases import register_hidden_aliases # noqa: E402 + +register_hidden_aliases(app) + + +# "roboflow help" command +@app.command("help", hidden=True) +def help_command(ctx: typer.Context) -> None: # noqa: ARG001 + """Show help information.""" + _print_flattened_help() + + +# --------------------------------------------------------------------------- +# Backwards-compat: build_parser returns None (argparse is gone) +# --------------------------------------------------------------------------- + + +class _LegacyParserShim: + """Argparse-compatible shim wrapping the typer app. + + Supports ``parser.parse_args(argv)`` and ``parser.print_help()``. + This keeps ``from roboflow.roboflowpy import _argparser`` working + for the ~5M monthly installs that may depend on it. + """ + + def parse_args(self, argv: list[str] | None = None) -> object: # noqa: ANN001 + """Parse *argv* and return an argparse-like namespace with ``func``. + + Does NOT execute the command β€” callers are expected to call + ``args.func(args)`` themselves, matching the old argparse pattern. + """ + import sys + import types + + if argv is None: + argv = sys.argv[1:] + + argv = _reorder_argv(list(argv)) + + # Build a namespace with the parsed values by invoking the CLI + # in a dry-run fashion: we intercept before execution. + ns = types.SimpleNamespace( + json=False, + api_key=None, + workspace=None, + quiet=False, + func=None, + ) + + # Extract global flags manually + remaining = [] + i = 0 + while i < len(argv): + if argv[i] in ("--json", "-j"): + ns.json = True + elif argv[i] in ("--quiet", "-q"): + ns.quiet = True + elif argv[i] in ("--api-key", "-k") and i + 1 < len(argv): + i += 1 + ns.api_key = argv[i] + elif argv[i] == "--workspace" and i + 1 < len(argv): + i += 1 + ns.workspace = argv[i] + else: + remaining.append(argv[i]) + i += 1 + + # Set func to a lambda that invokes the CLI with the original argv + original_argv = list(argv) + + def _run_via_typer(_args: object) -> None: + from typer.testing import CliRunner as _TyperRunner + + runner = _TyperRunner() + result = runner.invoke(app, original_argv, catch_exceptions=False) + if result.output: + print(result.output, end="") # noqa: T201 + if result.exit_code: + sys.exit(result.exit_code) + + ns.func = _run_via_typer + return ns + + def print_help(self) -> None: + """Print the CLI help text.""" + from typer.testing import CliRunner as _TyperRunner + + runner = _TyperRunner() + result = runner.invoke(app, ["--help"]) + if result.output: + print(result.output, end="") # noqa: T201 + + +def build_parser() -> _LegacyParserShim: + """Legacy compat: returns an argparse-like shim wrapping the typer app.""" + return _LegacyParserShim() + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def _reorder_argv(argv: list[str]) -> list[str]: + """Move known global flags that appear after the subcommand to the front. + + Typer/Click only recognises parent-level options when they appear + *before* the subcommand. Many users (and AI agents) naturally write + them at the end, e.g. ``roboflow project list --json``. This helper + transparently re-orders the argv so those flags are consumed by the + root callback. + """ + # Note: -w is intentionally excluded β€” it collides with deployment's + # -w/--wait_on_pending (boolean). --workspace (long form) is safe. + global_flags_with_value = {"--api-key", "-k", "--workspace"} + global_flags_bool = {"--json", "-j", "--quiet", "-q", "--version"} + + reordered: list[str] = [] + rest: list[str] = [] + i = 0 + while i < len(argv): + arg = argv[i] + if arg in global_flags_bool: + reordered.append(arg) + elif arg in global_flags_with_value: + if i + 1 < len(argv): + reordered.append(arg) + i += 1 + reordered.append(argv[i]) + else: + # No value follows β€” leave in place so typer shows a proper error + rest.append(arg) + else: + rest.append(arg) + i += 1 + return reordered + rest + + +def main() -> None: + """CLI entry point β€” called by ``roboflow`` console script.""" + import sys + + complete_mode = os.environ.get("_ROBOFLOW_COMPLETE") + if complete_mode in {"complete_bash", "bash_complete"} and ( + "COMP_WORDS" not in os.environ or "COMP_CWORD" not in os.environ + ): + sys.exit(0) + + sys.argv[1:] = _reorder_argv(sys.argv[1:]) + + # Intercept root-level --help/-h: show our flattened help instead of typer's grouped view. + # Only for the ROOT command (not subcommands like 'roboflow project --help'). + if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]: + argv = sys.argv[1:] + help_idx = next((i for i, a in enumerate(argv) if a in ("--help", "-h")), -1) + pre_help = [a for a in argv[:help_idx] if not a.startswith("-")] + if not pre_help: + _print_flattened_help() + sys.exit(0) + + # In --json mode, intercept Click/typer validation errors and emit + # structured JSON on stderr instead of Rich-formatted text. + json_mode = "--json" in sys.argv or "-j" in sys.argv + if json_mode: + try: + app(standalone_mode=False) + except SystemExit as exc: + # Exit code 0 = success (already handled), just re-raise + if exc.code == 0: + raise + # Exit code 2 = Click usage error (missing arg, bad option) + # Other codes = our output_error already printed JSON + raise + except click.exceptions.UsageError as exc: + # Click/typer validation error β€” emit JSON on stderr + import json as _json + + payload = {"error": {"message": str(exc), "hint": "Run with --help for usage information."}} + print(_json.dumps(payload), file=sys.stderr) + sys.exit(2) + except click.exceptions.Abort: + sys.exit(1) + except Exception as exc: + import json as _json + + payload = {"error": {"message": str(exc)}} + print(_json.dumps(payload), file=sys.stderr) + sys.exit(1) + else: + app() diff --git a/roboflow/cli/_compat.py b/roboflow/cli/_compat.py new file mode 100644 index 00000000..ae6b1804 --- /dev/null +++ b/roboflow/cli/_compat.py @@ -0,0 +1,79 @@ +"""Bridge helpers for the argparse β†’ typer migration. + +Provides ``ctx_to_args()`` which converts a :class:`typer.Context` to a +:class:`types.SimpleNamespace` matching the shape that ``output()``, +``output_error()``, and other CLI helpers expect. This allows existing +handler business logic to remain unchanged during migration. +""" + +from __future__ import annotations + +import types +from typing import Any + +import click +import typer # noqa: TC002 β€” needed at runtime for Context type + + +def _sort_params(params: list[click.Parameter]) -> None: + """Sort params in-place: required first, then alphabetical by option name.""" + params.sort( + key=lambda p: ( + # --help always last + "help" in (p.opts if hasattr(p, "opts") else [p.name or ""]), + # Required options first + not getattr(p, "required", False), + # Arguments before options (positionals first) + not isinstance(p, click.Argument), + # Alphabetical by the first long option name + (p.opts[0].lstrip("-") if hasattr(p, "opts") and p.opts else p.name or ""), + ) + ) + + +class SortedGroup(typer.core.TyperGroup): + """Click Group that alphabetizes commands and options in --help output. + + Use as ``cls=SortedGroup`` when creating Typer apps so that subcommand + help pages show options and commands in alphabetical order (with + required options first). + """ + + def list_commands(self, ctx: click.Context) -> list[str]: # type: ignore[override] + return sorted(super().list_commands(ctx)) + + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + """Sort options alphabetically before rendering help.""" + _sort_params(self.params) + super().format_help(ctx, formatter) + + def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: # type: ignore[override] + """Wrap returned commands to sort their options too.""" + cmd = super().get_command(ctx, cmd_name) + if cmd is not None and not isinstance(cmd, SortedGroup): + # Sort the command's params for its --help output + _sort_params(cmd.params) + return cmd + + +def ctx_to_args(ctx: typer.Context, **kwargs: Any) -> types.SimpleNamespace: + """Convert a typer Context (with global opts in ``ctx.obj``) to an args namespace. + + Parameters + ---------- + ctx: + The typer Context, whose ``.obj`` dict holds the global options + set by the root callback (``json``, ``api_key``, ``workspace``, + ``quiet``). + **kwargs: + Command-specific parameters to include in the namespace. These + override anything in ``ctx.obj``. + """ + obj = ctx.obj or {} + return types.SimpleNamespace( + json=obj.get("json", False), + api_key=obj.get("api_key"), + workspace=obj.get("workspace"), + quiet=obj.get("quiet", False), + **kwargs, + ) diff --git a/roboflow/cli/_output.py b/roboflow/cli/_output.py new file mode 100644 index 00000000..70ba2c6c --- /dev/null +++ b/roboflow/cli/_output.py @@ -0,0 +1,283 @@ +"""Structured output helpers for the Roboflow CLI. + +Every command should use ``output()`` for its result and ``output_error()`` +for failures so that ``--json`` mode works uniformly. +""" + +from __future__ import annotations + +import contextlib +import io +import json +import sys +from typing import Any, Iterator, Optional + + +def output(args: Any, data: Any, text: Optional[str] = None) -> None: + """Print a command result in JSON or human-readable format. + + Parameters + ---------- + args: + The parsed argparse namespace (must have a ``json`` attribute). + data: + Structured data to emit when ``--json`` is active. Also used as + fallback when *text* is ``None``. + text: + Human-readable string printed in normal (non-JSON) mode. When + ``None``, *data* is pretty-printed as JSON regardless of mode. + """ + if getattr(args, "json", False): + print(json.dumps(data, indent=2, default=str)) + elif text is not None: + print(text) + else: + # Fallback: pretty-print data even in non-JSON mode + print(json.dumps(data, indent=2, default=str)) + + +_PLAN_HINT_PATTERNS: list[tuple[str, str]] = [ + ("require", "This feature requires a higher plan. Visit https://roboflow.com/pricing to upgrade."), + ("Growth plan", "This feature requires a Growth plan or higher. Visit https://roboflow.com/pricing to upgrade."), + ("Enterprise", "This feature requires an Enterprise plan. Contact sales@roboflow.com to upgrade."), + ("folder billing", "This feature requires folder billing. Visit https://app.roboflow.com/settings to enable it."), + ("Unauthorized", "Check your API key and workspace permissions. Some features require specific plan tiers."), + ("over_quota", "Your workspace has exceeded its quota. Visit https://roboflow.com/pricing to upgrade."), +] + +# Patterns to translate raw API hints into CLI-friendly hints +_API_HINT_REPLACEMENTS: list[tuple[str, str]] = [ + ( + "You can see your active workspace by issuing a GET request to / with your api_key", + "Check available resources with 'roboflow project list' or 'roboflow workspace get'.", + ), + ( + "You can find the API docs at https://docs.roboflow.com", + "Run the command with --help for usage information.", + ), + ( + "You can see your available workspaces by issuing a GET request to /workspaces", + "List workspaces with 'roboflow workspace list'.", + ), +] + + +def _detect_plan_hint(message: str) -> Optional[str]: + """Detect plan/billing-related errors and return an appropriate upgrade hint.""" + lower = message.lower() + for pattern, hint in _PLAN_HINT_PATTERNS: + if pattern.lower() in lower: + return hint + return None + + +def _translate_api_hints(message: str) -> str: + """Replace raw API hints with CLI-friendly equivalents.""" + for api_hint, cli_hint in _API_HINT_REPLACEMENTS: + message = message.replace(api_hint, cli_hint) + # Generic fallback: strip any remaining "issuing a GET/POST request" phrasing + import re + + message = re.sub( + r"You can [^.]*(?:GET|POST|PUT|DELETE) request[^.]*\.", + "Run the command with --help for usage information.", + message, + ) + return message + + +def _sanitize_credentials(text: str) -> str: + """Strip API keys from URLs and other sensitive patterns in error messages.""" + import re + + # Match api_key=... up to the next whitespace, query separator, quote, or backslash. + # Older patterns missed keys containing '-' or other URL-safe characters and would + # echo them to the terminal when an exception bubbled up from `requests`. + return re.sub(r"api_key=[^\s&\"'\\<>]+", "api_key=***", text) + + +def _parse_error_message(raw: str) -> tuple[Optional[dict[str, Any]], str]: + """Try to parse a raw error string that may contain embedded JSON. + + Returns ``(parsed_dict_or_None, human_readable_message)``. + The *parsed_dict* is the deserialized JSON when the string is JSON, + otherwise ``None``. The *human_readable_message* drills into nested + ``error.message`` structures so the text-mode output is clean. + """ + text = _translate_api_hints(_sanitize_credentials(raw.strip())) + # Strip status-code prefix like "404: {...}" + colon_idx = text.find(": {") + if 0 < colon_idx < 5: + text = text[colon_idx + 2 :] + try: + parsed = json.loads(text) + if isinstance(parsed, dict): + err = parsed.get("error", parsed) + if isinstance(err, dict): + human = str(err.get("message") or err.get("hint") or err) + # Translate API hints in the parsed dict too + if "hint" in err and isinstance(err["hint"], str): + err["hint"] = _translate_api_hints(err["hint"]) + else: + human = str(err) + return parsed, _translate_api_hints(human) + except (json.JSONDecodeError, TypeError, ValueError): + pass + return None, text # Return sanitized text, not the original raw + + +def output_error( + args: Any, + message: str, + hint: Optional[str] = None, + exit_code: int = 1, +) -> None: + """Print an error and exit. + + Parameters + ---------- + args: + The parsed argparse namespace. + message: + What went wrong. + hint: + Actionable suggestion for the user / agent. + exit_code: + Process exit code. Convention: 1 = general, 2 = auth, 3 = not found. + """ + parsed, human_message = _parse_error_message(message) + + # Auto-detect plan-gated errors and add upgrade hints when none provided + if not hint: + hint = _detect_plan_hint(human_message) + + if getattr(args, "json", False): + # Normalise error to always be {"error": {"message": "..."}} so + # consumers see a consistent schema regardless of error source. + if parsed is not None and "error" in parsed: + inner: Any = parsed["error"] + elif parsed is not None: + inner = parsed + else: + inner = None + + if isinstance(inner, dict): + error_obj: dict[str, Any] = dict(inner) + error_obj.setdefault("message", human_message) + else: + error_obj = {"message": human_message} + + if hint: + error_obj.setdefault("hint", hint) + payload: dict[str, Any] = {"error": error_obj} + print(json.dumps(payload), file=sys.stderr) + else: + msg = f"Error: {human_message}" + if hint: + msg += f"\n Hint: {hint}" + print(msg, file=sys.stderr) + sys.exit(exit_code) + + +def confirm_destructive(args: Any, prompt: str) -> bool: + """Gate a destructive action on either ``--yes`` or an interactive TTY confirmation. + + Returns ``True`` if the action is approved (caller should proceed) or + ``False`` if the user declined at the prompt (caller should bail + cleanly via ``output(args, {"cancelled": True}, ...)``). + + Calls ``output_error`` and exits with code 1 when *no* TTY is available + and ``--yes`` wasn't passed, rather than prompting on a closed stdin + (which would either hang or β€” worse β€” silently default to a permissive + behavior). + + The previous logic gated on ``--json`` ("if --json is set, skip the + prompt") which conflated *output formatting* with *destructive intent*. + Anyone piping ``roboflow project delete X --json`` into ``jq`` for + parsing got their project nuked without any confirmation. Now ``--json`` + is purely a formatting flag; the kill-switch is ``--yes``/``-y``. + """ + if getattr(args, "yes", False): + return True + + # Either explicit `--yes` is required or we need an interactive TTY to + # ask. typer.confirm() reads from stdin; if stdin is closed (CI, piped + # input, agent) we'd hang β€” bail with a useful hint instead. + if not sys.stdin.isatty(): + output_error( + args, + "This is a destructive action and requires confirmation.", + hint=( + "Re-run with --yes / -y to confirm, or run interactively. " + "(--json is a formatting flag and does not bypass this.)" + ), + exit_code=1, + ) + return False # unreachable: output_error sys.exits + + import typer + + confirmed = typer.confirm(prompt, default=False) + if not confirmed: + # Caller renders the cancelled state. + output(args, {"cancelled": True}, text="Cancelled.") + return confirmed + + +def output_api_error( + args: Any, + exc: Exception, + *, + hint: Optional[str] = None, + auth_hint: Optional[str] = None, + not_found_hint: Optional[str] = None, +) -> None: + """Render a server-side error and exit with the correct code. + + Maps an exception's HTTP status (carried as ``exc.status_code`` when the + raiser sets it β€” see ``_raise_for_trash_response`` in ``adapters.rfapi``) + to the per-CONTRIBUTING.md exit-code contract: + + * **401** β†’ exit code 2 ("auth error"). ``auth_hint`` overrides ``hint`` + so we can surface a key-specific suggestion ("check ROBOFLOW_API_KEY" + etc.) regardless of what the caller passes. + * **404** β†’ exit code 3 ("not found"). ``not_found_hint`` overrides + ``hint`` similarly. Useful for resources that may legitimately be + absent (deleted, mis-typed slug, etc.). + * **anything else / no status_code attached** β†’ exit code 1 ("error"), + using ``hint`` verbatim. + + Without this helper every handler had to either string-match the message + (brittle) or fall back to a single ``exit_code=3`` for both 401 and 404, + which broke the ``$? == 2`` contract that scripts rely on to decide + whether to retry vs. re-auth. + """ + status = getattr(exc, "status_code", None) + if status == 401: + output_error( + args, + str(exc), + hint=auth_hint or hint or "Check that ROBOFLOW_API_KEY is set and not revoked.", + exit_code=2, + ) + elif status == 404: + output_error(args, str(exc), hint=not_found_hint or hint, exit_code=3) + else: + output_error(args, str(exc), hint=hint, exit_code=1) + + +def stub(args: Any) -> None: + """Placeholder handler for not-yet-implemented commands.""" + output_error(args, "This command is not yet implemented.", hint="Coming soon.", exit_code=1) + + +@contextlib.contextmanager +def suppress_sdk_output(args: Any = None) -> Iterator[None]: + """Suppress SDK stdout noise (e.g. 'loading Roboflow workspace...'). + + Always active β€” the SDK's "loading Roboflow workspace..." messages + are not useful CLI output in any mode. The CLI controls its own + output via ``output()`` and ``output_error()``. + """ + with contextlib.redirect_stdout(io.StringIO()): + yield diff --git a/roboflow/cli/_resolver.py b/roboflow/cli/_resolver.py new file mode 100644 index 00000000..11f5e3c5 --- /dev/null +++ b/roboflow/cli/_resolver.py @@ -0,0 +1,137 @@ +"""Universal resource shorthand resolver. + +Parses compact resource identifiers into (workspace, project, version) +tuples, filling in the default workspace from configuration when omitted. + +Disambiguation rule: version numbers are always numeric. So ``x/y`` where +``y`` is numeric means project/version; where ``y`` is non-numeric means +workspace/project. + +Examples +-------- +- ``"my-project"`` β†’ (default_ws, "my-project", None) +- ``"my-ws/my-project"`` β†’ ("my-ws", "my-project", None) +- ``"my-project/3"`` β†’ (default_ws, "my-project", 3) +- ``"my-ws/my-project/3"`` β†’ ("my-ws", "my-project", 3) +""" + +from __future__ import annotations + +import os +from typing import Optional, Tuple + +from roboflow.config import get_conditional_configuration_variable + + +def resolve_default_workspace(api_key: Optional[str] = None) -> Optional[str]: + """Return the default workspace URL, querying the API if necessary. + + Checks (in order): ``RF_WORKSPACE`` in config/env, then the API + validation endpoint using the supplied *api_key* (or ``ROBOFLOW_API_KEY``). + """ + ws = get_conditional_configuration_variable("RF_WORKSPACE", default=None) + if ws: + return ws + + key = api_key or os.getenv("ROBOFLOW_API_KEY") + if not key: + return None + + import requests + + from roboflow.config import API_URL + + try: + resp = requests.post(API_URL + "/?api_key=" + key) + if resp.status_code == 200: + return resp.json().get("workspace") or None + except Exception: # noqa: BLE001 + pass + return None + + +def resolve_resource( + shorthand: str, + workspace_override: Optional[str] = None, +) -> Tuple[str, str, Optional[int]]: + """Parse a resource shorthand into (workspace, project, version). + + Parameters + ---------- + shorthand: + The compact identifier (see module docstring for formats). + workspace_override: + Explicit workspace from ``--workspace`` / ``-w``. Takes precedence + over the shorthand's workspace segment when the shorthand is + ambiguous (single segment). + + Returns + ------- + tuple[str, str, int | None] + ``(workspace_url, project_slug, version_number_or_none)`` + + Raises + ------ + ValueError + If the shorthand cannot be parsed or no workspace can be resolved. + """ + parts = shorthand.strip("/").split("/") + + default_ws = workspace_override or resolve_default_workspace() + + if len(parts) == 1: + # "my-project" + if not default_ws: + raise ValueError( + f"Cannot resolve '{shorthand}': no workspace specified and no default configured. " + "Use --workspace or run 'roboflow auth login'." + ) + return (default_ws, parts[0], None) + + if len(parts) == 2: + # Could be "workspace/project" OR "project/version" + if parts[1].isdigit(): + # "project/3" + if not default_ws: + raise ValueError( + f"Cannot resolve '{shorthand}': no workspace specified and no default configured. " + "Use --workspace or run 'roboflow auth login'." + ) + return (default_ws, parts[0], int(parts[1])) + # "workspace/project" + ws = workspace_override or parts[0] + return (ws, parts[1], None) + + if len(parts) == 3: + # "workspace/project/version" + if not parts[2].isdigit(): + raise ValueError(f"Cannot resolve '{shorthand}': expected numeric version but got '{parts[2]}'.") + ws = workspace_override or parts[0] + return (ws, parts[1], int(parts[2])) + + raise ValueError( + f"Cannot resolve '{shorthand}': expected 1-3 path segments " + "(project, workspace/project, or workspace/project/version)." + ) + + +def resolve_ws_and_key(args) -> Optional[Tuple[str, str]]: + """Resolve workspace and API key from CLI args. + + Returns (workspace_url, api_key) or ``None`` after calling + ``output_error`` on failure. + """ + from roboflow.cli._output import output_error + from roboflow.config import load_roboflow_api_key + + ws = getattr(args, "workspace", None) or resolve_default_workspace(api_key=getattr(args, "api_key", None)) + if not ws: + output_error(args, "No workspace specified.", hint="Use --workspace or run 'roboflow auth login'.", exit_code=2) + return None + + api_key = getattr(args, "api_key", None) or load_roboflow_api_key(ws) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return None + + return ws, api_key diff --git a/roboflow/cli/_table.py b/roboflow/cli/_table.py new file mode 100644 index 00000000..b02e9133 --- /dev/null +++ b/roboflow/cli/_table.py @@ -0,0 +1,79 @@ +"""Simple table formatter for CLI list commands. + +No external dependency β€” uses plain string formatting. Respects terminal +width when available and truncates long fields. +""" + +from __future__ import annotations + +import os +import shutil +from typing import Any, Dict, List, Optional, Sequence + + +def format_table( + rows: Sequence[Dict[str, Any]], + columns: Sequence[str], + headers: Optional[Sequence[str]] = None, + max_width: Optional[int] = None, +) -> str: + """Format a list of dicts as a columnar table. + + Parameters + ---------- + rows: + Each row is a dict whose keys match *columns*. + columns: + Ordered list of dict keys to include as columns. + headers: + Display names for each column. Defaults to *columns* with + title-casing and hyphens replaced by spaces. + max_width: + Terminal width cap. ``None`` means auto-detect. + + Returns + ------- + str + The formatted table string (without trailing newline). + """ + if not rows: + return "(no results)" + + if headers is None: + headers = [c.replace("_", " ").replace("-", " ").upper() for c in columns] + + # Stringify all cell values + str_rows: List[List[str]] = [] + for row in rows: + str_rows.append([str(row.get(c, "")) for c in columns]) + + # Compute column widths + col_widths = [len(h) for h in headers] + for sr in str_rows: + for i, cell in enumerate(sr): + col_widths[i] = max(col_widths[i], len(cell)) + + # Optionally clamp to terminal width + if max_width is None: + max_width = shutil.get_terminal_size((120, 24)).columns + # Leave room for column separators (2 spaces between columns) + total = sum(col_widths) + 2 * (len(columns) - 1) + if total > max_width and len(columns) > 1: + # Shrink the widest column proportionally + excess = total - max_width + widest_idx = col_widths.index(max(col_widths)) + col_widths[widest_idx] = max(col_widths[widest_idx] - excess, 10) + + def _truncate(s: str, width: int) -> str: + return s if len(s) <= width else s[: width - 1] + "\u2026" + + # Build lines + lines: list[str] = [] + header_line = " ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)) + lines.append(header_line) + lines.append(" ".join("-" * col_widths[i] for i in range(len(columns)))) + for sr in str_rows: + line = " ".join(_truncate(sr[i], col_widths[i]).ljust(col_widths[i]) for i in range(len(columns))) + lines.append(line) + + return os.linesep.join(lines) diff --git a/roboflow/cli/handlers/__init__.py b/roboflow/cli/handlers/__init__.py new file mode 100644 index 00000000..89c9cb1c --- /dev/null +++ b/roboflow/cli/handlers/__init__.py @@ -0,0 +1,8 @@ +"""Handler modules for the Roboflow CLI. + +Each module in this package that exposes a ``register(subparsers)`` function +is auto-discovered and loaded by ``roboflow.cli.build_parser()``. + +Modules whose names start with ``_`` (e.g. ``_aliases.py``) are *not* +auto-discovered β€” they are loaded explicitly after all other handlers. +""" diff --git a/roboflow/cli/handlers/_aliases.py b/roboflow/cli/handlers/_aliases.py new file mode 100644 index 00000000..301c2dab --- /dev/null +++ b/roboflow/cli/handlers/_aliases.py @@ -0,0 +1,194 @@ +"""Top-level backwards-compatibility aliases. + +Split into three registration functions called at different points in +``__init__.py`` to control help ordering: + +- ``register_download_alias(app)`` β€” visible ``download`` command (alphabetical slot) +- ``register_hidden_aliases(app)`` β€” all hidden aliases (loaded last) +""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import ctx_to_args + + +def register_hidden_aliases(app: typer.Typer) -> None: + """Register all hidden backwards-compat aliases (not shown in --help).""" + + @app.command("download", hidden=True) + def download_alias( + ctx: typer.Context, + url_or_id: Annotated[ + str, typer.Argument(metavar="datasetUrl", help="Dataset URL (e.g. workspace/project/version)") + ], + format: Annotated[str, typer.Option("-f", "--format", help="Export format")] = "voc", + location: Annotated[Optional[str], typer.Option("-l", "--location", help="Download location")] = None, + ) -> None: + """Download a dataset version (alias for 'version download').""" + from roboflow.cli.handlers.version import _download + + args = ctx_to_args(ctx, url_or_id=url_or_id, format=format, location=location) + _download(args) + + @app.command("login", hidden=True) + def login_alias( + ctx: typer.Context, + login_api_key: Annotated[ + Optional[str], typer.Option("--api-key", help="API key (skip interactive login)") + ] = None, + force: Annotated[bool, typer.Option("--force", "-f", help="Force re-login")] = False, + ) -> None: + """Log in to Roboflow (alias for 'auth login').""" + from roboflow.cli.handlers.auth import _login + + args = ctx_to_args(ctx, login_api_key=login_api_key, force=force) + _login(args) + + @app.command("whoami", hidden=True) + def whoami_alias(ctx: typer.Context) -> None: + """Show current user (alias for 'auth status').""" + from roboflow.cli.handlers.auth import _status + + args = ctx_to_args(ctx) + _status(args) + + @app.command("upload", hidden=True) + def upload_alias( + ctx: typer.Context, + path: Annotated[str, typer.Argument(help="Path to image file or directory")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + annotation: Annotated[Optional[str], typer.Option("-a", "--annotation", help="Annotation file")] = None, + labelmap: Annotated[Optional[str], typer.Option("-m", "--labelmap", help="Labelmap file")] = None, + split: Annotated[ + Optional[str], + typer.Option("-s", "--split", help="Override split for all uploaded images (default: infer from folder)"), + ] = None, + num_retries: Annotated[int, typer.Option("-r", "--retries", help="Retry count")] = 0, + batch: Annotated[Optional[str], typer.Option("-b", "--batch", help="Batch name")] = None, + tag_names: Annotated[Optional[str], typer.Option("-t", "--tag", help="Tag names")] = None, + metadata: Annotated[Optional[str], typer.Option("-M", "--metadata", help="JSON metadata")] = None, + concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Concurrency")] = 10, + is_prediction: Annotated[bool, typer.Option("--is-prediction", help="Mark as prediction")] = False, + ) -> None: + """Upload images to a project (alias for 'image upload').""" + from roboflow.cli.handlers.image import _handle_upload + + args = ctx_to_args( + ctx, + path=path, + project=project, + annotation=annotation, + labelmap=labelmap, + split=split, + num_retries=num_retries, + batch=batch, + tag_names=tag_names, + metadata=metadata, + concurrency=concurrency, + is_prediction=is_prediction, + ) + _handle_upload(args) + + @app.command("import", hidden=True) + def import_alias( + ctx: typer.Context, + path: Annotated[str, typer.Argument(metavar="folder", help="Path to dataset folder")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Concurrency")] = 10, + batch: Annotated[Optional[str], typer.Option("-n", "--batch-name", help="Batch name")] = None, + num_retries: Annotated[int, typer.Option("-r", "--retries", help="Retry count")] = 0, + ) -> None: + """Import dataset from folder (alias for 'image upload').""" + from roboflow.cli.handlers.image import _handle_upload + + args = ctx_to_args( + ctx, path=path, project=project, concurrency=concurrency, batch=batch, num_retries=num_retries + ) + _handle_upload(args) + + @app.command("search-export", hidden=True) + def search_export_alias( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="Search query")], + format: Annotated[str, typer.Option("-f", help="Format")] = "coco", + location: Annotated[Optional[str], typer.Option("-l", help="Export location")] = None, + dataset: Annotated[Optional[str], typer.Option("-d", help="Limit to dataset")] = None, + annotation_group: Annotated[Optional[str], typer.Option("-g", help="Annotation group")] = None, + name: Annotated[Optional[str], typer.Option("-n", help="Export name")] = None, + no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip")] = False, + ) -> None: + """Export search results as a dataset.""" + from roboflow.cli.handlers.search import _search + + args = ctx_to_args( + ctx, + query=query, + format=format, + location=location, + dataset=dataset, + annotation_group=annotation_group, + name=name, + no_extract=no_extract, + export=True, + ) + _search(args) + + @app.command("upload_model", hidden=True) + def upload_model_alias( + ctx: typer.Context, + project: Annotated[Optional[list[str]], typer.Option("-p", help="Project ID (repeatable)")] = None, + version_number: Annotated[Optional[int], typer.Option("-v", help="Version")] = None, + model_type: Annotated[Optional[str], typer.Option("-t", help="Model type")] = None, + model_path: Annotated[Optional[str], typer.Option("-m", help="Model path")] = None, + filename: Annotated[str, typer.Option("-f", help="Filename")] = "weights/best.pt", + model_name: Annotated[Optional[str], typer.Option("-n", help="Model name")] = None, + ) -> None: + """Upload a model (hidden legacy alias).""" + from roboflow.cli.handlers.model import _upload_model + + args = ctx_to_args( + ctx, + project=project, + version_number=version_number, + model_type=model_type, + model_path=model_path, + filename=filename, + model_name=model_name, + ) + _upload_model(args) + + @app.command("get_workspace_info", hidden=True) + def get_workspace_info_alias( + ctx: typer.Context, + project: Annotated[Optional[str], typer.Option("-p", help="Project ID")] = None, + version_number: Annotated[Optional[int], typer.Option("-v", help="Version")] = None, + ) -> None: + """Get workspace info (hidden legacy alias).""" + import roboflow as rf_mod + + args = ctx_to_args(ctx, project=project, version_number=version_number) + rf_obj = rf_mod.Roboflow(args.api_key) + workspace = rf_obj.workspace() + print("workspace", workspace) # noqa: T201 + proj = workspace.project(args.project) + print("project", proj) # noqa: T201 + ver = proj.version(args.version_number) + print("version", ver) # noqa: T201 + + @app.command("run_video_inference_api", hidden=True) + def run_video_inference_api_alias( + ctx: typer.Context, + project: Annotated[Optional[str], typer.Option("-p", help="Project ID")] = None, + version_number: Annotated[Optional[int], typer.Option("-v", help="Version")] = None, + video_file: Annotated[Optional[str], typer.Option("-f", help="Video file")] = None, + fps: Annotated[int, typer.Option("-fps", help="FPS")] = 5, + ) -> None: + """Run video inference (hidden legacy alias).""" + from roboflow.cli.handlers.video import _video_infer + + args = ctx_to_args(ctx, project=project, version_number=version_number, video_file=video_file, fps=fps) + _video_infer(args) diff --git a/roboflow/cli/handlers/annotation.py b/roboflow/cli/handlers/annotation.py new file mode 100644 index 00000000..2259960e --- /dev/null +++ b/roboflow/cli/handlers/annotation.py @@ -0,0 +1,275 @@ +"""Annotation management commands: batch and job operations.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +annotation_app = typer.Typer(cls=SortedGroup, help="Annotation management commands", no_args_is_help=True) +batch_app = typer.Typer(cls=SortedGroup, help="Annotation batch commands", no_args_is_help=True) +job_app = typer.Typer(cls=SortedGroup, help="Annotation job commands", no_args_is_help=True) + +annotation_app.add_typer(batch_app, name="batch") +annotation_app.add_typer(job_app, name="job") + + +# --------------------------------------------------------------------------- +# batch commands +# --------------------------------------------------------------------------- + + +@batch_app.command("list") +def batch_list( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """List annotation batches.""" + args = ctx_to_args(ctx, project=project) + _batch_list(args) + + +@batch_app.command("get") +def batch_get( + ctx: typer.Context, + batch_id: Annotated[str, typer.Argument(help="Batch ID")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """Get annotation batch details.""" + args = ctx_to_args(ctx, batch_id=batch_id, project=project) + _batch_get(args) + + +# --------------------------------------------------------------------------- +# job commands +# --------------------------------------------------------------------------- + + +@job_app.command("list") +def job_list( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """List annotation jobs.""" + args = ctx_to_args(ctx, project=project) + _job_list(args) + + +@job_app.command("get") +def job_get( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Job ID")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """Get annotation job details.""" + args = ctx_to_args(ctx, job_id=job_id, project=project) + _job_get(args) + + +@job_app.command("create") +def job_create( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + name: Annotated[str, typer.Option(help="Job name")], + batch: Annotated[str, typer.Option(help="Batch ID")], + num_images: Annotated[int, typer.Option("--num-images", help="Number of images")], + labeler: Annotated[str, typer.Option(help="Labeler email")], + reviewer: Annotated[str, typer.Option(help="Reviewer email")], +) -> None: + """Create an annotation job.""" + args = ctx_to_args( + ctx, + project=project, + name=name, + batch=batch, + num_images=num_images, + labeler=labeler, + reviewer=reviewer, + ) + _job_create(args) + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _normalize_timestamps(obj): # noqa: ANN001 + """Recursively convert Firestore timestamp dicts ({"_seconds": N, "_nanoseconds": N}) to ISO 8601 strings.""" + from datetime import datetime, timezone + + if isinstance(obj, dict): + if "_seconds" in obj and "_nanoseconds" in obj and len(obj) == 2: + return datetime.fromtimestamp(obj["_seconds"], tz=timezone.utc).isoformat() + return {k: _normalize_timestamps(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_normalize_timestamps(item) for item in obj] + return obj + + +def _resolve_project_context(args): # noqa: ANN001 + """Resolve workspace/project from -p flag and return (api_key, ws, proj) or call output_error.""" + from roboflow.cli._output import output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _version = resolve_resource(args.project, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return None + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return None + + return api_key, workspace_url, project_slug + + +# --------------------------------------------------------------------------- +# handler implementations +# --------------------------------------------------------------------------- + + +def _batch_list(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + ctx = _resolve_project_context(args) + if ctx is None: + return + api_key, workspace_url, project_slug = ctx + + try: + data = rfapi.list_batches(api_key, workspace_url, project_slug) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + batches = data if isinstance(data, list) else data.get("batches", data) + batches = _normalize_timestamps(batches) + + table = format_table( + batches if isinstance(batches, list) else [], + columns=["name", "id", "status", "images"], + headers=["NAME", "ID", "STATUS", "IMAGE_COUNT"], + ) + output(args, batches, text=table) + + +def _batch_get(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + ctx = _resolve_project_context(args) + if ctx is None: + return + api_key, workspace_url, project_slug = ctx + + try: + data = rfapi.get_batch(api_key, workspace_url, project_slug, args.batch_id) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + data = _normalize_timestamps(data) + batch = data.get("batch", data) if isinstance(data, dict) else data + + lines = [] + if isinstance(batch, dict): + for key, val in batch.items(): + lines.append(f" {key:16s} {val}") + text = "\n".join(lines) if lines else "(no batch details)" + + output(args, data, text=text) + + +def _job_list(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + ctx = _resolve_project_context(args) + if ctx is None: + return + api_key, workspace_url, project_slug = ctx + + try: + data = rfapi.list_annotation_jobs(api_key, workspace_url, project_slug) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + jobs = data if isinstance(data, list) else data.get("jobs", data) + jobs = _normalize_timestamps(jobs) + + table = format_table( + jobs if isinstance(jobs, list) else [], + columns=["name", "id", "status", "assigned_to"], + headers=["NAME", "ID", "STATUS", "ASSIGNED_TO"], + ) + output(args, jobs, text=table) + + +def _job_get(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + ctx = _resolve_project_context(args) + if ctx is None: + return + api_key, workspace_url, project_slug = ctx + + try: + data = rfapi.get_annotation_job(api_key, workspace_url, project_slug, args.job_id) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + data = _normalize_timestamps(data) + job = data.get("job", data) if isinstance(data, dict) else data + + lines = [] + if isinstance(job, dict): + for key, val in job.items(): + lines.append(f" {key:16s} {val}") + text = "\n".join(lines) if lines else "(no job details)" + + output(args, data, text=text) + + +def _job_create(args): # noqa: ANN001 + import roboflow + from roboflow.cli._output import output, output_error, suppress_sdk_output + + ctx = _resolve_project_context(args) + if ctx is None: + return + _api_key, workspace_url, project_slug = ctx + + with suppress_sdk_output(args): + try: + rf = roboflow.Roboflow(api_key=_api_key) + workspace = rf.workspace(workspace_url) + project = workspace.project(project_slug) + except Exception as exc: + output_error(args, str(exc)) + return + + try: + result = project.create_annotation_job( + name=args.name, + batch_id=args.batch, + num_images=args.num_images, + labeler_email=args.labeler, + reviewer_email=args.reviewer, + ) + except Exception as exc: + output_error(args, str(exc)) + return + + output(args, result, text=f"Created annotation job: {args.name}") diff --git a/roboflow/cli/handlers/api_key.py b/roboflow/cli/handlers/api_key.py new file mode 100644 index 00000000..d4831492 --- /dev/null +++ b/roboflow/cli/handlers/api_key.py @@ -0,0 +1,589 @@ +"""API key management commands.""" + +from __future__ import annotations + +from typing import Annotated, List, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +api_key_app = typer.Typer(cls=SortedGroup, help="Manage workspace API keys", no_args_is_help=True) + + +@api_key_app.command("list") +def list_keys( + ctx: typer.Context, + include_disabled: Annotated[bool, typer.Option("--include-disabled", help="Include disabled keys")] = False, + include_folders: Annotated[bool, typer.Option("--include-folders", help="Include folder ID details")] = False, +) -> None: + """List all API keys for the workspace.""" + args = ctx_to_args(ctx, include_disabled=include_disabled, include_folders=include_folders) + _list_keys(args) + + +@api_key_app.command("get") +def get_key( + ctx: typer.Context, + key_id: Annotated[str, typer.Argument(help="Key ID (keyId) to retrieve")], +) -> None: + """Show details for a single API key.""" + args = ctx_to_args(ctx, key_id=key_id) + _get_key(args) + + +@api_key_app.command("publishable") +def get_publishable(ctx: typer.Context) -> None: + """Print the workspace publishable key (rf_). + + This is the non-secret key used for browser / inference.js requests. + It is safe to embed in client-side code. To use it programmatically: + + roboflow --json api-key publishable | jq -r .publishableKey + """ + args = ctx_to_args(ctx) + _get_publishable(args) + + +@api_key_app.command("create") +def create_key( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Display name for the new key")], + scope: Annotated[ + Optional[List[str]], + typer.Option( + "--scope", + help="Scope or role: preset (repeatable). Omit to inherit the calling key's scopes.", + ), + ] = None, + no_scopes: Annotated[ + bool, + typer.Option( + "--no-scopes", + help="Create a key with an empty scope list (no abilities). Mutually exclusive with --scope/--full-access.", + ), + ] = False, + full_access: Annotated[ + bool, + typer.Option( + "--full-access", + help="Create an unscoped, full-access key (sends null). Mutually exclusive with --scope/--no-scopes.", + ), + ] = False, + folder: Annotated[ + Optional[List[str]], + typer.Option("--folder", help="Folder ID to restrict access to (repeatable)."), + ] = None, + metadata: Annotated[ + Optional[List[str]], + typer.Option("--metadata", help="Custom metadata as KEY=VALUE (repeatable)."), + ] = None, + protected: Annotated[ + bool, + typer.Option("--protected", help="Mark key as protected (cannot be revoked/disabled via CLI)"), + ] = False, +) -> None: + """Create a new API key. + + The secret key value is printed ONCE β€” save it immediately. + To capture it programmatically use --json and pipe to jq: + + roboflow --json api-key create MY-KEY | jq -r .key + + Scope selection is three-way: omit all scope flags to inherit the calling + key's scopes, pass --scope (repeatable) to scope the key, --no-scopes for a + key with no abilities, or --full-access for an unscoped/full-access key. + """ + args = ctx_to_args( + ctx, + name=name, + scope=scope, + no_scopes=no_scopes, + full_access=full_access, + folder=folder, + metadata=metadata, + protected=protected, + ) + _create_key(args) + + +@api_key_app.command("update") +def update_key( + ctx: typer.Context, + key_id: Annotated[str, typer.Argument(help="Key ID (keyId) to update")], + name: Annotated[Optional[str], typer.Option("--name", help="New display name")] = None, + scope: Annotated[ + Optional[List[str]], + typer.Option( + "--scope", + help="Scope or role: preset (repeatable). Replaces existing scopes.", + ), + ] = None, + no_scopes: Annotated[ + bool, + typer.Option( + "--no-scopes", + help="Replace scopes with an empty list (no abilities). Mutually exclusive with --scope/--full-access.", + ), + ] = False, + full_access: Annotated[ + bool, + typer.Option( + "--full-access", + help="Make the key unscoped/full access (sends null). Mutually exclusive with --scope/--no-scopes.", + ), + ] = False, + metadata: Annotated[ + Optional[List[str]], + typer.Option("--metadata", help="Custom metadata as KEY=VALUE (repeatable)."), + ] = None, + clear_metadata: Annotated[ + bool, + typer.Option( + "--clear-metadata", + help="Clear all custom metadata (sends {}). Mutually exclusive with --metadata.", + ), + ] = False, +) -> None: + """Update an API key's display name, scopes, or metadata. + + Scopes are three-way: --scope (repeatable) replaces scopes, --no-scopes + replaces them with an empty list (no abilities), and --full-access makes the + key unscoped/full access. Metadata: --metadata sets custom KEY=VALUE pairs, + --clear-metadata removes all custom metadata. + """ + args = ctx_to_args( + ctx, + key_id=key_id, + name=name, + scope=scope, + no_scopes=no_scopes, + full_access=full_access, + metadata=metadata, + clear_metadata=clear_metadata, + ) + _update_key(args) + + +@api_key_app.command("protect") +def protect_key( + ctx: typer.Context, + key_id: Annotated[str, typer.Argument(help="Key ID (keyId) to protect")], +) -> None: + """Mark an API key as protected. + + Protected keys cannot be revoked or disabled via the CLI or API. + To unprotect a key, visit app.roboflow.com/settings/api. + """ + args = ctx_to_args(ctx, key_id=key_id) + _protect_key(args) + + +@api_key_app.command("disable") +def disable_key( + ctx: typer.Context, + key_id: Annotated[str, typer.Argument(help="Key ID (keyId) to enable/disable")], + enable: Annotated[ + bool, typer.Option("--enable/--disable", help="Enable (--enable) or disable (--disable) the key") + ] = False, +) -> None: + """Enable or disable an API key (default: --disable). + + Disabled keys are rejected by the API but can be re-enabled. + Protected keys cannot be disabled β€” revoke them from the dashboard. + """ + args = ctx_to_args(ctx, key_id=key_id, enable=enable) + _disable_key(args) + + +@api_key_app.command("revoke") +def revoke_key( + ctx: typer.Context, + key_id: Annotated[str, typer.Argument(help="Key ID (keyId) to revoke")], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt")] = False, +) -> None: + """Permanently revoke an API key. This action cannot be undone. + + Protected keys cannot be revoked via the CLI β€” use the dashboard. + """ + args = ctx_to_args(ctx, key_id=key_id, yes=yes) + _revoke_key(args) + + +# --------------------------------------------------------------------------- +# Business logic +# --------------------------------------------------------------------------- + + +def _resolve_ws_and_key(args): # noqa: ANN001 + from roboflow.cli._resolver import resolve_ws_and_key + + return resolve_ws_and_key(args) + + +def _parse_metadata(args, pairs: Optional[List[str]]) -> Optional[dict]: # noqa: ANN001 + """Parse repeated ``KEY=VALUE`` strings into a dict, or ``None`` if empty.""" + if not pairs: + return None + from roboflow.cli._output import output_error + + metadata: dict[str, str] = {} + for pair in pairs: + key, sep, value = pair.partition("=") + if not sep or not key: + output_error( + args, + f"Invalid --metadata value '{pair}'. Expected KEY=VALUE.", + hint="Example: --metadata team=vision --metadata env=prod", + exit_code=1, + ) + metadata[key] = value + return metadata + + +# Sentinel meaning "field not provided" β€” distinct from an explicit ``None``/``[]``/``{}``. +_UNSET = object() + + +def _resolve_scopes(args): # noqa: ANN001 + """Resolve the three-way ``--scope`` / ``--no-scopes`` / ``--full-access`` selection. + + Returns one of: + * ``_UNSET`` β€” no scope flag given (inherit / leave unchanged), + * a list β€” explicit ``--scope`` values (``[]`` for ``--no-scopes``), + * ``rfapi.FULL_ACCESS`` β€” ``--full-access`` (send ``null``). + + Exits 1 if more than one of the three is supplied. + """ + from roboflow.adapters import rfapi + from roboflow.cli._output import output_error + + scope = getattr(args, "scope", None) or None + no_scopes = getattr(args, "no_scopes", False) + full_access = getattr(args, "full_access", False) + + if sum([scope is not None, no_scopes, full_access]) > 1: + output_error( + args, + "Only one of --scope, --no-scopes, or --full-access may be used.", + hint="Use --scope to scope the key, --no-scopes for no abilities, or --full-access for unscoped access.", + exit_code=1, + ) + + if full_access: + return rfapi.FULL_ACCESS + if no_scopes: + return [] + if scope is not None: + return scope + return _UNSET + + +def _resolve_metadata(args): # noqa: ANN001 + """Resolve the ``--metadata`` / ``--clear-metadata`` selection. + + Returns one of: + * ``_UNSET`` β€” neither flag given (leave unchanged), + * a dict β€” parsed ``--metadata`` pairs (``{}`` for ``--clear-metadata``). + + Exits 1 if both are supplied. + """ + from roboflow.cli._output import output_error + + metadata = getattr(args, "metadata", None) + clear_metadata = getattr(args, "clear_metadata", False) + + if metadata and clear_metadata: + output_error( + args, + "Only one of --metadata or --clear-metadata may be used.", + hint="Use --metadata KEY=VALUE to set metadata, or --clear-metadata to remove it.", + exit_code=1, + ) + + if clear_metadata: + return {} + parsed = _parse_metadata(args, metadata) + if parsed is not None: + return parsed + return _UNSET + + +def _list_keys(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error + from roboflow.cli._table import format_table + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = rfapi.list_api_keys( + api_key, + ws, + include_disabled=getattr(args, "include_disabled", False), + include_folders=getattr(args, "include_folders", False), + ) + except rfapi.RoboflowError as exc: + output_api_error(args, exc) + return + + keys = result.get("apiKeys", []) + rows = [] + for k in keys: + rows.append( + { + "keyId": k.get("keyId", ""), + "name": k.get("name", ""), + "prefix": k.get("prefix", ""), + "default": str(k.get("default", False)), + "protected": str(k.get("protected", False)), + "disabled": str(k.get("disabled", False)), + } + ) + table = format_table( + rows, + columns=["keyId", "name", "prefix", "default", "protected", "disabled"], + headers=["KEY ID", "NAME", "PREFIX", "DEFAULT", "PROTECTED", "DISABLED"], + ) + output(args, result, text=table) + + +def _get_key(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = rfapi.get_api_key(api_key, ws, args.key_id) + except rfapi.RoboflowError as exc: + output_api_error(args, exc, not_found_hint=f"No API key with ID '{args.key_id}' in this workspace.") + return + + key_obj = result.get("apiKey", result) + lines = [ + f"Key ID: {key_obj.get('keyId', '')}", + f" Name: {key_obj.get('name', '')}", + f" Prefix: {key_obj.get('prefix', '')}", + f" Default: {key_obj.get('default', False)}", + f" Protected: {key_obj.get('protected', False)}", + f" Disabled: {key_obj.get('disabled', False)}", + ] + if key_obj.get("scopes"): + lines.append(f" Scopes: {', '.join(key_obj['scopes'])}") + if key_obj.get("created_on"): + lines.append(f" Created: {key_obj['created_on']}") + output(args, result, text="\n".join(lines)) + + +def _get_publishable(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = rfapi.get_publishable_key(api_key, ws) + except rfapi.RoboflowError as exc: + output_api_error(args, exc) + return + + pub_key = result.get("publishableKey", "") + output(args, result, text=pub_key) + + +def _create_key(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + scopes = _resolve_scopes(args) + folder_ids = getattr(args, "folder", None) or None + metadata = _resolve_metadata(args) + + try: + result = rfapi.create_api_key( + api_key, + ws, + name=args.name, + scopes=None if scopes is _UNSET else scopes, + folder_ids=folder_ids, + custom_metadata=None if metadata is _UNSET else metadata, + protected=getattr(args, "protected", False), + ) + except rfapi.RoboflowError as exc: + status = getattr(exc, "status_code", None) + if status in (403, 404): + output_api_error( + args, + exc, + hint=( + "Creating API keys requires an unscoped key or one granted the " + "'api-key:create' scope (OAuth also needs the create_api_key permission). " + "A workspace-wide key additionally requires access to all folders. Use an " + "unscoped key, grant this key 'api-key:create', or create the key at " + "app.roboflow.com/settings/api." + ), + ) + else: + output_api_error( + args, + exc, + hint="Scopes, folders, and metadata require the Advanced API Keys plan feature.", + ) + return + + secret = result.get("key", "") + key_id = result.get("keyId", "") + + lines = [ + f"Created API key '{args.name}' (keyId: {key_id})", + "", + "WARNING: This is the only time the secret key will be shown.", + "Save it somewhere secure now.", + "", + f" Key: {secret}", + ] + output(args, result, text="\n".join(lines)) + + +def _update_key(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + fields = {} + if getattr(args, "name", None) is not None: + fields["name"] = args.name + scopes = _resolve_scopes(args) + if scopes is not _UNSET: + fields["scopes"] = scopes + metadata = _resolve_metadata(args) + if metadata is not _UNSET: + fields["custom_metadata"] = metadata + + try: + result = rfapi.update_api_key(api_key, ws, args.key_id, **fields) + except rfapi.RoboflowError as exc: + output_api_error( + args, + exc, + not_found_hint=f"No API key with ID '{args.key_id}' in this workspace.", + ) + return + + output(args, result, text=f"Updated API key '{args.key_id}'") + + +def _protect_key(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = rfapi.update_api_key(api_key, ws, args.key_id, protected=True) + except rfapi.RoboflowError as exc: + output_api_error( + args, + exc, + hint="The API cannot unprotect a key. To unprotect, visit app.roboflow.com/settings/api.", + not_found_hint=f"No API key with ID '{args.key_id}' in this workspace.", + ) + return + + output(args, result, text=f"Marked API key '{args.key_id}' as protected.") + + +def _disable_key(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + enable = getattr(args, "enable", False) + disabled_value = not enable + + try: + result = rfapi.update_api_key(api_key, ws, args.key_id, disabled=disabled_value) + except rfapi.RoboflowError as exc: + if getattr(exc, "status_code", None) == 409: + output_error( + args, + str(exc), + hint="Protected keys cannot be disabled. To disable, visit app.roboflow.com/settings/api.", + exit_code=1, + ) + elif getattr(exc, "status_code", None) == 403: + output_error( + args, + str(exc), + hint="Enabling/disabling keys requires the Advanced API Keys plan feature.", + exit_code=1, + ) + else: + output_api_error( + args, + exc, + not_found_hint=f"No API key with ID '{args.key_id}' in this workspace.", + ) + return + + action = "Enabled" if enable else "Disabled" + output(args, result, text=f"{action} API key '{args.key_id}'.") + + +def _revoke_key(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import confirm_destructive, output, output_api_error, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + if not confirm_destructive(args, f"Permanently revoke API key '{args.key_id}'?"): + return + + try: + result = rfapi.revoke_api_key(api_key, ws, args.key_id) + except rfapi.RoboflowError as exc: + if getattr(exc, "status_code", None) == 409: + output_error( + args, + str(exc), + hint=("Protected keys cannot be revoked via the CLI. To revoke, visit app.roboflow.com/settings/api."), + exit_code=1, + ) + else: + output_api_error( + args, + exc, + not_found_hint=f"No API key with ID '{args.key_id}' in this workspace.", + ) + return + + output(args, result, text=f"Revoked API key '{args.key_id}'.") diff --git a/roboflow/cli/handlers/asynctasks.py b/roboflow/cli/handlers/asynctasks.py new file mode 100644 index 00000000..51dbe26f --- /dev/null +++ b/roboflow/cli/handlers/asynctasks.py @@ -0,0 +1,136 @@ +"""Async task polling commands. + +These mirror the generic ``GET /:workspace/asynctasks/:id`` endpoint so any +backend operation that returns ``{taskId, url}`` can be inspected with the +same CLI tools. +""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +asynctasks_app = typer.Typer( + cls=SortedGroup, + help="Inspect async background tasks (e.g. project forks)", + no_args_is_help=True, +) + + +@asynctasks_app.command("get") +def get_async_task( + ctx: typer.Context, + task_id: Annotated[str, typer.Argument(help="Async task id (returned by /projects/fork etc.)")], +) -> None: + """Show the current status of an async task.""" + args = ctx_to_args(ctx, task_id=task_id) + _get_async_task(args) + + +@asynctasks_app.command("wait") +def wait_async_task( + ctx: typer.Context, + task_id: Annotated[str, typer.Argument(help="Async task id")], + timeout: Annotated[ + int, + typer.Option("--timeout", help="Seconds to wait for completion (0 = no timeout)."), + ] = 1800, +) -> None: + """Block until an async task is completed or failed.""" + args = ctx_to_args(ctx, task_id=task_id, timeout=timeout) + _wait_async_task(args) + + +# --------------------------------------------------------------------------- +# Business logic +# --------------------------------------------------------------------------- + + +def _resolve_ws_and_key(args): # noqa: ANN001 + from roboflow.cli._output import output_error + from roboflow.cli._resolver import resolve_default_workspace + from roboflow.config import load_roboflow_api_key + + workspace_url = args.workspace or resolve_default_workspace(api_key=args.api_key) + if not workspace_url: + output_error( + args, + "No workspace specified.", + hint="Use --workspace or run 'roboflow auth login'.", + exit_code=2, + ) + return None, None + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return None, None + return workspace_url, api_key + + +def _get_async_task(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + workspace_url, api_key = _resolve_ws_and_key(args) + if not api_key: + return + + try: + status = rfapi.get_async_task(api_key, workspace_url, args.task_id) + except rfapi.RoboflowError as exc: + # Server returns 404 for unknown ids OR cross-workspace probes. + output_error(args, str(exc), exit_code=3) + return + + output(args, status, text=f"taskId={status.get('taskId')} status={status.get('status')}") + + +def _wait_async_task(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.core.async_tasks import poll_until_terminal + + workspace_url, api_key = _resolve_ws_and_key(args) + if not api_key: + return + + def _print_progress(status): # noqa: ANN001 + if args.json: + return + progress = status.get("progress") + if not isinstance(progress, dict): + return + # Don't use `or` here: `current == 0` is a legitimate value. + current = progress["current"] if "current" in progress else progress.get("completed") + total = progress.get("total") + if current is not None and total is not None: + print(f"Task progress: {current}/{total}", flush=True) + + try: + final = poll_until_terminal( + api_key, + workspace_url, + args.task_id, + timeout=args.timeout, + on_update=_print_progress, + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + except TimeoutError as exc: + output_error(args, str(exc)) + return + + if final.get("status") == "failed": + output_error(args, final.get("error") or "Task failed.") + return + + output(args, final, text=f"taskId={final.get('taskId')} status={final.get('status')}") diff --git a/roboflow/cli/handlers/auth.py b/roboflow/cli/handlers/auth.py new file mode 100644 index 00000000..374b7fc2 --- /dev/null +++ b/roboflow/cli/handlers/auth.py @@ -0,0 +1,295 @@ +"""Auth commands: login, logout, status, set-workspace.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +auth_app = typer.Typer(cls=SortedGroup, help="Manage authentication and credentials", no_args_is_help=True) + + +@auth_app.command("login") +def login( + ctx: typer.Context, + login_api_key: Annotated[Optional[str], typer.Option("--api-key", help="API key (skip interactive prompt)")] = None, + login_workspace: Annotated[ + Optional[str], typer.Option("--workspace", help="Set default workspace during login") + ] = None, + force: Annotated[bool, typer.Option("--force", "-f", help="Force re-login even if already logged in")] = False, +) -> None: + """Log in to Roboflow.""" + args = ctx_to_args(ctx, login_api_key=login_api_key, login_workspace=login_workspace, force=force) + _login(args) + + +@auth_app.command("status") +def status(ctx: typer.Context) -> None: + """Show current auth status.""" + args = ctx_to_args(ctx) + _status(args) + + +@auth_app.command("set-workspace") +def set_workspace( + ctx: typer.Context, + workspace_id: Annotated[str, typer.Argument(help="Workspace URL or ID to set as default")], +) -> None: + """Set the default workspace.""" + args = ctx_to_args(ctx, workspace_id=workspace_id) + _set_workspace(args) + + +@auth_app.command("logout") +def logout(ctx: typer.Context) -> None: + """Remove stored credentials.""" + args = ctx_to_args(ctx) + _logout(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _get_config_path() -> str: + import os + from pathlib import Path + + if os.name == "nt": + default_path = str(Path.home() / "roboflow" / "config.json") + else: + default_path = str(Path.home() / ".config" / "roboflow" / "config.json") + return os.getenv("ROBOFLOW_CONFIG_DIR", default=default_path) + + +def _load_config() -> dict: + import json + import os + + path = _get_config_path() + if os.path.exists(path): + with open(path) as f: + return json.load(f) + return {} + + +def _save_config(config: dict) -> None: + import json + import os + import stat + + path = _get_config_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + # Write with owner-only permissions (0600) since the file contains API keys + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR) + with os.fdopen(fd, "w") as f: + json.dump(config, f, indent=2) + + +def _mask_key(key: str) -> str: + if not key or len(key) <= 4: + return "****" + return key[:2] + "*" * (len(key) - 4) + key[-2:] + + +def _print_completion_tip(args) -> None: # noqa: ANN001 + """Nudge users towards shell completion after a successful login. + + Suppressed under --json (would corrupt the JSON output) and --quiet + (user explicitly opted out of non-essential output). + """ + if getattr(args, "json", False) or getattr(args, "quiet", False): + return + print("\nTip: enable shell completion with 'roboflow completion install'") # noqa: T201 + + +def _login(args): # noqa: ANN001 + from roboflow.cli._output import output, output_error + + api_key = getattr(args, "login_api_key", None) or getattr(args, "api_key", None) + workspace_id = getattr(args, "login_workspace", None) or getattr(args, "workspace", None) + force = getattr(args, "force", False) + + if api_key: + # Non-interactive: validate key and fetch workspace info + import requests + + from roboflow.config import API_URL + + resp = requests.post(API_URL + "/?api_key=" + api_key) + if resp.status_code == 401: + output_error(args, "Invalid API key.", hint="Check your key at app.roboflow.com/settings", exit_code=2) + return + if resp.status_code != 200: + output_error(args, f"API error ({resp.status_code}).", exit_code=1) + return + + r_login = resp.json() + if r_login is None: + output_error(args, "Invalid API key.", exit_code=2) + return + + # The validation endpoint returns {"workspace": "", ...} + ws_url = workspace_id or r_login.get("workspace", "") + if not ws_url: + output_error(args, "Could not determine workspace.", hint="Pass --workspace explicitly.", exit_code=1) + return + + # Fetch workspace name from the API + ws_name = ws_url + try: + from roboflow.adapters import rfapi + + ws_json = rfapi.get_workspace(api_key, ws_url) + ws_detail = ws_json.get("workspace", ws_json) + ws_name = ws_detail.get("name", ws_url) + except Exception: # noqa: BLE001 + pass # Fall back to using the URL as the name + + # Build config with workspace info + config = _load_config() + workspaces = config.get("workspaces", {}) + workspaces[ws_url] = {"url": ws_url, "name": ws_name, "apiKey": api_key} + config["workspaces"] = workspaces + config["RF_WORKSPACE"] = ws_url + _save_config(config) + + note = "" + if len(workspaces) == 1: + note = "\n Note: API key login stores only the key's workspace. Use interactive login for all workspaces." + output( + args, + {"status": "logged_in", "workspace": ws_url, "api_key": _mask_key(api_key)}, + text=f"Logged in. Default workspace: {ws_url}{note}", + ) + _print_completion_tip(args) + else: + # Interactive flow + import roboflow + + conf_path = _get_config_path() + import os + + if os.path.isfile(conf_path) and not force: + # Already logged in β€” show status + config = _load_config() + ws = config.get("RF_WORKSPACE", "unknown") + output( + args, + {"status": "logged_in", "workspace": ws, "api_key": "****"}, + text=f"Already logged in. Default workspace: {ws}\nUse --force to re-login.", + ) + return + + roboflow.login(workspace=workspace_id, force=force) + # Re-read config after interactive login + config = _load_config() + ws = config.get("RF_WORKSPACE", "unknown") + output( + args, + {"status": "logged_in", "workspace": ws, "api_key": "****"}, + text=f"Logged in. Default workspace: {ws}", + ) + _print_completion_tip(args) + _print_completion_tip(args) + + +def _status(args): # noqa: ANN001 + import os + + from roboflow.cli._output import output, output_error + + config = _load_config() + workspaces = config.get("workspaces", {}) + default_ws_url = config.get("RF_WORKSPACE") + + # Explicit --api-key flag takes priority, then env var + explicit_api_key = getattr(args, "api_key", None) + api_key = explicit_api_key or os.getenv("ROBOFLOW_API_KEY") + + # When an explicit --api-key is provided, always validate it against the + # API rather than showing saved config β€” the user wants to check *this* key. + if explicit_api_key or (api_key and not default_ws_url): + import requests + + from roboflow.config import API_URL + + assert api_key is not None # guaranteed by the condition above + resp = requests.post(API_URL + "/?api_key=" + api_key) + if resp.status_code == 200: + ws_url = resp.json().get("workspace", "unknown") + data = {"url": ws_url, "name": ws_url, "apiKey": _mask_key(api_key)} + lines = [ + f"Workspace: {ws_url}", + f" URL: {ws_url}", + f" API Key: {_mask_key(api_key)}", + " (authenticated via --api-key or ROBOFLOW_API_KEY)", + ] + output(args, data, text="\n".join(lines)) + else: + output_error(args, "API key is invalid or expired.", exit_code=2) + return + + if not workspaces and not default_ws_url and not api_key: + output_error(args, "Not logged in.", hint="Run 'roboflow auth login' to authenticate.", exit_code=2) + return # unreachable, but helps mypy + + if not default_ws_url: + output_error(args, "No default workspace configured.", hint="Run 'roboflow auth set-workspace '.") + return # unreachable, but helps mypy + + workspaces_by_url = {w["url"]: w for w in workspaces.values()} + default_ws = workspaces_by_url.get(default_ws_url) + + if default_ws: + # Use stored API key, or fall back to env var + display_key = api_key or default_ws.get("apiKey", "") + masked = dict(default_ws) + masked["apiKey"] = _mask_key(display_key) + lines = [ + f"Workspace: {masked.get('name', 'unknown')}", + f" URL: {masked.get('url', 'unknown')}", + f" API Key: {masked['apiKey']}", + ] + output(args, masked, text="\n".join(lines)) + else: + # RF_WORKSPACE is set but no matching workspace details + data = {"url": default_ws_url, "name": default_ws_url} + output( + args, + data, + text=f"Workspace: {default_ws_url}\n (no detailed info available)", + ) + + +def _set_workspace(args): # noqa: ANN001 + from roboflow.cli._output import output + + workspace_id = args.workspace_id + config = _load_config() + config["RF_WORKSPACE"] = workspace_id + _save_config(config) + output( + args, + {"default_workspace": workspace_id}, + text=f"Default workspace set to: {workspace_id}", + ) + + +def _logout(args): # noqa: ANN001 + import os + + from roboflow.cli._output import output + + conf_path = _get_config_path() + if os.path.isfile(conf_path): + os.remove(conf_path) + + output( + args, + {"status": "logged_out"}, + text="Logged out. Credentials removed.", + ) diff --git a/roboflow/cli/handlers/batch.py b/roboflow/cli/handlers/batch.py new file mode 100644 index 00000000..31d24647 --- /dev/null +++ b/roboflow/cli/handlers/batch.py @@ -0,0 +1,63 @@ +"""Batch processing commands.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +batch_app = typer.Typer(cls=SortedGroup, help="Batch processing operations", no_args_is_help=True) + + +def _stub(args) -> None: # noqa: ANN001 + from roboflow.cli._output import output_error + + output_error(args, "This command is not yet implemented.", hint="Coming soon.", exit_code=1) + + +@batch_app.command("create") +def create( + ctx: typer.Context, + workflow: Annotated[str, typer.Option(help="Workflow ID to run")], + input: Annotated[str, typer.Option(help="Input path (image directory or video file)")], + model: Annotated[Optional[str], typer.Option(help="Model ID override (default: workflow model)")] = None, + output_dir: Annotated[Optional[str], typer.Option("--output", help="Output directory for results")] = None, +) -> None: + """Create a batch processing job.""" + args = ctx_to_args(ctx, workflow=workflow, input=input, model=model, output=output_dir) + _stub(args) + + +@batch_app.command("status") +def status( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Batch job ID")], +) -> None: + """Check batch job status.""" + args = ctx_to_args(ctx, job_id=job_id) + _stub(args) + + +@batch_app.command("list") +def list_jobs( + ctx: typer.Context, + status_filter: Annotated[ + Optional[str], typer.Option("--status", help="Filter by status (pending, running, completed, failed)") + ] = None, +) -> None: + """List batch jobs.""" + args = ctx_to_args(ctx, status=status_filter) + _stub(args) + + +@batch_app.command("results") +def results( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Batch job ID")], + format: Annotated[Optional[str], typer.Option(help="Output format (json, csv)")] = None, +) -> None: + """Get batch job results.""" + args = ctx_to_args(ctx, job_id=job_id, format=format) + _stub(args) diff --git a/roboflow/cli/handlers/completion.py b/roboflow/cli/handlers/completion.py new file mode 100644 index 00000000..8acebd71 --- /dev/null +++ b/roboflow/cli/handlers/completion.py @@ -0,0 +1,88 @@ +"""Shell completion: install + raw script generators. + +Delegates installation to ``typer.completion.install`` (which itself +wraps Click's ``shell_completion`` and auto-detects the shell via +shellingham). Hidden commands are filtered by Click automatically. +""" + +from __future__ import annotations + +import shutil +from typing import Annotated, Optional + +import click +import typer +from typer._completion_classes import completion_init +from typer._completion_shared import get_completion_script +from typer.completion import install as typer_install + +from roboflow.cli._compat import SortedGroup, ctx_to_args +from roboflow.cli._output import output, output_error + +completion_app = typer.Typer( + cls=SortedGroup, + help="Generate and install shell completions", + no_args_is_help=True, +) + +completion_init() + + +def _generate_completion(shell: str) -> str: + return get_completion_script(prog_name="roboflow", complete_var="_ROBOFLOW_COMPLETE", shell=shell) + + +@completion_app.command("bash") +def bash() -> None: + """Print bash completion script. Usage: eval "$(roboflow completion bash)".""" + print(_generate_completion("bash")) # noqa: T201 + + +@completion_app.command("zsh") +def zsh() -> None: + """Print zsh completion script. Usage: eval "$(roboflow completion zsh)".""" + print(_generate_completion("zsh")) # noqa: T201 + + +@completion_app.command("fish") +def fish() -> None: + """Print fish completion script. Usage: roboflow completion fish | source.""" + print(_generate_completion("fish")) # noqa: T201 + + +@completion_app.command("install") +def install( + ctx: typer.Context, + shell: Annotated[ + Optional[str], + typer.Option("--shell", help="bash, zsh, or fish. Auto-detected when omitted."), + ] = None, +) -> None: + """Install shell completion. Writes the script and updates your shell rc. Idempotent.""" + args = ctx_to_args(ctx, shell=shell) + + if shutil.which("roboflow") is None: + output_error( + args, + "The 'roboflow' command is not on your PATH.", + hint="Ensure your install bin directory (e.g. ~/.local/bin) is on PATH.", + exit_code=1, + ) + return + + try: + installed_shell, path = typer_install(shell=shell, prog_name="roboflow", complete_var="_ROBOFLOW_COMPLETE") + except click.exceptions.Exit: + output_error( + args, + "Could not detect or install completion.", + hint="Pass --shell with one of: bash, zsh, fish.", + exit_code=3, + ) + return + + output( + args, + {"shell": installed_shell, "path": str(path)}, + text=f"Installed {installed_shell} completion to {path}.\nOpen a new shell to enable it.", + ) diff --git a/roboflow/cli/handlers/deployment.py b/roboflow/cli/handlers/deployment.py new file mode 100644 index 00000000..9b7bcbc8 --- /dev/null +++ b/roboflow/cli/handlers/deployment.py @@ -0,0 +1,305 @@ +"""Deployment management commands. + +Builds clean, kebab-case subcommands that delegate to the handler +functions in ``roboflow.deployment``. Legacy snake_case names are +registered as hidden aliases so old scripts keep working. +""" + +from __future__ import annotations + +import io +import sys +from typing import Annotated, Any, Callable, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +# --------------------------------------------------------------------------- +# Wrapper that captures legacy handler stdout/exit and normalises output +# --------------------------------------------------------------------------- + + +def _wrap(func: Callable[..., Any]) -> Callable[..., None]: + """Wrap a legacy deployment handler for structured errors + JSON output.""" + + def _wrapped(args): # noqa: ANN001 + from roboflow.cli._output import output, output_error + + captured = io.StringIO() + orig_stdout = sys.stdout + try: + sys.stdout = captured + func(args) + except SystemExit as exc: + sys.stdout = orig_stdout + code = exc.code if isinstance(exc.code, int) else 1 + exit_code = {0: 1, 1: 1, 2: 2, 3: 3}.get(code, 1) if code else 1 + text = captured.getvalue().strip() + if text: + output_error(args, text, exit_code=exit_code) + else: + output_error(args, "Deployment command failed.", exit_code=1) + return + except Exception as exc: + sys.stdout = orig_stdout + output_error( + args, + f"Deployment service unavailable: {type(exc).__name__}", + hint="The dedicated deployment service may be down or unreachable. Try again later.", + exit_code=1, + ) + return + finally: + sys.stdout = orig_stdout + + text = captured.getvalue() + if text: + if getattr(args, "json", False): + import json + + try: + data = json.loads(text) + output(args, data) + except (ValueError, TypeError): + print(text, end="") + else: + print(text, end="") + + return _wrapped + + +deployment_app = typer.Typer(cls=SortedGroup, help="Manage dedicated deployments", no_args_is_help=True) + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +@deployment_app.command("machine-type") +def machine_type(ctx: typer.Context) -> None: + """List available machine types.""" + from roboflow.deployment import list_machine_types + + args = ctx_to_args(ctx) + _wrap(list_machine_types)(args) + + +@deployment_app.command("create") +def create_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name (5-15 lowercase chars, starts with letter)")], + machine_type_opt: Annotated[ + str, typer.Option("-m", "--machine-type", help="Machine type (run 'roboflow deployment machine-type' to list)") + ], + creator_email: Annotated[str, typer.Option("-e", "--email", help="Your email (must be a workspace member)")], + duration: Annotated[float, typer.Option(help="Duration in hours")] = 3, + no_delete_on_expiration: Annotated[ + bool, typer.Option("--no-delete-on-expiration", help="Keep deployment when it expires") + ] = False, + inference_version: Annotated[str, typer.Option("--inference-version", help="Inference server version")] = "latest", + wait_on_pending: Annotated[bool, typer.Option("--wait", help="Wait until deployment is ready")] = False, +) -> None: + """Create a dedicated deployment.""" + from roboflow.deployment import add_deployment + + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + machine_type=machine_type_opt, + creator_email=creator_email, + duration=duration, + no_delete_on_expiration=no_delete_on_expiration, + inference_version=inference_version, + wait_on_pending=wait_on_pending, + ) + _wrap(add_deployment)(args) + + +@deployment_app.command("get") +def get_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], + wait_on_pending: Annotated[bool, typer.Option("--wait", help="Wait if deployment is pending")] = False, +) -> None: + """Show details for a deployment.""" + from roboflow.deployment import get_deployment + + args = ctx_to_args(ctx, deployment_name=deployment_name, wait_on_pending=wait_on_pending) + _wrap(get_deployment)(args) + + +@deployment_app.command("list") +def list_deployments(ctx: typer.Context) -> None: + """List deployments in workspace.""" + from roboflow.deployment import list_deployment + + args = ctx_to_args(ctx) + _wrap(list_deployment)(args) + + +@deployment_app.command("usage") +def usage( + ctx: typer.Context, + deployment_name: Annotated[Optional[str], typer.Argument(help="Deployment name (omit for workspace-wide)")] = None, + from_timestamp: Annotated[Optional[str], typer.Option("--from", help="Start time (ISO 8601)")] = None, + to_timestamp: Annotated[Optional[str], typer.Option("--to", help="End time (ISO 8601)")] = None, +) -> None: + """Show usage statistics.""" + from roboflow.deployment import get_deployment_usage, get_workspace_usage + + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + from_timestamp=from_timestamp, + to_timestamp=to_timestamp, + ) + if deployment_name: + _wrap(get_deployment_usage)(args) + else: + _wrap(get_workspace_usage)(args) + + +@deployment_app.command("pause") +def pause_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], +) -> None: + """Pause a deployment.""" + from roboflow.deployment import pause_deployment + + args = ctx_to_args(ctx, deployment_name=deployment_name) + _wrap(pause_deployment)(args) + + +@deployment_app.command("resume") +def resume_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], +) -> None: + """Resume a paused deployment.""" + from roboflow.deployment import resume_deployment + + args = ctx_to_args(ctx, deployment_name=deployment_name) + _wrap(resume_deployment)(args) + + +@deployment_app.command("delete") +def delete_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], +) -> None: + """Delete a deployment.""" + from roboflow.deployment import delete_deployment + + args = ctx_to_args(ctx, deployment_name=deployment_name) + _wrap(delete_deployment)(args) + + +@deployment_app.command("log") +def deployment_log( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], + duration: Annotated[int, typer.Option("-d", "--duration", help="Log window in seconds")] = 3600, + tail: Annotated[int, typer.Option("-n", "--tail", help="Lines to show from end (max 50)")] = 10, + follow: Annotated[bool, typer.Option("-f", "--follow", help="Follow log output")] = False, +) -> None: + """Show deployment logs.""" + from roboflow.deployment import get_deployment_log + + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + duration=duration, + tail=tail, + follow=follow, + ) + _wrap(get_deployment_log)(args) + + +# --------------------------------------------------------------------------- +# Hidden legacy aliases +# --------------------------------------------------------------------------- + + +@deployment_app.command("machine_type", hidden=True) +def legacy_machine_type( + ctx: typer.Context, + api_key: Annotated[Optional[str], typer.Option("-a", "--api_key")] = None, +) -> None: + """Legacy alias for machine-type.""" + from roboflow.deployment import list_machine_types + + args = ctx_to_args(ctx) + if api_key: + args.api_key = api_key + _wrap(list_machine_types)(args) + + +@deployment_app.command("add", hidden=True) +def legacy_add( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument()], + machine_type_opt: Annotated[str, typer.Option("-m", "--machine_type")], + creator_email: Annotated[str, typer.Option("-e", "--creator_email")], + api_key: Annotated[Optional[str], typer.Option("-a", "--api_key")] = None, + duration: Annotated[float, typer.Option("-t", "--duration")] = 3, + no_delete_on_expiration: Annotated[bool, typer.Option("-nodel", "--no_delete_on_expiration")] = False, + inference_version: Annotated[str, typer.Option("-v", "--inference_version")] = "latest", + wait_on_pending: Annotated[bool, typer.Option("-w", "--wait_on_pending")] = False, +) -> None: + """Legacy alias for create.""" + from roboflow.deployment import add_deployment + + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + machine_type=machine_type_opt, + creator_email=creator_email, + duration=duration, + no_delete_on_expiration=no_delete_on_expiration, + inference_version=inference_version, + wait_on_pending=wait_on_pending, + ) + if api_key: + args.api_key = api_key + _wrap(add_deployment)(args) + + +@deployment_app.command("usage_workspace", hidden=True) +def legacy_usage_workspace( + ctx: typer.Context, + api_key: Annotated[Optional[str], typer.Option("-a", "--api_key")] = None, + from_timestamp: Annotated[Optional[str], typer.Option("-f", "--from_timestamp")] = None, + to_timestamp: Annotated[Optional[str], typer.Option("-t", "--to_timestamp")] = None, +) -> None: + """Legacy alias for usage (workspace).""" + from roboflow.deployment import get_workspace_usage + + args = ctx_to_args(ctx, from_timestamp=from_timestamp, to_timestamp=to_timestamp) + if api_key: + args.api_key = api_key + _wrap(get_workspace_usage)(args) + + +@deployment_app.command("usage_deployment", hidden=True) +def legacy_usage_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument()], + api_key: Annotated[Optional[str], typer.Option("-a", "--api_key")] = None, + from_timestamp: Annotated[Optional[str], typer.Option("-f", "--from_timestamp")] = None, + to_timestamp: Annotated[Optional[str], typer.Option("-t", "--to_timestamp")] = None, +) -> None: + """Legacy alias for usage (deployment).""" + from roboflow.deployment import get_deployment_usage + + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + from_timestamp=from_timestamp, + to_timestamp=to_timestamp, + ) + if api_key: + args.api_key = api_key + _wrap(get_deployment_usage)(args) diff --git a/roboflow/cli/handlers/device.py b/roboflow/cli/handlers/device.py new file mode 100644 index 00000000..940de899 --- /dev/null +++ b/roboflow/cli/handlers/device.py @@ -0,0 +1,563 @@ +"""Device management commands. + +Wraps the workspace-scoped Deployments / Device Management API +(``/:workspace/devices/v2/*``). All commands honor ``--workspace`` / +``--api-key`` from the global callback and ``--json`` for stable output. + +Exit codes: + 0 success + 1 general error (incl. 400 bad params, 429 rate limited) + 2 auth (401/403) + 3 not found (404) +""" + +from __future__ import annotations + +from typing import Annotated, Any, Dict, List, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +device_app = typer.Typer(cls=SortedGroup, help="Manage RFDM devices", no_args_is_help=True) + + +def _resolve_ws_and_key(args): # noqa: ANN001 + from roboflow.cli._resolver import resolve_ws_and_key + + return resolve_ws_and_key(args) + + +def _exit_code_for(exc: Exception) -> int: + from roboflow.adapters.devicesapi import ( + DeviceAuthError, + DeviceNotFoundError, + DeviceRateLimitedError, + ) + + if isinstance(exc, DeviceAuthError): + return 2 + if isinstance(exc, DeviceNotFoundError): + return 3 + if isinstance(exc, DeviceRateLimitedError): + return 1 + return 1 + + +def _hint_for(exc: Exception) -> Optional[str]: + from roboflow.adapters.devicesapi import DeviceAuthError, DeviceRateLimitedError + + if isinstance(exc, DeviceRateLimitedError): + return "Logs are limited to 5 req/min/IP and telemetry to 60 req/min β€” wait and retry." + if isinstance(exc, DeviceAuthError): + return "Verify the api_key has the device:read scope, or device:update for create." + return None + + +def _split_csv(value: Optional[str]) -> Optional[List[str]]: + if value is None: + return None + parts = [p.strip() for p in value.split(",") if p.strip()] + return parts or None + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + + +@device_app.command("list") +def list_cmd(ctx: typer.Context) -> None: + """List devices in the workspace.""" + args = ctx_to_args(ctx) + _list(args) + + +@device_app.command("get") +def get_cmd( + ctx: typer.Context, + device_id: Annotated[str, typer.Argument(help="Device ID")], +) -> None: + """Show a single device.""" + args = ctx_to_args(ctx, device_id=device_id) + _get(args) + + +@device_app.command("create") +def create_cmd( + ctx: typer.Context, + device_name: Annotated[str, typer.Argument(help="Human-readable device name")], + device_type: Annotated[Optional[str], typer.Option("--type", help="Device type: ai1, edge, or custom")] = None, + workflow_id: Annotated[ + Optional[str], typer.Option("--workflow-id", help="Initial workflow assignment (AI1 only)") + ] = None, + tags: Annotated[Optional[str], typer.Option("--tags", help="Comma-separated tags")] = None, + offline_mode: Annotated[ + Optional[bool], typer.Option("--offline-mode/--no-offline-mode", help="AI1 offline mode") + ] = None, + source_device_id: Annotated[ + Optional[str], typer.Option("--source-device-id", help="Duplicate config from this device") + ] = None, +) -> None: + """Create a v2 device. Requires the device:update scope.""" + args = ctx_to_args( + ctx, + device_name=device_name, + device_type=device_type, + workflow_id=workflow_id, + tags=_split_csv(tags), + offline_mode=offline_mode, + source_device_id=source_device_id, + ) + _create(args) + + +@device_app.command("config") +def config_cmd( + ctx: typer.Context, + device_id: Annotated[str, typer.Argument(help="Device ID")], +) -> None: + """Show the device's full runtime config (sensitive β€” may contain credentials).""" + args = ctx_to_args(ctx, device_id=device_id) + _config(args) + + +@device_app.command("config-history") +def config_history_cmd( + ctx: typer.Context, + device_id: Annotated[str, typer.Argument(help="Device ID")], + limit: Annotated[Optional[int], typer.Option("--limit", help="Max revisions (1-500, default 10)")] = None, + cursor: Annotated[Optional[str], typer.Option("--cursor", help="ISO timestamp from previous next_cursor")] = None, +) -> None: + """List prior config revisions, newest first.""" + args = ctx_to_args(ctx, device_id=device_id, limit=limit, cursor=cursor) + _config_history(args) + + +@device_app.command("streams") +def streams_cmd( + ctx: typer.Context, + device_id: Annotated[str, typer.Argument(help="Device ID")], +) -> None: + """List streams configured on the device.""" + args = ctx_to_args(ctx, device_id=device_id) + _streams(args) + + +@device_app.command("stream") +def stream_cmd( + ctx: typer.Context, + device_id: Annotated[str, typer.Argument(help="Device ID")], + stream_id: Annotated[str, typer.Argument(help="Stream ID")], +) -> None: + """Show a single stream.""" + args = ctx_to_args(ctx, device_id=device_id, stream_id=stream_id) + _stream(args) + + +@device_app.command("logs") +def logs_cmd( + ctx: typer.Context, + device_id: Annotated[str, typer.Argument(help="Device ID")], + start_time: Annotated[Optional[str], typer.Option("--start-time", help="ISO timestamp")] = None, + end_time: Annotated[Optional[str], typer.Option("--end-time", help="ISO timestamp")] = None, + service: Annotated[Optional[str], typer.Option("--service", help="Comma-separated service names")] = None, + severity: Annotated[ + Optional[str], typer.Option("--severity", help="Comma-separated levels (INFO,WARN,ERROR,...)") + ] = None, + limit: Annotated[Optional[int], typer.Option("--limit", help="1-1000, default 100")] = None, + cursor: Annotated[Optional[str], typer.Option("--cursor", help="ISO timestamp from previous next_cursor")] = None, +) -> None: + """Fetch device logs (5 req/min/IP).""" + args = ctx_to_args( + ctx, + device_id=device_id, + start_time=start_time, + end_time=end_time, + service=_split_csv(service), + severity=_split_csv(severity), + limit=limit, + cursor=cursor, + ) + _logs(args) + + +@device_app.command("telemetry") +def telemetry_cmd( + ctx: typer.Context, + device_id: Annotated[str, typer.Argument(help="Device ID")], + time_period: Annotated[ + Optional[str], typer.Option("--time-period", help="One of 1h, 24h (default), 7d, 14d") + ] = None, +) -> None: + """Fetch aggregated hardware telemetry (60 req/min).""" + args = ctx_to_args(ctx, device_id=device_id, time_period=time_period) + _telemetry(args) + + +@device_app.command("events") +def events_cmd( + ctx: typer.Context, + device_id: Annotated[str, typer.Argument(help="Device ID")], + entity_type: Annotated[Optional[str], typer.Option("--entity-type", help="Filter to a single entity type")] = None, + entity_id: Annotated[Optional[str], typer.Option("--entity-id", help="Filter to a single entity id")] = None, + event: Annotated[Optional[str], typer.Option("--event", help="Filter by event name")] = None, + start_time: Annotated[Optional[str], typer.Option("--start-time", help="ISO timestamp")] = None, + end_time: Annotated[Optional[str], typer.Option("--end-time", help="ISO timestamp")] = None, + limit: Annotated[Optional[int], typer.Option("--limit", help="1-1000, default 100")] = None, + cursor: Annotated[ + Optional[str], typer.Option("--cursor", help="Opaque base64url cursor from previous page") + ] = None, + direction: Annotated[ + Optional[str], typer.Option("--direction", help="forward or backward (default backward)") + ] = None, +) -> None: + """Query device/stream lifecycle events.""" + args = ctx_to_args( + ctx, + device_id=device_id, + entity_type=entity_type, + entity_id=entity_id, + event=event, + start_time=start_time, + end_time=end_time, + limit=limit, + cursor=cursor, + direction=direction, + ) + _events(args) + + +# --------------------------------------------------------------------------- +# Business logic +# --------------------------------------------------------------------------- + + +def _list(args) -> None: # noqa: ANN001 + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = devicesapi.list_devices(api_key, ws) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + rows: List[Dict[str, Any]] = result.get("data", []) + table_rows = [ + { + "id": r.get("id", ""), + "name": r.get("name", "") or "", + "status": r.get("status", "") or "", + "type": r.get("type", "") or "", + "last_heartbeat": r.get("last_heartbeat", "") or "", + } + for r in rows + ] + table = format_table( + table_rows, + columns=["id", "name", "status", "type", "last_heartbeat"], + headers=["ID", "NAME", "STATUS", "TYPE", "LAST HEARTBEAT"], + ) + output(args, result, text=table) + + +def _get(args) -> None: # noqa: ANN001 + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + device = devicesapi.get_device(api_key, ws, args.device_id) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + hardware = device.get("hardware") or {} + lines = [ + f"Device: {device.get('name') or device.get('id')}", + f" ID: {device.get('id', '')}", + f" Status: {device.get('status', '')}", + f" Type: {device.get('type') or ''}", + f" Platform: {device.get('platform') or ''}", + f" RFDM Version: {device.get('rfdm_version') or ''}", + f" Last Heartbeat: {device.get('last_heartbeat') or ''}", + f" Memory: {hardware.get('total_memory_mb') or ''} MB", + f" Disk: {hardware.get('total_disk_space_mb') or ''} MB", + ] + tags = device.get("tags") or [] + if tags: + lines.append(f" Tags: {', '.join(tags)}") + output(args, device, text="\n".join(lines)) + + +def _create(args) -> None: # noqa: ANN001 + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = devicesapi.create_device( + api_key, + ws, + device_name=args.device_name, + device_type=args.device_type, + workflow_id=args.workflow_id, + tags=args.tags, + offline_mode=args.offline_mode, + source_device_id=args.source_device_id, + ) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + output( + args, + result, + text=( + f"Created device '{args.device_name}'\n" + f" Device ID: {result.get('deviceId', '')}\n" + f" Install ID: {result.get('installId', '')}" + ), + ) + + +def _config(args) -> None: # noqa: ANN001 + import sys + + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + config = devicesapi.get_device_config(api_key, ws, args.device_id) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + # `GET .../config` is a documented passthrough of the Firestore config doc β€” it can + # contain `environment_variables` and integration credentials. We deliberately do + # NOT redact: that would silently corrupt round-trips (backup/restore/diff) and + # diverge from what the API contract returns. Instead, surface a stderr warning + # in interactive (non-JSON, non-quiet) mode so a human running `roboflow device + # config ` is reminded before they paste the output anywhere. JSON mode stays + # byte-identical to the API response. + if not getattr(args, "json", False) and not getattr(args, "quiet", False): + sys.stderr.write( + "WARNING: Device config may contain environment variables, API keys, " + "and integration credentials. Do not paste this output into chats, " + "tickets, screenshots, or shared logs.\n" + ) + output(args, config) + + +def _config_history(args) -> None: # noqa: ANN001 + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = devicesapi.get_device_config_history(api_key, ws, args.device_id, limit=args.limit, cursor=args.cursor) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + revisions = result.get("data", []) + rows = [ + { + "revision_id": r.get("revision_id", "") or "", + "created_at": r.get("created_at", "") or "", + "created_by": r.get("created_by", "") or "", + } + for r in revisions + ] + table = format_table( + rows, + columns=["revision_id", "created_at", "created_by"], + headers=["REVISION", "CREATED AT", "CREATED BY"], + ) + output(args, result, text=table) + + +def _streams(args) -> None: # noqa: ANN001 + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = devicesapi.list_device_streams(api_key, ws, args.device_id) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + streams = result.get("data", []) + rows = [ + { + "id": s.get("id", "") or "", + "name": s.get("name", "") or "", + "status": s.get("status", "") or "", + "workflow_id": s.get("workflow_id", "") or "", + } + for s in streams + ] + table = format_table( + rows, + columns=["id", "name", "status", "workflow_id"], + headers=["ID", "NAME", "STATUS", "WORKFLOW"], + ) + output(args, result, text=table) + + +def _stream(args) -> None: # noqa: ANN001 + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + stream = devicesapi.get_device_stream(api_key, ws, args.device_id, args.stream_id) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + lines = [ + f"Stream: {stream.get('name') or stream.get('id')}", + f" ID: {stream.get('id', '')}", + f" Status: {stream.get('status') or ''}", + f" Workflow: {stream.get('workflow_id') or ''}", + f" Pipeline: {stream.get('pipeline_id') or ''}", + f" Started: {stream.get('started_at') or ''}", + f" Last Event: {stream.get('last_event_at') or ''}", + ] + if stream.get("error"): + lines.append(f" Error: {stream['error']}") + output(args, stream, text="\n".join(lines)) + + +def _logs(args) -> None: # noqa: ANN001 + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = devicesapi.get_device_logs( + api_key, + ws, + args.device_id, + start_time=args.start_time, + end_time=args.end_time, + service=args.service, + severity=args.severity, + limit=args.limit, + cursor=args.cursor, + ) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + logs = result.get("data", []) + text_lines = [ + f"{log.get('timestamp', '')} [{log.get('severity', '')}] {log.get('service', '')} {log.get('message', '')}" + for log in logs + ] + if not text_lines: + text_lines = ["(no logs)"] + output(args, result, text="\n".join(text_lines)) + + +def _telemetry(args) -> None: # noqa: ANN001 + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = devicesapi.get_device_telemetry(api_key, ws, args.device_id, time_period=args.time_period) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + buckets = result.get("buckets", []) + lines = [ + f"Time period: {result.get('time_period', '')} " + f"Bucket: {result.get('bucket_interval', '')} " + f"Buckets: {len(buckets)}" + ] + output(args, result, text="\n".join(lines)) + + +def _events(args) -> None: # noqa: ANN001 + from roboflow.adapters import devicesapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = devicesapi.get_device_events( + api_key, + ws, + args.device_id, + entity_type=args.entity_type, + entity_id=args.entity_id, + event=args.event, + start_time=args.start_time, + end_time=args.end_time, + limit=args.limit, + cursor=args.cursor, + direction=args.direction, + ) + except Exception as exc: # noqa: BLE001 + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_exit_code_for(exc)) + return + + events = result.get("data", []) + text_lines = [ + f"{e.get('server_timestamp', '')} {e.get('event', '')} " + f"{e.get('entity_type', '')}/{e.get('entity_id', '')} " + f"{e.get('event_description', '') or ''}" + for e in events + ] + if not text_lines: + text_lines = ["(no events)"] + output(args, result, text="\n".join(text_lines)) diff --git a/roboflow/cli/handlers/eval.py b/roboflow/cli/handlers/eval.py new file mode 100644 index 00000000..0e41cc90 --- /dev/null +++ b/roboflow/cli/handlers/eval.py @@ -0,0 +1,496 @@ +"""Model evaluation commands. + +Wraps the public ``/{workspace}/model-evals`` REST surface β€” list runs in a +workspace and pull each panel (mAP, confidence sweep, per-class table, +confusion matrix, vector clusters, per-image stats, recommendations). + +The eval-id is opaque (the human in the UI navigates by URL); commands take +it as a positional argument so it composes well with ``--json | jq``. +""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +eval_app = typer.Typer(cls=SortedGroup, help="Inspect model evaluation runs", no_args_is_help=True) + + +# --------------------------------------------------------------------------- +# Command surface (Typer) +# --------------------------------------------------------------------------- + + +@eval_app.command("list") +def list_evals_cmd( + ctx: typer.Context, + project: Annotated[Optional[str], typer.Option("-p", "--project", help="Filter by project slug or id")] = None, + version: Annotated[Optional[str], typer.Option("-v", "--version", help="Filter by version id")] = None, + model: Annotated[Optional[str], typer.Option("-m", "--model", help="Filter by model id")] = None, + status: Annotated[ + Optional[str], typer.Option("-s", "--status", help="Filter by status (running/done/failed)") + ] = None, + limit: Annotated[Optional[int], typer.Option("-n", "--limit", help="Max results (default 50, max 200)")] = None, +) -> None: + """List model evaluations in the workspace.""" + args = ctx_to_args(ctx, project=project, version=version, model=model, status=status, limit=limit) + _list_evals(args) + + +@eval_app.command("get") +def get_eval_cmd( + ctx: typer.Context, + eval_id: Annotated[str, typer.Argument(help="Eval id (from `roboflow eval list`)")], +) -> None: + """Show a single eval's metadata and summary metrics.""" + args = ctx_to_args(ctx, eval_id=eval_id) + _get_eval(args) + + +@eval_app.command("map-results") +def map_results_cmd( + ctx: typer.Context, + eval_id: Annotated[str, typer.Argument(help="Eval id")], +) -> None: + """Show per-split mAP results (mAP50, mAP50-95, mAP75, by object size, per class).""" + args = ctx_to_args(ctx, eval_id=eval_id) + _map_results(args) + + +@eval_app.command("confidence-sweep") +def confidence_sweep_cmd( + ctx: typer.Context, + eval_id: Annotated[str, typer.Argument(help="Eval id")], +) -> None: + """Show the confidence-threshold sweep (precision/recall/F1) for the test split.""" + args = ctx_to_args(ctx, eval_id=eval_id) + _confidence_sweep(args) + + +@eval_app.command("performance-by-class") +def performance_by_class_cmd( + ctx: typer.Context, + eval_id: Annotated[str, typer.Argument(help="Eval id")], + split: Annotated[ + Optional[str], + typer.Option("-s", "--split", help="Split: train, valid, or test (default test). 'all' is rejected."), + ] = None, +) -> None: + """Show per-class precision / recall / F1 / mAP for the chosen split.""" + args = ctx_to_args(ctx, eval_id=eval_id, split=split) + _performance_by_class(args) + + +@eval_app.command("confusion-matrix") +def confusion_matrix_cmd( + ctx: typer.Context, + eval_id: Annotated[str, typer.Argument(help="Eval id")], + split: Annotated[ + Optional[str], typer.Option("-s", "--split", help="Split: train, valid, test, or all (default test)") + ] = None, + confidence: Annotated[ + Optional[int], + typer.Option("-c", "--confidence", help="Integer confidence threshold (0-100)"), + ] = None, +) -> None: + """Show the confusion matrix for *split* at *confidence*.""" + args = ctx_to_args(ctx, eval_id=eval_id, split=split, confidence=confidence) + _confusion_matrix(args) + + +@eval_app.command("vector-analysis") +def vector_analysis_cmd( + ctx: typer.Context, + eval_id: Annotated[str, typer.Argument(help="Eval id")], + confidence: Annotated[ + Optional[int], + typer.Option("-c", "--confidence", help="Integer confidence threshold (0-100)"), + ] = None, +) -> None: + """Show embedding-cluster diagnostics (per-cluster sample images + metrics).""" + args = ctx_to_args(ctx, eval_id=eval_id, confidence=confidence) + _vector_analysis(args) + + +@eval_app.command("image-predictions") +def image_predictions_cmd( + ctx: typer.Context, + eval_id: Annotated[str, typer.Argument(help="Eval id")], + split: Annotated[ + Optional[str], typer.Option("-s", "--split", help="Split: train, valid, test, or all (default test)") + ] = None, + confidence: Annotated[ + Optional[int], + typer.Option("-c", "--confidence", help="Integer confidence threshold (0-100)"), + ] = None, + limit: Annotated[ + Optional[int], + typer.Option("-n", "--limit", help="Page size (default 200, max 1000)"), + ] = None, + offset: Annotated[ + Optional[int], + typer.Option("-o", "--offset", help="Pagination offset"), + ] = None, +) -> None: + """Show paginated per-image stats (TP/FP/FN, augmentations, cluster id).""" + args = ctx_to_args(ctx, eval_id=eval_id, split=split, confidence=confidence, limit=limit, offset=offset) + _image_predictions(args) + + +@eval_app.command("recommendations") +def recommendations_cmd( + ctx: typer.Context, + eval_id: Annotated[str, typer.Argument(help="Eval id")], +) -> None: + """Show server-generated suggestions for improving the model.""" + args = ctx_to_args(ctx, eval_id=eval_id) + _recommendations(args) + + +# --------------------------------------------------------------------------- +# Business logic +# --------------------------------------------------------------------------- + + +def _resolve(args): # noqa: ANN001 + from roboflow.cli._resolver import resolve_ws_and_key + + return resolve_ws_and_key(args) + + +def _eval_error_exit_code(exc: Exception) -> int: + """Map a model-eval error to the canonical CLI exit code. + + 1 = general; 2 = auth; 3 = not found; 4 = conflict (eval not done); + 5 = invalid argument (bad split / confidence). Keeping these distinct + lets shell scripts and AI agents react to specific failure modes + without parsing message strings. + """ + from roboflow.adapters import rfapi + + if isinstance(exc, rfapi.ModelEvalNotFoundError): + return 3 + if isinstance(exc, rfapi.ModelEvalNotDoneError): + return 4 + if isinstance(exc, (rfapi.InvalidSplitError, rfapi.InvalidConfidenceError)): + return 5 + return 1 + + +def _hint_for(exc: Exception) -> Optional[str]: + """Per-error actionable hint shown alongside the message in non-JSON mode.""" + from roboflow.adapters import rfapi + + if isinstance(exc, rfapi.ModelEvalNotFoundError): + return "Run 'roboflow eval list' to see eval ids in this workspace." + if isinstance(exc, rfapi.ModelEvalNotDoneError): + return "Wait for the eval to finish (status='done') before reading panel data." + if isinstance(exc, rfapi.InvalidSplitError): + return "Use one of: train, valid, test (or 'all' where supported)." + if isinstance(exc, rfapi.InvalidConfidenceError): + return "Pass an integer between 0 and 100." + return None + + +def _list_evals(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve(args) + if not resolved: + return + workspace_url, api_key = resolved + + try: + result = rfapi.list_model_evals( + api_key, + workspace_url, + project=args.project, + version=args.version, + model=args.model, + status=args.status, + limit=args.limit, + ) + except Exception as exc: + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_eval_error_exit_code(exc)) + return + + evals = result.get("evals", []) + rows = [ + { + # Prefer DNA's `evalId`; tolerate legacy `id` from older server versions. + "id": e.get("evalId", e.get("id", "")), + "status": e.get("status", ""), + # `project` is the URL slug; the public API does not expose the doc id. + # Tolerate legacy `projectId` for forward-compat against older deploys. + "project": e.get("project") or e.get("projectId", ""), + "version": e.get("versionId", ""), + "model": e.get("modelId", "") or "", + "created": e.get("createdAt", ""), + } + for e in evals + ] + table = format_table( + rows, + columns=["id", "status", "project", "version", "model", "created"], + headers=["ID", "STATUS", "PROJECT", "VERSION", "MODEL", "CREATED"], + ) + output(args, evals, text=table) + + +def _get_eval(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve(args) + if not resolved: + return + workspace_url, api_key = resolved + + try: + info = rfapi.get_model_eval(api_key, workspace_url, args.eval_id) + except Exception as exc: + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_eval_error_exit_code(exc)) + return + + lines = [ + # Prefer DNA's `evalId`; tolerate legacy `id`. + f"Eval: {info.get('evalId', info.get('id', args.eval_id))}", + f" Status: {info.get('status', '')}", + # `project` is the URL slug; tolerate legacy `projectId` for forward-compat. + f" Project: {info.get('project') or info.get('projectId', '')}", + f" Version: {info.get('versionId', '')}", + f" Model: {info.get('modelId', '') or '(none)'}", + f" Created: {info.get('createdAt', '')}", + ] + summary = info.get("summary") or {} + if summary: + lines.append( + f" Summary: mAP={summary.get('mAP')} precision={summary.get('precision')} recall={summary.get('recall')}" + ) + output(args, info, text="\n".join(lines)) + + +def _emit_dict(args, payload, *, header: Optional[str] = None) -> None: # noqa: ANN001 + """Default text rendering for panel commands: pretty-printed JSON. + + Each panel has a deeply nested per-eval shape that doesn't tabulate + well in the general case (per-class tables exist, but vector clusters + and recommendations don't). For agent ergonomics we lean on --json, + and for humans we just pretty-print so they can pipe to jq or eyeball. + """ + import json as _json + + from roboflow.cli._output import output + + text = _json.dumps(payload, indent=2, default=str) + if header: + text = f"{header}\n{text}" + output(args, payload, text=text) + + +def _map_results(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output_error + + resolved = _resolve(args) + if not resolved: + return + workspace_url, api_key = resolved + + try: + data = rfapi.get_model_eval_map_results(api_key, workspace_url, args.eval_id) + except Exception as exc: + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_eval_error_exit_code(exc)) + return + _emit_dict(args, data) + + +def _confidence_sweep(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output_error + + resolved = _resolve(args) + if not resolved: + return + workspace_url, api_key = resolved + + try: + data = rfapi.get_model_eval_confidence_sweep(api_key, workspace_url, args.eval_id) + except Exception as exc: + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_eval_error_exit_code(exc)) + return + _emit_dict(args, data) + + +def _performance_by_class(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve(args) + if not resolved: + return + workspace_url, api_key = resolved + + try: + data = rfapi.get_model_eval_performance_by_class(api_key, workspace_url, args.eval_id, split=args.split) + except Exception as exc: + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_eval_error_exit_code(exc)) + return + + classes = data.get("classes", []) + rows = [] + for c in classes: + rows.append( + { + "class": c.get("className", ""), + "map50": _fmt_float(c.get("map50")), + "map50_95": _fmt_float(c.get("map50_95")), + "map75": _fmt_float(c.get("map75")), + "precision": _fmt_float(c.get("precision")), + "recall": _fmt_float(c.get("recall")), + "f1": _fmt_float(c.get("f1")), + "opt_thresh": _fmt_float(c.get("optimalThreshold")), + } + ) + table = format_table( + rows, + columns=["class", "map50", "map50_95", "map75", "precision", "recall", "f1", "opt_thresh"], + headers=["CLASS", "mAP50", "mAP50-95", "mAP75", "P", "R", "F1", "OPT_THR"], + ) + header = f"Split: {data.get('split', args.split or 'test')}" + output(args, data, text=f"{header}\n{table}") + + +def _fmt_float(value): + """Format a float to 4 decimal places for table output; pass through ``None`` as ''.""" + if value is None: + return "" + try: + return f"{float(value):.4f}" + except (TypeError, ValueError): + return str(value) + + +def _confusion_matrix(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output_error + + resolved = _resolve(args) + if not resolved: + return + workspace_url, api_key = resolved + + try: + data = rfapi.get_model_eval_confusion_matrix( + api_key, + workspace_url, + args.eval_id, + split=args.split, + confidence=args.confidence, + ) + except Exception as exc: + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_eval_error_exit_code(exc)) + return + + header = ( + f"Split: {data.get('split', args.split or 'test')} " + f"Confidence: {data.get('confidenceThreshold', args.confidence or 'default')}" + ) + _emit_dict(args, data, header=header) + + +def _vector_analysis(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output_error + + resolved = _resolve(args) + if not resolved: + return + workspace_url, api_key = resolved + + try: + data = rfapi.get_model_eval_vector_analysis(api_key, workspace_url, args.eval_id, confidence=args.confidence) + except Exception as exc: + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_eval_error_exit_code(exc)) + return + _emit_dict(args, data) + + +def _image_predictions(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve(args) + if not resolved: + return + workspace_url, api_key = resolved + + try: + data = rfapi.get_model_eval_image_predictions( + api_key, + workspace_url, + args.eval_id, + split=args.split, + confidence=args.confidence, + limit=args.limit, + offset=args.offset, + ) + except Exception as exc: + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_eval_error_exit_code(exc)) + return + + images = data.get("images", []) + rows = [] + for img in images: + # Stats are camelCase per the public API: + # `truePositives`/`falsePositives`/`falseNegatives` (not `tp`/`fp`/`fn`). + stats = img.get("stats") or {} + cluster = img.get("cluster") or {} + cluster_id = cluster.get("id") if isinstance(cluster, dict) else cluster + rows.append( + { + "image": img.get("imageName", img.get("imageId", "")), + "split": img.get("split", ""), + "tp": stats.get("truePositives", ""), + "fp": stats.get("falsePositives", ""), + "fn": stats.get("falseNegatives", ""), + "cluster": cluster_id if cluster_id is not None else "", + } + ) + table = format_table( + rows, + columns=["image", "split", "tp", "fp", "fn", "cluster"], + headers=["IMAGE", "SPLIT", "TP", "FP", "FN", "CLUSTER"], + ) + header = ( + f"Split: {data.get('split', args.split or 'test')} " + f"Confidence: {data.get('confidenceThreshold', args.confidence or 'default')} " + f"Total: {data.get('totalImages', len(images))} " + f"Offset: {data.get('offset', args.offset or 0)} " + f"Limit: {data.get('limit', args.limit or 200)}" + ) + output(args, data, text=f"{header}\n{table}") + + +def _recommendations(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output_error + + resolved = _resolve(args) + if not resolved: + return + workspace_url, api_key = resolved + + try: + data = rfapi.get_model_eval_recommendations(api_key, workspace_url, args.eval_id) + except Exception as exc: + output_error(args, str(exc), hint=_hint_for(exc), exit_code=_eval_error_exit_code(exc)) + return + _emit_dict(args, data) diff --git a/roboflow/cli/handlers/folder.py b/roboflow/cli/handlers/folder.py new file mode 100644 index 00000000..822ae294 --- /dev/null +++ b/roboflow/cli/handlers/folder.py @@ -0,0 +1,272 @@ +"""Folder management commands.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +folder_app = typer.Typer(cls=SortedGroup, help="Manage workspace folders", no_args_is_help=True) + + +@folder_app.command("list") +def list_folders(ctx: typer.Context) -> None: + """List folders.""" + args = ctx_to_args(ctx) + _list_folders(args) + + +@folder_app.command("get") +def get_folder( + ctx: typer.Context, + folder_id: Annotated[str, typer.Argument(help="Folder ID")], +) -> None: + """Show folder details.""" + args = ctx_to_args(ctx, folder_id=folder_id) + _get_folder(args) + + +@folder_app.command("create") +def create_folder( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Folder name")], + parent: Annotated[Optional[str], typer.Option(help="Parent folder ID")] = None, + projects: Annotated[Optional[str], typer.Option(help="Comma-separated project IDs")] = None, +) -> None: + """Create a folder.""" + args = ctx_to_args(ctx, name=name, parent=parent, projects=projects) + _create_folder(args) + + +@folder_app.command("update") +def update_folder( + ctx: typer.Context, + folder_id: Annotated[str, typer.Argument(help="Folder ID")], + name: Annotated[Optional[str], typer.Option(help="New folder name")] = None, +) -> None: + """Update a folder.""" + args = ctx_to_args(ctx, folder_id=folder_id, name=name) + _update_folder(args) + + +@folder_app.command("delete") +def delete_folder( + ctx: typer.Context, + folder_id: Annotated[str, typer.Argument(help="Folder ID")], +) -> None: + """Delete a folder.""" + args = ctx_to_args(ctx, folder_id=folder_id) + _delete_folder(args) + + +@folder_app.command("add-projects") +def add_projects( + ctx: typer.Context, + folder_id: Annotated[str, typer.Argument(help="Folder ID")], + projects: Annotated[str, typer.Argument(help="Comma-separated project IDs")], +) -> None: + """Add projects to a folder.""" + args = ctx_to_args(ctx, folder_id=folder_id, projects=projects) + _add_projects(args) + + +@folder_app.command("remove-projects") +def remove_projects( + ctx: typer.Context, + folder_id: Annotated[str, typer.Argument(help="Folder ID")], + projects: Annotated[str, typer.Argument(help="Comma-separated project IDs")], +) -> None: + """Remove projects from a folder.""" + args = ctx_to_args(ctx, folder_id=folder_id, projects=projects) + _remove_projects(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _resolve_ws_and_key(args): # noqa: ANN001 + """Resolve workspace and API key, returning (ws, api_key) or None on error.""" + from roboflow.cli._resolver import resolve_ws_and_key + + return resolve_ws_and_key(args) + + +def _list_folders(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = rfapi.list_folders(api_key, ws) + except rfapi.RoboflowError as exc: + # The API returns 404 when there are no folders β€” treat as empty, not error + if "Not Found" in str(exc): + result = {"data": []} + else: + output_error(args, str(exc), exit_code=3) + return + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + folders = result.get("data", result.get("groups", result if isinstance(result, list) else [])) + rows = [] + for f in folders: + projects = f.get("projects", []) + project_count = len(projects) if isinstance(projects, list) else projects + rows.append({"name": f.get("name", ""), "id": f.get("id", ""), "projects": str(project_count)}) + + table = format_table(rows, columns=["name", "id", "projects"], headers=["NAME", "ID", "PROJECTS"]) + output(args, folders, text=table) + + +def _get_folder(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = rfapi.get_folder(api_key, ws, args.folder_id) + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + # API returns {"data": [folder_obj]} β€” extract the first item + data_list = result.get("data", []) + folder = data_list[0] if isinstance(data_list, list) and data_list else result.get("group", result) + lines = [ + f"Folder: {folder.get('name', '')}", + f" ID: {folder.get('id', '')}", + ] + projects = folder.get("projects", []) + if isinstance(projects, list): + lines.append(f" Projects: {len(projects)}") + for p in projects: + if isinstance(p, dict): + lines.append(f" - {p.get('name', p.get('id', ''))}") + else: + lines.append(f" - {p}") + output(args, result, text="\n".join(lines)) + + +def _create_folder(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + project_ids = None + if args.projects: + project_ids = [p.strip() for p in args.projects.split(",")] + + try: + result = rfapi.create_folder(api_key, ws, args.name, parent_id=args.parent, project_ids=project_ids) + except Exception as exc: + output_error(args, str(exc), exit_code=1) + return + + folder_id = result.get("id", result.get("group", {}).get("id", "")) + data = {"status": "created", "id": folder_id} + output(args, data, text=f"Created folder '{args.name}' (id: {folder_id})") + + +def _update_folder(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + rfapi.update_folder(api_key, ws, args.folder_id, name=args.name) + except Exception as exc: + output_error(args, str(exc), exit_code=1) + return + + data = {"status": "updated"} + output(args, data, text=f"Updated folder '{args.folder_id}'") + + +def _delete_folder(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + rfapi.delete_folder(api_key, ws, args.folder_id) + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + data = {"status": "deleted"} + output(args, data, text=f"Deleted folder '{args.folder_id}'") + + +def _add_projects(args) -> None: # noqa: ANN001 + import roboflow + from roboflow.cli._output import output, output_error, suppress_sdk_output + + with suppress_sdk_output(args): + try: + rf = roboflow.Roboflow(api_key=args.api_key) + workspace = rf.workspace(args.workspace) + except Exception as exc: + output_error(args, str(exc)) + return + + project_ids = [p.strip() for p in args.projects.split(",")] + + try: + workspace.add_projects_to_folder(args.folder_id, project_ids) + except Exception as exc: + output_error(args, str(exc), exit_code=1) + return + + data = {"status": "added", "folder_id": args.folder_id, "projects": project_ids} + output(args, data, text=f"Added {len(project_ids)} project(s) to folder '{args.folder_id}'") + + +def _remove_projects(args) -> None: # noqa: ANN001 + import roboflow + from roboflow.cli._output import output, output_error, suppress_sdk_output + + with suppress_sdk_output(args): + try: + rf = roboflow.Roboflow(api_key=args.api_key) + workspace = rf.workspace(args.workspace) + except Exception as exc: + output_error(args, str(exc)) + return + + project_ids = [p.strip() for p in args.projects.split(",")] + + try: + workspace.remove_projects_from_folder(args.folder_id, project_ids) + except Exception as exc: + output_error(args, str(exc), exit_code=1) + return + + data = {"status": "removed", "folder_id": args.folder_id, "projects": project_ids} + output(args, data, text=f"Removed {len(project_ids)} project(s) from folder '{args.folder_id}'") diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py new file mode 100644 index 00000000..9a250f4e --- /dev/null +++ b/roboflow/cli/handlers/image.py @@ -0,0 +1,653 @@ +"""Image management commands: upload, get, search, tag, delete, annotate.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +image_app = typer.Typer(cls=SortedGroup, help="Image management commands", no_args_is_help=True) + + +@image_app.command("upload") +def upload_image( + ctx: typer.Context, + path: Annotated[ + str, typer.Argument(help="Path to image file or directory (auto-detects single vs. directory bulk import)") + ], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + annotation: Annotated[ + Optional[str], typer.Option("-a", "--annotation", help="Path to annotation file (single upload)") + ] = None, + split: Annotated[ + Optional[str], + typer.Option( + "-s", + "--split", + help="Override split for all images (default: infer from folder for dirs, 'train' for files)", + ), + ] = None, + batch: Annotated[Optional[str], typer.Option("-b", "--batch", help="Batch name")] = None, + tag: Annotated[Optional[str], typer.Option("-t", "--tag", help="Comma-separated tag names")] = None, + metadata: Annotated[Optional[str], typer.Option(help="JSON string of key-value metadata")] = None, + concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Concurrency for directory import")] = 10, + retries: Annotated[int, typer.Option("-r", "--retries", help="Retry failed uploads N times")] = 0, + labelmap: Annotated[Optional[str], typer.Option(help="Path to labelmap file")] = None, + is_prediction: Annotated[bool, typer.Option("--is-prediction", help="Mark upload as prediction")] = False, + zip_upload: Annotated[ + bool, + typer.Option("--zip-upload", help="Zip the directory client-side and use the async zip upload flow"), + ] = False, + no_wait: Annotated[ + bool, + typer.Option("--no-wait", help="Zip flow: return immediately with task_id instead of polling"), + ] = False, +) -> None: + """Upload an image file or import a directory.""" + args = ctx_to_args( + ctx, + path=path, + project=project, + annotation=annotation, + split=split, + batch=batch, + tag=tag, + metadata=metadata, + concurrency=concurrency, + retries=retries, + labelmap=labelmap, + is_prediction=is_prediction, + zip_upload=zip_upload, + no_wait=no_wait, + ) + _handle_upload(args) + + +@image_app.command("get") +def get_image( + ctx: typer.Context, + image_id: Annotated[str, typer.Argument(help="Image ID")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """Get image details.""" + args = ctx_to_args(ctx, image_id=image_id, project=project) + _handle_get(args) + + +@image_app.command("search") +def search_images( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="RoboQL search query (e.g. 'tag:review' or '*')")], + project: Annotated[ + Optional[str], + typer.Option("-p", "--project", help="Project slug to scope results (omit to search entire workspace)"), + ] = None, + limit: Annotated[int, typer.Option(help="Number of results")] = 50, + cursor: Annotated[Optional[str], typer.Option(help="Continuation token for pagination")] = None, + export: Annotated[bool, typer.Option("--export", help="Export search results as a dataset")] = False, + format: Annotated[str, typer.Option("-f", "--format", help="Annotation format for export")] = "coco", + location: Annotated[Optional[str], typer.Option("-l", "--location", help="Local directory for export")] = None, + dataset: Annotated[ + Optional[str], typer.Option("-d", "--dataset", help="Limit export to a specific dataset") + ] = None, + annotation_group: Annotated[ + Optional[str], typer.Option("-g", "--annotation-group", help="Annotation group") + ] = None, + name: Annotated[Optional[str], typer.Option(help="Optional name for the export")] = None, + no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip file, skip extraction")] = False, +) -> None: + """Search images in workspace or project. + + Without -p/--project, searches across the entire workspace using RoboQL. + With -p/--project, searches within a specific project. + Use --export to download matching results as a dataset. + """ + if export: + # Export scopes to a project via the `dataset` (project slug) body param, + # so route -p through as the dataset. Check export before project so + # `-p ... --export` exports the project instead of silently ignoring --export. + from roboflow.cli.handlers.search import _search + + args = ctx_to_args( + ctx, + query=query, + limit=limit, + cursor=cursor, + export=True, + format=format, + location=location, + dataset=dataset or project, + annotation_group=annotation_group, + name=name, + no_extract=no_extract, + ) + _search(args) + elif project: + # _handle_search scopes by injecting a `project:` RoboQL filter. + args = ctx_to_args(ctx, query=query, project=project, limit=limit, cursor=cursor) + _handle_search(args) + else: + # Workspace-level search + from roboflow.cli.handlers.search import _search + + args = ctx_to_args( + ctx, + query=query, + limit=limit, + cursor=cursor, + export=False, + format=format, + location=location, + dataset=dataset, + annotation_group=annotation_group, + name=name, + no_extract=no_extract, + fields=None, + ) + _search(args) + + +def _metadata_command( + ctx: typer.Context, + image_ids: str, + metadata: Optional[str] = None, + remove_metadata: Optional[str] = None, + tags: Optional[str] = None, + remove_tags: Optional[str] = None, + poll: bool = False, + timeout: int = 1800, +) -> None: + """Update metadata and/or tags on existing images. + + Single image ID: updates synchronously. + Multiple comma-separated IDs: uses the batch async endpoint. + """ + args = ctx_to_args( + ctx, + image_ids=image_ids, + metadata=metadata, + remove_metadata=remove_metadata, + add_tags=tags, + remove_tags=remove_tags, + poll=poll, + timeout=timeout, + ) + _handle_metadata(args) + + +@image_app.command("metadata") +def metadata_image( + ctx: typer.Context, + image_ids: Annotated[str, typer.Argument(help="Comma-separated image IDs (batch mode if multiple)")], + metadata: Annotated[ + Optional[str], typer.Option("-m", "--metadata", help="JSON string of key-value metadata to set") + ] = None, + remove_metadata: Annotated[ + Optional[str], typer.Option("--remove-metadata", help="Comma-separated metadata keys to remove") + ] = None, + tags: Annotated[Optional[str], typer.Option("--tags", help="Comma-separated tags to add")] = None, + remove_tags: Annotated[Optional[str], typer.Option("--remove-tags", help="Comma-separated tags to remove")] = None, + poll: Annotated[bool, typer.Option("--poll/--no-poll", help="For batch updates: poll until complete")] = False, + timeout: Annotated[int, typer.Option("--timeout", help="Polling timeout in seconds")] = 1800, +) -> None: + """Update metadata and/or tags on existing images.""" + _metadata_command(ctx, image_ids, metadata, remove_metadata, tags, remove_tags, poll, timeout) + + +@image_app.command("tag", hidden=True) +def tag_image( + ctx: typer.Context, + image_ids: Annotated[str, typer.Argument(help="Comma-separated image IDs (batch mode if multiple)")], + metadata: Annotated[ + Optional[str], typer.Option("-m", "--metadata", help="JSON string of key-value metadata to set") + ] = None, + remove_metadata: Annotated[ + Optional[str], typer.Option("--remove-metadata", help="Comma-separated metadata keys to remove") + ] = None, + tags: Annotated[Optional[str], typer.Option("--tags", help="Comma-separated tags to add")] = None, + remove_tags: Annotated[Optional[str], typer.Option("--remove-tags", help="Comma-separated tags to remove")] = None, + poll: Annotated[bool, typer.Option("--poll/--no-poll", help="For batch updates: poll until complete")] = False, + timeout: Annotated[int, typer.Option("--timeout", help="Polling timeout in seconds")] = 1800, +) -> None: + """Alias for 'metadata'.""" + _metadata_command(ctx, image_ids, metadata, remove_metadata, tags, remove_tags, poll, timeout) + + +@image_app.command("delete") +def delete_images( + ctx: typer.Context, + image_ids: Annotated[str, typer.Argument(help="Comma-separated image IDs")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """Delete images from workspace.""" + args = ctx_to_args(ctx, image_ids=image_ids, project=project) + _handle_delete(args) + + +@image_app.command("annotate") +def annotate_image( + ctx: typer.Context, + image_id: Annotated[str, typer.Argument(help="Image ID")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + annotation_file: Annotated[str, typer.Option("--annotation-file", help="Path to annotation file")], + annotation_format: Annotated[Optional[str], typer.Option("--format", help="Annotation format name")] = None, + labelmap: Annotated[Optional[str], typer.Option(help="Path to labelmap file")] = None, +) -> None: + """Upload annotation for an image.""" + args = ctx_to_args( + ctx, + image_id=image_id, + project=project, + annotation_file=annotation_file, + annotation_format=annotation_format, + labelmap=labelmap, + ) + _handle_annotate(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _handle_upload(args): # noqa: ANN001 + import os + + from roboflow.cli._output import output_error + from roboflow.config import load_roboflow_api_key + + api_key = args.api_key or load_roboflow_api_key(args.workspace) + if not api_key: + output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) + return + + path = args.path + if os.path.isdir(path) or (os.path.isfile(path) and path.lower().endswith(".zip")): + _handle_upload_directory(args, api_key, path) + elif os.path.isfile(path): + _handle_upload_single(args, api_key, path) + else: + output_error(args, f"Path not found: {path}", hint="Provide a valid file or directory path") + return + + +def _handle_upload_single(args, api_key: str, path: str) -> None: # noqa: ANN001 + import json + + import roboflow + from roboflow.cli._output import output, output_error, suppress_sdk_output + + metadata_raw = getattr(args, "metadata", None) + metadata = json.loads(metadata_raw) if metadata_raw else None + tag_raw = getattr(args, "tag", None) or getattr(args, "tag_names", None) + tag_names = tag_raw.split(",") if tag_raw else [] + retries = getattr(args, "retries", None) or getattr(args, "num_retries", 0) or 0 + + # Always suppress SDK "loading..." noise during workspace/project init + with suppress_sdk_output(): + try: + rf = roboflow.Roboflow(api_key) + workspace = rf.workspace(args.workspace) + project = workspace.project(args.project) + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + try: + project.single_upload( + image_path=path, + annotation_path=args.annotation, + annotation_labelmap=getattr(args, "labelmap", None), + split=args.split or "train", + num_retry_uploads=retries, + batch_name=args.batch, + tag_names=tag_names, + is_prediction=getattr(args, "is_prediction", False), + metadata=metadata, + ) + except Exception as exc: + msg = str(exc) + hint = None + if "cannot identify image file" in msg: + hint = "Supported formats: JPEG, PNG, BMP, GIF, TIFF, WebP." + output_error(args, msg, hint=hint) + return + + data = {"status": "uploaded", "path": path, "project": args.project} + output(args, data, text=f"Uploaded {path} to {args.project}") + + +def _handle_upload_directory(args, api_key: str, path: str) -> None: # noqa: ANN001 + import os + + import roboflow + from roboflow.cli._output import output, output_error, suppress_sdk_output + + # Always suppress SDK "loading..." noise during workspace init + with suppress_sdk_output(): + try: + rf = roboflow.Roboflow(api_key) + workspace = rf.workspace(args.workspace) + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + retries = getattr(args, "retries", None) or getattr(args, "num_retries", 0) or 0 + tag_raw = getattr(args, "tag", None) + tags = [t.strip() for t in tag_raw.split(",") if t.strip()] if tag_raw else None + wait = not getattr(args, "no_wait", False) + + try: + result = workspace.upload_dataset( + dataset_path=path, + project_name=args.project, + num_workers=args.concurrency, + batch_name=getattr(args, "batch", None), + num_retries=retries, + is_prediction=getattr(args, "is_prediction", False), + use_zip_upload=getattr(args, "zip_upload", False), + split=getattr(args, "split", None), + tags=tags, + wait=wait, + ) + except Exception as exc: + output_error(args, str(exc)) + return + + if isinstance(result, dict): + status = result.get("status", "unknown") + data = { + "status": status, + "task_id": result.get("task_id") or result.get("taskId"), + "path": path, + "project": args.project, + "result": result, + } + output(args, data, text=f"Imported {path} to {args.project} (zip upload, status={status})") + return + + # Per-image fallback β€” count files via image extensions + count = 0 + image_exts = {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp"} + for root, _dirs, files in os.walk(path): + for f in files: + if os.path.splitext(f)[1].lower() in image_exts: + count += 1 + + data = {"status": "imported", "path": path, "count": count} + output(args, data, text=f"Imported {count} images from {path} to {args.project}") + + +def _handle_get(args): # noqa: ANN001 + import json + + import requests + + from roboflow.cli._output import output, output_error + from roboflow.config import API_URL, load_roboflow_api_key + + api_key = args.api_key or load_roboflow_api_key(args.workspace) + if not api_key: + output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) + return + + workspace_url = args.workspace or _default_workspace() + if not workspace_url: + output_error(args, "No workspace specified", hint="Use --workspace or run 'roboflow auth login'") + return + + url = f"{API_URL}/{workspace_url}/{args.project}/images/{args.image_id}" + response = requests.get(url, params={"api_key": api_key}) + if response.status_code != 200: + output_error(args, f"Failed to get image: {response.text}", exit_code=3) + return + + data = response.json() + output(args, data, text=json.dumps(data, indent=2)) + + +def _handle_search(args): # noqa: ANN001 + import json + + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.config import load_roboflow_api_key + + api_key = args.api_key or load_roboflow_api_key(args.workspace) + if not api_key: + output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) + return + + workspace_url: str = args.workspace or _default_workspace() or "" + if not workspace_url: + output_error(args, "No workspace specified", hint="Use --workspace or run 'roboflow auth login'") + return + + # search/v1 only scopes via a `project:` RoboQL filter (body params are + # ignored). Leading space = implicit AND; `AND (...)` 500s on free-text queries. + query = args.query + project = getattr(args, "project", None) + if project: + query = f"project:{project} {args.query}" + + result = rfapi.workspace_search( + api_key=api_key, + workspace_url=workspace_url, + query=query, + page_size=args.limit, + continuation_token=args.cursor, + ) + output(args, result, text=json.dumps(result, indent=2)) + + +def _handle_metadata(args): # noqa: ANN001 + import json as json_mod + + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_ws_and_key + + ids = [i.strip() for i in args.image_ids.split(",") if i.strip()] + if not ids: + output_error(args, "No image IDs provided") + return + + metadata_dict = None + if args.metadata: + try: + metadata_dict = json_mod.loads(args.metadata) + if not isinstance(metadata_dict, dict): + output_error(args, "Metadata must be a JSON object", hint='Example: \'{"key": "value"}\'') + return + except json_mod.JSONDecodeError as exc: + output_error(args, f"Invalid metadata JSON: {exc}", hint='Example: \'{"key": "value"}\'') + return + + remove_meta_list = ( + [k.strip() for k in args.remove_metadata.split(",") if k.strip()] if args.remove_metadata else None + ) + add_tags_list = [t.strip() for t in args.add_tags.split(",") if t.strip()] if args.add_tags else None + remove_tags_list = [t.strip() for t in args.remove_tags.split(",") if t.strip()] if args.remove_tags else None + + if metadata_dict is None and remove_meta_list is None and add_tags_list is None and remove_tags_list is None: + output_error( + args, + "Nothing to update", + hint="Specify at least one of --metadata, --remove-metadata, --tags, --remove-tags", + ) + return + + resolved = resolve_ws_and_key(args) + if not resolved: + return + workspace_url, api_key = resolved + + if len(ids) == 1: + try: + rfapi.update_image_metadata( + api_key=api_key, + workspace_url=workspace_url, + image_id=ids[0], + metadata=metadata_dict, + remove_metadata=remove_meta_list, + add_tags=add_tags_list, + remove_tags=remove_tags_list, + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=1) + return + data = {"success": True, "imageId": ids[0]} + output(args, data, text=f"Updated image {ids[0]}") + else: + _handle_metadata_batch( + args, api_key, workspace_url, ids, metadata_dict, remove_meta_list, add_tags_list, remove_tags_list + ) + + +def _handle_metadata_batch(args, api_key, workspace_url, image_ids, metadata, remove_metadata, add_tags, remove_tags): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + BATCH_LIMIT = 1000 # matches the workspace images/metadata endpoint limit + if len(image_ids) > BATCH_LIMIT: + output_error( + args, + f"Too many images: {len(image_ids)} (limit: {BATCH_LIMIT})", + hint=f"Split into batches of {BATCH_LIMIT} or fewer", + ) + return + + updates = [] + for img_id in image_ids: + entry: dict = {"imageId": img_id} + if metadata: + entry["metadata"] = metadata + if remove_metadata: + entry["removeMetadata"] = remove_metadata + if add_tags: + entry["addTags"] = add_tags + if remove_tags: + entry["removeTags"] = remove_tags + updates.append(entry) + + try: + result = rfapi.batch_update_image_metadata( + api_key=api_key, + workspace_url=workspace_url, + updates=updates, + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=1) + return + + task_id = result.get("taskId") + polling_url = result.get("url") + + if not args.poll: + data = {"taskId": task_id, "url": polling_url, "imageCount": len(image_ids)} + output(args, data, text=f"Batch update started: taskId={task_id} ({len(image_ids)} images)") + return + + from roboflow.core.async_tasks import poll_until_terminal + + try: + final = poll_until_terminal( + api_key, + workspace_url, + task_id, + timeout=args.timeout, + polling_url=polling_url, + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=1) + return + except TimeoutError as exc: + output_error(args, str(exc), exit_code=1) + return + + result_data = final.get("result", {}) + data = {"taskId": task_id, "status": final.get("status"), **result_data} + succeeded = result_data.get("succeeded", 0) + failed = result_data.get("failed", 0) + output(args, data, text=f"Batch update complete: {succeeded} succeeded, {failed} failed (taskId={task_id})") + + +def _handle_delete(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.config import load_roboflow_api_key + + api_key = args.api_key or load_roboflow_api_key(args.workspace) + if not api_key: + output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) + return + + workspace_url: str = args.workspace or _default_workspace() or "" + if not workspace_url: + output_error(args, "No workspace specified", hint="Use --workspace or run 'roboflow auth login'") + return + + ids = [i.strip() for i in args.image_ids.split(",") if i.strip()] + result = rfapi.workspace_delete_images( + api_key=api_key, + workspace_url=workspace_url, + image_ids=ids, + ) + + deleted = result.get("deleted", 0) + skipped = result.get("skipped", 0) + data = {"deleted": deleted, "skipped": skipped} + output(args, data, text=f"Deleted {deleted}, skipped {skipped}") + + +def _handle_annotate(args): # noqa: ANN001 + import json + import os + + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.config import load_roboflow_api_key + + api_key = args.api_key or load_roboflow_api_key(args.workspace) + if not api_key: + output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) + return + + annotation_path = args.annotation_file + if not os.path.isfile(annotation_path): + output_error(args, f"Annotation file not found: {annotation_path}") + return + + with open(annotation_path) as f: + annotation_string = f.read() + + annotation_name = os.path.basename(annotation_path) + labelmap = None + if args.labelmap: + with open(args.labelmap) as f: + labelmap = json.load(f) + + rfapi.save_annotation( + api_key=api_key, + project_url=args.project, + annotation_name=annotation_name, + annotation_string=annotation_string, + image_id=args.image_id, + annotation_labelmap=labelmap, + ) + + data = {"status": "saved"} + output(args, data, text=f"Annotation saved for image {args.image_id}") + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _default_workspace() -> str | None: + from roboflow.config import get_conditional_configuration_variable + + return get_conditional_configuration_variable("RF_WORKSPACE", default=None) diff --git a/roboflow/cli/handlers/infer.py b/roboflow/cli/handlers/infer.py new file mode 100644 index 00000000..9f3ddca1 --- /dev/null +++ b/roboflow/cli/handlers/infer.py @@ -0,0 +1,129 @@ +"""Infer command: run inference on an image.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import ctx_to_args + + +def infer_command(app: typer.Typer) -> None: + """Register the top-level ``infer`` command on *app*.""" + + @app.command("infer", hidden=True) + def infer( + ctx: typer.Context, + file: Annotated[str, typer.Argument(help="Path to an image file")], + model: Annotated[str, typer.Option("-m", "--model", help="Model ID (project/version, e.g. my-project/3)")], + confidence: Annotated[float, typer.Option("-c", "--confidence", help="Confidence threshold 0.0-1.0")] = 0.5, + overlap: Annotated[float, typer.Option("-o", "--overlap", help="Overlap threshold 0.0-1.0")] = 0.5, + type: Annotated[ + Optional[str], + typer.Option( + "-t", + "--type", + help="Model type (auto-detected if not specified)", + ), + ] = None, + ) -> None: + """Run inference on an image.""" + args = ctx_to_args(ctx, file=file, model=model, confidence=confidence, overlap=overlap, type=type) + _infer(args) + + +def _infer(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, version = resolve_resource(args.model, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + project_type = args.type + if not project_type: + try: + dataset_json = rfapi.get_project(api_key, workspace_url, project_slug) + project_type = dataset_json["project"]["type"] + except (rfapi.RoboflowError, KeyError) as exc: + output_error(args, f"Could not determine project type: {exc}", hint="Use -t/--type to specify.") + return + + # Lazy imports of model classes + from roboflow.models.classification import ClassificationModel + from roboflow.models.instance_segmentation import InstanceSegmentationModel + from roboflow.models.keypoint_detection import KeypointDetectionModel + from roboflow.models.object_detection import ObjectDetectionModel + from roboflow.models.semantic_segmentation import SemanticSegmentationModel + from roboflow.models.vlm import VLMModel + + model_class_map = { + "object-detection": ObjectDetectionModel, + "classification": ClassificationModel, + "instance-segmentation": InstanceSegmentationModel, + "semantic-segmentation": SemanticSegmentationModel, + "keypoint-detection": KeypointDetectionModel, + "text-image-pairs": VLMModel, + } + + model_cls = model_class_map.get(project_type) + if model_cls is None: + output_error(args, f"Unsupported project type: {project_type}") + return + + if version is not None: + project_url = f"{workspace_url}/{project_slug}/{version}" + else: + project_url = f"{workspace_url}/{project_slug}" + + model = model_cls(api_key, project_url) + + kwargs = {} + if args.confidence is not None and project_type in [ + "object-detection", + "instance-segmentation", + "semantic-segmentation", + ]: + kwargs["confidence"] = int(args.confidence * 100) + if args.overlap is not None and project_type == "object-detection": + kwargs["overlap"] = int(args.overlap * 100) + + try: + result = model.predict(args.file, **kwargs) + except Exception as exc: + output_error(args, f"Inference failed: {exc}") + return + + # VLM models return raw dict response; pass through as-is. + if isinstance(result, dict): + if getattr(args, "json", False): + output(args, result) + else: + import json as _json + + output(args, None, text=_json.dumps(result, indent=2)) + return + + # Serialize predictions for JSON output + if getattr(args, "json", False): + predictions = [] + for pred in result: + if hasattr(pred, "json"): + predictions.append(pred.json()) + elif hasattr(pred, "__dict__"): + predictions.append(pred.__dict__) + else: + predictions.append(str(pred)) + output(args, predictions) + else: + output(args, None, text=str(result)) diff --git a/roboflow/cli/handlers/model.py b/roboflow/cli/handlers/model.py new file mode 100644 index 00000000..2f304fc7 --- /dev/null +++ b/roboflow/cli/handlers/model.py @@ -0,0 +1,352 @@ +"""Model management commands: list, get, upload.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +model_app = typer.Typer(cls=SortedGroup, help="Manage trained models", no_args_is_help=True) + + +@model_app.command("list") +def list_models( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID or shorthand (e.g. my-ws/my-project)")], + group: Annotated[ + Optional[str], + typer.Option( + "-g", + "--group", + help=( + "NAS modelGroup to scope the list to a single NAS run. " + "Get the value from 'roboflow train results /'." + ), + ), + ] = None, +) -> None: + """List trained models for a project. + + Pass --group to filter to a single NAS run. + """ + args = ctx_to_args(ctx, project=project, group=group) + _list_models(args) + + +@model_app.command("get") +def get_model( + ctx: typer.Context, + model_url: Annotated[str, typer.Argument(help="Model URL (e.g. workspace/model-name)")], +) -> None: + """Show details for a trained model.""" + args = ctx_to_args(ctx, model_url=model_url) + _get_model(args) + + +@model_app.command("star") +def star_model( + ctx: typer.Context, + model_id: Annotated[ + str, + typer.Argument( + help=( + "Model id (e.g. workspace/model-id, or just the bare id if -w is set). " + "Get it from 'roboflow train results /' (models[].modelId)." + ), + ), + ], + unstar: Annotated[bool, typer.Option("--unstar", help="Unstar instead of starring")] = False, +) -> None: + """Star or unstar a NAS-trained model. + + NAS-only by design β€” the server rejects non-NAS modelTypes with a + MODEL_NOT_NAS error. Starring triggers TRT compilation for the model's + recommended hardware so the model becomes deployable as an edge target. + """ + args = ctx_to_args(ctx, model_id=model_id, starred=not unstar) + _star_model(args) + + +@model_app.command("infer") +def model_infer( + ctx: typer.Context, + file: Annotated[str, typer.Argument(help="Path to an image file")], + model: Annotated[str, typer.Option("-m", "--model", help="Model ID (project/version, e.g. my-project/3)")], + confidence: Annotated[float, typer.Option("-c", "--confidence", help="Confidence threshold 0.0-1.0")] = 0.5, + overlap: Annotated[float, typer.Option("-o", "--overlap", help="Overlap/NMS threshold 0.0-1.0")] = 0.5, + type: Annotated[ + Optional[str], + typer.Option("-t", "--type", help="Model type (auto-detected if not specified)"), + ] = None, +) -> None: + """Run inference on an image using a trained model.""" + from roboflow.cli.handlers.infer import _infer + + args = ctx_to_args(ctx, file=file, model=model, confidence=confidence, overlap=overlap, type=type) + _infer(args) + + +@model_app.command("upload") +def upload_model( + ctx: typer.Context, + model_type: Annotated[str, typer.Option("-t", "--type", help="Model type (e.g. yolov8, yolov5)")], + model_path: Annotated[str, typer.Option("-m", "--model-path", help="Path to the trained model file")], + project: Annotated[ + Optional[list[str]], typer.Option("-p", "--project", help="Project ID (repeatable for multi-project deploy)") + ] = None, + version_number: Annotated[ + Optional[int], typer.Option("-v", "--version", help="Version number to deploy to (single-version deploy)") + ] = None, + filename: Annotated[str, typer.Option("-f", "--filename", help="Model file name")] = "weights/best.pt", + model_name: Annotated[ + Optional[str], typer.Option("-n", "--model-name", help="Name for the model (multi-project deploy)") + ] = None, +) -> None: + """Upload a trained model.""" + args = ctx_to_args( + ctx, + project=project, + version_number=version_number, + model_type=model_type, + model_path=model_path, + filename=filename, + model_name=model_name, + ) + _upload_model(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _list_models(args): # noqa: ANN001 + import roboflow + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error, suppress_sdk_output + from roboflow.cli._resolver import resolve_resource + from roboflow.cli._table import format_table + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _version = resolve_resource(args.project, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + group = getattr(args, "group", None) + + if group: + # NAS path β€” hit the public /models endpoint with ?group= filter. + # Surfaces full per-row NAS metadata (nasFamily, group, + # train.results.{hardware,latency,map5095,paretoOptimalFor}, + # favorites, recommended). + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + try: + rows = rfapi.list_project_models(api_key, workspace_url, project_slug, group=group) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + if not isinstance(rows, list): + rows = [] + # Project a leaderboard view for the text table; full row stays in JSON. + table_rows = [] + for r in rows: + metrics = r.get("metrics") or {} + table_rows.append( + { + "url": r.get("url", ""), + "type": r.get("modelType", ""), + "hardware": metrics.get("hardware", ""), + "latency": metrics.get("latency", ""), + "map50": metrics.get("map50", ""), + "map5095": metrics.get("map5095", ""), + "recommended": "β˜…" if r.get("recommended") else "", + } + ) + table = format_table( + table_rows, + columns=["url", "type", "hardware", "latency", "map50", "map5095", "recommended"], + headers=["URL", "TYPE", "HARDWARE", "LATENCY", "MAP50", "MAP5095", "REC"], + ) + output(args, rows, text=table) + return + + api_key = args.api_key or None + + try: + with suppress_sdk_output(args): + rf = roboflow.Roboflow(api_key=api_key) + workspace = rf.workspace(workspace_url) + project = workspace.project(project_slug) + versions = project.versions() + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + models = [] + for v in versions: + # version.model is deprecated; read the underlying legacy model directly. + v_model = getattr(v, "_model", None) + if v_model: + models.append( + { + "version": v.version, + "id": v.id, + "model": getattr(v, "model_format", ""), + "map": v_model.get("map", "") if isinstance(v_model, dict) else "", + } + ) + + table = format_table( + models, + columns=["version", "id", "model", "map"], + headers=["VERSION", "ID", "MODEL", "MAP"], + ) + output(args, models, text=table) + + +def _star_model(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.config import load_roboflow_api_key + + # Accept either "workspace/model-id" or just "model-id" (when -w is + # set). Mirrors the parsing pattern used by `roboflow model get`. + raw = args.model_id.strip("/") + if "/" in raw: + ws_from_arg, _sep, public_model_id = raw.partition("/") + else: + ws_from_arg, public_model_id = None, raw + + workspace_url = args.workspace or ws_from_arg + if not workspace_url: + from roboflow.cli._resolver import resolve_default_workspace + + workspace_url = resolve_default_workspace(args.api_key) + if not workspace_url: + output_error( + args, + "Could not determine workspace.", + hint=( + "Pass -w/--workspace, prefix the model id (workspace/id), or run 'roboflow auth set-workspace '." + ), + exit_code=2, + ) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + try: + result = rfapi.favorite_nas_model(api_key, workspace_url, public_model_id, starred=args.starred) + except rfapi.RoboflowError as exc: + msg = str(exc) + hint = None + if "MODEL_NOT_NAS" in msg or "non-NAS" in msg: + hint = "Star is NAS-only. Use 'roboflow train results' to find NAS model ids (models[].modelId)." + elif "MODEL_NOT_IN_WORKSPACE" in msg: + hint = ( + "Verify the model id and workspace. The id is the same value " + "'roboflow train results' returns as models[].modelId." + ) + output_error(args, msg, hint=hint, exit_code=3) + return + + verb = "starred" if args.starred else "unstarred" + output( + args, + result, + text=f"Model {workspace_url}/{public_model_id} {verb}.", + ) + + +def _get_model(args): # noqa: ANN001 + import json + + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, version = resolve_resource(args.model_url, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + if version is not None: + data = rfapi.get_version(api_key, workspace_url, project_slug, str(version)) + else: + data = rfapi.get_project(api_key, workspace_url, project_slug) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + output(args, data, text=json.dumps(data, indent=2, default=str)) + + +def _upload_model(args): # noqa: ANN001 + import roboflow + from roboflow.cli._output import output, output_error + + api_key = args.api_key or None + rf = roboflow.Roboflow(api_key=api_key) + workspace = rf.workspace(args.workspace) + + if args.version_number is not None: + # Deploy to a specific version + project_id = args.project[0] if isinstance(args.project, list) else args.project + if not project_id: + output_error(args, "Project is required for model upload.", hint="Use -p/--project.") + return + + try: + project = workspace.project(project_id) + version = project.version(args.version_number) + version.deploy(str(args.model_type), str(args.model_path), str(args.filename)) + except Exception as exc: + output_error(args, str(exc)) + return + else: + # Deploy to multiple projects + if not args.project: + output_error(args, "At least one project is required.", hint="Use -p/--project.") + return + + try: + workspace.deploy_model( + model_type=str(args.model_type), + model_path=str(args.model_path), + project_ids=args.project, + model_name=str(args.model_name) if args.model_name else "", + filename=str(args.filename), + ) + except Exception as exc: + output_error(args, str(exc)) + return + + output(args, {"status": "uploaded"}, text="Model uploaded successfully.") diff --git a/roboflow/cli/handlers/project.py b/roboflow/cli/handlers/project.py new file mode 100644 index 00000000..fd072dc0 --- /dev/null +++ b/roboflow/cli/handlers/project.py @@ -0,0 +1,513 @@ +"""Project management commands: list, get, create, health.""" + +from __future__ import annotations + +from enum import Enum +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + + +class ProjectType(str, Enum): + """Supported project types.""" + + object_detection = "object-detection" + single_label_classification = "single-label-classification" + multi_label_classification = "multi-label-classification" + instance_segmentation = "instance-segmentation" + semantic_segmentation = "semantic-segmentation" + keypoint_detection = "keypoint-detection" + + +project_app = typer.Typer(cls=SortedGroup, help="Manage projects", no_args_is_help=True) + + +@project_app.command("list") +def list_projects( + ctx: typer.Context, + type: Annotated[Optional[str], typer.Option(help="Filter by project type")] = None, +) -> None: + """List projects in a workspace.""" + args = ctx_to_args(ctx, type=type) + _list_projects(args) + + +@project_app.command("get") +def get_project( + ctx: typer.Context, + project_id: Annotated[str, typer.Argument(help="Project ID or shorthand (e.g. my-ws/my-project)")], +) -> None: + """Show detailed info for a project.""" + args = ctx_to_args(ctx, project_id=project_id) + _get_project(args) + + +@project_app.command("create") +def create_project( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Project name")], + type: Annotated[ProjectType, typer.Option("--type", help="Project type")], + license: Annotated[str, typer.Option(help="Project license")] = "Private", + annotation: Annotated[str, typer.Option(help="Annotation group name")] = "", +) -> None: + """Create a new project.""" + args = ctx_to_args(ctx, name=name, type=type.value, license=license, annotation=annotation) + _create_project(args) + + +@project_app.command("delete") +def delete_project( + ctx: typer.Context, + project_id: Annotated[str, typer.Argument(help="Project ID or shorthand (e.g. my-ws/my-project)")], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, +) -> None: + """Move a project to Trash (30-day retention; cancels in-flight trainings).""" + args = ctx_to_args(ctx, project_id=project_id, yes=yes) + _delete_project(args) + + +@project_app.command("restore") +def restore_project( + ctx: typer.Context, + project_id: Annotated[str, typer.Argument(help="Project ID or shorthand (e.g. my-ws/my-project)")], +) -> None: + """Restore a project from Trash.""" + args = ctx_to_args(ctx, project_id=project_id) + _restore_project(args) + + +@project_app.command("fork") +def fork_project( + ctx: typer.Context, + source: Annotated[ + str, + typer.Argument(help="Source project: Universe URL or '/' shorthand."), + ], + no_wait: Annotated[ + bool, + typer.Option("--no-wait", help="Return immediately with the taskId instead of waiting."), + ] = False, + timeout: Annotated[ + int, + typer.Option("--timeout", help="Seconds to wait for completion (0 = no timeout)."), + ] = 1800, +) -> None: + """Fork a public Universe project into a workspace.""" + args = ctx_to_args(ctx, source=source, no_wait=no_wait, timeout=timeout) + _fork_project(args) + + +@project_app.command("health") +def health_project( + ctx: typer.Context, + project_id: Annotated[str, typer.Argument(help="Project ID or shorthand (e.g. my-ws/my-project)")], + regenerate: Annotated[ + bool, typer.Option("--regenerate", "-r", help="Force regeneration of health check data.") + ] = False, +) -> None: + """Show dataset health check for a project (class balance, dimensions, splits).""" + args = ctx_to_args(ctx, project_id=project_id, regenerate=regenerate) + _health_project(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _list_projects(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + from roboflow.config import load_roboflow_api_key + + workspace_url = args.workspace + if not workspace_url: + from roboflow.cli._resolver import resolve_default_workspace + + workspace_url = resolve_default_workspace(api_key=args.api_key) + + if not workspace_url: + output_error(args, "No workspace specified.", hint="Use --workspace or run 'roboflow auth login'.") + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + data = rfapi.get_workspace(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + projects = data.get("workspace", {}).get("projects", []) + + if args.type: + projects = [p for p in projects if p.get("type") == args.type] + + table = format_table( + projects, + columns=["name", "id", "type", "versions", "images"], + headers=["NAME", "ID", "TYPE", "VERSIONS", "IMAGES"], + ) + output(args, projects, text=table) + + +def _get_project(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + data = rfapi.get_project(api_key, workspace_url, project_slug) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + project = data.get("project", data) + lines = [] + field_map = [ + ("Name", "name"), + ("ID", "id"), + ("Type", "type"), + ("License", "license"), + ("Annotation", "annotation"), + ("Classes", "classes"), + ("Images", "images"), + ("Versions", "versions"), + ("Created", "created"), + ("Updated", "updated"), + ("Public", "public"), + ] + epoch_keys = {"created", "updated"} + for label, key in field_map: + if key in project: + val = project[key] + if key in epoch_keys and isinstance(val, (int, float)): + import datetime + + val = datetime.datetime.fromtimestamp(val).strftime("%Y-%m-%d %H:%M:%S") + elif isinstance(val, dict): + val = ", ".join(f"{k}: {v}" for k, v in val.items()) + lines.append(f" {label:12s} {val}") + text = "\n".join(lines) if lines else "(no project details)" + + output(args, data, text=text) + + +def _create_project(args): # noqa: ANN001 + import roboflow + from roboflow.cli._output import output, output_error, suppress_sdk_output + + annotation = args.annotation if args.annotation else args.name + + with suppress_sdk_output(args): + try: + rf = roboflow.Roboflow() + workspace = rf.workspace(args.workspace) + except Exception as exc: + output_error(args, str(exc)) + return + + try: + project = workspace.create_project( + project_name=args.name, + project_type=args.type, + project_license=args.license, + annotation=annotation, + ) + except Exception as exc: + msg = str(exc) + hint = None + if hasattr(exc, "response"): + try: + body = exc.response.json() # type: ignore[union-attr] + if "error" in body: + hint = body["error"].get("message", None) if isinstance(body["error"], dict) else str(body["error"]) + elif "message" in body: + hint = str(body["message"]) + except Exception: + pass + output_error(args, msg, hint=hint) + return + + data = { + "id": project.id, + "name": project.name, + "type": project.type, + } + output(args, data, text=f"Created project: {project.name} ({project.id})") + + +def _delete_project(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import confirm_destructive, output, output_api_error, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace) + except ValueError as exc: + output_error( + args, + str(exc), + hint="Use 'my-workspace/my-project' or set --workspace and pass 'my-project'.", + ) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + if not confirm_destructive( + args, + f"Move '{workspace_url}/{project_slug}' to Trash? " + "(Retained for 30 days. Any in-flight trainings will be cancelled.)", + ): + return + + try: + data = rfapi.delete_project(api_key, workspace_url, project_slug) + except rfapi.RoboflowError as exc: + # Idempotent re-delete: when the project is already in Trash the + # public API's URL filter excludes it, so the DELETE returns 404 + # with a generic "endpoint does not exist" message. That looks like + # a permissions error to the user. Probe Trash explicitly β€” if the + # slug is there, treat the call as a no-op success so scripts can + # safely retry without special-casing the second attempt. + if getattr(exc, "status_code", None) == 404: + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError: + trash = None + if trash is not None: + already = next( + (p for p in trash.get("sections", {}).get("projects", []) if p.get("url") == project_slug), + None, + ) + if already is not None: + data = { + "deleted": True, + "type": "project", + "workspace": workspace_url, + "project": project_slug, + "projectId": already.get("id"), + "trash": True, + "alreadyInTrash": True, + } + output( + args, + data, + text=f"{workspace_url}/{project_slug} is already in Trash (no-op).", + ) + return + output_api_error( + args, + exc, + hint="Check your API key has 'project:update' scope on this workspace.", + ) + return + + output( + args, + data, + text=f"Moved {workspace_url}/{project_slug} to Trash (30-day retention).", + ) + + +def _restore_project(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _version = resolve_resource(args.project_id, workspace_override=args.workspace) + except ValueError as exc: + output_error( + args, + str(exc), + hint="Use 'my-workspace/my-project' or set --workspace and pass 'my-project'.", + ) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_api_error( + args, + exc, + auth_hint="Check that ROBOFLOW_API_KEY is valid for this workspace.", + hint="Check your API key has 'project:read' scope on this workspace.", + ) + return + + projects = trash.get("sections", {}).get("projects", []) + match = next((p for p in projects if p.get("url") == project_slug), None) + if not match: + output_error( + args, + f"Project '{workspace_url}/{project_slug}' is not in Trash.", + hint="Run 'roboflow trash list' to see what can be restored.", + exit_code=3, + ) + return + + try: + data = rfapi.restore_trash_item(api_key, workspace_url, "project", match["id"]) + except rfapi.RoboflowError as exc: + output_api_error( + args, + exc, + hint="Check your API key has 'project:update' scope on this workspace.", + ) + return + + output(args, data, text=f"Restored {workspace_url}/{project_slug} from Trash.") + + +def _fork_project(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_default_workspace + from roboflow.config import load_roboflow_api_key + from roboflow.core.async_tasks import poll_until_terminal + + # The server accepts the full URL (or `/` shorthand) as `url` + # and parses it itself β€” forward verbatim so the CLI doesn't duplicate + # that logic. + source = (args.source or "").strip() + if not source: + output_error( + args, + "Source is required.", + hint="Use '/' or a Universe URL.", + ) + return + + dest_workspace = args.workspace or resolve_default_workspace(api_key=args.api_key) + if not dest_workspace: + output_error( + args, + "No workspace specified.", + hint="Use --workspace or run 'roboflow auth login'.", + exit_code=2, + ) + return + + api_key = args.api_key or load_roboflow_api_key(dest_workspace) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + try: + enqueued = rfapi.fork_project(api_key, dest_workspace, url=source) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + + task_id = enqueued["taskId"] + + if args.no_wait: + polling_url = enqueued.get("url") + text = f"Fork enqueued: taskId={task_id}" + if polling_url: + text += f"\nPoll: {polling_url}" + output(args, enqueued, text=text) + return + + def _print_progress(status): # noqa: ANN001 + if args.json: + return + progress = status.get("progress") + if not isinstance(progress, dict): + return + # Don't use `or` here: `current == 0` is a legitimate value. + current = progress["current"] if "current" in progress else progress.get("completed") + total = progress.get("total") + if current is not None and total is not None: + print(f"Task progress: {current}/{total}", flush=True) + + try: + final = poll_until_terminal( + api_key, + dest_workspace, + task_id, + timeout=args.timeout, + on_update=_print_progress, + polling_url=enqueued.get("url"), + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + except TimeoutError as exc: + output_error(args, str(exc)) + return + + if final.get("status") == "failed": + output_error(args, final.get("error") or "Fork task failed.") + return + + project_url = (final.get("result") or {}).get("url", "") + text = f"Forked.\nDestination URL: {project_url}" if project_url else "Forked." + output(args, final, text=text) + + +def _health_project(args): # noqa: ANN001 + import json + + import roboflow + from roboflow.cli._output import output, output_error, suppress_sdk_output + + with suppress_sdk_output(args): + try: + rf = roboflow.Roboflow(api_key=args.api_key) + project = rf.workspace(args.workspace).project(args.project_id) + except Exception as exc: + output_error(args, str(exc)) + return + + try: + data = project.health(regenerate=args.regenerate) + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + output(args, data, text=json.dumps(data, indent=2)) diff --git a/roboflow/cli/handlers/search.py b/roboflow/cli/handlers/search.py new file mode 100644 index 00000000..fb2e3d48 --- /dev/null +++ b/roboflow/cli/handlers/search.py @@ -0,0 +1,122 @@ +"""Search commands: query workspace images and export search results.""" + +from __future__ import annotations + +from typing import Annotated, Any, Optional + +import typer + +from roboflow.cli._compat import ctx_to_args + + +def search_command(app: typer.Typer) -> None: + """Register the top-level ``search`` command on *app*.""" + + @app.command("search", hidden=True) + def search( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="Search query (e.g. 'tag:review' or '*')")], + limit: Annotated[int, typer.Option(help="Max results to return")] = 50, + cursor: Annotated[Optional[str], typer.Option(help="Continuation token for pagination")] = None, + fields: Annotated[Optional[str], typer.Option(help="Comma-separated list of fields to include")] = None, + export: Annotated[bool, typer.Option("--export", help="Export search results as a dataset")] = False, + format: Annotated[str, typer.Option("-f", "--format", help="Annotation format for export")] = "coco", + location: Annotated[Optional[str], typer.Option("-l", "--location", help="Local directory for export")] = None, + dataset: Annotated[ + Optional[str], typer.Option("-d", "--dataset", help="Limit to a specific dataset (project slug)") + ] = None, + annotation_group: Annotated[ + Optional[str], + typer.Option("-g", "--annotation-group", help="Limit export to a specific annotation group"), + ] = None, + name: Annotated[Optional[str], typer.Option(help="Optional name for the export")] = None, + no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip file, skip extraction")] = False, + ) -> None: + """Search workspace images or export results as a dataset.""" + args = ctx_to_args( + ctx, + query=query, + limit=limit, + cursor=cursor, + fields=fields, + export=export, + format=format, + location=location, + dataset=dataset, + annotation_group=annotation_group, + name=name, + no_extract=no_extract, + ) + _search(args) + + +def _search(args): # noqa: ANN001 + import roboflow + from roboflow.cli._output import output_error, suppress_sdk_output + + try: + with suppress_sdk_output(): + # Forward the CLI --api-key; Roboflow() falls back to saved/env creds when None. + rf = roboflow.Roboflow(api_key=args.api_key) + workspace = rf.workspace(args.workspace) + except Exception as exc: + output_error(args, str(exc), exit_code=2) + return + + if args.export: + _do_export(args, workspace) + else: + _do_search(args, workspace) + + +def _do_search(args: Any, workspace: Any) -> None: + from roboflow.cli._output import output, output_error + + fields = args.fields.split(",") if args.fields else None + try: + result = workspace.search( + query=args.query, + page_size=args.limit, + fields=fields, + continuation_token=args.cursor, + ) + except Exception as exc: + output_error(args, str(exc)) + return + + results = result.get("results", []) + total = result.get("total", len(results)) + token = result.get("continuationToken") + + data = {"results": results, "total": total} + if token: + data["cursor"] = token + + text_lines = [f"Found {total} result(s)."] + for r in results: + text_lines.append(f" {r.get('filename', r.get('id', ''))}") + if token: + text_lines.append(f"\nNext page: --cursor {token}") + + output(args, data, text="\n".join(text_lines)) + + +def _do_export(args: Any, workspace: Any) -> None: + from roboflow.cli._output import output, output_error + + try: + result_path = workspace.search_export( + query=args.query, + format=args.format, + location=args.location, + dataset=args.dataset, + annotation_group=getattr(args, "annotation_group", None), + name=args.name, + extract_zip=not args.no_extract, + ) + except Exception as exc: + output_error(args, str(exc)) + return + + data = {"status": "completed", "path": str(result_path)} + output(args, data, text=f"Export completed: {result_path}") diff --git a/roboflow/cli/handlers/train.py b/roboflow/cli/handlers/train.py new file mode 100644 index 00000000..f2a69651 --- /dev/null +++ b/roboflow/cli/handlers/train.py @@ -0,0 +1,781 @@ +"""Train commands: start training for a dataset version.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +train_app = typer.Typer(cls=SortedGroup, help="Train a model", invoke_without_command=True) + + +@train_app.callback(invoke_without_command=True) +def _train_callback( + ctx: typer.Context, + project: Annotated[Optional[str], typer.Option("-p", "--project", help="Project ID to train")] = None, + version_number: Annotated[Optional[int], typer.Option("-v", "--version", help="Version number to train")] = None, + model_type: Annotated[ + Optional[str], typer.Option("-t", "--type", help="Model type (e.g. rfdetr-nano, yolov8n)") + ] = None, + checkpoint: Annotated[Optional[str], typer.Option(help="Checkpoint to resume training from")] = None, + speed: Annotated[Optional[str], typer.Option(help="Training speed preset")] = None, + epochs: Annotated[Optional[int], typer.Option(help="Number of training epochs")] = None, + train_recipe: Annotated[ + Optional[str], + typer.Option( + "--train-recipe", + help=( + "Full trainRecipe as inline JSON or @path/to/file.json (see 'roboflow train " + "recipe'); --epochs is folded into its hyperparameters unless the recipe " + "already sets epochs" + ), + ), + ] = None, +) -> None: + """Train a model. When invoked without a subcommand, behaves like ``train start``.""" + if ctx.invoked_subcommand is not None: + return + # No subcommand β€” behave like `train start` + if not project: + from roboflow.cli._output import output_error + + args = ctx_to_args(ctx) + output_error(args, "Project is required.", hint="Use -p/--project.") + return + if version_number is None: + from roboflow.cli._output import output_error + + args = ctx_to_args(ctx) + output_error(args, "Version is required.", hint="Use -v/--version.") + return + args = ctx_to_args( + ctx, + project=project, + version_number=version_number, + model_type=model_type, + checkpoint=checkpoint, + speed=speed, + epochs=epochs, + train_recipe=train_recipe, + ) + _start(args) + + +@train_app.command("start") +def start_training( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID to train")], + version_number: Annotated[int, typer.Option("-v", "--version", help="Version number to train")], + model_type: Annotated[ + Optional[str], typer.Option("-t", "--type", help="Model type (e.g. rfdetr-nano, yolov8n)") + ] = None, + checkpoint: Annotated[Optional[str], typer.Option(help="Checkpoint to resume training from")] = None, + speed: Annotated[Optional[str], typer.Option(help="Training speed preset")] = None, + epochs: Annotated[Optional[int], typer.Option(help="Number of training epochs")] = None, + train_recipe: Annotated[ + Optional[str], + typer.Option( + "--train-recipe", + help=( + "Full trainRecipe as inline JSON or @path/to/file.json (see 'roboflow train " + "recipe'); --epochs is folded into its hyperparameters unless the recipe " + "already sets epochs" + ), + ), + ] = None, +) -> None: + """Start training for a dataset version. + + With --train-recipe, the training is created via the v2 trainings API + and the new trainingId is printed. Start from the ``template`` field of + ``roboflow train recipe`` output, edit it (hyperparameters, online + augmentation), and pass it inline or as ``@path/to/file.json``; --epochs is folded into its + hyperparameters unless the recipe already sets epochs. + """ + args = ctx_to_args( + ctx, + project=project, + version_number=version_number, + model_type=model_type, + checkpoint=checkpoint, + speed=speed, + epochs=epochs, + train_recipe=train_recipe, + ) + _start(args) + + +@train_app.command("recipe") +def describe_train_recipe( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + version_number: Annotated[int, typer.Option("-v", "--version", help="Version number")], + model_type: Annotated[ + str, + typer.Option("-m", "--model-type", "-t", "--type", help="Model type to describe (e.g. rfdetr-medium)"), + ], +) -> None: + """Show the training recipe schema and template for a model type. + + Prints the tunable hyperparameter schema, the allowed online + augmentation/preprocessing steps, and a ready-to-submit ``template`` + that can be edited and passed to ``roboflow train start --train-recipe``. + """ + args = ctx_to_args(ctx, project=project, version_number=version_number, model_type=model_type) + _recipe(args) + + +@train_app.command("cancel") +def cancel_training( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument( + help="Training to cancel as 'project/version' (e.g. 'my-project/3' or 'workspace/my-project/3')" + ), + ], + continue_if_no_refund: Annotated[ + bool, + typer.Option( + "--continue-if-no-refund", + help=( + "Cancel even if the run is past the refund window. " + "Default: false (server replies refund:false without cancelling)." + ), + ), + ] = False, +) -> None: + """Cancel an in-flight training run. + + Works for any architecture, including NAS sweeps in the mining or + training phase. Server-side gate: only valid while the run is in-flight; + a finished/failed run returns 409 CANNOT_CANCEL. + """ + args = ctx_to_args(ctx, target=target, continue_if_no_refund=continue_if_no_refund) + _cancel(args) + + +@train_app.command("stop") +def stop_training( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help="Training to stop as 'project/version'"), + ], +) -> None: + """Request a graceful early-stop on an in-flight training run. + + Distinct from cancel: the run finishes the current phase (mining or + training) instead of terminating immediately. Idempotent β€” calling + stop on an already-stopped run is a no-op. + """ + args = ctx_to_args(ctx, target=target) + _stop(args) + + +@train_app.command("delete") +def delete_training( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help="Training to delete as 'project/version'"), + ], + training_id: Annotated[ + Optional[str], + typer.Option( + "--training-id", + help=( + "Training id of the run to delete (versions can own several). Omit to target the version's sole run." + ), + ), + ] = None, +) -> None: + """Move a terminal training run to the workspace Trash (soft delete). + + The run and every model it produced disappear from listings but stay + restorable for 30 days ('roboflow train restore' or the web Trash view), + after which they are permanently deleted. In-flight runs are refused β€” + stop or cancel first. The version's hosted endpoint always serves the + oldest remaining run's model, so deleting the serving run switches + serving to the next-oldest run, or stops it when none survives. + Permanent deletion is only available in the web UI's Trash view. + """ + args = ctx_to_args(ctx, target=target, training_id=training_id) + _delete(args) + + +@train_app.command("restore") +def restore_training( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help="Version the trashed training belongs to, as 'project/version'"), + ], + training_id: Annotated[ + str, + typer.Option( + "--training-id", + help="Training id of the trashed run to restore (required).", + ), + ], +) -> None: + """Restore a trashed training run (and its models) back into listings. + + Fails while the parent project or version is itself in Trash β€” restore + those first ('roboflow trash list' shows what is trashed). + """ + args = ctx_to_args(ctx, target=target, training_id=training_id) + _restore(args) + + +@train_app.command("list") +def list_trainings( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help="Version whose trainings to list, as 'project/version'"), + ], +) -> None: + """List a version's training runs with their ids. + + A version may own several training runs; use the TRAINING_ID column with + 'roboflow train delete/restore --training-id' or 'train cancel/stop'. + """ + args = ctx_to_args(ctx, target=target) + _list(args) + + +@train_app.command("results") +def training_results( + ctx: typer.Context, + target: Annotated[ + str, + typer.Argument(help="Training to inspect as 'project/version'"), + ], +) -> None: + """Run-level training results bundle. + + For NAS sweeps returns { trainingId, status, modelGroup, modelCount, + recommendedByHardware, mining?, models: [...] }. For non-NAS trainings + returns a minimal bundle with the produced model. + + Pass the returned `modelGroup` to `roboflow model list --group ...` to + list every NAS model from that run with full metadata. + """ + args = ctx_to_args(ctx, target=target) + _results(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _start(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + if not getattr(args, "project", None): + output_error(args, "Project is required.", hint="Use -p/--project.") + return + if getattr(args, "version_number", None) is None: + output_error(args, "Version is required.", hint="Use -v/--version.") + return + + try: + workspace_url, project_slug, _version = resolve_resource(args.project, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + # Custom recipes go through the v2 trainings API. Presence, not + # truthiness: an explicitly supplied empty value (e.g. an unset shell + # variable) must fail JSON validation, not fall through and start a + # legacy training. + if getattr(args, "train_recipe", None) is not None: + _start_v2(args, api_key, workspace_url, project_slug) + return + + # Ensure the version has the required export format before training + if args.model_type: + _ensure_export(args, api_key, workspace_url, project_slug, str(args.version_number), args.model_type) + + try: + rfapi.start_version_training( + api_key, + workspace_url, + project_slug, + str(args.version_number), + speed=args.speed, + checkpoint=args.checkpoint, + model_type=args.model_type, + epochs=args.epochs, + ) + except rfapi.RoboflowError as exc: + err_str = str(exc) + if "Unknown error" in err_str: + output_error( + args, + "Training failed. The server returned an unexpected error.", + hint="Ensure the version is fully generated and exported. " + "Run 'roboflow version export -p -f coco' first.", + ) + else: + output_error(args, err_str) + return + + data = { + "status": "training_started", + "project": project_slug, + "version": args.version_number, + } + output(args, data, text=f"Training started for {project_slug} version {args.version_number}.") + + +def _parse_json_flag(args, raw, flag): + """Parse a JSON-object CLI flag value; exits with a clean error on invalid input. + + Accepts inline JSON, or ``@path/to/file.json`` to read the JSON from a + file (curl-style; unambiguous because ``@`` can never start valid JSON). + """ + import json + import os + + from roboflow.cli._output import output_error + + source = "string" + if raw.startswith("@"): + path = os.path.expanduser(raw[1:]) + try: + with open(path, encoding="utf-8") as f: + raw = f.read() + except OSError as exc: + output_error( + args, + f"Cannot read {flag} file {path}: {exc.strerror or exc}", + hint="Pass inline JSON, or @ pointing to a readable JSON file.", + ) + return None # unreachable: output_error sys.exits + source = "file" + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + output_error(args, f"Invalid JSON in {flag} {source}: {exc}", hint="Pass a valid JSON string.") + return None # unreachable: output_error sys.exits + if not isinstance(parsed, dict): + output_error( + args, + f"{flag} must be a JSON object, got {type(parsed).__name__}", + hint="Pass a JSON object string, e.g. '{\"lr\": 0.0002}'.", + ) + return None # unreachable: output_error sys.exits + return parsed + + +def _start_v2(args, api_key, workspace_url, project_slug): + """Create a training via the v2 trainings API with a custom trainRecipe.""" + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.util.train_recipe import fold_epochs_into_recipe + + version_str = str(args.version_number) + if not args.model_type: + output_error( + args, + "--train-recipe requires a model type.", + hint=( + "Recipes are minted per model type; without -t/--type the platform " + "would train the project's default architecture. Pass the model type " + "the recipe was described for (e.g. -t rfdetr-medium)." + ), + ) + return + train_recipe = _parse_json_flag(args, args.train_recipe, "--train-recipe") + if args.epochs is not None: + # Fold --epochs into the recipe: the server dense-fills recipe + # hyperparameters (including a default epochs) and resolves them + # ahead of the body's top-level value, which would otherwise be + # silently ignored. An epochs set in the recipe wins. + train_recipe = fold_epochs_into_recipe(train_recipe, args.epochs) + + # Ensure the version has the required export format before training + if args.model_type: + _ensure_export(args, api_key, workspace_url, project_slug, version_str, args.model_type) + + try: + result = rfapi.create_training_v2( + api_key, + workspace_url, + project_slug, + version_str, + model_type=args.model_type, + speed=args.speed, + checkpoint=args.checkpoint, + epochs=args.epochs, + train_recipe=train_recipe, + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + + data = { + "status": "training_created", + "project": project_slug, + "version": args.version_number, + **result, + } + training_id = result.get("trainingId") + output( + args, + data, + text=f"Training created for {project_slug} version {args.version_number}. trainingId: {training_id}", + ) + + +def _recipe(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _version = resolve_resource(args.project, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + result = rfapi.get_train_recipe( + api_key, workspace_url, project_slug, str(args.version_number), model_type=args.model_type + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + + # No text form β€” the recipe is structured data; print JSON in both modes. + output(args, result) + + +def _ensure_export(args, api_key, workspace_url, project_slug, version_str, model_type): + """Check if the version has the required export format; trigger and poll if not.""" + import sys + import time + + from roboflow.adapters import rfapi + from roboflow.util.versions import get_model_format + + required_format = get_model_format(model_type) + + try: + version_data = rfapi.get_version(api_key, workspace_url, project_slug, version_str) + except rfapi.RoboflowError: + return # Can't check; let the train call handle errors + + version_info = version_data.get("version", {}) + + # Check if still generating + if version_info.get("generating"): + if not getattr(args, "quiet", False): + print(f"Version is still generating ({version_info.get('progress', 0):.0%})... waiting.", file=sys.stderr) + while True: + time.sleep(5) + try: + version_data = rfapi.get_version(api_key, workspace_url, project_slug, version_str, nocache=True) + version_info = version_data.get("version", {}) + if not version_info.get("generating"): + break + if not getattr(args, "quiet", False): + print( + f" Generating... {version_info.get('progress', 0):.0%}", + file=sys.stderr, + ) + except rfapi.RoboflowError: + break + + # Check if export exists + exports = version_info.get("exports", []) + if required_format not in exports: + if not getattr(args, "quiet", False): + print( + f"Exporting version in {required_format} format (required for {model_type})...", + file=sys.stderr, + ) + try: + rfapi.get_version_export(api_key, workspace_url, project_slug, version_str, required_format) + except rfapi.RoboflowError: + pass # Export may have been triggered; poll below + + # Poll until export is ready + for _ in range(120): # Up to 10 minutes + time.sleep(5) + try: + version_data = rfapi.get_version(api_key, workspace_url, project_slug, version_str, nocache=True) + current_exports = version_data.get("version", {}).get("exports", []) + if required_format in current_exports: + if not getattr(args, "quiet", False): + print(" Export complete.", file=sys.stderr) + return + except rfapi.RoboflowError: + pass + + +def _resolve_train_target(args): + """Parse '/' (or full 'workspace//') and resolve api key. + + Returns (api_key, workspace_url, project_slug, version_str) or None if validation fails. + """ + from roboflow.cli._output import output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, version = resolve_resource(args.target, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return None + if version is None: + output_error( + args, + "Version is required.", + hint="Pass it as 'project/version' or 'workspace/project/version'.", + ) + return None + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return None + return api_key, workspace_url, project_slug, str(version) + + +def _cancel(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_train_target(args) + if resolved is None: + return + api_key, workspace_url, project_slug, version_str = resolved + + try: + result = rfapi.cancel_version_training( + api_key, + workspace_url, + project_slug, + version_str, + continue_if_no_refund=getattr(args, "continue_if_no_refund", False), + ) + except rfapi.RoboflowError as exc: + msg = str(exc) + # 409 from server lands here as a RoboflowError carrying the JSON + # body; surface it with code "CANNOT_CANCEL" if present. + hint = None + if "non-running" in msg or "Cannot cancel" in msg: + hint = ( + "Cancel only applies to in-flight runs. Check status with 'roboflow train results /'." + ) + output_error(args, msg, hint=hint, exit_code=3) + return + + output( + args, + {"status": "cancelled", "project": project_slug, "version": version_str, **(result or {})}, + text=f"Training cancelled for {project_slug} version {version_str}.", + ) + + +def _stop(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_train_target(args) + if resolved is None: + return + api_key, workspace_url, project_slug, version_str = resolved + + try: + result = rfapi.stop_version_training(api_key, workspace_url, project_slug, version_str) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + output( + args, + {"status": "stop_requested", "project": project_slug, "version": version_str, **(result or {})}, + text=f"Early-stop requested for {project_slug} version {version_str}.", + ) + + +def _delete(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_train_target(args) + if resolved is None: + return + api_key, workspace_url, project_slug, version_str = resolved + + try: + training_id = rfapi.resolve_version_training_id( + api_key, + workspace_url, + project_slug, + version_str, + getattr(args, "training_id", None), + ) + result = rfapi.delete_version_training( + api_key, + workspace_url, + project_slug, + version_str, + training_id=training_id, + ) + except ValueError as exc: + output_error(args, str(exc), hint="Pass a non-empty --training-id.", exit_code=2) + return + except rfapi.RoboflowError as exc: + msg = str(exc) + hint = None + if "in progress" in msg: + hint = "Stop or cancel the run first: 'roboflow train stop /'." + elif "MULTIPLE_TRAININGS" in msg: + hint = "This version owns several runs. Pass --training-id (see 'roboflow train list /')." + output_error(args, msg, hint=hint, exit_code=3) + return + + alias_action = (result or {}).get("versionAliasAction") + if alias_action == "repointed": + alias_note = ( + f" Serving for '{project_slug}/{version_str}' switched to " + f"'{(result or {}).get('versionAliasTarget', 'the next-oldest model')}'." + ) + elif alias_action == "deleted": + alias_note = ( + f" No other model remains, so '{project_slug}/{version_str}' stops serving " + "until a new training completes or this run is restored." + ) + else: + alias_note = "" + output( + args, + {"status": "in_trash", "project": project_slug, "version": version_str, **(result or {})}, + text=( + f"Training moved to Trash for {project_slug} version {version_str}. " + f"Restorable for 30 days via 'roboflow train restore'.{alias_note}" + ), + ) + + +def _restore(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_train_target(args) + if resolved is None: + return + api_key, workspace_url, project_slug, version_str = resolved + + try: + result = rfapi.restore_trash_item(api_key, workspace_url, "training", args.training_id) + except ValueError as exc: + output_error(args, str(exc), hint="Pass a non-empty --training-id.", exit_code=2) + return + except rfapi.RoboflowError as exc: + msg = str(exc) + hint = None + # The shared trash route reports a non-trashed id as "not found in + # trash"; the service-level guard says "not in trash". Match both + # before the parent-blocked case, which also mentions "in trash". + if "not found in trash" in msg.lower() or "not in trash" in msg.lower(): + hint = "Only trashed runs can be restored. 'roboflow trash list' shows what is trashed." + elif "in trash" in msg.lower(): + hint = "Restore the parent project/version first ('roboflow trash list')." + output_error(args, msg, hint=hint, exit_code=3) + return + + output( + args, + {"status": "restored", "project": project_slug, "version": version_str, **(result or {})}, + text=f"Training restored for {project_slug} version {version_str}.", + ) + + +def _list(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve_train_target(args) + if resolved is None: + return + api_key, workspace_url, project_slug, version_str = resolved + + try: + trainings = rfapi.list_trainings_for_version(api_key, workspace_url, project_slug, version_str) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + rows = [ + { + "trainingId": t.get("id", ""), + "status": t.get("status", ""), + "modelType": t.get("modelType", ""), + "models": len(t.get("modelIds") or []), + } + for t in trainings + ] + table = format_table( + rows, + columns=["trainingId", "status", "modelType", "models"], + headers=["TRAINING_ID", "STATUS", "MODEL_TYPE", "MODELS"], + ) + if not rows: + table = "(No trainings on this version)" + output(args, {"trainings": trainings}, text=table) + + +def _results(args): # noqa: ANN001 + + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_train_target(args) + if resolved is None: + return + api_key, workspace_url, project_slug, version_str = resolved + + try: + result = rfapi.get_training_results(api_key, workspace_url, project_slug, version_str) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + job_type = result.get("jobType", "unknown") + model_count = result.get("modelCount", 0) + model_group = result.get("modelGroup") + text_summary = ( + f"{job_type} run for {project_slug} v{version_str}: status={result.get('status')}, models={model_count}" + ) + if model_group: + text_summary += f", group={model_group}" + output(args, result, text=text_summary) diff --git a/roboflow/cli/handlers/trash.py b/roboflow/cli/handlers/trash.py new file mode 100644 index 00000000..60056505 --- /dev/null +++ b/roboflow/cli/handlers/trash.py @@ -0,0 +1,97 @@ +"""Trash management commands. + +Only `list` is exposed here β€” permanent-delete actions (empty Trash, delete a +single Trash item immediately) destroy data irrecoverably and are available +only through the web UI's Trash view. Items left in Trash are cleaned up +automatically after 30 days. +""" + +from __future__ import annotations + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +trash_app = typer.Typer(cls=SortedGroup, help="Manage items in Trash", no_args_is_help=True) + + +@trash_app.command("list") +def list_trash_cmd(ctx: typer.Context) -> None: + """List projects, versions, and workflows currently in Trash.""" + args = ctx_to_args(ctx) + _list_trash(args) + + +# --------------------------------------------------------------------------- +# Business logic +# --------------------------------------------------------------------------- + + +def _resolve_workspace(args): + from roboflow.cli._output import output_error + from roboflow.cli._resolver import resolve_default_workspace + from roboflow.config import load_roboflow_api_key + + workspace_url = args.workspace or resolve_default_workspace(api_key=args.api_key) + if not workspace_url: + output_error(args, "No workspace specified.", hint="Use --workspace or run 'roboflow auth login'.") + return None, None + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return None, None + + return workspace_url, api_key + + +def _list_trash(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error + from roboflow.cli._table import format_table + + workspace_url, api_key = _resolve_workspace(args) + if not workspace_url: + return + + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_api_error( + args, + exc, + hint="Check your API key has 'project:read' scope on this workspace.", + ) + return + + items = trash.get("items", []) + rows = [] + for item in items: + name = item.get("name", "") + if item.get("type") == "version": + parent = item.get("parentName") or item.get("parentUrl") or "" + name = f"{parent} β€” {name} (v{item.get('id', '')})" + rows.append( + { + "type": item.get("type", ""), + "id": item.get("id", ""), + "name": name, + "deletedAt": item.get("deletedAt", ""), + "scheduledCleanupAt": item.get("scheduledCleanupAt", ""), + "deletedBy": item.get("deletedByName") or item.get("deletedBy", ""), + } + ) + + table = format_table( + rows, + columns=["type", "id", "name", "deletedAt", "scheduledCleanupAt", "deletedBy"], + headers=["TYPE", "ID", "NAME", "DELETED", "CLEANUP_AT", "BY"], + ) + if not rows: + table = "(Trash is empty)" + output(args, trash, text=table) diff --git a/roboflow/cli/handlers/universe.py b/roboflow/cli/handlers/universe.py new file mode 100644 index 00000000..9cbcf8a8 --- /dev/null +++ b/roboflow/cli/handlers/universe.py @@ -0,0 +1,61 @@ +"""Universe search commands.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +universe_app = typer.Typer(cls=SortedGroup, help="Browse Roboflow Universe", no_args_is_help=True) + + +@universe_app.command("search") +def search( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="Search query")], + type: Annotated[Optional[str], typer.Option(help="Filter by type (dataset or model)")] = None, + limit: Annotated[int, typer.Option(help="Max results")] = 12, +) -> None: + """Search Roboflow Universe.""" + args = ctx_to_args(ctx, query=query, type=type, limit=limit) + _search(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _search(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + from roboflow.config import load_roboflow_api_key + + api_key = args.api_key or load_roboflow_api_key(None) + + try: + data = rfapi.search_universe(args.query, api_key=api_key, project_type=args.type, limit=args.limit) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + + results = data.get("results", []) + # The API may ignore the limit param; enforce it client-side + if args.limit and len(results) > args.limit: + results = results[: args.limit] + rows = [] + for r in results: + rows.append( + { + "name": r.get("name", r.get("id", "")), + "type": r.get("type", ""), + "images": r.get("images", 0), + "url": r.get("url", ""), + } + ) + + table = format_table(rows, columns=["name", "type", "images", "url"], headers=["NAME", "TYPE", "IMAGES", "URL"]) + output(args, results, text=table) diff --git a/roboflow/cli/handlers/version.py b/roboflow/cli/handlers/version.py new file mode 100644 index 00000000..d1bd51d6 --- /dev/null +++ b/roboflow/cli/handlers/version.py @@ -0,0 +1,530 @@ +"""Version management commands: list, get, download, export, create.""" + +from __future__ import annotations + +import re +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +version_app = typer.Typer(cls=SortedGroup, help="Manage dataset versions", no_args_is_help=True) + + +@version_app.command("list") +def list_versions( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")] = ..., # type: ignore[assignment] +) -> None: + """List versions for a project.""" + args = ctx_to_args(ctx, project=project) + _list_versions(args) + + +@version_app.command("get") +def get_version( + ctx: typer.Context, + version_num: Annotated[str, typer.Argument(help="Version number or shorthand (e.g. my-project/3)")], + project: Annotated[Optional[str], typer.Option("-p", "--project", help="Project ID")] = None, +) -> None: + """Show detailed info for a version.""" + args = ctx_to_args(ctx, version_num=version_num, project=project) + _get_version(args) + + +@version_app.command("download") +def download( + ctx: typer.Context, + url_or_id: Annotated[str, typer.Argument(help="Dataset URL or shorthand (e.g. ws/project/3)")], + format: Annotated[str, typer.Option("-f", "--format", help="Export format (default: voc)")] = "voc", + location: Annotated[Optional[str], typer.Option("-l", "--location", help="Download location")] = None, +) -> None: + """Download a dataset version.""" + args = ctx_to_args(ctx, url_or_id=url_or_id, format=format, location=location) + _download(args) + + +@version_app.command("export") +def export( + ctx: typer.Context, + version_num: Annotated[str, typer.Argument(help="Version number")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")] = ..., # type: ignore[assignment] + format: Annotated[str, typer.Option("-f", "--format", help="Export format (default: voc)")] = "voc", +) -> None: + """Trigger an async export.""" + args = ctx_to_args(ctx, version_num=version_num, project=project, format=format) + _export(args) + + +@version_app.command("create") +def create( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")] = ..., # type: ignore[assignment] + settings: Annotated[str, typer.Option(help="Path to JSON file with augmentation/preprocessing config")] = ..., # type: ignore[assignment] +) -> None: + """Create a new dataset version. + + Settings JSON example:: + + {"augmentation": {"flip": {"horizontal": true, "vertical": false}, + "rotate": {"degrees": 15}, "brightness": {"percent": 25}}, + "preprocessing": {"auto-orient": true, "resize": {"width": 640, + "height": 640, "format": "Stretch to"}}} + + See https://docs.roboflow.com/datasets/create-a-dataset-version for all options. + """ + args = ctx_to_args(ctx, project=project, settings=settings) + _create(args) + + +@version_app.command("delete") +def delete_version( + ctx: typer.Context, + version_ref: Annotated[ + str, + typer.Argument(help="Version shorthand (e.g. ws/project/3 or project/3)"), + ], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, +) -> None: + """Move a version to Trash (30-day retention; cancels its in-flight training).""" + args = ctx_to_args(ctx, version_ref=version_ref, yes=yes) + _delete_version(args) + + +@version_app.command("restore") +def restore_version_cmd( + ctx: typer.Context, + version_ref: Annotated[ + str, + typer.Argument(help="Version shorthand (e.g. ws/project/3 or project/3)"), + ], +) -> None: + """Restore a version from Trash (parent project must be active).""" + args = ctx_to_args(ctx, version_ref=version_ref) + _restore_version(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _list_versions(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.cli._table import format_table + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _ver = resolve_resource(args.project, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + project_data = rfapi.get_project(api_key, workspace_url, project_slug) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + versions = project_data.get("versions", []) + rows = [] + for v in versions: + rows.append( + { + "id": v.get("id", ""), + "name": v.get("name", ""), + "images": v.get("images", 0), + "splits": _format_splits(v.get("splits", {})), + "created": v.get("created", ""), + } + ) + + table = format_table( + rows, + columns=["id", "name", "images", "splits", "created"], + headers=["ID", "NAME", "IMAGES", "SPLITS", "CREATED"], + ) + output(args, versions, text=table) + + +def _format_splits(splits: dict) -> str: + if not splits: + return "" + parts = [] + for key in ("train", "valid", "test"): + count = splits.get(key, 0) + if count: + parts.append(f"{key}:{count}") + return " ".join(parts) + + +def _get_version(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + # Build shorthand: if --project is given, combine with version_num + shorthand = args.version_num + if args.project: + shorthand = f"{args.project}/{args.version_num}" + + try: + workspace_url, project_slug, version_num = resolve_resource(shorthand, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + if version_num is None: + output_error(args, "Version number is required.", hint="Use e.g. 'version get 3 -p my-project'.") + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + data = rfapi.get_version(api_key, workspace_url, project_slug, str(version_num)) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + import json + + output(args, data, text=json.dumps(data, indent=2, default=str)) + + +def _parse_url(url: str) -> tuple: + """Parse a Roboflow URL or shorthand into (workspace, project, version). + + Supports: + - Full URLs: https://universe.roboflow.com/ws/proj/3 + - Three segments: ws/proj/3 + - Two segments: ws/proj OR proj/3 (numeric = version, uses default ws) + - One segment: proj (uses default ws, no version) + """ + # Try full URL first + url_regex = r"(?:https?://)?(?:universe|app)\.roboflow\.(?:com|one)/([^/]+)/([^/]+)(?:/dataset)?(?:/(\d+))?" + match = re.match(url_regex, url) + if match: + return match.group(1), match.group(2), match.group(3) + + # Non-URL shorthand: use resolve_resource for proper disambiguation + from roboflow.cli._resolver import resolve_resource + + try: + ws, proj, ver = resolve_resource(url, workspace_override=None) + return ws, proj, str(ver) if ver is not None else None + except ValueError: + return None, None, None + + +def _download(args): # noqa: ANN001 + import roboflow + from roboflow.cli._output import output, output_error, suppress_sdk_output + + w, p, v = _parse_url(args.url_or_id) + + if not w or not p: + output_error(args, f"Could not parse URL or shorthand: {args.url_or_id}") + return + + # Always suppress SDK "loading..." noise during workspace/project init + with suppress_sdk_output(): + try: + rf = roboflow.Roboflow() + project = rf.workspace(w).project(p) + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + try: + if not v: + versions = project.versions() + if not versions: + output_error(args, f"Project {p} does not have any versions.") + return + version_obj = versions[-1] + else: + version_obj = project.version(int(v)) + + version_obj.download(args.format, location=args.location, overwrite=True) + except SystemExit: + raise + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + data = { + "workspace": w, + "project": p, + "version": int(v) if v else version_obj.version, + "format": args.format, + "location": args.location or "", + } + output(args, data, text=f"Downloaded {w}/{p}/{data['version']} in {args.format} format") + + +def _export(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + shorthand = f"{args.project}/{args.version_num}" + try: + workspace_url, project_slug, version_num = resolve_resource(shorthand, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + if version_num is None: + output_error(args, "Version number is required.") + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + data = rfapi.get_version_export(api_key, workspace_url, project_slug, str(version_num), args.format) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + if data.get("ready") is False: + progress = data.get("progress", 0) + output(args, data, text=f"Export in progress ({progress:.0%})...") + else: + output(args, data, text=f"Export ready for {project_slug}/{version_num} in {args.format} format") + + +def _create(args): # noqa: ANN001 + import json + + import roboflow + from roboflow.cli._output import output, output_error, suppress_sdk_output + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, _ver = resolve_resource(args.project, workspace_override=args.workspace) + except ValueError as exc: + output_error(args, str(exc)) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + with open(args.settings) as f: + settings = json.load(f) + except FileNotFoundError: + output_error(args, f"Settings file not found: {args.settings}") + return + except json.JSONDecodeError as exc: + output_error(args, f"Invalid JSON in settings file: {exc}") + return + + with suppress_sdk_output(): + try: + rf = roboflow.Roboflow(api_key) + project = rf.workspace(workspace_url).project(project_slug) + version_id = project.generate_version(settings) + except Exception as exc: + output_error(args, str(exc)) + return + + # generate_version returns the version number/ID directly + version_num = version_id if version_id else "unknown" + + data = {"status": "created", "project": project_slug, "version": version_num} + output(args, data, text=f"Created version {version_num} for project {project_slug}") + + +def _delete_version(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import confirm_destructive, output, output_api_error, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, version_num = resolve_resource(args.version_ref, workspace_override=args.workspace) + except ValueError as exc: + output_error( + args, + str(exc), + hint="Use 'workspace/project/3' or 'project/3' (version must be a number).", + ) + return + + if version_num is None: + output_error( + args, + "Version number is required.", + hint="Pass 'project/3' or 'workspace/project/3' β€” the trailing segment must be the numeric version id.", + ) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + if not confirm_destructive( + args, + f"Move version '{workspace_url}/{project_slug}/{version_num}' to Trash? " + "(Retained for 30 days. Any in-flight training will be cancelled.)", + ): + return + + try: + data = rfapi.delete_version(api_key, workspace_url, project_slug, version_num) + except rfapi.RoboflowError as exc: + # Idempotent re-delete: if the version is already in Trash, the + # public API URL is filtered and DELETE returns 404 β€” surface it + # as an explicit no-op so retries don't surface a misleading + # "missing scope" message. Same shape as project delete above. + if getattr(exc, "status_code", None) == 404: + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError: + trash = None + if trash is not None: + target = str(version_num) + already = next( + ( + v + for v in trash.get("sections", {}).get("versions", []) + if str(v.get("id")) == target and v.get("parentUrl") == project_slug + ), + None, + ) + if already is not None: + data = { + "deleted": True, + "type": "version", + "workspace": workspace_url, + "project": project_slug, + "version": str(version_num), + "trash": True, + "alreadyInTrash": True, + } + output( + args, + data, + text=f"{workspace_url}/{project_slug}/{version_num} is already in Trash (no-op).", + ) + return + output_api_error( + args, + exc, + hint="Check your API key has 'version:update' scope and the version exists.", + ) + return + + output( + args, + data, + text=f"Moved {workspace_url}/{project_slug}/{version_num} to Trash.", + ) + + +def _restore_version(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error, output_error + from roboflow.cli._resolver import resolve_resource + from roboflow.config import load_roboflow_api_key + + try: + workspace_url, project_slug, version_num = resolve_resource(args.version_ref, workspace_override=args.workspace) + except ValueError as exc: + output_error( + args, + str(exc), + hint="Use 'workspace/project/3' or 'project/3' (version must be a number).", + ) + return + + if version_num is None: + output_error( + args, + "Version number is required.", + hint="Pass 'project/3' or 'workspace/project/3' β€” the trailing segment must be the numeric version id.", + ) + return + + api_key = args.api_key or load_roboflow_api_key(workspace_url) + if not api_key: + output_error( + args, + "No API key found.", + hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", + exit_code=2, + ) + return + + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_api_error( + args, + exc, + hint="Check your API key has 'project:read' scope on this workspace.", + ) + return + + versions = trash.get("sections", {}).get("versions", []) + target = str(version_num) + match = next( + (v for v in versions if str(v.get("id")) == target and v.get("parentUrl") == project_slug), + None, + ) + if not match: + output_error( + args, + f"Version '{workspace_url}/{project_slug}/{version_num}' is not in Trash.", + hint="Run 'roboflow trash list' to see what can be restored. " + "If the parent project is also in Trash, restore the project first.", + exit_code=3, + ) + return + + try: + data = rfapi.restore_trash_item( + api_key, + workspace_url, + "version", + match["id"], + parent_id=match.get("parentId"), + ) + except rfapi.RoboflowError as exc: + output_api_error( + args, + exc, + hint="Check your API key has 'version:update' scope on this workspace.", + ) + return + + output( + args, + data, + text=f"Restored {workspace_url}/{project_slug}/{version_num} from Trash.", + ) diff --git a/roboflow/cli/handlers/video.py b/roboflow/cli/handlers/video.py new file mode 100644 index 00000000..1fdc9905 --- /dev/null +++ b/roboflow/cli/handlers/video.py @@ -0,0 +1,115 @@ +"""Video inference commands.""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +video_app = typer.Typer(cls=SortedGroup, help="Video inference operations", no_args_is_help=True) + + +@video_app.command("infer") +def infer( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + version_number: Annotated[int, typer.Option("-v", "--version", help="Model version number")], + video_file: Annotated[str, typer.Option("-f", "--file", help="Path to video file")], + fps: Annotated[int, typer.Option("--fps", help="Frames per second")] = 5, +) -> None: + """Run video inference.""" + args = ctx_to_args(ctx, project=project, version_number=version_number, video_file=video_file, fps=fps) + _video_infer(args) + + +@video_app.command("status") +def status( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Job ID to check")], +) -> None: + """Check video inference job status.""" + args = ctx_to_args(ctx, job_id=job_id) + _video_status(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _video_infer(args) -> None: # noqa: ANN001 + import roboflow + from roboflow.cli._output import output, output_error + from roboflow.config import load_roboflow_api_key + + api_key = args.api_key or load_roboflow_api_key(None) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + from roboflow.cli._output import suppress_sdk_output + + with suppress_sdk_output(): + rf = roboflow.Roboflow(api_key) + project = rf.workspace().project(args.project) + version = project.version(args.version_number) + model = getattr(version, "_model", None) + if model is None: + output_error( + args, + f"No model found for project '{args.project}' version {args.version_number}.", + hint="Train or deploy a model for this version before running video inference.", + exit_code=3, + ) + return + + job_id, _signed_url, _expire_time = model.predict_video( + args.video_file, + args.fps, + prediction_type="batch-video", + ) + except Exception as exc: + output_error(args, str(exc)) + return + + data = {"job_id": job_id, "status": "submitted"} + output(args, data, text=f"Video inference submitted. Job ID: {job_id}") + + +def _video_status(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.config import load_roboflow_api_key + + api_key = args.api_key or load_roboflow_api_key(None) + if not api_key: + output_error(args, "No API key found.", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'.", exit_code=2) + return + + try: + data = rfapi.get_video_job_status(api_key, args.job_id) + except rfapi.RoboflowError as exc: + msg = str(exc) + if "NOT FOUND" in msg.upper(): + output_error( + args, + f"Video job '{args.job_id}' not found.", + hint="Check the job ID. You can get job IDs from 'roboflow video infer'.", + exit_code=3, + ) + else: + output_error(args, msg, exit_code=3) + return + + status = data.get("status", "unknown") + progress = data.get("progress", "") + text_lines = [ + f"Job ID: {args.job_id}", + f"Status: {status}", + ] + if progress: + text_lines.append(f"Progress: {progress}") + output(args, data, text="\n".join(text_lines)) diff --git a/roboflow/cli/handlers/vision_events.py b/roboflow/cli/handlers/vision_events.py new file mode 100644 index 00000000..61a521da --- /dev/null +++ b/roboflow/cli/handlers/vision_events.py @@ -0,0 +1,441 @@ +"""Vision events commands: write, query, list use cases, and upload images.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +vision_events_app = typer.Typer( + help="Create, query, and manage vision events.", + cls=SortedGroup, + no_args_is_help=True, +) + + +def _resolve(args): # noqa: ANN001 + """Return api_key or call output_error and return None.""" + from roboflow.cli._resolver import resolve_ws_and_key + + resolved = resolve_ws_and_key(args) + if resolved is None: + return None + _ws, api_key = resolved + return api_key + + +# --------------------------------------------------------------------------- +# write +# --------------------------------------------------------------------------- + + +@vision_events_app.command("write") +def write( + ctx: typer.Context, + event: Annotated[str, typer.Argument(help="JSON string of the event payload")], +) -> None: + """Create a single vision event.""" + args = ctx_to_args(ctx, event=event) + _write(args) + + +def _write(args) -> None: # noqa: ANN001 + import json + + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + try: + event = json.loads(args.event) + except (json.JSONDecodeError, TypeError) as exc: + output_error(args, f"Invalid JSON: {exc}", hint="Pass a valid JSON string.") + return + + try: + result = vision_events_api.write_event(api_key, event) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + output(args, result, text=f"Created event {result.get('eventId', '')}") + + +# --------------------------------------------------------------------------- +# write-batch +# --------------------------------------------------------------------------- + + +@vision_events_app.command("write-batch") +def write_batch( + ctx: typer.Context, + events: Annotated[str, typer.Argument(help="JSON string of the events array")], +) -> None: + """Create multiple vision events in a single request.""" + args = ctx_to_args(ctx, events=events) + _write_batch(args) + + +def _write_batch(args) -> None: # noqa: ANN001 + import json + + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + try: + events = json.loads(args.events) + except (json.JSONDecodeError, TypeError) as exc: + output_error(args, f"Invalid JSON: {exc}", hint="Pass a valid JSON array string.") + return + + try: + result = vision_events_api.write_batch(api_key, events) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + output(args, result, text=f"Created {result.get('created', 0)} event(s)") + + +# --------------------------------------------------------------------------- +# query +# --------------------------------------------------------------------------- + + +@vision_events_app.command("query") +def query( + ctx: typer.Context, + use_case: Annotated[str, typer.Argument(help="Use case identifier to query")], + event_type: Annotated[Optional[str], typer.Option("-t", "--event-type", help="Filter by event type")] = None, + start_time: Annotated[Optional[str], typer.Option("--start", help="ISO 8601 start time")] = None, + end_time: Annotated[Optional[str], typer.Option("--end", help="ISO 8601 end time")] = None, + limit: Annotated[Optional[int], typer.Option("-l", "--limit", help="Max events to return")] = None, + cursor: Annotated[Optional[str], typer.Option("--cursor", help="Pagination cursor")] = None, +) -> None: + """Query vision events with filters and pagination.""" + args = ctx_to_args( + ctx, + use_case=use_case, + event_type=event_type, + start_time=start_time, + end_time=end_time, + limit=limit, + cursor=cursor, + ) + _query(args) + + +def _query(args) -> None: # noqa: ANN001 + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + payload = {"useCaseId": args.use_case} + if args.event_type is not None: + payload["eventType"] = args.event_type + if args.start_time is not None: + payload["startTime"] = args.start_time + if args.end_time is not None: + payload["endTime"] = args.end_time + if args.limit is not None: + payload["limit"] = args.limit + if args.cursor is not None: + payload["cursor"] = args.cursor + + try: + result = vision_events_api.query(api_key, payload) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + events = result.get("events", []) + lines = [f"Found {len(events)} event(s)."] + for evt in events: + lines.append(f" {evt.get('eventId', '')} [{evt.get('eventType', '')}]") + if result.get("nextCursor"): + lines.append(f"\nNext page: --cursor {result['nextCursor']}") + + output(args, result, text="\n".join(lines)) + + +# --------------------------------------------------------------------------- +# use-cases +# --------------------------------------------------------------------------- + + +@vision_events_app.command("use-cases") +def use_cases( + ctx: typer.Context, + status: Annotated[Optional[str], typer.Option("-s", "--status", help="Filter by status (active, inactive)")] = None, +) -> None: + """List vision event use cases for the workspace.""" + args = ctx_to_args(ctx, status=status) + _use_cases(args) + + +def _use_cases(args) -> None: # noqa: ANN001 + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + try: + result = vision_events_api.list_use_cases(api_key, status=args.status) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + items = result.get("useCases") or result.get("solutions", []) + lines = [f"{len(items)} use case(s):"] + for uc in items: + name = uc.get("name", uc.get("id", "")) + if uc.get("eventCount") is not None: + detail = f" ({uc['eventCount']} events)" + elif uc.get("status"): + detail = f" [{uc['status']}]" + else: + detail = "" + lines.append(f" {name}{detail}") + + output(args, result, text="\n".join(lines)) + + +# --------------------------------------------------------------------------- +# create-use-case +# --------------------------------------------------------------------------- + + +@vision_events_app.command("create-use-case") +def create_use_case( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Name for the new use case")], +) -> None: + """Create a new vision event use case.""" + args = ctx_to_args(ctx, name=name) + _create_use_case(args) + + +def _create_use_case(args) -> None: # noqa: ANN001 + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + try: + result = vision_events_api.create_use_case(api_key, args.name) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + output(args, result, text=f"Created use case {result.get('id', '')} ({result.get('name', '')})") + + +# --------------------------------------------------------------------------- +# rename-use-case +# --------------------------------------------------------------------------- + + +@vision_events_app.command("rename-use-case") +def rename_use_case( + ctx: typer.Context, + use_case: Annotated[str, typer.Argument(help="Use case identifier")], + name: Annotated[str, typer.Option("-n", "--name", help="New name for the use case")], +) -> None: + """Rename an existing vision event use case.""" + args = ctx_to_args(ctx, use_case=use_case, name=name) + _rename_use_case(args) + + +def _rename_use_case(args) -> None: # noqa: ANN001 + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + try: + result = vision_events_api.rename_use_case(api_key, args.use_case, args.name) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + output(args, result, text=f"Renamed use case {result.get('id', '')} to {result.get('name', '')}") + + +# --------------------------------------------------------------------------- +# archive-use-case +# --------------------------------------------------------------------------- + + +@vision_events_app.command("archive-use-case") +def archive_use_case( + ctx: typer.Context, + use_case: Annotated[str, typer.Argument(help="Use case identifier")], +) -> None: + """Archive a vision event use case.""" + args = ctx_to_args(ctx, use_case=use_case) + _archive_use_case(args) + + +def _archive_use_case(args) -> None: # noqa: ANN001 + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + try: + result = vision_events_api.archive_use_case(api_key, args.use_case) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + output(args, result, text=f"Archived use case {args.use_case}") + + +# --------------------------------------------------------------------------- +# unarchive-use-case +# --------------------------------------------------------------------------- + + +@vision_events_app.command("unarchive-use-case") +def unarchive_use_case( + ctx: typer.Context, + use_case: Annotated[str, typer.Argument(help="Use case identifier")], +) -> None: + """Unarchive a vision event use case.""" + args = ctx_to_args(ctx, use_case=use_case) + _unarchive_use_case(args) + + +def _unarchive_use_case(args) -> None: # noqa: ANN001 + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + try: + result = vision_events_api.unarchive_use_case(api_key, args.use_case) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + output(args, result, text=f"Unarchived use case {args.use_case}") + + +# --------------------------------------------------------------------------- +# metadata-schema +# --------------------------------------------------------------------------- + + +@vision_events_app.command("metadata-schema") +def metadata_schema( + ctx: typer.Context, + use_case: Annotated[str, typer.Argument(help="Use case identifier")], +) -> None: + """Get the custom metadata schema for a use case.""" + args = ctx_to_args(ctx, use_case=use_case) + _metadata_schema(args) + + +def _metadata_schema(args) -> None: # noqa: ANN001 + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + try: + result = vision_events_api.get_custom_metadata_schema(api_key, args.use_case) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + fields = result.get("fields", {}) + lines = [f"{len(fields)} field(s):"] + for name, info in fields.items(): + types = ", ".join(info.get("types", [])) + lines.append(f" {name} ({types})") + + output(args, result, text="\n".join(lines)) + + +# --------------------------------------------------------------------------- +# upload-image +# --------------------------------------------------------------------------- + + +@vision_events_app.command("upload-image") +def upload_image( + ctx: typer.Context, + image: Annotated[str, typer.Argument(help="Path to the image file")], + name: Annotated[Optional[str], typer.Option("-n", "--name", help="Custom image name")] = None, + metadata: Annotated[ + Optional[str], + typer.Option("-M", "--metadata", help='JSON string of metadata (e.g. \'{"camera_id":"cam001"}\')'), + ] = None, +) -> None: + """Upload an image for use in vision events.""" + args = ctx_to_args(ctx, image=image, name=name, metadata=metadata) + _upload_image(args) + + +def _upload_image(args) -> None: # noqa: ANN001 + import json + + from roboflow.adapters import vision_events_api + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + + api_key = _resolve(args) + if api_key is None: + return + + try: + parsed_metadata = json.loads(args.metadata) if args.metadata else None + except (json.JSONDecodeError, TypeError) as exc: + output_error(args, f"Invalid metadata JSON: {exc}", hint="Pass a valid JSON string.") + return + + try: + result = vision_events_api.upload_image( + api_key, + image_path=args.image, + name=args.name, + metadata=parsed_metadata, + ) + except RoboflowError as exc: + output_error(args, str(exc)) + return + + output(args, result, text=f"Uploaded image: sourceId={result.get('sourceId', '')}") diff --git a/roboflow/cli/handlers/workflow.py b/roboflow/cli/handlers/workflow.py new file mode 100644 index 00000000..8db60b67 --- /dev/null +++ b/roboflow/cli/handlers/workflow.py @@ -0,0 +1,539 @@ +"""Workflow management commands.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +workflow_app = typer.Typer(cls=SortedGroup, help="Manage workflows", no_args_is_help=True) + +# --------------------------------------------------------------------------- +# Sub-app for ``workflow version`` subcommands +# --------------------------------------------------------------------------- + +_version_app = typer.Typer(cls=SortedGroup, help="Manage workflow versions", no_args_is_help=True) +workflow_app.add_typer(_version_app, name="version") + + +@workflow_app.command("list") +def list_workflows(ctx: typer.Context) -> None: + """List workflows in a workspace.""" + args = ctx_to_args(ctx) + _list_workflows(args) + + +@workflow_app.command("get") +def get_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """Show details for a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _get_workflow(args) + + +@workflow_app.command("create") +def create_workflow( + ctx: typer.Context, + name: Annotated[str, typer.Option("--name", help="Workflow name")], + definition: Annotated[Optional[str], typer.Option(help="Path to JSON definition file")] = None, + description: Annotated[Optional[str], typer.Option(help="Workflow description")] = None, +) -> None: + """Create a new workflow.""" + args = ctx_to_args(ctx, name=name, definition=definition, description=description) + _create_workflow(args) + + +@workflow_app.command("update") +def update_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], + definition: Annotated[Optional[str], typer.Option(help="Path to JSON definition file")] = None, +) -> None: + """Update an existing workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url, definition=definition) + _update_workflow(args) + + +@_version_app.command("list") +def list_workflow_versions( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """List versions of a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _list_workflow_versions(args) + + +@workflow_app.command("fork") +def fork_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """Fork a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _fork_workflow(args) + + +@workflow_app.command("build", hidden=True) +def build_workflow( + ctx: typer.Context, + prompt: Annotated[str, typer.Argument(help="Natural language prompt describing the workflow")], +) -> None: + """Build a workflow from a prompt.""" + args = ctx_to_args(ctx, prompt=prompt) + _stub_build(args) + + +@workflow_app.command("run", hidden=True) +def run_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], + input: Annotated[Optional[str], typer.Option("--input", help="Input file or URL")] = None, +) -> None: + """Run a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url, input=input) + _stub_run(args) + + +@workflow_app.command("deploy", hidden=True) +def deploy_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """Deploy a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _stub_deploy(args) + + +@workflow_app.command("delete") +def delete_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], + yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False, +) -> None: + """Move a workflow to Trash (30-day retention).""" + args = ctx_to_args(ctx, workflow_url=workflow_url, yes=yes) + _delete_workflow(args) + + +@workflow_app.command("restore") +def restore_workflow_cmd( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """Restore a workflow from Trash.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _restore_workflow(args) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _resolve_workspace_and_key(args): # noqa: ANN001 + """Return (workspace_url, api_key) or call output_error and return None.""" + from roboflow.cli._resolver import resolve_ws_and_key + + return resolve_ws_and_key(args) + + +def _read_definition_file(args): # noqa: ANN001 + """Read and parse a JSON definition file. Returns the parsed dict, or None if no file given. + + Calls output_error and returns False on failure. + """ + import json + import os + + from roboflow.cli._output import output_error + + if not args.definition: + return None + + if not os.path.isfile(args.definition): + output_error(args, f"File not found: {args.definition}", hint="Provide a valid JSON file path.") + return False + + with open(args.definition) as f: + try: + return json.load(f) + except json.JSONDecodeError as exc: + output_error(args, f"Invalid JSON in {args.definition}: {exc}") + return False + + +# --------------------------------------------------------------------------- +# Implemented commands +# --------------------------------------------------------------------------- + + +def _list_workflows(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + try: + data = rfapi.list_workflows(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + workflows = data if isinstance(data, list) else data.get("workflows", []) + + table = format_table( + workflows, + columns=["name", "url", "status"], + headers=["NAME", "URL", "STATUS"], + ) + output(args, workflows, text=table) + + +def _get_workflow(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + try: + data = rfapi.get_workflow(api_key, workspace_url, args.workflow_url) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + workflow = data.get("workflow", data) if isinstance(data, dict) else data + + lines = [] + if isinstance(workflow, dict): + field_map = [ + ("Name", "name"), + ("URL", "url"), + ("Description", "description"), + ("Blocks", "blockCount"), + ] + for label, key in field_map: + if key in workflow: + lines.append(f" {label:14s} {workflow[key]}") + text = "\n".join(lines) if lines else "(no workflow details)" + + output(args, data, text=text) + + +def _create_workflow(args) -> None: # noqa: ANN001 + import json as _json + + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + definition = _read_definition_file(args) + if definition is False: + return + + # The API expects config/template as JSON strings. + config = _json.dumps(definition) if definition is not None else "{}" + template = "{}" + + try: + data = rfapi.create_workflow( + api_key, + workspace_url, + name=args.name, + config=config, + template=template, + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + + text = f"Created workflow: {args.name}" + output(args, data, text=text) + + +def _update_workflow(args) -> None: # noqa: ANN001 + import json as _json + + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + definition = _read_definition_file(args) + if definition is False: + return + + # Fetch the existing workflow to get required id/name/url fields. + try: + existing = rfapi.get_workflow(api_key, workspace_url, args.workflow_url) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + wf = existing.get("workflow", existing) if isinstance(existing, dict) else existing + if not isinstance(wf, dict): + output_error(args, "Unexpected response from API when fetching workflow.") + return + + workflow_id = wf.get("id", "") + workflow_name = wf.get("name", "") + workflow_url_slug = wf.get("url", args.workflow_url) + + # Merge: use new definition as config if provided, otherwise keep existing. + if definition is not None: + config = _json.dumps(definition) if not isinstance(definition, str) else definition + else: + config = wf.get("config", "{}") + if not isinstance(config, str): + config = _json.dumps(config) + + try: + data = rfapi.update_workflow( + api_key, + workspace_url, + workflow_id=workflow_id, + workflow_name=workflow_name, + workflow_url=workflow_url_slug, + config=config, + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + + text = f"Updated workflow: {args.workflow_url}" + output(args, data, text=text) + + +def _list_workflow_versions(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.cli._table import format_table + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + try: + data = rfapi.list_workflow_versions(api_key, workspace_url, args.workflow_url) + except rfapi.RoboflowError as exc: + output_error(args, str(exc), exit_code=3) + return + + versions = data if isinstance(data, list) else data.get("versions", []) + + table = format_table( + versions, + columns=["version", "created"], + headers=["VERSION", "CREATED"], + ) + output(args, versions, text=table) + + +def _fork_workflow(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + # Parse workflow_url: could be "workflow-slug" or "source-ws/workflow-slug". + parts = args.workflow_url.strip("/").split("/") + if len(parts) == 2: + source_workspace = parts[0] + source_workflow = parts[1] + else: + # Default: source workspace is the current workspace. + source_workspace = workspace_url + source_workflow = parts[0] + + try: + data = rfapi.fork_workflow( + api_key, + workspace_url, + source_workspace=source_workspace, + source_workflow=source_workflow, + ) + except rfapi.RoboflowError as exc: + output_error(args, str(exc)) + return + + # Extract the forked workflow URL from potentially nested response + new_url = "" + if isinstance(data, dict): + wf = data.get("workflow", data) + if isinstance(wf, dict): + new_url = str(wf.get("url", wf.get("workflow_url", ""))) + else: + new_url = str(wf) if wf else "" + result = {"status": "forked", "source": args.workflow_url, "new_url": new_url} + text = f"Forked workflow: {args.workflow_url} -> {new_url}" + output(args, result, text=text) + + +# --------------------------------------------------------------------------- +# Stubs +# --------------------------------------------------------------------------- + + +def _stub_build(args) -> None: # noqa: ANN001 + from roboflow.cli._output import output_error + + output_error( + args, + "This command is not yet implemented.", + hint="Requires Roboflow Agent API. Coming in a future release.", + ) + + +def _stub_run(args) -> None: # noqa: ANN001 + from roboflow.cli._output import output_error + + output_error( + args, + "This command is not yet implemented.", + hint="Requires inference_sdk integration. Coming in a future release.", + ) + + +def _stub_deploy(args) -> None: # noqa: ANN001 + from roboflow.cli._output import output_error + + output_error( + args, + "This command is not yet implemented.", + hint="Coming in a future release.", + ) + + +def _delete_workflow(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import confirm_destructive, output, output_api_error + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + if not confirm_destructive( + args, + f"Move workflow '{workspace_url}/{args.workflow_url}' to Trash? (Retained for 30 days.)", + ): + return + + try: + data = rfapi.delete_workflow(api_key, workspace_url, args.workflow_url) + except rfapi.RoboflowError as exc: + # Idempotent re-delete: a workflow already in Trash returns 404 + # because the public API filters trashed workflows out of the URL + # match. Probe Trash and treat as a no-op success when found β€” + # mirrors the project / version delete behavior. + if getattr(exc, "status_code", None) == 404: + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError: + trash = None + if trash is not None: + already = next( + ( + w + for w in trash.get("sections", {}).get("workflows", []) + if w.get("url") == args.workflow_url or w.get("id") == args.workflow_url + ), + None, + ) + if already is not None: + data = { + "deleted": True, + "type": "workflow", + "workspace": workspace_url, + "workflow": args.workflow_url, + "workflowId": already.get("id"), + "trash": True, + "alreadyInTrash": True, + } + output( + args, + data, + text=f"{workspace_url}/{args.workflow_url} is already in Trash (no-op).", + ) + return + output_api_error( + args, + exc, + hint="Check your API key has 'workflow:update' scope on this workspace.", + ) + return + + output( + args, + data, + text=f"Moved {workspace_url}/{args.workflow_url} to Trash (30-day retention).", + ) + + +def _restore_workflow(args) -> None: # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_api_error, output_error + + resolved = _resolve_workspace_and_key(args) + if resolved is None: + return + workspace_url, api_key = resolved + + try: + trash = rfapi.list_trash(api_key, workspace_url) + except rfapi.RoboflowError as exc: + output_api_error( + args, + exc, + hint="Check your API key has 'project:read' scope on this workspace.", + ) + return + + workflows = trash.get("sections", {}).get("workflows", []) + # Match on URL first, fall back to id for callers who pass a Firestore id. + match = next( + (w for w in workflows if w.get("url") == args.workflow_url or w.get("id") == args.workflow_url), + None, + ) + if not match: + output_error( + args, + f"Workflow '{workspace_url}/{args.workflow_url}' is not in Trash.", + hint="Run 'roboflow trash list' to see what can be restored.", + exit_code=3, + ) + return + + try: + data = rfapi.restore_trash_item(api_key, workspace_url, "workflow", match["id"]) + except rfapi.RoboflowError as exc: + output_api_error( + args, + exc, + hint="Check your API key has 'workflow:update' scope on this workspace.", + ) + return + + output(args, data, text=f"Restored {workspace_url}/{args.workflow_url} from Trash.") diff --git a/roboflow/cli/handlers/workspace.py b/roboflow/cli/handlers/workspace.py new file mode 100644 index 00000000..eb1ea142 --- /dev/null +++ b/roboflow/cli/handlers/workspace.py @@ -0,0 +1,240 @@ +"""Workspace commands: list, get, usage, plan, stats.""" + +from __future__ import annotations + +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +workspace_app = typer.Typer(cls=SortedGroup, help="Manage workspaces", no_args_is_help=True) + + +@workspace_app.command("list") +def list_workspaces(ctx: typer.Context) -> None: + """List configured workspaces.""" + args = ctx_to_args(ctx) + _list_workspaces(args) + + +@workspace_app.command("get") +def get_workspace( + ctx: typer.Context, + workspace_id: Annotated[ + Optional[str], typer.Argument(help="Workspace URL or ID (defaults to current workspace)") + ] = None, +) -> None: + """Get workspace details.""" + # Default to current workspace if not specified + if not workspace_id: + from roboflow.cli._resolver import resolve_default_workspace + + workspace_id = (ctx.obj or {}).get("workspace") or resolve_default_workspace( + api_key=(ctx.obj or {}).get("api_key") + ) + args = ctx_to_args(ctx, workspace_id=workspace_id) + _get_workspace(args) + + +@workspace_app.command("usage") +def workspace_usage(ctx: typer.Context) -> None: + """Show billing usage report.""" + args = ctx_to_args(ctx) + _workspace_usage(args) + + +@workspace_app.command("plan") +def workspace_plan(ctx: typer.Context) -> None: + """Show workspace plan info and limits.""" + args = ctx_to_args(ctx) + _workspace_plan(args) + + +@workspace_app.command("stats") +def workspace_stats( + ctx: typer.Context, + start_date: Annotated[str, typer.Option("--start-date", help="Start date (YYYY-MM-DD)")], + end_date: Annotated[str, typer.Option("--end-date", help="End date (YYYY-MM-DD)")], +) -> None: + """Show annotation/labeling statistics.""" + args = ctx_to_args(ctx, start_date=start_date, end_date=end_date) + _workspace_stats(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _list_workspaces(args): # noqa: ANN001 + import os + + from roboflow.cli._output import output + from roboflow.cli._resolver import resolve_default_workspace + from roboflow.cli._table import format_table + from roboflow.config import APP_URL, get_conditional_configuration_variable + + workspaces = get_conditional_configuration_variable("workspaces", default={}) + default_ws_url = get_conditional_configuration_variable("RF_WORKSPACE", default=None) + + # When no workspaces in config, fall back to API using available API key + if not workspaces: + api_key = getattr(args, "api_key", None) or os.getenv("ROBOFLOW_API_KEY") + ws_url = resolve_default_workspace(api_key=api_key) + if ws_url: + ws_name = ws_url + if api_key: + try: + from roboflow.adapters import rfapi + + ws_json = rfapi.get_workspace(api_key, ws_url) + ws_detail = ws_json.get("workspace", ws_json) + ws_name = ws_detail.get("name", ws_url) + except Exception: # noqa: BLE001 + pass + workspaces = {ws_url: {"url": ws_url, "name": ws_name}} + if not default_ws_url: + default_ws_url = ws_url + + rows = [] + for w in workspaces.values(): + rows.append( + { + "name": w.get("name", ""), + "url": w.get("url", ""), + "link": f"{APP_URL}/{w.get('url', '')}", + "default": "yes" if w.get("url") == default_ws_url else "", + } + ) + + table = format_table(rows, columns=["name", "url", "default"], headers=["NAME", "ID", "DEFAULT"]) + output(args, rows, text=table) + + +def _get_workspace(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli._output import output, output_error + from roboflow.config import APP_URL, load_roboflow_api_key + + workspace_id = args.workspace_id + api_key = getattr(args, "api_key", None) or load_roboflow_api_key(workspace_id) + + if not api_key: + output_error( + args, + "No API key found.", + hint="Run 'roboflow auth login' or pass --api-key.", + exit_code=2, + ) + return # unreachable, but helps mypy + + try: + workspace_json = rfapi.get_workspace(api_key, workspace_id) + except RoboflowError: + output_error( + args, + f"Workspace '{workspace_id}' not found.", + hint=f"Check the workspace ID and try again. Browse workspaces at {APP_URL}.", + exit_code=3, + ) + return # unreachable, but helps mypy + + # Human-readable text for non-JSON mode + ws = workspace_json.get("workspace", workspace_json) + name = ws.get("name", workspace_id) + members = ws.get("members", 0) + projects = ws.get("projects", []) + member_count = members if isinstance(members, int) else len(members) + project_count = len(projects) if isinstance(projects, list) else projects + lines = [ + f"Workspace: {name}", + f" URL: {workspace_id}", + f" Link: {APP_URL}/{workspace_id}", + f" Members: {member_count}", + f" Projects: {project_count}", + ] + output(args, workspace_json, text="\n".join(lines)) + + +def _resolve_ws_and_key(args): # noqa: ANN001 + """Resolve workspace and API key for workspace subcommands.""" + from roboflow.cli._resolver import resolve_ws_and_key + + return resolve_ws_and_key(args) + + +def _workspace_usage(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = rfapi.get_billing_usage(api_key, ws) + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + usage = result.get("usage", result) + lines = ["Billing Usage:"] + if isinstance(usage, dict): + for key, val in usage.items(): + lines.append(f" {key}: {val}") + else: + lines.append(f" {usage}") + output(args, result, text="\n".join(lines)) + + +def _workspace_plan(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + _ws, api_key = resolved + + try: + result = rfapi.get_plan_info(api_key) + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + plan = result.get("plan", result) + lines = ["Plan Info:"] + if isinstance(plan, dict): + for key, val in plan.items(): + lines.append(f" {key}: {val}") + else: + lines.append(f" {plan}") + output(args, result, text="\n".join(lines)) + + +def _workspace_stats(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + + resolved = _resolve_ws_and_key(args) + if not resolved: + return + ws, api_key = resolved + + try: + result = rfapi.get_labeling_stats(api_key, ws, start_date=args.start_date, end_date=args.end_date) + except Exception as exc: + output_error(args, str(exc), exit_code=3) + return + + stats = result.get("stats", result) + lines = ["Labeling Stats:"] + if isinstance(stats, dict): + for key, val in stats.items(): + lines.append(f" {key}: {val}") + else: + lines.append(f" {stats}") + output(args, result, text="\n".join(lines)) diff --git a/roboflow/config.py b/roboflow/config.py index dd4eed0d..800ef6ac 100644 --- a/roboflow/config.py +++ b/roboflow/config.py @@ -44,6 +44,7 @@ def get_conditional_configuration_variable(key, default): CLASSIFICATION_MODEL = os.getenv("CLASSIFICATION_MODEL", "ClassificationModel") INSTANCE_SEGMENTATION_MODEL = "InstanceSegmentationModel" +KEYPOINT_DETECTION_MODEL = "KeypointDetectionModel" OBJECT_DETECTION_MODEL = os.getenv("OBJECT_DETECTION_MODEL", "ObjectDetectionModel") SEMANTIC_SEGMENTATION_MODEL = "SemanticSegmentationModel" PREDICTION_OBJECT = os.getenv("PREDICTION_OBJECT", "Prediction") @@ -53,12 +54,12 @@ def get_conditional_configuration_variable(key, default): UNIVERSE_URL = get_conditional_configuration_variable("UNIVERSE_URL", "https://universe.roboflow.com") INSTANCE_SEGMENTATION_URL = get_conditional_configuration_variable( - "INSTANCE_SEGMENTATION_URL", "https://outline.roboflow.com" + "INSTANCE_SEGMENTATION_URL", "https://serverless.roboflow.com" ) SEMANTIC_SEGMENTATION_URL = get_conditional_configuration_variable( "SEMANTIC_SEGMENTATION_URL", "https://segment.roboflow.com" ) -OBJECT_DETECTION_URL = get_conditional_configuration_variable("OBJECT_DETECTION_URL", "https://detect.roboflow.com") +OBJECT_DETECTION_URL = get_conditional_configuration_variable("OBJECT_DETECTION_URL", "https://serverless.roboflow.com") CLIP_FEATURIZE_URL = get_conditional_configuration_variable("CLIP_FEATURIZE_URL", "CLIP FEATURIZE URL NOT IN ENV") OCR_URL = get_conditional_configuration_variable("OCR_URL", "OCR URL NOT IN ENV") @@ -72,6 +73,14 @@ def get_conditional_configuration_variable(key, default): TYPE_INSTANCE_SEGMENTATION = "instance-segmentation" TYPE_SEMANTIC_SEGMENTATION = "semantic-segmentation" TYPE_KEYPOINT_DETECTION = "keypoint-detection" +TYPE_TEXT_IMAGE_PAIRS = "text-image-pairs" + +TASK_DET = "det" +TASK_SEG = "seg" +TASK_SEM = "sem" +TASK_POSE = "pose" +TASK_CLS = "cls" +TASK_OBB = "obb" DEFAULT_BATCH_NAME = "Pip Package Upload" DEFAULT_JOB_NAME = "Annotated via API" diff --git a/roboflow/core/async_tasks.py b/roboflow/core/async_tasks.py new file mode 100644 index 00000000..fd385a83 --- /dev/null +++ b/roboflow/core/async_tasks.py @@ -0,0 +1,51 @@ +"""Helpers for polling Roboflow async tasks.""" + +from __future__ import annotations + +import time +from typing import Any, Callable, Dict, Optional + +from roboflow.adapters import rfapi + +NON_TERMINAL_STATUSES = frozenset({"created", "pending", "queued", "running", "in_progress"}) + + +def poll_until_terminal( + api_key: str, + workspace_url: str, + task_id: str, + *, + interval: float = 4.0, + timeout: float = 1800.0, + on_update: Optional[Callable[[Dict[str, Any]], None]] = None, + polling_url: Optional[str] = None, +) -> Dict[str, Any]: + """Poll an async task until status is terminal or timeout elapses. + + If ``polling_url`` is provided, hit it verbatim (the server returns one + alongside ``taskId`` from enqueue endpoints; it may point at a different + host than ``API_URL``). Otherwise build the URL from ``API_URL`` / + ``workspace_url`` / ``task_id`` via :func:`rfapi.get_async_task`. + + A non-positive ``timeout`` disables the timeout. Returns the final + status dict on terminal status. ``RoboflowError`` from the underlying + API call is propagated; ``TimeoutError`` is raised if the deadline + passes before a terminal status is observed. + """ + deadline = None if timeout <= 0 else time.monotonic() + timeout + while True: + if polling_url: + status = rfapi.get_async_task_at(api_key, polling_url) + else: + status = rfapi.get_async_task(api_key, workspace_url, task_id) + # Invoke the callback before the terminal check so the final tick + # (typically `current == total`) is delivered to the caller. + if on_update: + on_update(status) + if status.get("status") not in NON_TERMINAL_STATUSES: + return status + if deadline is not None and time.monotonic() >= deadline: + raise TimeoutError( + f"Timed out after {timeout:.0f}s waiting for task {task_id} (last status: {status.get('status')})." + ) + time.sleep(interval) diff --git a/roboflow/core/device.py b/roboflow/core/device.py new file mode 100644 index 00000000..b79675bc --- /dev/null +++ b/roboflow/core/device.py @@ -0,0 +1,158 @@ +"""Workspace-scoped device handle. + +Wraps the read endpoints of the external Deployments API +(``/:workspace/devices/v2/*``) added in roboflow/roboflow PR #11350. A +``Device`` is constructed by ``Workspace.device(id)`` or implicitly when +listing via ``Workspace.devices()``; it caches the device summary returned +by the API and exposes lazy methods for the per-device sub-resources. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from roboflow.adapters import devicesapi + + +class Device: + """A v2 Roboflow device (RFDM, AI1, edge, …). + + Instances are created by :class:`roboflow.core.workspace.Workspace`. + The ``info`` dict mirrors the entity documented in + ``docs/api/deployments/overview.md`` of the platform repo (fields + ``id``, ``name``, ``status``, ``last_heartbeat``, ``platform``, + ``hardware``, ``tags``, …). + + Note: + :meth:`config` returns the raw Firestore config doc, which can + contain ``environment_variables`` and integration credentials. + """ + + def __init__(self, api_key: str, workspace_url: str, info: Dict[str, Any]) -> None: + self.__api_key = api_key + self.__workspace = workspace_url + self.info: Dict[str, Any] = info + self.id: str = info.get("id", "") + self.name: Optional[str] = info.get("name") + self.status: Optional[str] = info.get("status") + self.type: Optional[str] = info.get("type") + self.tags: List[str] = list(info.get("tags") or []) + + def __repr__(self) -> str: # pragma: no cover - trivial + return f"Device(id={self.id!r}, name={self.name!r}, status={self.status!r})" + + def refresh(self) -> "Device": + """Re-fetch the device summary from the API.""" + self.info = devicesapi.get_device(self.__api_key, self.__workspace, self.id) + self.name = self.info.get("name") + self.status = self.info.get("status") + self.type = self.info.get("type") + self.tags = list(self.info.get("tags") or []) + return self + + def config(self) -> Dict[str, Any]: + """Fetch the device's full runtime config (sensitive β€” see class docstring).""" + return devicesapi.get_device_config(self.__api_key, self.__workspace, self.id) + + def config_history( + self, + *, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Dict[str, Any]: + """List prior config revisions, newest first. + + Args: + limit: 1-500, default 10. + cursor: ISO timestamp from a previous page's ``next_cursor``. + """ + return devicesapi.get_device_config_history( + self.__api_key, self.__workspace, self.id, limit=limit, cursor=cursor + ) + + def streams(self) -> List[Dict[str, Any]]: + """List streams currently configured on this device.""" + return devicesapi.list_device_streams(self.__api_key, self.__workspace, self.id).get("data", []) + + def stream(self, stream_id: str) -> Dict[str, Any]: + """Get a single stream by id.""" + return devicesapi.get_device_stream(self.__api_key, self.__workspace, self.id, stream_id) + + def logs( + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + service: Optional[List[str]] = None, + severity: Optional[List[str]] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + ) -> Dict[str, Any]: + """Fetch device logs from Elasticsearch (5/min/IP rate limit). + + Args: + start_time: ISO timestamp. + end_time: ISO timestamp. + service: List of service names; serialized as comma-separated string. + severity: List of severity levels (``INFO``, ``WARN``, ``ERROR``, …). + limit: 1-1000, default 100. + cursor: ISO timestamp from a previous page's ``next_cursor``. + """ + return devicesapi.get_device_logs( + self.__api_key, + self.__workspace, + self.id, + start_time=start_time, + end_time=end_time, + service=service, + severity=severity, + limit=limit, + cursor=cursor, + ) + + def telemetry(self, time_period: Optional[str] = None) -> Dict[str, Any]: + """Fetch aggregated hardware telemetry (60/min rate limit). + + Args: + time_period: One of ``"1h"``, ``"24h"`` (default), ``"7d"``, ``"14d"``. + """ + return devicesapi.get_device_telemetry(self.__api_key, self.__workspace, self.id, time_period=time_period) + + def events( + self, + *, + entity_type: Optional[str] = None, + entity_id: Optional[str] = None, + event: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + direction: Optional[str] = None, + ) -> Dict[str, Any]: + """Query device/stream lifecycle events. + + Args: + entity_type: Filter to a single entity type (``stream``, ``device``, …). + entity_id: Filter to a single entity id. + event: Filter by event name. + start_time: ISO timestamp. + end_time: ISO timestamp. + limit: 1-1000, default 100. + cursor: Opaque base64url cursor from a previous page (round-trip only; + do not parse). + direction: ``"forward"`` or ``"backward"`` (default ``"backward"``). + """ + return devicesapi.get_device_events( + self.__api_key, + self.__workspace, + self.id, + entity_type=entity_type, + entity_id=entity_id, + event=event, + start_time=start_time, + end_time=end_time, + limit=limit, + cursor=cursor, + direction=direction, + ) diff --git a/roboflow/core/model_eval.py b/roboflow/core/model_eval.py new file mode 100644 index 00000000..49479c72 --- /dev/null +++ b/roboflow/core/model_eval.py @@ -0,0 +1,169 @@ +"""Model evaluation results β€” wraps the public ``/model-evals`` REST surface. + +A :class:`ModelEval` is a thin lazy wrapper around a single evaluation run. +The constructor accepts the eval id (and optional cached metadata from a list +response); each panel (``map_results``, ``confusion_matrix``, etc.) is fetched +on demand and returned as the raw JSON dict the server emits. + +The shape mirrors the REST endpoints documented at +``docs.roboflow.com/api-reference/model-evaluations``. Errors surface as +typed :mod:`roboflow.adapters.rfapi` subclasses so callers can distinguish +"eval doesn't exist" from "eval still running" without parsing strings. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from roboflow.adapters import rfapi + + +class ModelEval: + """A single model-evaluation run. + + Construct via :meth:`roboflow.core.workspace.Workspace.eval` or list via + :meth:`roboflow.core.workspace.Workspace.evals`. Direct construction is + supported when you already hold an eval id:: + + from roboflow.core.model_eval import ModelEval + ev = ModelEval(api_key, "lee-sandbox", "huUF720inUcymARwqAGK") + ev.refresh() # populates .status, .summary, etc. + """ + + def __init__( + self, + api_key: str, + workspace_url: str, + eval_id: str, + info: Optional[Dict[str, Any]] = None, + ) -> None: + self._api_key = api_key + self._workspace_url = workspace_url + self.id = eval_id + # Populate metadata from a cached list/get response when available; the + # caller can still refresh() to re-fetch from the server. + self._apply(info or {}) + + # -- internal ----------------------------------------------------------- + + def _apply(self, info: Dict[str, Any]) -> None: + # Server returns `evalId` (per DNA's identifier-embedding convention, + # consistent with every panel response). Accept legacy `id` for + # forward-compat with cached responses from older server versions. + if info.get("evalId"): + self.id = info["evalId"] + self.status: Optional[str] = info.get("status") + # `project` is the project URL slug β€” the same identifier the REST API + # uses in URL paths. The internal Firestore doc id is intentionally + # never exposed in the public API. Accept legacy `projectId` for + # forward-compat with older server versions. + self.project: Optional[str] = info.get("project") or info.get("projectId") + self.version_id: Optional[str] = info.get("versionId") + self.model_id: Optional[str] = info.get("modelId") + self.created_at: Optional[str] = info.get("createdAt") + self.summary: Optional[Dict[str, Any]] = info.get("summary") + self._raw: Dict[str, Any] = info + + # -- core --------------------------------------------------------------- + + def refresh(self) -> "ModelEval": + """Re-fetch the eval header (status, summary, …) from the server.""" + info = rfapi.get_model_eval(self._api_key, self._workspace_url, self.id) + self._apply(info) + return self + + # -- panel accessors ---------------------------------------------------- + + def map_results(self) -> Dict[str, Any]: + """Per-split mAP results (mAP50, mAP50-95, mAP75, by object size, per class).""" + return rfapi.get_model_eval_map_results(self._api_key, self._workspace_url, self.id) + + def confidence_sweep(self) -> Dict[str, Any]: + """Confidence-threshold sweep (precision/recall/F1) for the test split.""" + return rfapi.get_model_eval_confidence_sweep(self._api_key, self._workspace_url, self.id) + + def performance_by_class(self, split: Optional[str] = None) -> Dict[str, Any]: + """Per-class precision / recall / F1 / mAP for the chosen split. + + ``split`` defaults to ``"test"`` server-side. Passing ``"all"`` raises + :class:`rfapi.InvalidSplitError` β€” this panel does not support an + aggregate view. + """ + return rfapi.get_model_eval_performance_by_class(self._api_key, self._workspace_url, self.id, split=split) + + def confusion_matrix( + self, + split: Optional[str] = None, + confidence: Optional[int] = None, + ) -> Dict[str, Any]: + """Confusion matrix (classes + matrix) for *split* at integer *confidence* (0-100).""" + return rfapi.get_model_eval_confusion_matrix( + self._api_key, self._workspace_url, self.id, split=split, confidence=confidence + ) + + def vector_analysis(self, confidence: Optional[int] = None) -> Dict[str, Any]: + """Embedding-cluster diagnostics (per-cluster sample images + metrics).""" + return rfapi.get_model_eval_vector_analysis(self._api_key, self._workspace_url, self.id, confidence=confidence) + + def image_predictions( + self, + split: Optional[str] = None, + confidence: Optional[int] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> Dict[str, Any]: + """Paginated per-image stats (TP/FP/FN counts, augmentations, cluster id).""" + return rfapi.get_model_eval_image_predictions( + self._api_key, + self._workspace_url, + self.id, + split=split, + confidence=confidence, + limit=limit, + offset=offset, + ) + + def recommendations(self) -> Dict[str, Any]: + """Server-generated suggestions for improving the model.""" + return rfapi.get_model_eval_recommendations(self._api_key, self._workspace_url, self.id) + + # -- helpers ------------------------------------------------------------ + + # Mapping (json_key, attr_name) used by `to_dict()` to round-trip a + # constructor-only ModelEval (one with no `info=` payload) back into the + # public JSON shape. Same fields the server returns at the top level of + # `modelEvals.get`, in the same order. + _PUBLIC_FIELDS = ( + ("status", "status"), + ("project", "project"), + ("versionId", "version_id"), + ("modelId", "model_id"), + ("createdAt", "created_at"), + ("summary", "summary"), + ) + + def to_dict(self) -> Dict[str, Any]: + """Return the cached eval metadata as a plain dict (evalId + last header fetch). + + When the instance was created from a server payload (the usual path β€” + via ``Workspace.eval`` or ``Workspace.evals``) the raw payload is + round-tripped, with ``evalId`` overlaid so legacy ``id``-keyed + responses still emit the DNA-aligned field. When the instance was + created without a payload (constructor only β€” ``ModelEval(key, ws, + eval_id)`` with no ``refresh()``) only the attributes the caller has + set get serialised, omitting any ``None`` fields. + """ + if self._raw: + return {**self._raw, "evalId": self.id} + data: Dict[str, Any] = {"evalId": self.id} + for json_key, attr_name in self._PUBLIC_FIELDS: + value = getattr(self, attr_name, None) + if value is not None: + data[json_key] = value + return data + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f"ModelEval(id={self.id!r}, status={self.status!r}, project={self.project!r})" + + +__all__: List[str] = ["ModelEval"] diff --git a/roboflow/core/project.py b/roboflow/core/project.py index b7e66c0f..8d4aad75 100644 --- a/roboflow/core/project.py +++ b/roboflow/core/project.py @@ -11,7 +11,7 @@ import requests from roboflow.adapters import rfapi -from roboflow.adapters.rfapi import ImageUploadError +from roboflow.adapters.rfapi import AnnotationSaveError, ImageUploadError from roboflow.config import API_URL, DEMO_KEYS from roboflow.core.version import Version from roboflow.util.general import Retry @@ -22,6 +22,9 @@ "image/jpeg", "image/png", "image/webp", + "image/tiff", + "image/avif", + "image/heic", } @@ -230,7 +233,7 @@ def generate_version(self, settings): ) r = requests.post( - f"{API_URL}/{self.__workspace}/{self.__project_name}/" f"generate?api_key={self.__api_key}", + f"{API_URL}/{self.__workspace}/{self.__project_name}/generate?api_key={self.__api_key}", json=settings, ) @@ -253,13 +256,7 @@ def generate_version(self, settings): def train( self, - new_version_settings={ - "preprocessing": { - "auto-orient": True, - "resize": {"width": 640, "height": 640, "format": "Stretch to"}, - }, - "augmentation": {}, - }, + new_version_settings: Optional[Dict] = None, speed=None, checkpoint=None, plot_in_notebook=False, @@ -291,6 +288,15 @@ def train( >>> version.train() """ # noqa: E501 // docs + if new_version_settings is None: + new_version_settings = { + "preprocessing": { + "auto-orient": True, + "resize": {"width": 640, "height": 640, "format": "Stretch to"}, + }, + "augmentation": {}, + } + new_version = self.generate_version(settings=new_version_settings) new_version = self.version(new_version) new_model = new_version.train(speed=speed, checkpoint=checkpoint, plot_in_notebook=plot_in_notebook) @@ -381,8 +387,9 @@ def upload( split: str = "train", num_retry_uploads: int = 0, batch_name: Optional[str] = None, - tag_names: list = [], + tag_names: Optional[List[str]] = None, is_prediction: bool = False, + metadata: Optional[Dict] = None, **kwargs, ): """ @@ -399,6 +406,15 @@ def upload( batch_name (str): name of batch to upload to within project tag_names (list[str]): tags to be applied to an image is_prediction (bool): whether the annotation data is a prediction rather than ground truth + metadata (dict, optional): custom key-value metadata to attach to the image. + Example: {"camera_id": "cam001", "location": "warehouse"} + + Returns: + A list of result dicts (one per successfully uploaded image), regardless of + whether a single file or a directory was provided. Each dict is the return + value of ``single_upload`` (keys: ``image``, ``annotation``, ``upload_time``, + ``annotation_time``, ``upload_retry_attempts``, ``annotation_upload_retry_attempts``). + Skipped (non-image) files in directory mode are excluded from the list. Example: >>> import roboflow @@ -410,7 +426,12 @@ def upload( >>> project.upload(image_path="YOUR_IMAGE.jpg") """ # noqa: E501 // docs + if tag_names is None: + tag_names = [] + is_hosted = image_path.startswith("http://") or image_path.startswith("https://") + if is_hosted: + hosted_image = True is_file = os.path.isfile(image_path) or is_hosted is_dir = os.path.isdir(image_path) @@ -426,30 +447,34 @@ def upload( if not is_image: raise RuntimeError( - "The image you provided {} is not a supported file format. We" " currently support: {}.".format( + "The image you provided {} is not a supported file format. We currently support: {}.".format( image_path, ", ".join(ACCEPTED_IMAGE_FORMATS) ) ) - self.single_upload( - image_path=image_path, - annotation_path=annotation_path, - hosted_image=hosted_image, - image_id=image_id, - split=split, - num_retry_uploads=num_retry_uploads, - batch_name=batch_name, - tag_names=tag_names, - is_prediction=is_prediction, - **kwargs, - ) + return [ + self.single_upload( + image_path=image_path, + annotation_path=annotation_path, + hosted_image=hosted_image, + image_id=image_id, + split=split, + num_retry_uploads=num_retry_uploads, + batch_name=batch_name, + tag_names=tag_names, + is_prediction=is_prediction, + metadata=metadata, + **kwargs, + ) + ] else: + results = [] images = os.listdir(image_path) for image in images: path = image_path + "/" + image if self.check_valid_image(path): - self.single_upload( + result = self.single_upload( image_path=path, annotation_path=annotation_path, hosted_image=hosted_image, @@ -459,12 +484,15 @@ def upload( batch_name=batch_name, tag_names=tag_names, is_prediction=is_prediction, + metadata=metadata, **kwargs, ) + results.append(result) print("[ " + path + " ] was uploaded succesfully.") else: print("[ " + path + " ] was skipped.") continue + return results def upload_image( self, @@ -473,13 +501,17 @@ def upload_image( split="train", num_retry_uploads=0, batch_name=None, - tag_names=[], + tag_names: Optional[List[str]] = None, sequence_number=None, sequence_size=None, + metadata: Optional[Dict] = None, **kwargs, ): project_url = self.id.rsplit("/")[1] + if tag_names is None: + tag_names = [] + t0 = time.time() upload_retry_attempts = 0 retry = Retry(num_retry_uploads, ImageUploadError) @@ -496,6 +528,7 @@ def upload_image( tag_names=tag_names, sequence_number=sequence_number, sequence_size=sequence_size, + metadata=metadata, **kwargs, ) upload_retry_attempts = retry.retries @@ -515,26 +548,34 @@ def save_annotation( job_name=None, is_prediction: bool = False, annotation_overwrite=False, + num_retry_uploads=0, ): project_url = self.id.rsplit("/")[1] annotation_name, annotation_str = self._annotation_params(annotation_path) t0 = time.time() + upload_retry_attempts = 0 + retry = Retry(num_retry_uploads, AnnotationSaveError) - annotation = rfapi.save_annotation( - self.__api_key, - project_url, - annotation_name, # type: ignore[type-var] - annotation_str, # type: ignore[type-var] - image_id, - job_name=job_name, # type: ignore[type-var] - is_prediction=is_prediction, - annotation_labelmap=annotation_labelmap, - overwrite=annotation_overwrite, - ) + try: + annotation = rfapi.save_annotation( + self.__api_key, + project_url, + annotation_name, # type: ignore[type-var] + annotation_str, # type: ignore[type-var] + image_id, + job_name=job_name, # type: ignore[type-var] + is_prediction=is_prediction, + annotation_labelmap=annotation_labelmap, + overwrite=annotation_overwrite, + ) + upload_retry_attempts = retry.retries + except AnnotationSaveError as e: + e.retries = upload_retry_attempts + raise upload_time = time.time() - t0 - return annotation, upload_time + return annotation, upload_time, upload_retry_attempts def single_upload( self, @@ -546,13 +587,16 @@ def single_upload( split="train", num_retry_uploads=0, batch_name=None, - tag_names=[], + tag_names: Optional[List[str]] = None, is_prediction: bool = False, annotation_overwrite=False, sequence_number=None, sequence_size=None, + metadata: Optional[Dict] = None, **kwargs, ): + if tag_names is None: + tag_names = [] if image_path and image_id: raise Exception("You can't pass both image_id and image_path") if not (image_path or image_id): @@ -563,6 +607,7 @@ def single_upload( uploaded_image, uploaded_annotation = None, None upload_time, annotation_time = None, None upload_retry_attempts = 0 + annotation_upload_retry_attempts = 0 if image_path: uploaded_image, upload_time, upload_retry_attempts = self.upload_image( @@ -574,18 +619,20 @@ def single_upload( tag_names, sequence_number, sequence_size, + metadata=metadata, **kwargs, ) image_id = uploaded_image["id"] # type: ignore[index] if annotation_path and image_id: - uploaded_annotation, annotation_time = self.save_annotation( + uploaded_annotation, annotation_time, annotation_upload_retry_attempts = self.save_annotation( annotation_path, annotation_labelmap, image_id, batch_name, is_prediction, annotation_overwrite, + num_retry_uploads=num_retry_uploads, ) return { @@ -594,6 +641,7 @@ def single_upload( "upload_time": upload_time, "annotation_time": annotation_time, "upload_retry_attempts": upload_retry_attempts, + "annotation_upload_retry_attempts": annotation_upload_retry_attempts, } def _annotation_params(self, annotation_path): @@ -627,7 +675,10 @@ def search( in_dataset: Optional[str] = None, batch: bool = False, batch_id: Optional[str] = None, - fields: list = ["id", "created", "name", "labels"], + fields: Optional[List[str]] = None, + *, + annotation_job: Optional[bool] = None, + annotation_job_id: Optional[str] = None, ): """ Search for images in a project. @@ -642,7 +693,11 @@ def search( in_dataset (str): dataset that an image must be in batch (bool): whether the image must be in a batch batch_id (str): batch id that an image must be in - fields (list): fields to return in results (default: ["id", "created", "name", "labels"]) + annotation_job (bool): whether the image must be in an annotation job + annotation_job_id (str): annotation job id that an image must be in + fields (list): fields to return in results (default: ["id", "created", "name", "labels"]). + Available fields: id, name, created, annotations, labels, split, tags, owner, + embedding, user_metadata. Returns: A list of images that match the search criteria. @@ -654,9 +709,19 @@ def search( >>> project = rf.workspace().project("PROJECT_ID") - >>> results = project.search(query="cat", limit=10) + >>> # Basic search + >>> results = project.search(prompt="cat", limit=10) + + >>> # Search with tags and user_metadata + >>> results = project.search( + ... limit=10, + ... fields=["id", "name", "tags", "user_metadata"] + ... ) """ # noqa: E501 // docs - payload: Dict[str, Union[str, int, List[str]]] = {} + if fields is None: + fields = ["id", "created", "name", "labels"] + + payload: Dict[str, Union[str, int, bool, List[str]]] = {} if like_image is not None: payload["like_image"] = like_image @@ -685,6 +750,12 @@ def search( if batch_id is not None: payload["batch_id"] = batch_id + if annotation_job is not None: + payload["annotation_job"] = annotation_job + + if annotation_job_id is not None: + payload["annotation_job_id"] = annotation_job_id + payload["fields"] = fields data = requests.post( @@ -705,7 +776,10 @@ def search_all( in_dataset: Optional[str] = None, batch: bool = False, batch_id: Optional[str] = None, - fields: list = ["id", "created"], + fields: Optional[List[str]] = None, + *, + annotation_job: Optional[bool] = None, + annotation_job_id: Optional[str] = None, ): """ Create a paginated list of search results for use in searching the images in a project. @@ -720,10 +794,14 @@ def search_all( in_dataset (str): dataset that an image must be in batch (bool): whether the image must be in a batch batch_id (str): batch id that an image must be in - fields (list): fields to return in results (default: ["id", "created", "name", "labels"]) + annotation_job (bool): whether the image must be in an annotation job + annotation_job_id (str): annotation job id that an image must be in + fields (list): fields to return in results (default: ["id", "created"]). + Available fields: id, name, created, annotations, labels, split, tags, owner, + embedding, user_metadata. Returns: - A list of images that match the search criteria. + A generator yielding images that match the search criteria. Example: >>> import roboflow @@ -732,12 +810,14 @@ def search_all( >>> project = rf.workspace().project("PROJECT_ID") - >>> results = project.search_all(query="cat", limit=10) + >>> results = project.search_all(prompt="cat", limit=10) >>> for result in results: - >>> print(result) """ # noqa: E501 // docs + if fields is None: + fields = ["id", "created"] + while True: data = self.search( like_image=like_image, @@ -750,6 +830,8 @@ def search_all( batch=batch, batch_id=batch_id, fields=fields, + annotation_job=annotation_job, + annotation_job_id=annotation_job_id, ) yield data @@ -767,3 +849,313 @@ def __str__(self): json_str = {"name": self.name, "type": self.type, "workspace": self.__workspace} return json.dumps(json_str, indent=2) + + def image(self, image_id: str) -> Dict: + """ + Fetch the details of a specific image from the Roboflow API. + + Args: + image_id (str): The ID of the image to fetch. + + Returns: + Dict: A dictionary containing the image details. + + Example: + >>> import roboflow + + >>> rf = roboflow.Roboflow(api_key="YOUR_API_KEY") + + >>> project = rf.workspace().project("PROJECT_ID") + + >>> image_details = project.image("image-id") + """ + url = f"{API_URL}/{self.__workspace}/{self.__project_name}/images/{image_id}?api_key={self.__api_key}" + + data = requests.get(url).json() + + if "error" in data: + raise RuntimeError(data["error"]) + + if "image" not in data: + print(data, image_id) + raise RuntimeError("Image not found") + + image_details = data["image"] + + return image_details + + def get_annotation_jobs(self) -> Dict: + """Get a list of all annotation jobs in the project. + + Returns: + Dict: A dictionary containing the list of annotation jobs. + """ + from roboflow.adapters import rfapi + + return rfapi.list_annotation_jobs(self.__api_key, self.__workspace, self.__project_name) + + def get_annotation_job(self, job_id: str) -> Dict: + """Get information for a specific annotation job. + + Args: + job_id: The ID of the annotation job to retrieve. + + Returns: + Dict: A dictionary containing the job details. + """ + from roboflow.adapters import rfapi + + return rfapi.get_annotation_job(self.__api_key, self.__workspace, self.__project_name, job_id) + + def create_annotation_job( + self, name: str, batch_id: str, num_images: int, labeler_email: str, reviewer_email: str + ) -> Dict: + """ + Create a new annotation job in the project. + + Args: + name (str): The name of the annotation job + batch_id (str): The ID of the batch that contains the images to annotate + num_images (int): The number of images to include in the job + labeler_email (str): The email of the user who will label the images + reviewer_email (str): The email of the user who will review the annotations + + Returns: + Dict: A dictionary containing the created job details + + Example: + >>> import roboflow + + >>> rf = roboflow.Roboflow(api_key="YOUR_API_KEY") + + >>> project = rf.workspace().project("PROJECT_ID") + + >>> job = project.create_annotation_job( + ... name="Job created by API", + ... batch_id="batch123", + ... num_images=10, + ... labeler_email="user@example.com", + ... reviewer_email="reviewer@example.com" + ... ) + """ + url = f"{API_URL}/{self.__workspace}/{self.__project_name}/jobs?api_key={self.__api_key}" + + payload = { + "name": name, + "batch": batch_id, + "num_images": num_images, + "labelerEmail": labeler_email, + "reviewerEmail": reviewer_email, + } + + response = requests.post(url, headers={"Content-Type": "application/json"}, json=payload) + + if response.status_code != 200: + try: + error_data = response.json() + if "error" in error_data: + raise RuntimeError(error_data["error"]) + raise RuntimeError(response.text) + except ValueError: + raise RuntimeError(f"Failed to create annotation job: {response.text}") + + return response.json() + + def get_batches(self) -> Dict: + """ + Get a list of all batches in the project. + + Returns: + Dict: A dictionary containing the list of batches + + Example: + >>> import roboflow + + >>> rf = roboflow.Roboflow(api_key="YOUR_API_KEY") + + >>> project = rf.workspace().project("PROJECT_ID") + + >>> batches = project.get_batches() + """ + url = f"{API_URL}/{self.__workspace}/{self.__project_name}/batches?api_key={self.__api_key}" + + response = requests.get(url) + + if response.status_code != 200: + try: + error_data = response.json() + if "error" in error_data: + raise RuntimeError(error_data["error"]) + raise RuntimeError(response.text) + except ValueError: + raise RuntimeError(f"Failed to get batches: {response.text}") + + return response.json() + + def get_batch(self, batch_id: str) -> Dict: + """ + Get information for a specific batch in the project. + + Args: + batch_id (str): The ID of the batch to retrieve + + Returns: + Dict: A dictionary containing the batch details + + Example: + >>> import roboflow + + >>> rf = roboflow.Roboflow(api_key="YOUR_API_KEY") + + >>> project = rf.workspace().project("PROJECT_ID") + + >>> batch = project.get_batch("batch123") + """ + url = f"{API_URL}/{self.__workspace}/{self.__project_name}/batches/{batch_id}?api_key={self.__api_key}" + + response = requests.get(url) + + if response.status_code != 200: + try: + error_data = response.json() + if "error" in error_data: + raise RuntimeError(error_data["error"]) + raise RuntimeError(response.text) + except ValueError: + raise RuntimeError(f"Failed to get batch {batch_id}: {response.text}") + + return response.json() + + def update_image_metadata( + self, + image_id: str, + *, + metadata: Optional[Dict] = None, + remove_metadata: Optional[List[str]] = None, + add_tags: Optional[List[str]] = None, + remove_tags: Optional[List[str]] = None, + ) -> Dict: + """Update metadata and tags on a single image (synchronous). + + Values in ``metadata`` are upserted: new keys are added, existing keys + are overwritten. The underlying endpoint is workspace-scoped, so the + image only needs to belong to this project's workspace. + + Args: + image_id: ID of an image in this workspace. + metadata: Key-value pairs to set (string, number, or boolean values). + remove_metadata: Metadata keys to delete. + add_tags: Tags to add. + remove_tags: Tags to remove. + + Returns: + ``{"success": True}`` on success. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="YOUR_API_KEY") + >>> project = rf.workspace().project("PROJECT_ID") + >>> project.update_image_metadata( + ... "IMAGE_ID", + ... metadata={"camera_id": "cam001"}, + ... add_tags=["reviewed"], + ... ) + """ + return rfapi.update_image_metadata( + api_key=self.__api_key, + workspace_url=self.__workspace, + image_id=image_id, + metadata=metadata, + remove_metadata=remove_metadata, + add_tags=add_tags, + remove_tags=remove_tags, + ) + + def delete_images(self, image_ids: List[str]): + """ + Delete images from a project. + + Args: + image_ids (List[str]): A list of image IDs to delete. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="") + >>> project = rf.workspace().project("PROJECT_ID") + >>> project.delete_images(image_ids=["image_id_1", "image_id_2"]) + """ + url = f"{API_URL}/{self.__workspace}/{self.__project_name}/images?api_key={self.__api_key}" + + payload = {"images": image_ids} + + response = requests.delete(url, headers={"Content-Type": "application/json"}, json=payload) + + if response.status_code != 204: + try: + error_data = response.json() + if "error" in error_data: + raise RuntimeError(error_data["error"]) + raise RuntimeError(response.text) + except ValueError: + raise RuntimeError(f"Failed to delete images: {response.text}") + + def delete(self): + """ + Move this project to Trash (soft delete). + + The project is hidden from the workspace but retained for 30 days. Any + in-flight training jobs for the project are cancelled. Within the 30-day + window you can restore it via `Project.restore()` or from the Trash UI. + + Returns: + dict: Server response with `{deleted: True, type: "project", ...}`. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="") + >>> project = rf.workspace().project("PROJECT_ID") + >>> project.delete() + """ + return rfapi.delete_project(self.__api_key, self.__workspace, self.__project_name) + + def restore(self): + """ + Restore this project from Trash. + + Looks up the project in the workspace Trash by its slug. Raises + RuntimeError if the project isn't currently in Trash. + + Returns: + dict: Server response with `{restored: True, type: "project", ...}`. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="") + >>> project = rf.workspace().project("PROJECT_ID") + >>> project.delete() + >>> project.restore() + """ + trash = rfapi.list_trash(self.__api_key, self.__workspace) + projects = trash.get("sections", {}).get("projects", []) + match = next((p for p in projects if p.get("url") == self.__project_name), None) + if not match: + raise RuntimeError(f"Project '{self.__project_name}' is not in Trash β€” nothing to restore.") + return rfapi.restore_trash_item(self.__api_key, self.__workspace, "project", match["id"]) + + def health(self, regenerate: bool = False) -> Dict: + """Get health check statistics for this project. + + Args: + regenerate: If True, force regeneration of health check data. + + Returns: + Dict: Health check statistics including class balance, + image dimensions, annotation counts, and split distribution. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="YOUR_API_KEY") + >>> project = rf.workspace().project("PROJECT_ID") + >>> health = project.health() + """ + return rfapi.get_project_health(self.__api_key, self.__workspace, self.__project_name, regenerate=regenerate) diff --git a/roboflow/core/training.py b/roboflow/core/training.py new file mode 100644 index 00000000..96e02f7f --- /dev/null +++ b/roboflow/core/training.py @@ -0,0 +1,293 @@ +"""DNA-style Training / TrainedModel objects for MMPV (multiple-models-per-version). + +A Version owns many Trainings; each Training owns one or more Models (a NAS run +owns many). These objects couple to the v2 trainings adapter (``rfapi``), which +mirrors the platform's DNA operations 1:1 β€” the legacy-vs-MMPV branch lives on +the backend, never here. +""" + +from __future__ import annotations + +import json +import os +from typing import List + +import requests + +from roboflow.adapters import rfapi +from roboflow.config import ( + CLASSIFICATION_MODEL, + INSTANCE_SEGMENTATION_MODEL, + KEYPOINT_DETECTION_MODEL, + OBJECT_DETECTION_MODEL, + OBJECT_DETECTION_URL, + SEMANTIC_SEGMENTATION_MODEL, + SEMANTIC_SEGMENTATION_URL, + TASK_CLS, + TASK_OBB, + TASK_POSE, + TASK_SEG, + TASK_SEM, +) +from roboflow.models.inference import InferenceModel +from roboflow.util.model_processor import task_of_model_type + + +def _serverless_base_url_for_task(task: str) -> str: + if task == TASK_SEM: + return SEMANTIC_SEGMENTATION_URL + return OBJECT_DETECTION_URL + + +def _prediction_type_for_task(task: str) -> str: + if task == TASK_CLS: + return CLASSIFICATION_MODEL + elif task == TASK_SEG: + return INSTANCE_SEGMENTATION_MODEL + elif task == TASK_SEM: + return SEMANTIC_SEGMENTATION_MODEL + elif task == TASK_POSE: + return KEYPOINT_DETECTION_MODEL + elif task == TASK_OBB: + return OBJECT_DETECTION_MODEL + else: + return OBJECT_DETECTION_MODEL + + +class TrainedModel: + """A single trained model produced by a Training. + + Wraps an inference-style model id of either form β€” ``/`` + (SMPV) or ``/`` (MMPV). Inference goes to the + serverless host by that id (which the server resolves to the model and its + task); weights download keys off the id's addressable segment. + """ + + def __init__(self, api_key, workspace, project, model_id, model_type=None, metrics=None): + self.__api_key = api_key + self.workspace = workspace + self.project = project + self.model_id = model_id + self.model_type = model_type + self.metrics = metrics + # The second segment addresses the model on /ptFile: a model slug for + # MMPV, a version number for SMPV. + self._weights_id = model_id.split("/", 1)[1] if "/" in str(model_id) else model_id + self._video_model_cache = None + + def predict(self, image_path, hosted=False, confidence=40, overlap=30, format="json", **kwargs): + """Run hosted inference on an image by this model's id. + + The id is passed straight to serverless, which resolves the model and + its task. Returns a ``PredictionGroup``. Set ``hosted=True`` when + ``image_path`` is a public URL. + """ + task = task_of_model_type(self.model_type or "") + prediction_type = _prediction_type_for_task(task) + base_url = _serverless_base_url_for_task(task).rstrip("/") + model = InferenceModel(self.__api_key, "BASE_MODEL") + model.api_url = f"{base_url}/{str(self.model_id).strip('/')}" + model.colors = {} + + params = {"confidence": confidence, "overlap": overlap, "format": format} + params.update(kwargs) + return model.predict(image_path, prediction_type=prediction_type, **params) + + def _video_model(self): + """Build (and cache) the legacy inference model used for video inference. + + Video upload and result polling still flow through the legacy + ``/videoinfer`` endpoints, which the task-specific models implement. + Caching keeps ``predict_video`` and the poll methods on one underlying + object, so a job started here can be polled without re-passing its id. + """ + if self._video_model_cache is not None: + return self._video_model_cache + + from roboflow.models.classification import ClassificationModel + from roboflow.models.instance_segmentation import InstanceSegmentationModel + from roboflow.models.keypoint_detection import KeypointDetectionModel + from roboflow.models.object_detection import ObjectDetectionModel + from roboflow.models.semantic_segmentation import SemanticSegmentationModel + + task = task_of_model_type(self.model_type or "") + legacy_class = { + TASK_CLS: ClassificationModel, + TASK_SEG: InstanceSegmentationModel, + TASK_SEM: SemanticSegmentationModel, + TASK_POSE: KeypointDetectionModel, + }.get(task, ObjectDetectionModel) + + legacy_id = f"{self.workspace}/{self.project}/{self._weights_id}" + self._video_model_cache = legacy_class(self.__api_key, legacy_id) + return self._video_model_cache + + def predict_video(self, video_path, fps=5, additional_models=None, prediction_type="batch-video"): + """Run hosted video inference for this model (DNA-era equivalent of the + legacy ``version.model.predict_video``). + + Delegates to the task-appropriate legacy inference model built from this + model's id, so a ``TrainedModel`` can do everything the old + ``version.model`` could. Returns ``(job_id, signed_url, expires)``; poll + with :meth:`poll_until_video_results` on the same object. + + NOTE: the legacy ``/videoinfer`` payload is keyed by ``/``. + For MMPV models addressed by ``/`` this routes the + slug through as the version segment; verify against staging before relying + on it for slug-addressed models. + """ + return self._video_model().predict_video( + video_path, fps=fps, additional_models=additional_models, prediction_type=prediction_type + ) + + def poll_for_video_results(self, job_id=None) -> dict: + """Check once for this model's video inference results (DNA-era equivalent + of the legacy ``version.model.poll_for_video_results``). + + Returns ``{}`` while the job is still running. Defaults to the job started + by the most recent :meth:`predict_video` call on this object. + """ + return self._video_model().poll_for_video_results(job_id) + + def poll_until_video_results(self, job_id=None) -> dict: + """Block until this model's video inference job completes, returning the + results (DNA-era equivalent of the legacy + ``version.model.poll_until_video_results``). + + Defaults to the job started by the most recent :meth:`predict_video` call + on this object. + """ + return self._video_model().poll_until_video_results(job_id) + + def download(self, format="pt", location="."): + """Download this model's PyTorch weights to ``location/weights.pt``.""" + weights_url = rfapi.get_model_weights_url( + self.__api_key, self.workspace, self.project, self._weights_id, model_format=format + ) + os.makedirs(location, exist_ok=True) + out_path = os.path.join(location, "weights.pt") + response = requests.get(weights_url, stream=True) + response.raise_for_status() + with open(out_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + return out_path + + def __str__(self): + return json.dumps( + {"model_id": self.model_id, "model_type": self.model_type, "metrics": self.metrics}, + indent=2, + ) + + +class Training: + """One training run on a dataset version. + + A version may own many trainings; a NAS run produces many models. Couples to + the v2 trainings adapter β€” ``.models`` resolves the run's produced models via + ``trainings.get``. + """ + + def __init__(self, api_key, workspace, project, version, raw): + self.__api_key = api_key + self.workspace = workspace + self.project = project + self.version = version + self._raw = raw or {} + self.training_id = self._raw.get("trainingId") or self._raw.get("id") + self.status = self._raw.get("status") + self.model_type = self._raw.get("modelType") + self.model_group = self._raw.get("modelGroup") + self.model_ids = self._raw.get("modelIds", []) or [] + self._models_cache = None + + @property + def models(self) -> List["TrainedModel"]: + """The models this run produced (DNA ``trainings.get`` β†’ ``models[]``).""" + if self._models_cache is not None: + return self._models_cache + + bundle = rfapi.get_training( + self.__api_key, self.workspace, self.project, self.version, training_id=self.training_id + ) + bundle_model_type = bundle.get("modelType") or self.model_type + models = [] + for entry in bundle.get("models", []) or []: + model_id = entry.get("modelId") + if not model_id: + continue + models.append( + TrainedModel( + self.__api_key, + self.workspace, + self.project, + model_id, + model_type=entry.get("modelType") or bundle_model_type, + metrics=entry.get("metrics"), + ) + ) + self._models_cache = models + return self._models_cache + + def refresh(self) -> "Training": + """Re-read this run's status/results from the backend in place.""" + bundle = rfapi.get_training( + self.__api_key, self.workspace, self.project, self.version, training_id=self.training_id + ) + self._raw.update(bundle) + self.training_id = bundle.get("trainingId") or bundle.get("id") or self.training_id + self.status = bundle.get("status", self.status) + self.model_type = bundle.get("modelType", self.model_type) + self.model_group = bundle.get("modelGroup", self.model_group) + self.model_ids = bundle.get("modelIds", self.model_ids) + self._models_cache = None + return self + + def cancel(self, continue_if_no_refund: bool = False): + """Cancel this run immediately (DNA ``trainings.cancel``).""" + return rfapi.cancel_training_v2( + self.__api_key, + self.workspace, + self.project, + self.version, + training_id=self.training_id, + continue_if_no_refund=continue_if_no_refund, + ) + + def stop(self): + """Request a graceful early stop on this run (DNA ``trainings.stop``).""" + return rfapi.stop_training_v2( + self.__api_key, self.workspace, self.project, self.version, training_id=self.training_id + ) + + def delete(self): + """Move this run to the workspace Trash (soft delete). + + The run and every model it produced disappear from listings but stay + restorable for 30 days via :meth:`restore` or the Trash UI. The server + refuses in-flight runs (stop or cancel first). The version's hosted + endpoint always serves the oldest remaining run's model: if this run + was serving, serving switches to the next-oldest run (the response + reports ``versionAliasAction: "repointed"`` and the new target) or + stops when no other run survives (``"deleted"``); restoring the + oldest run hands serving back. + """ + return rfapi.delete_version_training( + self.__api_key, self.workspace, self.project, self.version, training_id=self.training_id + ) + + def restore(self): + """Restore this run from Trash via the shared workspace trash-restore route.""" + return rfapi.restore_trash_item(self.__api_key, self.workspace, "training", self.training_id) + + def __str__(self): + return json.dumps( + { + "training_id": self.training_id, + "status": self.status, + "model_type": self.model_type, + "model_group": self.model_group, + }, + indent=2, + ) diff --git a/roboflow/core/version.py b/roboflow/core/version.py index 60fe4a4a..dee2cce2 100644 --- a/roboflow/core/version.py +++ b/roboflow/core/version.py @@ -3,18 +3,16 @@ import copy import json import os -import shutil import sys import time -import zipfile -from importlib import import_module +import warnings from typing import TYPE_CHECKING, Optional, Union import requests -import yaml from dotenv import load_dotenv from tqdm import tqdm +from roboflow.adapters import rfapi from roboflow.config import ( API_URL, APP_URL, @@ -25,6 +23,7 @@ TYPE_KEYPOINT_DETECTION, TYPE_OBJECT_DETECTION, TYPE_SEMANTIC_SEGMENTATION, + TYPE_TEXT_IMAGE_PAIRS, UNIVERSE_URL, ) from roboflow.core.dataset import Dataset @@ -33,9 +32,12 @@ from roboflow.models.keypoint_detection import KeypointDetectionModel from roboflow.models.object_detection import ObjectDetectionModel from roboflow.models.semantic_segmentation import SemanticSegmentationModel +from roboflow.models.vlm import VLMModel from roboflow.util.annotations import amend_data_yaml -from roboflow.util.general import write_line -from roboflow.util.versions import get_wrong_dependencies_versions, print_warn_for_wrong_dependencies_versions +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.train_recipe import fold_epochs_into_recipe +from roboflow.util.versions import get_model_format, get_wrong_dependencies_versions if TYPE_CHECKING: import numpy as np @@ -50,8 +52,6 @@ class Version: Class representing a Roboflow dataset version. """ - model: Optional[InferenceModel] - def __init__( self, version_dict, @@ -94,12 +94,11 @@ def __init__( version_without_workspace = os.path.basename(str(version)) - response = requests.get(f"{API_URL}/{workspace}/{project}/{self.version}?api_key={self.__api_key}") - if response.ok: - version_info = response.json()["version"] - has_model = bool(version_info.get("models")) - else: - has_model = False + # Derive the legacy single-model flag from the payload the caller + # already fetched. Keeping __init__ free of network side effects means + # a transient/mocked request failure can't break basic version + # retrieval; the v2 surface (models()/trainings()) does its own reads. + has_model = bool(version_dict.get("model")) if not has_model: self.model = None @@ -135,6 +134,16 @@ def __init__( self.model = SemanticSegmentationModel(self.__api_key, self.id) elif self.type == TYPE_KEYPOINT_DETECTION: self.model = KeypointDetectionModel(self.__api_key, self.id, version=version_without_workspace) + elif self.type == TYPE_TEXT_IMAGE_PAIRS: + self.model = VLMModel( + self.__api_key, + self.id, + self.name, + version_without_workspace, + local=local, + colors=self.colors, + preprocessing=self.preprocessing, + ) else: self.model = None @@ -152,18 +161,176 @@ def __init__( self.version = "23" self.id = "joseph-nelson/chess-pieces-new" - def __check_if_generating(self): - # check Roboflow API to see if this version is still generating + @property + def model(self): + """Deprecated. The version's legacy single inference model, or ``None``. - url = f"{API_URL}/{self.workspace}/{self.project}/{self.version}?nocache=true" - response = requests.get(url, params={"api_key": self.__api_key}) - response.raise_for_status() - if response.json()["version"]["progress"] is None: - progress = 0.0 - else: - progress = float(response.json()["version"]["progress"]) + A version may now own many trained models (MMPV). This single-model + attribute cannot represent that, so it is deprecated in favor of + :meth:`models`, which returns every trained model for the version, and + :meth:`trainings`, which exposes the runs that produced them. + """ + warnings.warn( + "version.model is deprecated and will be removed in a future release; " + "use version.models() (all trained models) or version.trainings() instead.", + DeprecationWarning, + stacklevel=2, + ) + return getattr(self, "_model", None) + + @model.setter + def model(self, value): + self._model = value + + def trainings(self): + """List this version's trainings as Training objects (DNA ``trainings.list``). + + An MMPV version may own many; a legacy (SMPV) version reports its single + run. Returns a list of :class:`~roboflow.core.training.Training`. + """ + from roboflow.core.training import Training + + raw = rfapi.list_trainings_for_version(self.__api_key, self.workspace, self.project, self.version) + return [Training(self.__api_key, self.workspace, self.project, self.version, t) for t in raw] + + def models(self): + """All trained models for this version β€” the union across its trainings. + + Mirrors the backend's "a version's models are the union across its + trainings" rule. Returns a list of + :class:`~roboflow.core.training.TrainedModel`. + """ + result = [] + for training in self.trainings(): + result.extend(training.models) + return result + + def describe_train_recipe(self, model_type: str) -> dict: + """Fetch the v2 training recipe schema and template for a model type. + + Args: + model_type: The model type to describe (e.g. ``"rfdetr-medium"``). + + Returns: + dict: The API response with the tunable ``schema`` + (hyperparameters, allowed online augmentation/preprocessing + steps, input constraints) and a ready-to-submit ``template`` + that can be edited and passed to :meth:`create_training`. + + Raises: + RoboflowError: If the Roboflow API returns an error. + """ + workspace, project, *_ = self.id.rsplit("/") + return rfapi.get_train_recipe( + api_key=self.__api_key, + workspace_url=workspace, + project_url=project, + version=self.version, + model_type=model_type, + ) + + def create_training(self, speed=None, model_type=None, checkpoint=None, epochs=None, train_recipe=None): + """Create a v2 training run and return a Training object. + + Unlike :meth:`train`, this does not block until completion or return a + legacy task-specific model. It exposes the MMPV-aware training id so + callers can refresh the run, enumerate produced models, and select the + model they want. + + To customize hyperparameters or online augmentation, fetch the recipe + template via :meth:`describe_train_recipe`, edit it, and pass it as + ``train_recipe``; the server dense-fills any defaults the recipe + omits. + + Args: + speed: Training speed preset (e.g. ``"fast"``). + model_type: The model type to train (e.g. ``"rfdetr-medium"``). + checkpoint: Checkpoint to start training from. + epochs: Number of epochs to train. When a ``train_recipe`` is + given, this is folded into the recipe's hyperparameters + unless they already set ``"epochs"``, because the server + resolves the recipe's dense-filled epochs ahead of this + top-level value. + train_recipe: A full recipe to submit β€” typically the + ``template`` from :meth:`describe_train_recipe` with edited + ``hyperparameters`` / ``online_augmentation``. Requires + ``model_type``: recipes are minted per model type, and + without one the platform would train the project's default + architecture instead. + + Raises: + ValueError: If ``train_recipe`` is given without ``model_type``. + RoboflowError: If the Roboflow API returns an error. + + Example: + Launch a small learning-rate sweep and poll for completion:: + + import copy + import time + + template = version.describe_train_recipe("rfdetr-medium")["template"] + trainings = [] + for lr in (1e-4, 3e-4, 1e-3): + recipe = copy.deepcopy(template) + recipe["hyperparameters"] = {"lr": lr} + trainings.append( + version.create_training(model_type="rfdetr-medium", train_recipe=recipe) + ) + pending = list(trainings) + while pending: + for training in list(pending): + if training.refresh().status in ("finished", "failed"): + pending.remove(training) + time.sleep(60) + """ + from roboflow.core.training import Training + + if train_recipe is not None and not model_type: + raise ValueError( + "model_type is required when passing train_recipe: recipes are " + "minted per model type (see describe_train_recipe)." + ) + if train_recipe is not None and epochs is not None: + # Fold epochs into the recipe: the server dense-fills recipe + # hyperparameters (including a default epochs) and resolves them + # ahead of the body's top-level epochs, which would otherwise be + # silently ignored. An epochs set in the recipe wins. + train_recipe = fold_epochs_into_recipe(train_recipe, epochs) + + self.__wait_if_generating() + + if model_type: + train_model_format = get_model_format(model_type) + if train_model_format not in self.exports: + self.export(train_model_format) + + workspace, project, *_ = self.id.rsplit("/") + raw = rfapi.create_training_v2( + api_key=self.__api_key, + workspace_url=workspace, + project_url=project, + version=self.version, + speed=speed if speed else None, + checkpoint=checkpoint if checkpoint else None, + model_type=model_type if model_type else None, + epochs=epochs, + train_recipe=train_recipe, + ) + return Training(self.__api_key, workspace, project, self.version, raw) - return response.json()["version"]["generating"], progress + def __check_if_generating(self): + # check Roboflow API to see if this version is still generating + versiondict = rfapi.get_version( + api_key=self.__api_key, + workspace_url=self.workspace, + project_url=self.project, + version=self.version, + nocache=True, + ) + version_obj = versiondict.get("version", {}) + progress = 0.0 if version_obj.get("progress") is None else float(version_obj.get("progress")) + generating = bool(version_obj.get("generating") or version_obj.get("images", 0) == 0) + return generating, progress def __wait_if_generating(self, recurse=False): # checks if a given version is still in the progress of generating @@ -206,19 +373,6 @@ def download(self, model_format=None, location=None, overwrite: bool = False): self.__wait_if_generating() - if model_format == "yolov8": - # if ultralytics is installed, we will assume users will want to use yolov8 and we check for the supported version # noqa: E501 // docs - try: - import_module("ultralytics") - print_warn_for_wrong_dependencies_versions([("ultralytics", "==", "8.0.196")]) - except ImportError: - print( - "[WARNING] we noticed you are downloading a `yolov8` datasets but you don't have `ultralytics` installed. " # noqa: E501 // docs - "Roboflow `.deploy` supports only models trained with `ultralytics==8.0.196`, to intall it `pip install ultralytics==8.0.196`." # noqa: E501 // docs - ) - # silently fail - pass - model_format = self.__get_format_identifier(model_format) if model_format not in self.exports: @@ -234,23 +388,30 @@ def download(self, model_format=None, location=None, overwrite: bool = False): if self.__api_key == "coco-128-sample": link = "https://app.roboflow.com/ds/n9QwXwUK42?key=NnVCe2yMxP" else: - url = self.__get_download_url(model_format) - response = requests.get(url, params={"api_key": self.__api_key}) - if response.status_code == 200: - link = response.json()["export"]["link"] - else: - try: - raise RuntimeError(response.json()) - except json.JSONDecodeError: - response.raise_for_status() + workspace, project, *_ = self.id.rsplit("/") + try: + export_info = rfapi.get_version_export( + api_key=self.__api_key, + workspace_url=workspace, + project_url=project, + version=self.version, + format=model_format, + ) + except rfapi.RoboflowError as e: + raise RuntimeError(str(e)) + + if "ready" in export_info and export_info.get("ready") is False: + raise RuntimeError(export_info) + + link = export_info["export"]["link"] self.__download_zip(link, location, model_format) - self.__extract_zip(location, model_format) - self.__reformat_yaml(location, model_format) + extract_zip(location, desc=f"Extracting Dataset Version Zip to {location} in {model_format}:") + self.__reformat_yaml(location, model_format) # TODO: is roboflow-python a place to be munging yaml files? return Dataset(self.name, self.version, model_format, os.path.abspath(location)) - def export(self, model_format=None): + def export(self, model_format=None) -> bool | None: """ Ask the Roboflow API to generate a version's dataset in a given format so that it can be downloaded via the `download()` method. @@ -260,7 +421,7 @@ def export(self, model_format=None): model_format (str): A format to use for downloading Returns: - True + True if the export was successful, RuntimeError if the export failed Raises: RuntimeError: If the Roboflow API returns an error with a helpful JSON body @@ -271,48 +432,49 @@ def export(self, model_format=None): self.__wait_if_generating() - url = self.__get_download_url(model_format) - response = requests.get(url, params={"api_key": self.__api_key}) - if not response.ok: - try: - raise RuntimeError(response.json()) - except json.JSONDecodeError: - response.raise_for_status() - - # the rest api returns 202 if the export is still in progress - if response.status_code == 202: - status_code_check = 202 - while status_code_check == 202: - time.sleep(1) - response = requests.get(url, params={"api_key": self.__api_key}) - status_code_check = response.status_code - if status_code_check == 202: - progress = response.json()["progress"] - progress_message = ( - "Exporting format " + model_format + " in progress : " + str(round(progress * 100, 2)) + "%" - ) - sys.stdout.write("\r" + progress_message) - sys.stdout.flush() - - if response.status_code == 200: + workspace, project, *_ = self.id.rsplit("/") + export_info = rfapi.get_version_export( + api_key=self.__api_key, + workspace_url=workspace, + project_url=project, + version=self.version, + format=model_format, + ) + while "ready" in export_info and export_info.get("ready") is False: + progress = export_info.get("progress", 0.0) + progress_message = ( + "Exporting format " + model_format + " in progress : " + str(round(progress * 100, 2)) + "%" + ) + sys.stdout.write("\r" + progress_message) + sys.stdout.flush() + time.sleep(1) + export_info = rfapi.get_version_export( + api_key=self.__api_key, + workspace_url=workspace, + project_url=project, + version=self.version, + format=model_format, + ) + if "export" in export_info: sys.stdout.write("\n") print("\r" + "Version export complete for " + model_format + " format") sys.stdout.flush() return True else: - try: - raise RuntimeError(response.json()) - except json.JSONDecodeError: - response.raise_for_status() + raise RuntimeError(f"Unexpected export {export_info}") - def train(self, speed=None, checkpoint=None, plot_in_notebook=False) -> InferenceModel: + def train( + self, speed=None, model_type=None, checkpoint=None, plot_in_notebook=False, epochs=None + ) -> InferenceModel: """ Ask the Roboflow API to train a previously exported version's dataset. Args: speed: Whether to train quickly or accurately. Note: accurate training is a paid feature. Default speed is `fast`. + model_type: The type of model to train. Default depends on kind of project. It takes precedence over speed. You can check the list of model ids by sending an invalid parameter in this argument. checkpoint: A string representing the checkpoint to use while training - plot: Whether to plot the training results. Default is `False`. + epochs: Number of epochs to train the model + plot_in_notebook: Whether to plot the training results. Default is `False`. Returns: An instance of the trained model class @@ -324,39 +486,28 @@ def train(self, speed=None, checkpoint=None, plot_in_notebook=False) -> Inferenc self.__wait_if_generating() - train_model_format = "yolov5pytorch" - - if self.type == TYPE_CLASSICATION: - train_model_format = "folder" - - if self.type == TYPE_INSTANCE_SEGMENTATION: - train_model_format = "yolov5pytorch" - - if self.type == TYPE_SEMANTIC_SEGMENTATION: - train_model_format = "png-mask-semantic" - - # if classification + train_model_format = get_model_format(model_type) if train_model_format not in self.exports: self.export(train_model_format) workspace, project, *_ = self.id.rsplit("/") - url = f"{API_URL}/{workspace}/{project}/{self.version}/train" - data = {} - if speed: - data["speed"] = speed - - if checkpoint: - data["checkpoint"] = checkpoint + payload_speed = speed if speed else None + payload_checkpoint = checkpoint if checkpoint else None + payload_model_type = model_type if model_type else None write_line("Reaching out to Roboflow to start training...") - response = requests.post(url, json=data, params={"api_key": self.__api_key}) - if not response.ok: - try: - raise RuntimeError(response.json()) - except json.JSONDecodeError: - response.raise_for_status() + rfapi.start_version_training( + api_key=self.__api_key, + workspace_url=workspace, + project_url=project, + version=self.version, + speed=payload_speed, + checkpoint=payload_checkpoint, + model_type=payload_model_type, + epochs=epochs, + ) status = "training" @@ -383,10 +534,14 @@ def live_plot(epochs, mAP, loss, title=""): num_machine_spin_dots = [] while status == "training" or status == "running": - url = f"{API_URL}/{self.workspace}/{self.project}/{self.version}?nocache=true" - response = requests.get(url, params={"api_key": self.__api_key}) - response.raise_for_status() - version = response.json()["version"] + version_response = rfapi.get_version( + api_key=self.__api_key, + workspace_url=self.workspace, + project_url=self.project, + version=self.version, + nocache=True, + ) + version = version_response.get("version", {}) if "models" in version.keys(): models = version["models"] else: @@ -401,7 +556,7 @@ def live_plot(epochs, mAP, loss, title=""): write_line(line="Training failed") break - epochs: Union[np.ndarray, list] + epoch_ids: Union[np.ndarray, list] mAP: Union[np.ndarray, list] loss: Union[np.ndarray, list] @@ -409,7 +564,7 @@ def live_plot(epochs, mAP, loss, title=""): import numpy as np # training has started - epochs = np.array([int(epoch["epoch"]) for epoch in models["roboflow-train"]["epochs"]]) + epoch_ids = np.array([int(epoch["epoch"]) for epoch in models["roboflow-train"]["epochs"]]) mAP = np.array([float(epoch["mAP"]) for epoch in models["roboflow-train"]["epochs"]]) loss = np.array( [ @@ -426,29 +581,68 @@ def live_plot(epochs, mAP, loss, title=""): num_machine_spin_dots = ["."] title = "Training Machine Spinning Up" + "".join(num_machine_spin_dots) - epochs = [] + epoch_ids = [] mAP = [] loss = [] - if (len(epochs) > len(previous_epochs)) or (len(epochs) == 0): + if (len(epoch_ids) > len(previous_epochs)) or (len(epoch_ids) == 0): if plot_in_notebook: - live_plot(epochs, mAP, loss, title) + live_plot(epoch_ids, mAP, loss, title) else: - if len(epochs) > 0: + if len(epoch_ids) > 0: title = ( - title + ": Epoch: " + str(epochs[-1]) + " mAP: " + str(mAP[-1]) + " loss: " + str(loss[-1]) + title + + ": Epoch: " + + str(epoch_ids[-1]) + + " mAP: " + + str(mAP[-1]) + + " loss: " + + str(loss[-1]) ) if not first_graph_write: write_line(title) first_graph_write = True - previous_epochs = copy.deepcopy(epochs) + previous_epochs = copy.deepcopy(epoch_ids) time.sleep(5) + if not getattr(self, "_model", None): + if self.type == TYPE_OBJECT_DETECTION: + self.model = ObjectDetectionModel( + self.__api_key, + self.id, + self.name, + self.version, + colors=self.colors, + preprocessing=self.preprocessing, + ) + elif self.type == TYPE_CLASSICATION: + self.model = ClassificationModel( + self.__api_key, + self.id, + self.name, + self.version, + colors=self.colors, + preprocessing=self.preprocessing, + ) + elif self.type == TYPE_INSTANCE_SEGMENTATION: + self.model = InstanceSegmentationModel( + self.__api_key, + self.id, + colors=self.colors, + preprocessing=self.preprocessing, + ) + elif self.type == TYPE_SEMANTIC_SEGMENTATION: + self.model = SemanticSegmentationModel(self.__api_key, self.id) + elif self.type == TYPE_KEYPOINT_DETECTION: + self.model = KeypointDetectionModel(self.__api_key, self.id, version=self.version) + else: + raise ValueError(f"Unsupported model type: {self.type}") + # return the model object - assert self.model - return self.model + assert self._model + return self._model # @warn_for_wrong_dependencies_versions([("ultralytics", "==", "8.0.196")]) def deploy(self, model_type: str, model_path: str, filename: str = "weights/best.pt") -> None: @@ -459,265 +653,15 @@ 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". """ + bundle = package_custom_weights_interactive(model_type, model_path, filename, build_dir=model_path) - supported_models = ["yolov5", "yolov7-seg", "yolov8", "yolov9", "yolonas", "paligemma", "yolov10", "florence-2"] - - if not any(supported_model in model_type for supported_model in supported_models): - raise (ValueError(f"Model type {model_type} not supported. Supported models are" f" {supported_models}")) - - if model_type.startswith(("paligemma", "florence-2")): - if "paligemma" in model_type or "florence-2" in model_type: - supported_hf_types = [ - "florence-2-base", - "florence-2-large", - "paligemma-3b-pt-224", - "paligemma-3b-pt-448", - "paligemma-3b-pt-896", - ] - if model_type not in supported_hf_types: - raise RuntimeError( - f"{model_type} not supported for this type of upload." - f"Supported upload types are {supported_hf_types}" - ) - self.deploy_huggingface(model_type, model_path, filename) - return - - if "yolonas" in model_type: - self.deploy_yolonas(model_type, model_path, filename) - return - - if "yolov8" in model_type: - try: - import torch - import ultralytics - - except ImportError: - raise RuntimeError( - "The ultralytics python package is required to deploy yolov8" - " models. Please install it with `pip install ultralytics`" - ) - - print_warn_for_wrong_dependencies_versions([("ultralytics", "==", "8.0.196")], ask_to_continue=True) - - elif "yolov10" in model_type: - try: - import torch - import ultralytics - - except ImportError: - raise RuntimeError( - "The ultralytics python package is required to deploy yolov10" - " models. Please install it with `pip install ultralytics`" - ) - - elif "yolov5" in model_type or "yolov7" in model_type or "yolov9" in model_type: - try: - import torch - except ImportError: - raise RuntimeError( - "The torch python package is required to deploy yolov5 models." - " Please install it with `pip install torch`" - ) - - model = torch.load(os.path.join(model_path, filename)) - - if isinstance(model["model"].names, list): - class_names = model["model"].names - else: - class_names = [] - for i, val in enumerate(model["model"].names): - class_names.append((val, model["model"].names[val])) - class_names.sort(key=lambda x: x[0]) - class_names = [x[1] for x in class_names] - - if "yolov8" in model_type or "yolov10" in model_type: - # try except for backwards compatibility with older versions of ultralytics - if "-cls" in model_type or model_type.startswith("yolov10"): - nc = model["model"].yaml["nc"] - args = model["train_args"] - else: - nc = model["model"].nc - args = model["model"].args - try: - model_artifacts = { - "names": class_names, - "yaml": model["model"].yaml, - "nc": nc, - "args": {k: val for k, val in args.items() if ((k == "model") or (k == "imgsz") or (k == "batch"))}, - "ultralytics_version": ultralytics.__version__, - "model_type": model_type, - } - except Exception: - model_artifacts = { - "names": class_names, - "yaml": model["model"].yaml, - "nc": nc, - "args": { - k: val - for k, val in args.__dict__.items() - if ((k == "model") or (k == "imgsz") or (k == "batch")) - }, - "ultralytics_version": ultralytics.__version__, - "model_type": model_type, - } - elif "yolov5" in model_type or "yolov7" in model_type or "yolov9" in model_type: - # parse from yaml for yolov5 - - with open(os.path.join(model_path, "opt.yaml")) as stream: - opts = yaml.safe_load(stream) - - model_artifacts = { - "names": class_names, - "nc": model["model"].nc, - "args": { - "imgsz": opts["imgsz"] if "imgsz" in opts else opts["img_size"], - "batch": opts["batch_size"], - }, - "model_type": model_type, - } - if hasattr(model["model"], "yaml"): - model_artifacts["yaml"] = model["model"].yaml - - with open(os.path.join(model_path, "model_artifacts.json"), "w") as fp: - json.dump(model_artifacts, fp) - - torch.save(model["model"].state_dict(), os.path.join(model_path, "state_dict.pt")) - - list_files = [ - "results.csv", - "results.png", - "model_artifacts.json", - "state_dict.pt", - ] - - with zipfile.ZipFile(os.path.join(model_path, "roboflow_deploy.zip"), "w") as zipMe: - for file in list_files: - if os.path.exists(os.path.join(model_path, file)): - zipMe.write( - os.path.join(model_path, file), - arcname=file, - compress_type=zipfile.ZIP_DEFLATED, - ) - else: - if file in ["model_artifacts.json", "state_dict.pt"]: - raise (ValueError(f"File {file} not found. Please make sure to provide a" " valid model path.")) - - self.upload_zip(model_type, model_path) - - def deploy_huggingface( - self, model_type: str, model_path: str, filename: str = "fine-tuned-paligemma-3b-pt-224.f16.npz" - ) -> None: - # Check if model_path exists - if not os.path.exists(model_path): - raise FileNotFoundError(f"Model path {model_path} does not exist.") - model_files = os.listdir(model_path) - print(f"Model files found in {model_path}: {model_files}") - - files_to_deploy = [] - - # Find first .npz file in model_path - npz_filename = next((file for file in model_files if file.endswith(".npz")), None) - if any([file.endswith(".safetensors") for file in model_files]): - print(f"Found .safetensors file in model path. Deploying PyTorch {model_type} model.") - necessary_files = [ - "preprocessor_config.json", - "special_tokens_map.json", - "tokenizer_config.json", - "tokenizer.json", - ] - for file in necessary_files: - if file not in model_files: - print("Missing necessary file", file) - res = input("Do you want to continue? (y/n)") - if res.lower() != "y": - exit(1) - for file in model_files: - files_to_deploy.append(file) - elif npz_filename is not None: - print(f"Found .npz file {npz_filename} in model path. Deploying JAX PaliGemma model.") - files_to_deploy.append(npz_filename) - else: - raise FileNotFoundError(f"No .npz or .safetensors file found in model path {model_path}.") - - if len(files_to_deploy) == 0: - raise FileNotFoundError(f"No valid files found in model path {model_path}.") - print(f"Zipping files for deploy: {files_to_deploy}") - - import tarfile - - with tarfile.open(os.path.join(model_path, "roboflow_deploy.tar"), "w") as tar: - for file in files_to_deploy: - tar.add(os.path.join(model_path, file), arcname=file) - - print("Uploading to Roboflow... May take several minutes.") - self.upload_zip(model_type, model_path, "roboflow_deploy.tar") - - def deploy_yolonas(self, model_type: str, model_path: str, filename: str = "weights/best.pt") -> None: - try: - import torch - except ImportError: - raise RuntimeError( - "The torch python package is required to deploy yolonas models." - " Please install it with `pip install torch`" - ) - - model = torch.load(os.path.join(model_path, filename), map_location="cpu") - class_names = model["processing_params"]["class_names"] - - opt_path = os.path.join(model_path, "opt.yaml") - if not os.path.exists(opt_path): - raise RuntimeError( - f"You must create an opt.yaml file at {os.path.join(model_path, '')} of the format:\n" - f"imgsz: \n" - f"batch_size: \n" - f"architecture: \n" - ) - with open(os.path.join(model_path, "opt.yaml")) as stream: - opts = yaml.safe_load(stream) - required_keys = ["imgsz", "batch_size", "architecture"] - for key in required_keys: - if key not in opts: - raise RuntimeError(f"{opt_path} lacks required key {key}. Required keys: {required_keys}") - - model_artifacts = { - "names": class_names, - "nc": len(class_names), - "args": { - "imgsz": opts["imgsz"] if "imgsz" in opts else opts["img_size"], - "batch": opts["batch_size"], - "architecture": opts["architecture"], - }, - "model_type": model_type, - } - - with open(os.path.join(model_path, "model_artifacts.json"), "w") as fp: - json.dump(model_artifacts, fp) - - shutil.copy(os.path.join(model_path, filename), os.path.join(model_path, "state_dict.pt")) - - list_files = [ - "results.json", - "results.png", - "model_artifacts.json", - "state_dict.pt", - ] - - with zipfile.ZipFile(os.path.join(model_path, "roboflow_deploy.zip"), "w") as zipMe: - for file in list_files: - if os.path.exists(os.path.join(model_path, file)): - zipMe.write( - os.path.join(model_path, file), - arcname=file, - compress_type=zipfile.ZIP_DEFLATED, - ) - else: - if file in ["model_artifacts.json", filename]: - raise (ValueError(f"File {file} not found. Please make sure to provide a" " valid model path.")) + self._validate_against_project_type(bundle.model_type) + self._upload_zip(bundle.model_type, model_path, bundle.archive_path.name) - self.upload_zip(model_type, model_path) + def _validate_against_project_type(self, model_type: str) -> None: + validate_model_type_for_project(model_type, self.type, self.project) - def upload_zip(self, model_type: str, model_path: str, model_file_name: str = "roboflow_deploy.zip"): + def _upload_zip(self, model_type: str, model_path: str, model_file_name: str): res = requests.get( f"{API_URL}/{self.workspace}/{self.project}/{self.version}" f"/uploadModel?api_key={self.__api_key}&modelType={model_type}&nocache=true" @@ -743,8 +687,7 @@ def upload_zip(self, model_type: str, model_path: str, model_file_name: str = "r if self.public: print( - "View the status of your deployment at:" - f" {APP_URL}/{self.workspace}/{self.project}/{self.version}" + f"View the status of your deployment at: {APP_URL}/{self.workspace}/{self.project}/{self.version}" ) print( "Share your model with the world at:" @@ -753,8 +696,7 @@ def upload_zip(self, model_type: str, model_path: str, model_file_name: str = "r ) else: print( - "View the status of your deployment at:" - f" {APP_URL}/{self.workspace}/{self.project}/{self.version}" + f"View the status of your deployment at: {APP_URL}/{self.workspace}/{self.project}/{self.version}" ) except Exception as e: @@ -774,11 +716,8 @@ def __download_zip(self, link, location, format): def bar_progress(current, total, width=80): progress_message = ( - "Downloading Dataset Version Zip in " - + location - + " to " - + format - + ": %d%% [%d / %d] bytes" % (current / total * 100, current, total) + f"Downloading Dataset Version Zip in {location} to {format}: " + f"{current / total * 100:.0f}% [{current} / {total}] bytes" ) sys.stdout.write("\r" + progress_message) sys.stdout.flush() @@ -805,30 +744,6 @@ def bar_progress(current, total, width=80): sys.stdout.write("\n") sys.stdout.flush() - def __extract_zip(self, location, format): - """ - Extracts the contents of a downloaded ZIP file and then deletes the zipped file. - - Args: - location (str): filepath of the data directory that contains the ZIP file - format (str): the format identifier string - - Raises: - RuntimeError: If there is an error unzipping the file - """ # noqa: E501 // docs - desc = None if TQDM_DISABLE else f"Extracting Dataset Version Zip to {location} in {format}:" - with zipfile.ZipFile(location + "/roboflow.zip", "r") as zip_ref: - for member in tqdm( - zip_ref.infolist(), - desc=desc, - ): - try: - zip_ref.extract(member, location) - except zipfile.error: - raise RuntimeError("Error unzipping download") - - os.remove(location + "/roboflow.zip") - def __get_download_location(self): """ Get the local path to save a downloaded dataset to @@ -877,7 +792,7 @@ def __get_format_identifier(self, format): if not format: raise RuntimeError( - "You must pass a format argument to version.download() or define a" " model in your Roboflow object" + "You must pass a format argument to version.download() or define a model in your Roboflow object" ) friendly_formats = {"yolov5": "yolov5pytorch", "yolov7": "yolov7pytorch"} @@ -899,7 +814,7 @@ def data_yaml_callback(content: dict) -> dict: content["train"] = location + content["train"].lstrip(".") content["val"] = location + content["val"].lstrip(".") content["test"] = location + content["test"].lstrip(".") - if format in ["yolov5pytorch", "yolov7pytorch", "yolov8", "yolov9"]: + if format in ["yolov5pytorch", "yolov7pytorch"]: content["train"] = location + content["train"].lstrip("..") content["val"] = location + content["val"].lstrip("..") try: @@ -917,6 +832,105 @@ def data_yaml_callback(content: dict) -> dict: if format in ["yolov5pytorch", "mt-yolov6", "yolov7pytorch", "yolov8", "yolov9"]: amend_data_yaml(path=data_path, callback=data_yaml_callback) + def delete(self): + """ + Move this version to Trash (soft delete). + + Any in-flight training job on the version is cancelled. The version is + retained for 30 days and can be restored via `Version.restore()` or the + Trash UI. + + Returns: + dict: Server response with `{deleted: True, type: "version", ...}`. + """ + return rfapi.delete_version(self.__api_key, self.workspace, self.project, self.version) + + def restore(self): + """ + Restore this version from Trash. + + Looks up the version in the workspace Trash by (project, version id). + Raises RuntimeError if it isn't currently in Trash. The parent project + must not itself be in Trash. + + Returns: + dict: Server response with `{restored: True, type: "version", ...}`. + """ + trash = rfapi.list_trash(self.__api_key, self.workspace) + versions = trash.get("sections", {}).get("versions", []) + # `self.project` is the project URL slug (set by Project at init time + # from `a_project["id"].rsplit("/")[1]`), so we match against + # `parentUrl`. The trash payload's `parentId` is the Firestore doc id, + # which the SDK never holds β€” no need for a fallback. + match = next( + (v for v in versions if str(v.get("id")) == str(self.version) and v.get("parentUrl") == self.project), + None, + ) + if not match: + raise RuntimeError(f"Version '{self.project}/{self.version}' is not in Trash β€” nothing to restore.") + return rfapi.restore_trash_item( + self.__api_key, + self.workspace, + "version", + match["id"], + parent_id=match.get("parentId"), + ) + + def delete_training(self, training_id: Optional[str] = None): + """ + Move one of this version's training runs to Trash (soft delete). + + The run and every model it produced disappear from listings but stay + restorable for 30 days via `Version.restore_training()` or the Trash + UI, after which they are permanently deleted. The server refuses + in-flight runs (stop or cancel first). The version's hosted endpoint + always serves the oldest remaining run's model: deleting the serving + run switches serving to the next-oldest run (`versionAliasAction: + "repointed"`) or stops it when no other run survives (`"deleted"`); + restoring the oldest run hands serving back. + + Args: + training_id: Training id of the run to delete (a version can own + several runs). Omit to target the version's sole run β€” resolved + client-side; several runs raise with their ids listed. + + Returns: + dict: Server response with `{deleted: True, type: "training", ..., trash: True}` + (the same shape as project/version/workflow deletion). + """ + resolved_id = rfapi.resolve_version_training_id( + self.__api_key, + self.workspace, + self.project, + self.version, + training_id, + ) + return rfapi.delete_version_training( + self.__api_key, + self.workspace, + self.project, + self.version, + training_id=resolved_id, + ) + + def restore_training(self, training_id: str): + """ + Restore one of this version's trashed training runs. + + Goes through the shared workspace trash-restore route (the same one + project/version/workflow restores use) with `type: "training"`. + + Args: + training_id: Training id of the trashed run (required β€” trashed + runs are invisible to the sole-run fallback). + + Returns: + dict: Server response from the trash restore route. + """ + if not training_id or not str(training_id).strip(): + raise ValueError("training_id is required") + return rfapi.restore_trash_item(self.__api_key, self.workspace, "training", training_id) + def __str__(self): """ String representation of version object. @@ -935,4 +949,4 @@ def __str__(self): def unwrap_version_id(version_id: str) -> str: - return version_id if "/" not in str(version_id) else version_id.split("/")[-1] + return version_id if "/" not in str(version_id) else version_id.rsplit("/", maxsplit=1)[-1] diff --git a/roboflow/core/workspace.py b/roboflow/core/workspace.py index 9083c5d8..97f15555 100644 --- a/roboflow/core/workspace.py +++ b/roboflow/core/workspace.py @@ -1,21 +1,26 @@ +from __future__ import annotations + import concurrent.futures import glob import json import os import sys -from typing import Any, List +import tempfile +import time +import zipfile +from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional import requests -from PIL import Image +from requests.exceptions import HTTPError +from tqdm import tqdm -from roboflow.adapters import rfapi +from roboflow.adapters import rfapi, vision_events_api from roboflow.adapters.rfapi import AnnotationSaveError, ImageUploadError, RoboflowError -from roboflow.config import API_URL, CLIP_FEATURIZE_URL, DEMO_KEYS -from roboflow.core.project import Project -from roboflow.util import folderparser -from roboflow.util.active_learning_utils import check_box_size, clip_encode, count_comparisons -from roboflow.util.image_utils import load_labelmap -from roboflow.util.two_stage_utils import ocr_infer +from roboflow.config import API_URL, APP_URL, DEMO_KEYS + +if TYPE_CHECKING: + from roboflow.core.device import Device + from roboflow.core.model_eval import ModelEval class Workspace: @@ -56,6 +61,8 @@ def projects(self): Returns: List of Project objects. """ + from roboflow.core.project import Project + projects_array = [] for a_project in self.project_list: proj = Project(self.__api_key, a_project, self.model_format) @@ -75,6 +82,8 @@ def project(self, project_id): Returns: Project Object """ + from roboflow.core.project import Project + sys.stdout.write("\r" + "loading Roboflow project...") sys.stdout.write("\n") sys.stdout.flush() @@ -99,12 +108,14 @@ def create_project(self, project_name, project_type, project_license, annotation Args: project_name (str): name of the project project_type (str): type of the project - project_license (str): license of the project (set to `private` for private projects, only available for paid customers) + project_license (str): license of the project (set to `Private` for private projects, only available for paid customers) annotation (str): annotation of the project Returns: Project Object """ # noqa: E501 // docs + from roboflow.core.project import Project + data = { "name": project_name, "type": project_type, @@ -119,7 +130,215 @@ def create_project(self, project_name, project_type, project_license, annotation if "error" in r.json().keys(): raise RuntimeError(r.json()["error"]) - return self.project(r.json()["id"].split("/")[-1]) + return Project(self.__api_key, r.json(), self.model_format) + + def fork_project( + self, + *, + url: Optional[str] = None, + source_project_slug: Optional[str] = None, + ) -> Dict[str, Any]: + """Fork a public Universe project into this workspace. + + Args: + url: Universe project URL. + source_project_slug: Source project slug when not using ``url``. + + Returns: + The API response, typically ``{"taskId": "...", "url": "..."}``. + """ + return rfapi.fork_project( + self.__api_key, + self.url, + url=url, + source_project_slug=source_project_slug, + ) + + def get_async_task(self, task_id: str) -> Dict[str, Any]: + """Return the current status of an async task owned by this workspace.""" + return rfapi.get_async_task(self.__api_key, self.url, task_id) + + def update_image_metadata( + self, + image_id: str, + *, + metadata: Optional[Dict] = None, + remove_metadata: Optional[List[str]] = None, + add_tags: Optional[List[str]] = None, + remove_tags: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Update metadata and tags on a single image (synchronous). + + Values in ``metadata`` are upserted: new keys are added, existing keys + are overwritten. At least one argument must be provided; validation + (key/tag format, mutual exclusions) is performed server-side and + surfaces as :class:`~roboflow.adapters.rfapi.RoboflowError`. + + Args: + image_id: ID of an image in this workspace. + metadata: Key-value pairs to set (string, number, or boolean values). + remove_metadata: Metadata keys to delete. + add_tags: Tags to add. + remove_tags: Tags to remove. + + Returns: + ``{"success": True}`` on success. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="") + >>> workspace = rf.workspace("WORKSPACE_URL") + >>> workspace.update_image_metadata( + ... "IMAGE_ID", + ... metadata={"quality_score": 95, "reviewed": True}, + ... add_tags=["reviewed"], + ... ) + """ + return rfapi.update_image_metadata( + api_key=self.__api_key, + workspace_url=self.url, + image_id=image_id, + metadata=metadata, + remove_metadata=remove_metadata, + add_tags=add_tags, + remove_tags=remove_tags, + ) + + def batch_update_image_metadata( + self, + updates: List[Dict], + *, + wait: bool = False, + poll_interval: float = 4.0, + timeout: float = 1800.0, + ) -> Dict[str, Any]: + """Update metadata and tags on up to 1,000 images in one call (asynchronous). + + Each update dict must contain ``imageId`` plus at least one of + ``metadata``, ``removeMetadata``, ``addTags``, ``removeTags``. + Validation happens server-side before anything is enqueued; an invalid + item rejects the whole batch with :class:`~roboflow.adapters.rfapi.RoboflowError`. + Missing images do not fail the task β€” they are reported per-item in + the final result's ``failedItems`` while the rest succeed. + + The endpoint is workspace-scoped; to target a single project's images, + gather their IDs first (e.g. via ``project.search(fields=["id"])``). + + Args: + updates: List of update dicts, e.g. + ``[{"imageId": "abc", "metadata": {"k": "v"}, "addTags": ["t"]}]``. + wait: When ``True``, poll until the task finishes and return the + final task status instead of the enqueue response. + poll_interval: Seconds between polls when ``wait`` is ``True``. + timeout: Max seconds to wait when ``wait`` is ``True``; raises + ``TimeoutError`` when exceeded. Non-positive disables the timeout. + + Returns: + With ``wait=False``: ``{"taskId": "...", "url": "..."}`` β€” check later + with :meth:`get_async_task`. With ``wait=True``: the final task status, + including ``result`` (``succeeded``/``failed``/``failedItems``) when completed. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="") + >>> workspace = rf.workspace("WORKSPACE_URL") + >>> final = workspace.batch_update_image_metadata( + ... [ + ... {"imageId": "img1", "metadata": {"batch": "june"}}, + ... {"imageId": "img2", "addTags": ["processed"]}, + ... ], + ... wait=True, + ... ) + >>> final["result"]["succeeded"] + """ + result = rfapi.batch_update_image_metadata( + api_key=self.__api_key, + workspace_url=self.url, + updates=updates, + ) + if not wait: + return result + + from roboflow.core.async_tasks import poll_until_terminal + + return poll_until_terminal( + self.__api_key, + self.url, + result["taskId"], + interval=poll_interval, + timeout=timeout, + polling_url=result.get("url"), + ) + + def devices(self) -> List["Device"]: + """List v2 devices registered in this workspace. + + Returns: + List of :class:`roboflow.core.device.Device` objects. Each + wraps the entity returned by ``GET /:workspace/devices/v2`` + (id, name, status, last_heartbeat, hardware, tags, …). + """ + from roboflow.adapters import devicesapi + from roboflow.core.device import Device + + rows = devicesapi.list_devices(self.__api_key, self.url).get("data", []) + return [Device(self.__api_key, self.url, row) for row in rows] + + def device(self, device_id: str) -> "Device": + """Get a single device by id. + + Args: + device_id: The device id (as returned by :meth:`devices` or by + :meth:`create_device`). + + Returns: + A :class:`roboflow.core.device.Device` instance. + """ + from roboflow.adapters import devicesapi + from roboflow.core.device import Device + + info = devicesapi.get_device(self.__api_key, self.url, device_id) + return Device(self.__api_key, self.url, info) + + def create_device( + self, + device_name: str, + device_type: Optional[str] = None, + *, + workflow_id: Optional[str] = None, + tags: Optional[List[str]] = None, + offline_mode: Optional[bool] = None, + source_device_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a new v2 device in the workspace. + + Args: + device_name: Human-readable device name (required). + device_type: ``"ai1"``, ``"edge"``, or any custom string. + workflow_id: Optional initial workflow assignment. For AI1 devices + this seeds the default ``aione`` stream. + tags: Optional list of string tags. + offline_mode: Boolean; only valid for AI1 devices on workspaces + with the ``roboflowLiteMode`` feature. + source_device_id: When set, duplicates the named existing + device's config instead of generating a fresh one. + + Returns: + Dict with ``deviceId`` and ``installId`` (the short-lived install + token to feed into ``GET /devices/v2/:installId/install.sh``). + """ + from roboflow.adapters import devicesapi + + return devicesapi.create_device( + self.__api_key, + self.url, + device_name=device_name, + device_type=device_type, + workflow_id=workflow_id, + tags=tags, + offline_mode=offline_mode, + source_device_id=source_device_id, + ) def clip_compare(self, dir: str = "", image_ext: str = ".png", target_image: str = "") -> List[dict]: """ @@ -135,6 +354,9 @@ def clip_compare(self, dir: str = "", image_ext: str = ".png", target_image: str dict: a key:value mapping of image_name:comparison_score_to_target """ # noqa: E501 // docs + from roboflow.config import CLIP_FEATURIZE_URL + from roboflow.util.active_learning_utils import clip_encode + # list to store comparison results in comparisons = [] # grab all images in a given directory with ext type @@ -168,6 +390,8 @@ def two_stage( # TODO: fix docs dict: a json obj containing the results of the second stage detection """ # noqa: E501 // docs + from PIL import Image + results = [] # create PIL image for cropping @@ -237,6 +461,10 @@ def two_stage_ocr( # TODO: fix docs dict: a json obj containing the results of the second stage detection """ # noqa: E501 // docs + from PIL import Image + + from roboflow.util.two_stage_utils import ocr_infer + results = [] # create PIL image for cropping @@ -269,7 +497,7 @@ def two_stage_ocr( # capture OCR results from cropped image results.append(ocr_infer(croppedImg)["results"]) else: - print("please use an object detection model--can only perform two stage with" " bounding box results") + print("please use an object detection model--can only perform two stage with bounding box results") return results @@ -283,21 +511,45 @@ def upload_dataset( project_type: str = "object-detection", batch_name=None, num_retries=0, - ): + is_prediction=False, + *, + use_zip_upload: bool = False, + tags: Optional[List[str]] = None, + split: Optional[str] = None, + wait: bool = True, + poll_interval: float = 5.0, + poll_timeout: float = 3600.0, + ) -> Optional[dict]: """ Upload a dataset to Roboflow. + A `.zip` ``dataset_path`` or ``use_zip_upload=True`` routes to the + server's async zip upload flow. Everything else (directory inputs by + default) keeps the legacy per-image flow. + Args: - dataset_path (str): path to the dataset + dataset_path (str): path to the dataset directory or a `.zip` file. project_name (str): name of the project - num_workers (int): number of workers to use for parallel uploads + num_workers (int): number of workers to use for parallel uploads (per-image flow only) dataset_format (str): format of the dataset (`voc`, `yolov8`, `yolov5`) project_license (str): license of the project (set to `private` for private projects, only available for paid customers) project_type (str): type of the project (only `object-detection` is supported) + batch_name (str, optional): name of the batch to upload the images to. Defaults to an automatically generated value. + num_retries (int, optional): number of times to retry uploading an image if the upload fails. Defaults to 0. + is_prediction (bool, optional): whether the annotations provided in the dataset are predictions and not ground truth. Defaults to False. + use_zip_upload (bool, optional): opt-in to the zip flow for a directory input (the SDK zips it client-side). Ignored when dataset_path is already a `.zip`. + tags (list[str], optional): zip flow only β€” tags to apply to the uploaded batch. + split (str, optional): dataset split for the uploaded batch. In per-image directory + uploads, this overrides inferred splits for every image. + wait (bool, optional): zip flow only β€” poll for processing completion. Defaults to True. + poll_interval (float, optional): zip flow only β€” seconds between status polls. + poll_timeout (float, optional): zip flow only β€” total seconds to wait before timing out. + + Returns: + dict | None: zip flow returns the final/pending status dict; per-image flow returns None. """ # noqa: E501 // docs if dataset_format != "NOT_USED": print("Warning: parameter 'dataset_format' is deprecated and will be removed in a future release") - parsed_dataset = folderparser.parsefolder(dataset_path) project, created = self._get_or_create_project( project_id=project_name, license=project_license, type=project_type ) @@ -305,7 +557,54 @@ def upload_dataset( print(f"Created project {project.id}") else: print(f"Uploading to existing project {project.id}") + + is_zip_file = dataset_path.lower().endswith(".zip") and os.path.isfile(dataset_path) + use_zip_flow = is_zip_file or use_zip_upload + if use_zip_flow and is_prediction: + raise RoboflowError( + "Zip upload flow does not support is_prediction=True. " + "Call upload_dataset without use_zip_upload for prediction uploads." + ) + + if use_zip_flow: + project_slug = project.id.rsplit("/")[1] + temp_zip = None + try: + if dataset_path.lower().endswith(".zip") and os.path.isfile(dataset_path): + zip_path = dataset_path + else: + zip_path = temp_zip = _zip_directory(dataset_path) + print(f"Zipped {dataset_path} -> {zip_path}") + + init = rfapi.init_zip_upload( + self.__api_key, + self.url, + project_slug, + split=split, + tags=tags, + batch_name=batch_name, + ) + print(f"Uploading zip to Roboflow (task_id={init['taskId']})...") + rfapi.upload_zip_to_signed_url(init["signedUrl"], zip_path) + + if not wait: + print(f"Zip uploaded; not waiting for processing. task_id={init['taskId']}") + return {"task_id": init["taskId"], "status": "pending"} + + return _poll_zip_status(self.__api_key, self.url, init["taskId"], poll_interval, poll_timeout) + finally: + if temp_zip and os.path.exists(temp_zip): + os.unlink(temp_zip) + + from roboflow.util import folderparser + from roboflow.util.image_utils import load_labelmap + + is_classification = project.type == "classification" + parsed_dataset = folderparser.parsefolder(dataset_path, is_classification=is_classification) images = parsed_dataset["images"] + if split is not None: + for image in images: + image["split"] = split location = parsed_dataset["location"] @@ -346,6 +645,7 @@ def _upload_image(imagedesc): batch_name=batch_name, sequence_number=imagedesc.get("index"), sequence_size=len(images), + num_retry_uploads=num_retries, ) return image, upload_time, upload_retry_attempts @@ -355,24 +655,31 @@ def _save_annotation(image_id, imagedesc): annotation_path = None annotationdesc = imagedesc.get("annotationfile") - if annotationdesc: - if annotationdesc.get("rawText"): + if isinstance(annotationdesc, dict): + if annotationdesc.get("type") == "classification_folder": + annotation_path = annotationdesc.get("classification_label") + elif annotationdesc.get("type") == "classification_multilabel": + annotation_path = json.dumps(annotationdesc.get("labels", [])) + elif annotationdesc.get("rawText"): annotation_path = annotationdesc - else: + elif annotationdesc.get("file"): annotation_path = f"{location}{annotationdesc['file']}" - labelmap = annotationdesc.get("labelmap") + labelmap = annotationdesc.get("labelmap") if isinstance(labelmap, str): labelmap = load_labelmap(labelmap) - if not annotation_path: + # If annotation_path is still None at this point, then no annotation will be saved. + if annotation_path is None: return None, None - annotation, upload_time = project.save_annotation( + annotation, upload_time, _retry_attempts = project.save_annotation( annotation_path=annotation_path, annotation_labelmap=labelmap, image_id=image_id, job_name=batch_name, + num_retry_uploads=num_retries, + is_prediction=is_prediction, ) return annotation, upload_time @@ -404,6 +711,8 @@ def _upload(imagedesc): with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: list(executor.map(_upload, images)) + return None + def _get_or_create_project(self, project_id, license: str = "MIT", type: str = "object-detection"): try: existing_project = self.project(project_id) @@ -423,9 +732,9 @@ def active_learning( self, raw_data_location: str = "", raw_data_extension: str = "", - inference_endpoint: list = [], + inference_endpoint: Optional[List[str]] = None, upload_destination: str = "", - conditionals: dict = {}, + conditionals: Optional[Dict] = None, use_localhost: bool = False, local_server="http://localhost:9001/", ) -> Any: @@ -439,6 +748,14 @@ def active_learning( use_localhost: (bool) = determines if local http format used or remote endpoint local_server: (str) = local http address for inference server, use_localhost must be True for this to be used """ # noqa: E501 // docs + from roboflow.config import CLIP_FEATURIZE_URL + from roboflow.util.active_learning_utils import check_box_size, clip_encode, count_comparisons + + if inference_endpoint is None: + inference_endpoint = [] + if conditionals is None: + conditionals = {} + import numpy as np prediction_results = [] @@ -472,8 +789,11 @@ def active_learning( else: local = None - inference_model = ( - self.project(inference_endpoint[0]).version(version_number=inference_endpoint[1], local=local).model + # version.model is deprecated; read the underlying legacy model directly. + inference_model = getattr( + self.project(inference_endpoint[0]).version(version_number=inference_endpoint[1], local=local), + "_model", + None, ) upload_project = self.project(upload_destination) @@ -513,7 +833,7 @@ def active_learning( print(image2 + " --> similarity too high to --> " + image1) continue # skip this image if too similar or counter hits limit - predictions = inference_model.predict(image).json()["predictions"] # type: ignore[attribute-error] + predictions = inference_model.predict(image).json()["predictions"] # type: ignore[union-attr] # collect all predictions to return to user at end prediction_results.append({"image": image, "predictions": predictions}) @@ -566,8 +886,834 @@ def active_learning( prediction_results if type(raw_data_location) is not np.ndarray else prediction_results[-1]["predictions"] ) + def deploy_model( + self, + model_type: str, + model_path: str, + project_ids: list[str], + model_name: str, + filename: str = "weights/best.pt", + ): + """Uploads provided weights file to Roboflow. + Args: + model_type (str): The type of the model to be deployed. + model_path (str): File path to the model weights to be uploaded. + project_ids (list[str]): List of project IDs to deploy the model to. + filename (str, optional): The name of the weights file. Defaults to "weights/best.pt". + """ + + from roboflow.util.model_processor import ( + package_custom_weights_interactive, + validate_model_type_for_project, + ) + + if not project_ids: + raise ValueError("At least one project ID must be provided") + + # Validate if provided project URLs belong to user's projects, and look up + # each one's type (already cached on self.project_list β€” no extra API call). + projects_by_id = {p["id"].split("/")[-1]: p for p in self.project_list if "id" in p} + for project_id in project_ids: + if project_id not in projects_by_id: + raise ValueError(f"Project {project_id} is not accessible in this workspace") + + bundle = package_custom_weights_interactive(model_type, model_path, filename, build_dir=model_path) + + for project_id in project_ids: + validate_model_type_for_project(bundle.model_type, projects_by_id[project_id].get("type", ""), project_id) + + self._upload_zip(bundle.model_type, model_path, project_ids, model_name, bundle.archive_path.name) + + def _upload_zip( + self, + model_type: str, + model_path: str, + project_ids: list[str], + model_name: str, + model_file_name: str, + ): + # This endpoint returns a signed URL to upload the model + res = requests.post( + f"{API_URL}/{self.url}/models/prepareUpload?api_key={self.__api_key}&modelType={model_type}&modelName={model_name}&projectIds={','.join(project_ids)}&nocache=true" + ) + try: + res.raise_for_status() + except Exception as e: + error_message = str(e) + status_code = str(res.status_code) + + print("\n\033[91m❌ ERROR\033[0m: Failed to get model deployment URL") + print("\033[93mDetails\033[0m:", error_message) + print("\033[93mStatus\033[0m:", status_code) + print(f"\033[93mResponse\033[0m:\n{res.text}\n") + return + + # Upload the model to the signed URL + res = requests.put( + res.json()["url"], + data=open(os.path.join(model_path, model_file_name), "rb"), + ) + try: + res.raise_for_status() + + for project_id in project_ids: + print( + f"View the status of your deployment for project {project_id} at:" + f" {APP_URL}/{self.url}/{project_id}/models" + ) + + except Exception as e: + print(f"An error occured when uploading the model: {e}") + + def search( + self, + query: str, + page_size: int = 50, + fields: Optional[List[str]] = None, + continuation_token: Optional[str] = None, + ) -> dict: + """Search across all images in the workspace using RoboQL syntax. + + Args: + query: RoboQL search query (e.g. ``"tag:review"``, ``"project:false"`` + for orphan images, or free-text for semantic CLIP search). + page_size: Number of results per page (default 50). + fields: Fields to include in each result. + Defaults to ``["tags", "projects", "filename"]``. + continuation_token: Token returned by a previous call for fetching + the next page. + + Returns: + Dict with ``results`` (list), ``total`` (int), and + ``continuationToken`` (str or None). + + Example: + >>> ws = rf.workspace() + >>> page = ws.search("tag:review", page_size=10) + >>> print(page["total"]) + >>> for img in page["results"]: + ... print(img["filename"]) + """ + if fields is None: + fields = ["tags", "projects", "filename"] + + return rfapi.workspace_search( + api_key=self.__api_key, + workspace_url=self.url, + query=query, + page_size=page_size, + fields=fields, + continuation_token=continuation_token, + ) + + def delete_images(self, image_ids: List[str]) -> dict: + """Delete orphan images from the workspace. + + Only deletes images not associated with any project. + Images still in projects are skipped. + + Args: + image_ids: List of image IDs to delete. + + Returns: + Dict with ``deletedSources`` and ``skippedSources`` counts. + + Example: + >>> ws = rf.workspace() + >>> result = ws.delete_images(["img_id_1", "img_id_2"]) + >>> print(result["deletedSources"]) + """ + return rfapi.workspace_delete_images( + api_key=self.__api_key, + workspace_url=self.url, + image_ids=image_ids, + ) + + def search_all( + self, + query: str, + page_size: int = 50, + fields: Optional[List[str]] = None, + ) -> Generator[List[dict], None, None]: + """Paginated search across all images in the workspace. + + Yields one page of results at a time, automatically following + ``continuationToken`` until all results have been returned. + + Args: + query: RoboQL search query. + page_size: Number of results per page (default 50). + fields: Fields to include in each result. + Defaults to ``["tags", "projects", "filename"]``. + + Yields: + A list of result dicts for each page. + + Example: + >>> ws = rf.workspace() + >>> for page in ws.search_all("tag:review"): + ... for img in page: + ... print(img["filename"]) + """ + token = None + while True: + response = self.search( + query=query, + page_size=page_size, + fields=fields, + continuation_token=token, + ) + results = response.get("results", []) + if not results: + break + yield results + token = response.get("continuationToken") + if not token: + break + + def search_export( + self, + query: str, + format: str = "coco", + location: Optional[str] = None, + dataset: Optional[str] = None, + annotation_group: Optional[str] = None, + name: Optional[str] = None, + extract_zip: bool = True, + ) -> str: + """Export search results as a downloaded dataset. + + Args: + query: Search query string (e.g. ``"tag:annotate"`` or ``"class:apple"``). + format: Annotation format for the export (default ``"coco"``). + location: Local directory to save the exported dataset. + Defaults to ``./search-export-{format}``. + dataset: Limit export to a specific dataset (project) slug. + annotation_group: Limit export to a specific annotation group. + name: Optional name for the export. + extract_zip: If True (default), extract the zip and remove it. + If False, keep the zip file as-is. + + Returns: + Absolute path to the extracted directory or the zip file. + + Raises: + ValueError: If both *dataset* and *annotation_group* are provided. + RoboflowError: On API errors or export timeout. + """ + from roboflow.util.general import extract_zip as _extract_zip + + if dataset is not None and annotation_group is not None: + raise ValueError("dataset and annotation_group are mutually exclusive; provide only one") + + if location is None: + location = f"./search-export-{format}" + location = os.path.abspath(location) + + # 1. Start the export + session = requests.Session() + export_id = rfapi.start_search_export( + api_key=self.__api_key, + workspace_url=self.url, + query=query, + format=format, + dataset=dataset, + annotation_group=annotation_group, + name=name, + session=session, + ) + print(f"Export started (id={export_id}). Polling for completion...") + + status_url = f"{API_URL}/{self.url}/search/export/{export_id}?api_key=YOUR_API_KEY" + print(f"If this takes too long, you can check the export status at: {status_url}") + + # 2. Poll until ready + timeout = 1800 + poll_interval = 5 + elapsed = 0 + + while elapsed < timeout: + status = rfapi.get_search_export( + api_key=self.__api_key, + workspace_url=self.url, + export_id=export_id, + session=session, + ) + if status.get("ready"): + break + time.sleep(poll_interval) + elapsed += poll_interval + else: + raise RoboflowError(f"Search export timed out after {timeout}s") + + download_url = status["link"] + + # 3. Download zip + if not os.path.exists(location): + os.makedirs(location) + + zip_path = os.path.join(location, "roboflow.zip") + response = session.get(download_url, stream=True) + try: + response.raise_for_status() + except HTTPError as e: + raise RoboflowError(f"Failed to download search export: {e}") + + total_length = response.headers.get("content-length") + try: + total_kib = int(total_length) // 1024 + 1 if total_length is not None else None + except (TypeError, ValueError): + total_kib = None + with open(zip_path, "wb") as f: + for chunk in tqdm( + response.iter_content(chunk_size=1024), + desc=f"Downloading search export to {location}", + total=total_kib, + ): + if chunk: + f.write(chunk) + f.flush() + + if extract_zip: + _extract_zip(location, desc=f"Extracting search export to {location}") + print(f"Search export extracted to {location}") + return location + else: + print(f"Search export saved to {zip_path}") + return zip_path + + # ----------------------------------------------------------------- + # Phase 2: Folder management + # ----------------------------------------------------------------- + + def list_folders(self): + """List project folders in this workspace.""" + from roboflow.adapters import rfapi + + return rfapi.list_folders(self.__api_key, self.url) + + def create_folder(self, name, parent_id=None, project_ids=None): + """Create a project folder in this workspace.""" + from roboflow.adapters import rfapi + + return rfapi.create_folder(self.__api_key, self.url, name, parent_id=parent_id, project_ids=project_ids) + + def add_projects_to_folder(self, group_id, project_ids): + """Add projects to an existing folder.""" + from roboflow.adapters import rfapi + + return rfapi.add_projects_to_folder(self.__api_key, self.url, group_id, project_ids) + + def remove_projects_from_folder(self, group_id, project_ids): + """Remove projects from a folder.""" + from roboflow.adapters import rfapi + + return rfapi.remove_projects_from_folder(self.__api_key, self.url, group_id, project_ids) + + # ----------------------------------------------------------------- + # Phase 2: Workflow management + # ----------------------------------------------------------------- + + def list_workflows(self): + """List workflows in this workspace.""" + from roboflow.adapters import rfapi + + return rfapi.list_workflows(self.__api_key, self.url) + + def get_workflow(self, workflow_url): + """Get workflow details.""" + from roboflow.adapters import rfapi + + return rfapi.get_workflow(self.__api_key, self.url, workflow_url) + + def create_workflow(self, name, definition=None): + """Create a new workflow.""" + import json + + from roboflow.adapters import rfapi + + config = json.dumps(definition) if definition else None + return rfapi.create_workflow(self.__api_key, self.url, name=name, config=config) + + # ----------------------------------------------------------------- + # Phase 2: Workspace statistics + # ----------------------------------------------------------------- + + def get_usage(self): + """Get billing usage report for this workspace.""" + from roboflow.adapters import rfapi + + return rfapi.get_billing_usage(self.__api_key, self.url) + + def get_plan(self): + """Get workspace plan info and limits.""" + from roboflow.adapters import rfapi + + return rfapi.get_plan_info(self.__api_key) + + # --- Vision Events --- + + def write_vision_event(self, event: Dict[str, Any]) -> dict: + """Create a single vision event. + + The event dict is passed directly to the server with no client-side + validation, so new event types and fields work without an SDK update. + + Args: + event: Event payload containing at minimum ``eventId``, + ``eventType``, ``useCaseId``, and ``timestamp``. + + Returns: + Dict with ``eventId`` and ``created``. + + Example: + >>> ws = rf.workspace() + >>> ws.write_vision_event({ + ... "eventId": "evt-001", + ... "eventType": "quality_check", + ... "useCaseId": "manufacturing-qa", + ... "timestamp": "2024-01-15T10:30:00.000Z", + ... "eventData": {"result": "pass"}, + ... }) + """ + return vision_events_api.write_event( + api_key=self.__api_key, + event=event, + ) + + def write_vision_events_batch(self, events: List[Dict[str, Any]]) -> dict: + """Create multiple vision events in a single request. + + Args: + events: List of event payload dicts (server enforces max 100). + + Returns: + Dict with ``created`` count and ``eventIds`` list. + + Example: + >>> ws = rf.workspace() + >>> ws.write_vision_events_batch([ + ... {"eventId": "e1", "eventType": "custom", "useCaseId": "uc", "timestamp": "2024-01-15T10:00:00Z"}, + ... {"eventId": "e2", "eventType": "custom", "useCaseId": "uc", "timestamp": "2024-01-15T10:01:00Z"}, + ... ]) + """ + return vision_events_api.write_batch( + api_key=self.__api_key, + events=events, + ) + + def query_vision_events( + self, + use_case: str, + *, + event_type: Optional[str] = None, + event_types: Optional[List[str]] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + **filters: Any, + ) -> dict: + """Query vision events with filters and pagination. + + Common filter kwargs are passed through to the server as-is, + supporting ``deviceId``, ``streamId``, ``workflowId``, + ``customMetadataFilters``, ``eventFieldFilters``, etc. + + Args: + use_case: Use case identifier to query. + event_type: Filter by a single event type. + event_types: Filter by multiple event types. + start_time: ISO 8601 start time filter. + end_time: ISO 8601 end time filter. + limit: Maximum number of events to return. + cursor: Pagination cursor from a previous response. + **filters: Additional filter parameters passed to the API. + + Returns: + Dict with ``events``, ``nextCursor``, ``hasMore``, and ``lookbackDays``. + + Example: + >>> ws = rf.workspace() + >>> page = ws.query_vision_events("manufacturing-qa", event_type="quality_check", limit=50) + >>> for evt in page["events"]: + ... print(evt["eventId"]) + """ + payload: Dict[str, Any] = {"useCaseId": use_case} + if event_type is not None: + payload["eventType"] = event_type + if event_types is not None: + payload["eventTypes"] = event_types + if start_time is not None: + payload["startTime"] = start_time + if end_time is not None: + payload["endTime"] = end_time + if limit is not None: + payload["limit"] = limit + if cursor is not None: + payload["cursor"] = cursor + payload.update(filters) + + return vision_events_api.query( + api_key=self.__api_key, + query_params=payload, + ) + + def query_all_vision_events( + self, + use_case: str, + *, + event_type: Optional[str] = None, + event_types: Optional[List[str]] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + limit: Optional[int] = None, + **filters: Any, + ) -> Generator[List[dict], None, None]: + """Paginated query across vision events, yielding one page at a time. + + Automatically follows ``nextCursor`` until all matching events have + been returned. + + Args: + use_case: Use case identifier to query. + event_type: Filter by a single event type. + event_types: Filter by multiple event types. + start_time: ISO 8601 start time filter. + end_time: ISO 8601 end time filter. + limit: Maximum events per page. + **filters: Additional filter parameters passed to the API. + + Yields: + A list of event dicts for each page. + + Example: + >>> ws = rf.workspace() + >>> for page in ws.query_all_vision_events("manufacturing-qa"): + ... for evt in page: + ... print(evt["eventId"]) + """ + cursor = None + while True: + response = self.query_vision_events( + use_case, + event_type=event_type, + event_types=event_types, + start_time=start_time, + end_time=end_time, + limit=limit, + cursor=cursor, + **filters, + ) + events = response.get("events", []) + if not events: + break + yield events + cursor = response.get("nextCursor") + if not cursor or not response.get("hasMore", False): + break + + def list_vision_event_use_cases(self, status: Optional[str] = None) -> dict: + """List all vision event use cases for the workspace. + + Args: + status: Optional status filter (e.g. "active", "inactive"). + + Returns: + Dict with ``useCases`` list and ``lookbackDays``. + + Example: + >>> ws = rf.workspace() + >>> result = ws.list_vision_event_use_cases() + >>> for uc in result["useCases"]: + ... print(uc["name"], uc.get("status")) + """ + result = vision_events_api.list_use_cases( + api_key=self.__api_key, + status=status, + ) + if "useCases" not in result and "solutions" in result: + result["useCases"] = result["solutions"] + return result + + def create_vision_event_use_case(self, name: str) -> dict: + """Create a new vision event use case. + + Args: + name: Human-readable name for the use case. + + Returns: + Dict with ``id`` and ``name``. + + Example: + >>> ws = rf.workspace() + >>> result = ws.create_vision_event_use_case("manufacturing-qa") + >>> use_case_id = result["id"] + """ + return vision_events_api.create_use_case( + api_key=self.__api_key, + name=name, + ) + + def rename_vision_event_use_case(self, use_case: str, name: str) -> dict: + """Rename an existing vision event use case. + + Args: + use_case: Use case identifier. + name: New name for the use case. + + Returns: + Dict with ``id`` and ``name``. + + Example: + >>> ws = rf.workspace() + >>> ws.rename_vision_event_use_case("abc123", "new-name") + """ + return vision_events_api.rename_use_case( + api_key=self.__api_key, + use_case_id=use_case, + name=name, + ) + + def archive_vision_event_use_case(self, use_case: str) -> dict: + """Archive a vision event use case. + + Args: + use_case: Use case identifier. + + Returns: + Dict with ``success``. + + Example: + >>> ws = rf.workspace() + >>> ws.archive_vision_event_use_case("abc123") + """ + return vision_events_api.archive_use_case( + api_key=self.__api_key, + use_case_id=use_case, + ) + + def unarchive_vision_event_use_case(self, use_case: str) -> dict: + """Unarchive a vision event use case. + + Args: + use_case: Use case identifier. + + Returns: + Dict with ``success``. + + Example: + >>> ws = rf.workspace() + >>> ws.unarchive_vision_event_use_case("abc123") + """ + return vision_events_api.unarchive_use_case( + api_key=self.__api_key, + use_case_id=use_case, + ) + + def get_vision_event_metadata_schema(self, use_case: str) -> dict: + """Get the custom metadata schema for a vision event use case. + + Returns discovered field names and their types, useful for building + queries with ``customMetadataFilters``. + + Args: + use_case: Use case identifier. + + Returns: + Dict with ``fields`` mapping field names to ``{"types": [...]}``. + + Example: + >>> ws = rf.workspace() + >>> schema = ws.get_vision_event_metadata_schema("manufacturing-qa") + >>> for field, info in schema["fields"].items(): + ... print(field, info["types"]) + """ + return vision_events_api.get_custom_metadata_schema( + api_key=self.__api_key, + use_case_id=use_case, + ) + + def upload_vision_event_image( + self, + image_path: str, + name: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> dict: + """Upload an image for use in vision events. + + Args: + image_path: Local path to the image file. + name: Optional custom name for the image. + metadata: Optional flat dict of metadata to attach. + + Returns: + Dict with ``sourceId`` for referencing in events. + + Example: + >>> ws = rf.workspace() + >>> result = ws.upload_vision_event_image("photo.jpg") + >>> source_id = result["sourceId"] + """ + return vision_events_api.upload_image( + api_key=self.__api_key, + image_path=image_path, + name=name, + metadata=metadata, + ) + + # ----------------------------------------------------------------- + # Model evaluations + # ----------------------------------------------------------------- + + def evals( + self, + *, + project: Optional[str] = None, + version: Optional[str] = None, + model: Optional[str] = None, + status: Optional[str] = None, + limit: Optional[int] = None, + ) -> List["ModelEval"]: + """List model evaluations in this workspace. + + Args: + project: Filter by project slug or id. + version: Filter by version id (or numeric version). + model: Filter by model id. + status: Filter by status β€” one of ``"running"``, ``"done"``, ``"failed"``. + limit: Max evals to return (server caps at 200; default 50). + + Returns: + A list of :class:`ModelEval` instances pre-populated with the + metadata from the list response (``status``, ``createdAt``, etc.). + Call :meth:`ModelEval.refresh` to re-fetch the header, or any + panel method to load detailed data. + + Example: + >>> ws = rf.workspace("lee-sandbox") + >>> done = ws.evals(status="done", limit=5) + >>> for ev in done: + ... print(ev.id, ev.summary) + """ + from roboflow.core.model_eval import ModelEval + + result = rfapi.list_model_evals( + self.__api_key, + self.url, + project=project, + version=version, + model=model, + status=status, + limit=limit, + ) + # Server returns `evalId` (per DNA); fall back to legacy `id` for forward-compat. + return [ + ModelEval(self.__api_key, self.url, e.get("evalId") or e["id"], info=e) for e in result.get("evals", []) + ] + + def eval(self, eval_id: str) -> "ModelEval": + """Fetch a single model eval by id. + + Raises: + roboflow.adapters.rfapi.ModelEvalNotFoundError: If the id doesn't + exist in this workspace (HTTP 404). + + Example: + >>> ws = rf.workspace("lee-sandbox") + >>> ev = ws.eval("huUF720inUcymARwqAGK") + >>> ev.summary["mAP"] + """ + from roboflow.core.model_eval import ModelEval + + info = rfapi.get_model_eval(self.__api_key, self.url, eval_id) + return ModelEval(self.__api_key, self.url, info.get("id", eval_id), info=info) + + def trash(self) -> dict: + """ + List items currently in the workspace Trash. + + Returns a dict with: + - `items`: flat list of everything in Trash + - `sections`: grouped by `projects`, `versions`, `workflows` + Each item includes `id`, `type`, `name`, `deletedAt`, + `scheduledCleanupAt`, and β€” for versions β€” `parentId` / `parentUrl`. + + Example: + >>> import roboflow + >>> rf = roboflow.Roboflow(api_key="") + >>> ws = rf.workspace() + >>> trash = ws.trash() + >>> for item in trash["items"]: + ... print(item["type"], item["name"]) + """ + return rfapi.list_trash(self.__api_key, self.url) + + def restore_from_trash(self, item_type: str, item_id: str, parent_id: Optional[str] = None): + """ + Restore an item from Trash. + + Args: + item_type: one of "project", "version", "workflow" + item_id: the item's Firestore id (found via `trash()`) + parent_id: required when restoring a version β€” the parent project id + + Returns: + dict: Server response with `{restored: True, type, id}`. + """ + return rfapi.restore_trash_item(self.__api_key, self.url, item_type, item_id, parent_id) + + # Permanent-delete actions (empty trash / delete a single trash item + # immediately) are intentionally not exposed in the SDK β€” they destroy + # data irrecoverably and are only available through the web UI's Trash + # view. Items left in Trash are cleaned up automatically after 30 days. + def __str__(self): projects = self.projects() json_value = {"name": self.name, "url": self.url, "projects": projects} return json.dumps(json_value, indent=2) + + +def _zip_directory(src_dir: str) -> str: + """Zip src_dir into a temp file, skipping hidden and macOS-junk entries.""" + fd, zip_path = tempfile.mkstemp(suffix=".zip", prefix="roboflow-upload-") + os.close(fd) + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(src_dir): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__MACOSX"] + for name in files: + if name.startswith(".") or name == "Thumbs.db": + continue + abs_path = os.path.join(root, name) + rel = os.path.relpath(abs_path, src_dir) + zf.write(abs_path, arcname=rel) + return zip_path + + +def _poll_zip_status( + api_key: str, + workspace_url: str, + task_id: str, + poll_interval: float, + poll_timeout: float, +) -> dict: + deadline = time.monotonic() + poll_timeout + last_progress = None + while True: + status = rfapi.get_zip_upload_status(api_key, workspace_url, task_id) + state = status.get("status") + progress = (status.get("progress") or {}).get("current") + if progress is not None and progress != last_progress: + print(f" zip-upload progress: {progress}") + last_progress = progress + if state in {"completed", "failed"}: + return status + if time.monotonic() >= deadline: + raise RoboflowError( + f"Zip upload polling timed out after {poll_timeout}s " + f"(task_id={task_id}, last_status={state}). " + f"Call Workspace.upload_dataset(..., wait=False) and poll with " + f"rfapi.get_zip_upload_status to check later." + ) + time.sleep(poll_interval) diff --git a/roboflow/deployment.py b/roboflow/deployment.py index 3c415d7e..1657e5a0 100644 --- a/roboflow/deployment.py +++ b/roboflow/deployment.py @@ -1,11 +1,48 @@ import json import time -from datetime import datetime +from datetime import datetime, timedelta from roboflow.adapters import deploymentapi from roboflow.config import load_roboflow_api_key +def is_valid_ISO8601_timestamp(ts): + try: + datetime.fromisoformat(ts) + return True + except (ValueError, TypeError): + return False + + +def check_from_to_timestamp(from_timestamp, to_timestamp, default_timedelta): + if from_timestamp and not is_valid_ISO8601_timestamp(from_timestamp): + print("Please provide a valid from_timestamp in ISO8601 format (YYYY-MM-DD HH:MM:SS)") + exit(1) + + if to_timestamp and not is_valid_ISO8601_timestamp(to_timestamp): + print("Please provide a valid to_timestamp in ISO8601 format (YYYY-MM-DD HH:MM:SS)") + exit(1) + + time_now = datetime.now().astimezone() # local timezone + if from_timestamp is None and to_timestamp is None: + from_timestamp = time_now - default_timedelta + to_timestamp = time_now + elif from_timestamp is not None and to_timestamp is None: + from_timestamp = datetime.fromisoformat(from_timestamp).astimezone() + to_timestamp = from_timestamp + default_timedelta + elif from_timestamp is None and to_timestamp is not None: + to_timestamp = datetime.fromisoformat(to_timestamp).astimezone() + from_timestamp = to_timestamp - default_timedelta + else: + from_timestamp = datetime.fromisoformat(from_timestamp).astimezone() + to_timestamp = datetime.fromisoformat(to_timestamp).astimezone() + if from_timestamp >= to_timestamp: + print("from_timestamp should be earlier than to_timestamp") + exit(1) + + return from_timestamp, to_timestamp + + def add_deployment_parser(subparsers): deployment_parser = subparsers.add_parser( "deployment", @@ -18,7 +55,16 @@ def add_deployment_parser(subparsers): "get", help="show detailed info for a dedicated deployment" ) deployment_list_parser = deployment_subparsers.add_parser("list", help="list dedicated deployments in a workspace") + deployment_usage_workspace_parser = deployment_subparsers.add_parser( + "usage_workspace", help="get all dedicated deployments usage in a workspace" + ) + deployment_usage_deployment_parser = deployment_subparsers.add_parser( + "usage_deployment", help="get usage of a specific dedicated deployments" + ) + deployment_pause_parser = deployment_subparsers.add_parser("pause", help="pause a dedicated deployment") + deployment_resume_parser = deployment_subparsers.add_parser("resume", help="resume a dedicated deployment") deployment_delete_parser = deployment_subparsers.add_parser("delete", help="delete a dedicated deployment") + deployment_log_parser = deployment_subparsers.add_parser("log", help="show log info for a dedicated deployment") deployment_machine_type_parser.set_defaults(func=list_machine_types) deployment_machine_type_parser.add_argument("-a", "--api_key", help="api key") @@ -33,7 +79,13 @@ def add_deployment_parser(subparsers): # "-s", "--security_level", help="security level (protected)", default="protected" # ) deployment_add_parser.add_argument( - "-m", "--machine_type", help="machine type, run `roboflow deployment machine_type` to see available options" + "-m", + "--machine_type", + help="machine type, run `roboflow deployment machine_type` to see available options", + required=True, + ) + deployment_add_parser.add_argument( + "-e", "--creator_email", help="your email address (must be added to the workspace)", required=True ) deployment_add_parser.add_argument( "-t", @@ -43,7 +95,7 @@ def add_deployment_parser(subparsers): default=3, ) deployment_add_parser.add_argument( - "-e", "--no_delete_on_expiration", help="keep when expired (default: False)", action="store_true" + "-nodel", "--no_delete_on_expiration", help="keep when expired (default: False)", action="store_true" ) deployment_add_parser.add_argument( "-v", @@ -65,20 +117,58 @@ def add_deployment_parser(subparsers): deployment_list_parser.set_defaults(func=list_deployment) deployment_list_parser.add_argument("-a", "--api_key", help="api key") + deployment_usage_workspace_parser.set_defaults(func=get_workspace_usage) + deployment_usage_workspace_parser.add_argument("-a", "--api_key", help="api key") + deployment_usage_workspace_parser.add_argument( + "-f", "--from_timestamp", help="begin time stamp in ISO8601 format (YYYY-MM-DD HH:MM:SS)", default=None + ) + deployment_usage_workspace_parser.add_argument( + "-t", "--to_timestamp", help="end time stamp in ISO8601 format (YYYY-MM-DD HH:MM:SS)", default=None + ) + + deployment_usage_deployment_parser.set_defaults(func=get_deployment_usage) + deployment_usage_deployment_parser.add_argument("-a", "--api_key", help="api key") + deployment_usage_deployment_parser.add_argument("deployment_name", help="deployment name") + deployment_usage_deployment_parser.add_argument( + "-f", "--from_timestamp", help="begin time stamp in ISO8601 format (YYYY-MM-DD HH:MM:SS)", default=None + ) + deployment_usage_deployment_parser.add_argument( + "-t", "--to_timestamp", help="end time stamp in ISO8601 format (YYYY-MM-DD HH:MM:SS)", default=None + ) + + deployment_pause_parser.set_defaults(func=pause_deployment) + deployment_pause_parser.add_argument("-a", "--api_key", help="api key") + deployment_pause_parser.add_argument("deployment_name", help="deployment name") + + deployment_resume_parser.set_defaults(func=resume_deployment) + deployment_resume_parser.add_argument("-a", "--api_key", help="api key") + deployment_resume_parser.add_argument("deployment_name", help="deployment name") + deployment_delete_parser.set_defaults(func=delete_deployment) deployment_delete_parser.add_argument("-a", "--api_key", help="api key") deployment_delete_parser.add_argument("deployment_name", help="deployment name") + deployment_log_parser.set_defaults(func=get_deployment_log) + deployment_log_parser.add_argument("-a", "--api_key", help="api key") + deployment_log_parser.add_argument("deployment_name", help="deployment name") + deployment_log_parser.add_argument( + "-d", "--duration", help="duration of log (from now) in seconds", type=int, default=3600 + ) + deployment_log_parser.add_argument( + "-n", "--tail", help="number of lines to show from the end of the logs (<= 50)", type=int, default=10 + ) + deployment_log_parser.add_argument("-f", "--follow", help="follow log output", action="store_true") + def list_machine_types(args): api_key = args.api_key or load_roboflow_api_key(None) if api_key is None: print("Please provide an api key") - return + exit(1) status_code, msg = deploymentapi.list_machine_types(api_key) if status_code != 200: print(f"{status_code}: {msg}") - return + exit(status_code) print(json.dumps(msg, indent=2)) @@ -86,9 +176,10 @@ def add_deployment(args): api_key = args.api_key or load_roboflow_api_key(None) if api_key is None: print("Please provide an api key") - return + exit(1) status_code, msg = deploymentapi.add_deployment( api_key, + args.creator_email, # args.security_level, args.machine_type, args.duration, @@ -99,7 +190,7 @@ def add_deployment(args): if status_code != 200: print(f"{status_code}: {msg}") - return + exit(status_code) else: print(f"Deployment {args.deployment_name} created successfully") print(json.dumps(msg, indent=2)) @@ -112,18 +203,18 @@ def get_deployment(args): api_key = args.api_key or load_roboflow_api_key(None) if api_key is None: print("Please provide an api key") - return + exit(1) while True: status_code, msg = deploymentapi.get_deployment(api_key, args.deployment_name) if status_code != 200: print(f"{status_code}: {msg}") - return + exit(status_code) if (not args.wait_on_pending) or msg["status"] != "pending": print(json.dumps(msg, indent=2)) break - print(f'{datetime.now().strftime("%H:%M:%S")} Waiting for deployment {args.deployment_name} to be ready...\n') + print(f"{datetime.now().strftime('%H:%M:%S')} Waiting for deployment {args.deployment_name} to be ready...\n") time.sleep(30) @@ -131,11 +222,63 @@ def list_deployment(args): api_key = args.api_key or load_roboflow_api_key(None) if api_key is None: print("Please provide an api key") - return + exit(1) status_code, msg = deploymentapi.list_deployment(api_key) if status_code != 200: print(f"{status_code}: {msg}") - return + exit(status_code) + print(json.dumps(msg, indent=2)) + + +def get_workspace_usage(args): + api_key = args.api_key or load_roboflow_api_key(None) + if api_key is None: + print("Please provide an api key") + exit(1) + + from_timestamp, to_timestamp = check_from_to_timestamp(args.from_timestamp, args.to_timestamp, timedelta(days=1)) + status_code, msg = deploymentapi.get_workspace_usage(api_key, from_timestamp, to_timestamp) + if status_code != 200: + print(f"{status_code}: {msg}") + exit(status_code) + print(json.dumps(msg, indent=2)) + + +def get_deployment_usage(args): + api_key = args.api_key or load_roboflow_api_key(None) + if api_key is None: + print("Please provide an api key") + exit(1) + + from_timestamp, to_timestamp = check_from_to_timestamp(args.from_timestamp, args.to_timestamp, timedelta(days=1)) + status_code, msg = deploymentapi.get_deployment_usage(api_key, args.deployment_name, from_timestamp, to_timestamp) + if status_code != 200: + print(f"{status_code}: {msg}") + exit(status_code) + print(json.dumps(msg, indent=2)) + + +def pause_deployment(args): + api_key = args.api_key or load_roboflow_api_key(None) + if api_key is None: + print("Please provide an api key") + exit(1) + status_code, msg = deploymentapi.pause_deployment(api_key, args.deployment_name) + if status_code != 200: + print(f"{status_code}: {msg}") + exit(status_code) + print(json.dumps(msg, indent=2)) + + +def resume_deployment(args): + api_key = args.api_key or load_roboflow_api_key(None) + if api_key is None: + print("Please provide an api key") + exit(1) + status_code, msg = deploymentapi.resume_deployment(api_key, args.deployment_name) + if status_code != 200: + print(f"{status_code}: {msg}") + exit(status_code) print(json.dumps(msg, indent=2)) @@ -143,9 +286,45 @@ def delete_deployment(args): api_key = args.api_key or load_roboflow_api_key(None) if api_key is None: print("Please provide an api key") - return + exit(1) status_code, msg = deploymentapi.delete_deployment(api_key, args.deployment_name) if status_code != 200: print(f"{status_code}: {msg}") - return + exit(status_code) print(json.dumps(msg, indent=2)) + + +def get_deployment_log(args): + api_key = args.api_key or load_roboflow_api_key(None) + if api_key is None: + print("Please provide an api key") + exit(1) + + to_timestamp = datetime.now().astimezone() # local timezone + from_timestamp = to_timestamp - timedelta(seconds=args.duration) + last_log_timestamp = from_timestamp + log_ids = set() # to avoid duplicate logs + max_entries = args.tail + while True: + status_code, msg = deploymentapi.get_deployment_log( + api_key, args.deployment_name, from_timestamp, to_timestamp, max_entries + ) + if status_code != 200: + print(f"{status_code}: {msg}") + exit(status_code) + + for log in msg[::-1]: # logs are sorted by reversed timestamp + log_timestamp = datetime.fromisoformat(log["timestamp"]).astimezone() # local timezone + if (log["insert_id"] in log_ids) or (log_timestamp < last_log_timestamp): + continue + log_ids.add(log["insert_id"]) + last_log_timestamp = log_timestamp + print(f"[{log_timestamp.strftime('%Y-%m-%d %H:%M:%S.%f')}] {log['payload']}") + + if not args.follow: + break + + time.sleep(10) + from_timestamp = last_log_timestamp + to_timestamp = datetime.now().astimezone() # local timezone + max_entries = 300 # only set max_entries for the first request diff --git a/roboflow/models/classification.py b/roboflow/models/classification.py index c482fdaf..15c8be94 100644 --- a/roboflow/models/classification.py +++ b/roboflow/models/classification.py @@ -51,7 +51,7 @@ def __init__( self.id = id self.name = name self.version = version - self.base_url = "https://classify.roboflow.com/" + self.base_url = "https://serverless.roboflow.com/" if self.name is not None and version is not None: self.__generate_url() @@ -81,7 +81,7 @@ def predict(self, image_path, hosted=False): # type: ignore[override] >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("YOUR_IMAGE.jpg") """ diff --git a/roboflow/models/inference.py b/roboflow/models/inference.py index 7d2f5653..0a0fffc7 100644 --- a/roboflow/models/inference.py +++ b/roboflow/models/inference.py @@ -3,7 +3,7 @@ import os import time import urllib -from typing import Optional, Tuple +from typing import List, Optional, Tuple from urllib.parse import urljoin import requests @@ -62,7 +62,7 @@ def __get_image_params(self, image_path): Get parameters about an image (i.e. dimensions) for use in an inference request. Args: - image_path (str): path to the image you'd like to perform prediction on + image_path (Union[str, np.ndarray]): path to image or numpy array Returns: Tuple containing a dict of querystring params and a dict of requests kwargs @@ -70,6 +70,18 @@ def __get_image_params(self, image_path): Raises: Exception: Image path is not valid """ + import numpy as np + + if isinstance(image_path, np.ndarray): + # Convert numpy array to PIL Image + image = Image.fromarray(image_path) + dimensions = image.size + image_dims = {"width": str(dimensions[0]), "height": str(dimensions[1])} + buffered = io.BytesIO() + image.save(buffered, quality=90, format="JPEG") + data = MultipartEncoder(fields={"file": ("imageToUpload", buffered.getvalue(), "image/jpeg")}) + return {}, {"data": data, "headers": {"Content-Type": data.content_type}}, image_dims + validate_image_path(image_path) hosted_image = urllib.parse.urlparse(image_path).scheme in ("http", "https") @@ -112,7 +124,7 @@ def predict(self, image_path, prediction_type=None, **kwargs): >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("YOUR_IMAGE.jpg") """ @@ -137,7 +149,7 @@ def predict_video( self, video_path: str, fps: int = 5, - additional_models: list = [], + additional_models: Optional[List[str]] = None, prediction_type: str = "batch-video", ) -> Tuple[str, str, Optional[str]]: """ @@ -158,7 +170,7 @@ def predict_video( >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> job_id,signed_url,signed_url_expires = model.predict_video("video.mp4" ,fps=5, inference_type="object-detection") @@ -170,6 +182,9 @@ def predict_video( if fps > 120: raise Exception("FPS must be less than or equal to 120.") + if additional_models is None: + additional_models = [] + for model in additional_models: if model not in SUPPORTED_ADDITIONAL_MODELS: raise Exception(f"Model {model} is not supported for video inference.") @@ -292,7 +307,7 @@ def poll_for_video_results(self, job_id: Optional[str] = None) -> dict: >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("video.mp4") @@ -340,7 +355,7 @@ def poll_until_video_results(self, job_id) -> dict: >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("video.mp4") diff --git a/roboflow/models/instance_segmentation.py b/roboflow/models/instance_segmentation.py index b26c1f36..a04ccc8e 100644 --- a/roboflow/models/instance_segmentation.py +++ b/roboflow/models/instance_segmentation.py @@ -53,7 +53,7 @@ def predict(self, image_path, confidence=40): # type: ignore[override] >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("YOUR_IMAGE.jpg") """ # noqa: E501 diff --git a/roboflow/models/keypoint_detection.py b/roboflow/models/keypoint_detection.py index a0e86561..c3b7321e 100644 --- a/roboflow/models/keypoint_detection.py +++ b/roboflow/models/keypoint_detection.py @@ -8,7 +8,7 @@ import requests from PIL import Image -from roboflow.config import CLASSIFICATION_MODEL +from roboflow.config import KEYPOINT_DETECTION_MODEL from roboflow.models.inference import InferenceModel from roboflow.util.image_utils import check_image_url from roboflow.util.prediction import PredictionGroup @@ -26,6 +26,7 @@ def __init__( id: str, name: Optional[str] = None, version: Optional[str] = None, + confidence: Optional[int] = 40, local: Optional[str] = None, ): """ @@ -37,6 +38,7 @@ def __init__( name (str): is the name of the project version (str): version number local (str): localhost address and port if pointing towards local inference engine + confidence (int): A threshold for the returned predictions on a scale of 0-100. colors (dict): colors to use for the image preprocessing (dict): preprocessing to use for the image @@ -48,8 +50,10 @@ def __init__( self.__api_key = api_key self.id = id self.name = name + self.confidence = confidence self.version = version - self.base_url = "https://detect.roboflow.com/" + self.colors = {} + self.base_url = "https://serverless.roboflow.com/" if self.name is not None and version is not None: self.__generate_url() @@ -58,7 +62,7 @@ def __init__( print(f"initalizing local keypoint detection model hosted at : {local}") self.base_url = local - def predict(self, image_path, hosted=False): # type: ignore[override] + def predict(self, image_path, hosted=False, confidence=None): # type: ignore[override] """ Run inference on an image. @@ -76,11 +80,14 @@ def predict(self, image_path, hosted=False): # type: ignore[override] >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("YOUR_IMAGE.jpg") """ - self.__generate_url() + if confidence is not None: + self.confidence = confidence + + self.__generate_url(confidence=confidence) self.__exception_check(image_path_check=image_path) # If image is local image if not hosted: @@ -113,7 +120,7 @@ def predict(self, image_path, hosted=False): # type: ignore[override] resp.json(), image_dims=img_dims, image_path=image_path, - prediction_type=CLASSIFICATION_MODEL, + prediction_type=KEYPOINT_DETECTION_MODEL, colors=self.colors, ) @@ -130,7 +137,7 @@ def load_model(self, name, version): self.version = version self.__generate_url() - def __generate_url(self): + def __generate_url(self, confidence=None): """ Generate a Roboflow API URL on which to run inference. @@ -145,11 +152,15 @@ def __generate_url(self): if not version and len(splitted) > 2: version = splitted[2] + if confidence is not None: + self.confidence = confidence + self.api_url = "".join( [ self.base_url + without_workspace + "/" + str(version), "?api_key=" + self.__api_key, "&name=YOUR_IMAGE.jpg", + "&confidence=" + str(self.confidence), ] ) @@ -175,6 +186,7 @@ def __str__(self): json_value = { "name": self.name, "version": self.version, + "confidence": self.confidence, "base_url": self.base_url, } diff --git a/roboflow/models/object_detection.py b/roboflow/models/object_detection.py index 38005901..5793ec86 100644 --- a/roboflow/models/object_detection.py +++ b/roboflow/models/object_detection.py @@ -152,7 +152,7 @@ def predict( # type: ignore[override] >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("YOUR_IMAGE.jpg") """ @@ -172,13 +172,17 @@ def predict( # type: ignore[override] else: self.__exception_check(image_path_check=image_path) - resize = False original_dimensions = None + should_resize = False # If image is local image if not hosted: import cv2 import numpy as np + should_resize = ( + "resize" in self.preprocessing.keys() and "Stretch" in self.preprocessing["resize"]["format"] + ) + if isinstance(image_path, str): image = Image.open(image_path).convert("RGB") dimensions = image.size @@ -186,7 +190,7 @@ def predict( # type: ignore[override] # Here we resize the image to the preprocessing settings # before sending it over the wire - if "resize" in self.preprocessing.keys(): + if should_resize: if dimensions[0] > int(self.preprocessing["resize"]["width"]) or dimensions[1] > int( self.preprocessing["resize"]["height"] ): @@ -197,7 +201,6 @@ def predict( # type: ignore[override] ) ) dimensions = image.size - resize = True # Create buffer buffered = io.BytesIO() @@ -245,7 +248,7 @@ def predict( # type: ignore[override] if self.format == "json": resp_json = resp.json() - if resize and original_dimensions is not None: + if should_resize and original_dimensions is not None: new_preds = [] for p in resp_json["predictions"]: p["x"] = int(p["x"] * (int(original_dimensions[0]) / int(self.preprocessing["resize"]["width"]))) @@ -275,7 +278,7 @@ def predict( # type: ignore[override] def webcam( self, webcam_id=0, - inference_engine_url="https://detect.roboflow.com/", + inference_engine_url="https://serverless.roboflow.com/", within_jupyter=False, confidence=40, overlap=30, @@ -288,7 +291,7 @@ def webcam( Args: webcam_id (int): Webcam ID (default 0) - inference_engine_url (str): Inference engine address to use (default https://detect.roboflow.com) + inference_engine_url (str): Inference engine address to use (default https://serverless.roboflow.com) within_jupyter (bool): Whether or not to display the webcam within Jupyter notebook (default True) confidence (int): Confidence threshold for detections overlap (int): Overlap threshold for detections diff --git a/roboflow/models/semantic_segmentation.py b/roboflow/models/semantic_segmentation.py index c15b0c74..5dfd5659 100644 --- a/roboflow/models/semantic_segmentation.py +++ b/roboflow/models/semantic_segmentation.py @@ -36,7 +36,7 @@ def predict(self, image_path: str, confidence: int = 50): # type: ignore[overri >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("YOUR_IMAGE.jpg") """ # noqa: E501 // docs diff --git a/roboflow/models/video.py b/roboflow/models/video.py index 401a2aab..e1cae97b 100644 --- a/roboflow/models/video.py +++ b/roboflow/models/video.py @@ -90,7 +90,7 @@ def predict( # type: ignore[override] >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("video.mp4", fps=5, inference_type="object-detection") """ # noqa: E501 // docs @@ -164,7 +164,7 @@ def poll_for_results(self, job_id: Optional[str] = None) -> dict: >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("video.mp4") @@ -216,7 +216,7 @@ def poll_until_results(self, job_id) -> dict: >>> project = rf.workspace().project("PROJECT_ID") - >>> model = project.version("1").model + >>> model = project.version("1").models()[0] >>> prediction = model.predict("video.mp4") diff --git a/roboflow/models/vlm.py b/roboflow/models/vlm.py new file mode 100644 index 00000000..b24c3ceb --- /dev/null +++ b/roboflow/models/vlm.py @@ -0,0 +1,95 @@ +"""Vision-language (text-image-pairs) hosted inference. + +Wraps the serverless endpoint for VLM-style projects (e.g. PaliGemma). +Unlike detection/classification models, the response shape is free-form: +captions, VQA answers, OCR text, or tokenized detections depending on the +underlying model. `predict` returns the raw serverless JSON unchanged so +callers can interpret the payload for their specific model. +""" + +from __future__ import annotations + +import base64 +import io +import os +import urllib.parse +from typing import Any, Optional + +import requests +from PIL import Image + +from roboflow.models.inference import InferenceModel +from roboflow.util.image_utils import check_image_url + + +class VLMModel(InferenceModel): + """Run inference on a hosted text-image-pairs (VLM) model.""" + + def __init__( + self, + api_key: str, + id: str, + name: Optional[str] = None, + version: Optional[str] = None, + local: Optional[str] = None, + colors: Optional[dict] = None, + preprocessing: Optional[dict] = None, + ) -> None: + super().__init__(api_key, id, version=version) + self.__api_key = api_key + self.id = id + self.name = name + self.version = version + self.base_url = local if local else "https://serverless.roboflow.com/" + self.colors = {} if colors is None else colors + self.preprocessing = {} if preprocessing is None else preprocessing + + def _endpoint(self) -> str: + parts = self.id.rsplit("/") + without_workspace = parts[1] + version = self.version + if not version and len(parts) > 2: + version = parts[2] + base = self.base_url if self.base_url.endswith("/") else self.base_url + "/" + return f"{base}{without_workspace}/{version}" + + def predict(self, image_path: str, **kwargs: Any) -> dict: # type: ignore[override] + """Run inference and return the raw serverless response. + + Args: + image_path: local path or http(s) URL to an image. + **kwargs: extra query params forwarded to the endpoint. + + Returns: + The raw JSON response as a dict. Shape depends on the underlying + VLM (e.g. `{"response": {">": "..."}}` for PaliGemma). + """ + is_url = urllib.parse.urlparse(image_path).scheme in ("http", "https") + + params: dict[str, Any] = {"api_key": self.__api_key} + params.update(kwargs) + + if is_url: + if not check_image_url(image_path): + raise Exception(f"Image URL is not reachable: {image_path}") + params["image"] = image_path + url = f"{self._endpoint()}?{urllib.parse.urlencode(params)}" + resp = requests.get(url) + else: + if not os.path.exists(image_path): + raise Exception(f"Image does not exist at {image_path}!") + image = Image.open(image_path).convert("RGB") + buffered = io.BytesIO() + image.save(buffered, quality=90, format="JPEG") + img_b64 = base64.b64encode(buffered.getvalue()).decode("ascii") + url = f"{self._endpoint()}?{urllib.parse.urlencode(params)}" + resp = requests.post( + url, + data=img_b64, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if resp.status_code != 200: + raise Exception(resp.text) + + return resp.json() diff --git a/roboflow/roboflowpy.py b/roboflow/roboflowpy.py index 86e00832..e589e9a6 100755 --- a/roboflow/roboflowpy.py +++ b/roboflow/roboflowpy.py @@ -1,552 +1,20 @@ #!/usr/bin/env python3 -import argparse -import json -import re +"""Backwards-compatibility shim. -import roboflow -from roboflow import config as roboflow_config -from roboflow import deployment -from roboflow.adapters import rfapi -from roboflow.config import APP_URL, get_conditional_configuration_variable, load_roboflow_api_key -from roboflow.models.classification import ClassificationModel -from roboflow.models.instance_segmentation import InstanceSegmentationModel -from roboflow.models.keypoint_detection import KeypointDetectionModel -from roboflow.models.object_detection import ObjectDetectionModel -from roboflow.models.semantic_segmentation import SemanticSegmentationModel +The CLI implementation has moved to :mod:`roboflow.cli`. This module +re-exports ``main`` so that the ``setup.py`` entry-point +(``roboflow=roboflow.roboflowpy:main``) continues to work without changes. +It also re-exports legacy function names so that existing scripts doing +``from roboflow.roboflowpy import _argparser`` (etc.) continue to work. +""" -def login(args): - roboflow.login() +from roboflow.cli import build_parser, main +# Legacy alias: some scripts import _argparser directly +_argparser = build_parser -def _parse_url(url): - regex = r"(?:https?://)?(?:universe|app)\.roboflow\.(?:com|one)/([^/]+)/([^/]+)(?:/dataset)?(?:/(\d+))?|([^/]+)/([^/]+)(?:/(\d+))?" # noqa: E501 - match = re.match(regex, url) - if match: - organization = match.group(1) or match.group(4) - dataset = match.group(2) or match.group(5) - version = match.group(3) or match.group(6) # This can be None if not present in the URL - return organization, dataset, version - return None, None, None - - -def download(args): - rf = roboflow.Roboflow() - w, p, v = _parse_url(args.datasetUrl) - project = rf.workspace(w).project(p) - if not v: - versions = project.versions() - if not versions: - print(f"project {p} does not have any version. exiting") - exit(1) - version = versions[-1] - print(f"Version not provided. Downloading last one ({version.version})") - else: - version = project.version(int(v)) - version.download(args.format, location=args.location, overwrite=True) - - -def import_dataset(args): - api_key = load_roboflow_api_key(args.workspace) - rf = roboflow.Roboflow(api_key) - workspace = rf.workspace(args.workspace) - workspace.upload_dataset( - dataset_path=args.folder, - project_name=args.project, - num_workers=args.concurrency, - batch_name=args.batch_name, - num_retries=args.num_retries, - ) - - -def upload_image(args): - rf = roboflow.Roboflow() - workspace = rf.workspace(args.workspace) - project = workspace.project(args.project) - project.single_upload( - image_path=args.imagefile, - annotation_path=args.annotation, - annotation_labelmap=args.labelmap, - split=args.split, - num_retry_uploads=args.num_retries, - batch_name=args.batch, - tag_names=args.tag_names.split(",") if args.tag_names else [], - is_prediction=args.is_prediction, - ) - - -def upload_model(args): - rf = roboflow.Roboflow(args.api_key) - workspace = rf.workspace(args.workspace) - project = workspace.project(args.project) - version = project.version(args.version_number) - print(args.model_type, args.model_path, args.filename) - version.deploy(str(args.model_type), str(args.model_path), str(args.filename)) - - -def list_projects(args): - rf = roboflow.Roboflow() - workspace = rf.workspace(args.workspace) - projects = workspace.project_list - for p in projects: - print() - print(p["name"]) - print(f" link: {APP_URL}/{p['id']}") - print(f" id: {p['id']}") - print(f" type: {p['type']}") - print(f" versions: {p['versions']}") - print(f" images: {p['images']}") - print(f" classes: {p['classes'].keys()}") - - -def list_workspaces(args): - workspaces = roboflow_config.RF_WORKSPACES.values() - rf_workspace = get_conditional_configuration_variable("RF_WORKSPACE", default=None) - for w in workspaces: - print() - print(f"{w['name']}{' (default workspace)' if w['url'] == rf_workspace else ''}") - print(f" link: {APP_URL}/{w['url']}") - print(f" id: {w['url']}") - - -def get_workspace(args): - api_key = load_roboflow_api_key(args.workspaceId) - workspace_json = rfapi.get_workspace(api_key, args.workspaceId) - print(json.dumps(workspace_json, indent=2)) - - -def run_video_inference_api(args): - rf = roboflow.Roboflow(args.api_key) - project = rf.workspace().project(args.project) - version = project.version(args.version_number) - model = project.version(version).model - - # model = VideoInferenceModel(args.api_key, project.id, version.version, project.id) # Pass dataset_id - # Pass model_id and version - job_id, signed_url, expire_time = model.predict_video( - args.video_file, - args.fps, - prediction_type="batch-video", - ) - results = model.poll_until_video_results(job_id) - with open("test_video.json", "w") as f: - json.dump(results, f) - - -def get_workspace_project_version(args): - # api_key = load_roboflow_api_key(args.workspaceId) - rf = roboflow.Roboflow(args.api_key) - workspace = rf.workspace() - print("workspace", workspace) - project = workspace.project(args.project) - print("project", project) - version = project.version(args.version_number) - print("version", version) - - -def get_project(args): - workspace_url = args.workspace or get_conditional_configuration_variable("RF_WORKSPACE", default=None) - api_key = load_roboflow_api_key(workspace_url) - dataset_json = rfapi.get_project(api_key, workspace_url, args.projectId) - print(json.dumps(dataset_json, indent=2)) - - -def infer(args): - workspace_url = args.workspace or get_conditional_configuration_variable("RF_WORKSPACE", default=None) - api_key = load_roboflow_api_key(workspace_url) - project_url = f"{workspace_url}/{args.model}" - projectType = args.type - if not projectType: - projectId, _ = args.model.split("/") - dataset_json = rfapi.get_project(api_key, workspace_url, projectId) - projectType = dataset_json["project"]["type"] - modelClass = { - "object-detection": ObjectDetectionModel, - "classification": ClassificationModel, - "instance-segmentation": InstanceSegmentationModel, - "semantic-segmentation": SemanticSegmentationModel, - "keypoint-detection": KeypointDetectionModel, - }[projectType] - model = modelClass(api_key, project_url) - kwargs = {} - if args.confidence is not None and projectType in [ - "object-detection", - "instance-segmentation", - "semantic-segmentation", - ]: - kwargs["confidence"] = int(args.confidence * 100) - if args.overlap is not None and projectType == "object-detection": - kwargs["overlap"] = int(args.overlap * 100) - group = model.predict(args.file, **kwargs) - print(group) - - -def _argparser(): - parser = argparse.ArgumentParser(description="Welcome to the roboflow CLI: computer vision at your fingertips πŸͺ„") - subparsers = parser.add_subparsers(title="subcommands") - _add_login_parser(subparsers) - _add_download_parser(subparsers) - _add_upload_parser(subparsers) - _add_import_parser(subparsers) - _add_infer_parser(subparsers) - _add_projects_parser(subparsers) - _add_workspaces_parser(subparsers) - _add_upload_model_parser(subparsers) - _add_get_workspace_project_version_parser(subparsers) - _add_run_video_inference_api_parser(subparsers) - deployment.add_deployment_parser(subparsers) - _add_whoami_parser(subparsers) - - parser.add_argument("-v", "--version", help="show version info", action="store_true") - parser.set_defaults(func=show_version) - - return parser - - -def show_version(args): - print(roboflow.__version__) - - -def show_whoami(args): - RF_WORKSPACES = get_conditional_configuration_variable("workspaces", default={}) - workspaces_by_url = {w["url"]: w for w in RF_WORKSPACES.values()} - default_workspace_url = get_conditional_configuration_variable("RF_WORKSPACE", default=None) - default_workspace = workspaces_by_url.get(default_workspace_url, None) - default_workspace["apiKey"] = "**********" - print(json.dumps(default_workspace, indent=2)) - - -def _add_whoami_parser(subparsers): - download_parser = subparsers.add_parser("whoami", help="show current user info") - download_parser.set_defaults(func=show_whoami) - - -def _add_download_parser(subparsers): - download_parser = subparsers.add_parser( - "download", - help="Download a dataset version from your workspace or Roboflow Universe.", - ) - download_parser.add_argument("datasetUrl", help="Dataset URL (e.g., `roboflow-100/cells-uyemf/2`)") - download_parser.add_argument( - "-f", - dest="format", - default="voc", - help="Specify the format to download the version. Available options: [coco, " - "yolov5pytorch, yolov7pytorch, my-yolov6, darknet, voc, tfrecord, " - "createml, clip, multiclass, coco-segmentation, yolo5-obb, " - "png-mask-semantic, yolov8, yolov9]", - ) - download_parser.add_argument("-l", dest="location", help="Location to download the dataset") - download_parser.set_defaults(func=download) - - -def _add_upload_parser(subparsers): - upload_parser = subparsers.add_parser("upload", help="Upload a single image to a dataset") - upload_parser.add_argument( - "imagefile", - help="path to image file", - ) - upload_parser.add_argument( - "-w", - dest="workspace", - help="specify a workspace url or id " "(will use default workspace if not specified)", - ) - upload_parser.add_argument( - "-p", - dest="project", - help="project_id to upload the image into", - ) - upload_parser.add_argument( - "-a", - dest="annotation", - help="path to annotation file (optional)", - ) - upload_parser.add_argument( - "-m", - dest="labelmap", - help="path to labelmap file (optional)", - ) - upload_parser.add_argument( - "-s", - dest="split", - help="split set (train, valid, test) - optional", - default="train", - ) - upload_parser.add_argument( - "-r", - dest="num_retries", - help="Retry failed uploads this many times (default: 0)", - type=int, - default=0, - ) - upload_parser.add_argument( - "-b", - dest="batch", - help="Batch name to upload to (optional)", - ) - upload_parser.add_argument( - "-t", - dest="tag_names", - help="Tag names to apply to the image (optional)", - ) - upload_parser.add_argument( - "-i", - dest="is_prediction", - help="Whether this upload is a prediction (optional)", - action="store_true", - ) - upload_parser.set_defaults(func=upload_image) - - -def _add_import_parser(subparsers): - import_parser = subparsers.add_parser("import", help="Import a dataset from a local folder") - import_parser.add_argument( - "folder", - help="filesystem path to a folder that contains your dataset", - ) - import_parser.add_argument( - "-w", - dest="workspace", - help="specify a workspace url or id " "(will use default workspace if not specified)", - ) - import_parser.add_argument( - "-p", - dest="project", - help="project will be created if it does not exist", - ) - import_parser.add_argument( - "-c", - dest="concurrency", - type=int, - help="how many image uploads to perform concurrently (default: 10)", - default=10, - ) - import_parser.add_argument( - "-n", - dest="batch_name", - help="name of batch to upload to within project", - ) - import_parser.add_argument( - "-r", dest="num_retries", type=int, help="Retry failed uploads this many times (default=0)", default=0 - ) - import_parser.set_defaults(func=import_dataset) - - -def _add_projects_parser(subparsers): - project_parser = subparsers.add_parser( - "project", - help="project related commands. type 'roboflow project' to see detailed command help", - ) - projectsubparsers = project_parser.add_subparsers(title="project subcommands") - projectlist_parser = projectsubparsers.add_parser("list", help="list projects") - projectlist_parser.add_argument( - "-w", - dest="workspace", - help="specify a workspace url or id (will use default workspace if not specified)", - ) - projectlist_parser.set_defaults(func=list_projects) - projectget_parser = projectsubparsers.add_parser("get", help="show detailed info for a project") - projectget_parser.add_argument( - "projectId", - help="project ID", - ) - projectget_parser.add_argument( - "-w", - dest="workspace", - help="specify a workspace url or id (will use default workspace if not specified)", - ) - projectget_parser.set_defaults(func=get_project) - - -def _add_workspaces_parser(subparsers): - workspace_parser = subparsers.add_parser( - "workspace", - help="workspace related commands. type 'roboflow workspace' to see detailed command help", - ) - workspacesubparsers = workspace_parser.add_subparsers(title="workspace subcommands") - workspacelist_parser = workspacesubparsers.add_parser("list", help="list workspaces") - workspacelist_parser.set_defaults(func=list_workspaces) - workspaceget_parser = workspacesubparsers.add_parser("get", help="show detailed info for a workspace") - workspaceget_parser.add_argument( - "workspaceId", - help="project ID", - ) - workspaceget_parser.set_defaults(func=get_workspace) - - -def _add_run_video_inference_api_parser(subparsers): - run_video_inference_api_parser = subparsers.add_parser( - "run_video_inference_api", - help="run video inference api", - ) - - run_video_inference_api_parser.add_argument( - "-a", - dest="api_key", - help="api_key", - ) - run_video_inference_api_parser.add_argument( - "-p", - dest="project", - help="project_id to upload the image into", - ) - run_video_inference_api_parser.add_argument( - "-v", - dest="version_number", - type=int, - help="version number to upload the model to", - ) - run_video_inference_api_parser.add_argument( - "-f", - dest="video_file", - help="path to video file", - ) - run_video_inference_api_parser.add_argument( - "-fps", - dest="fps", - type=int, - help="fps", - default=5, - ) - run_video_inference_api_parser.set_defaults(func=run_video_inference_api) - - -def _add_infer_parser(subparsers): - infer_parser = subparsers.add_parser( - "infer", - help="perform inference on an image", - ) - infer_parser.add_argument( - "file", - help="filesystem path to an image file", - ) - infer_parser.add_argument( - "-w", - dest="workspace", - help="specify a workspace url or id (will use default workspace if not specified)", - ) - infer_parser.add_argument( - "-m", - dest="model", - help="model id (id of a version with trained model e.g. my-project/3)", - ) - infer_parser.add_argument( - "-c", - dest="confidence", - type=float, - help="specify a confidence threshold between 0.0 and 1.0, default is 0.5" - "(only applies to object-detection models)", - default=0.5, - ) - infer_parser.add_argument( - "-o", - dest="overlap", - type=float, - help="specify an overlap threshold between 0.0 and 1.0, default is 0.5" - "(only applies to object-detection models)", - default=0.5, - ) - infer_parser.add_argument( - "-t", - dest="type", - help="specify the model type to skip api call to look it up", - choices=[ - "object-detection", - "classification", - "instance-segmentation", - "semantic-segmentation", - ], - ) - infer_parser.set_defaults(func=infer) - - -def _add_upload_model_parser(subparsers): - upload_model_parser = subparsers.add_parser( - "upload_model", - help="Upload a trained model to Roboflow", - ) - upload_model_parser.add_argument( - "-a", - dest="api_key", - help="api_key", - ) - upload_model_parser.add_argument( - "-w", - dest="workspace", - help="specify a workspace url or id (will use default workspace if not specified)", - ) - upload_model_parser.add_argument( - "-p", - dest="project", - help="project_id to upload the model into", - ) - upload_model_parser.add_argument( - "-v", - dest="version_number", - type=int, - help="version number to upload the model to", - ) - upload_model_parser.add_argument( - "-t", - dest="model_type", - help="type of the model (e.g., yolov8, yolov5)", - ) - upload_model_parser.add_argument( - "-m", - dest="model_path", - help="path to the trained model file", - ) - upload_model_parser.add_argument( - "-f", - dest="filename", - default="weights/best.pt", - help="name of the model file", - ) - upload_model_parser.set_defaults(func=upload_model) - - -def _add_get_workspace_project_version_parser(subparsers): - workspace_project_version_parser = subparsers.add_parser( - "get_workspace_info", - help="get workspace project version info", - ) - workspace_project_version_parser.add_argument( - "-a", - dest="api_key", - help="api_key", - ) - workspace_project_version_parser.add_argument( - "-w", - dest="workspace", - help="specify a workspace url or id (will use default workspace if not specified)", - ) - workspace_project_version_parser.add_argument( - "-p", - dest="project", - help="project_id to upload the model into", - ) - workspace_project_version_parser.add_argument( - "-v", - dest="version_number", - type=int, - help="version number to upload the model to", - ) - workspace_project_version_parser.set_defaults(func=get_workspace_project_version) - - -def _add_login_parser(subparsers): - login_parser = subparsers.add_parser("login", help="Log in to Roboflow") - login_parser.set_defaults(func=login) - - -def main(): - parser = _argparser() - args = parser.parse_args() - if hasattr(args, "func"): - args.func(args) - else: - parser.print_help() - +__all__ = ["main", "_argparser"] if __name__ == "__main__": main() diff --git a/roboflow/util/folderparser.py b/roboflow/util/folderparser.py index bf469e84..047cdaee 100644 --- a/roboflow/util/folderparser.py +++ b/roboflow/util/folderparser.py @@ -7,15 +7,22 @@ from .image_utils import load_labelmap -IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp"} +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".avif", ".heic"} ANNOTATION_EXTENSIONS = {".txt", ".json", ".xml", ".csv", ".jsonl"} LABELMAPS_EXTENSIONS = {".labels", ".yaml", ".yml"} -def parsefolder(folder): - folder = folder.strip() - if folder.endswith("/"): - folder = folder[:-1] +def _patch_sep(filename): + """ + Replace Windows style slashes to keep filenames consistent. + + Roboflow depend on it server side. + """ + return filename.replace("\\", "/") + + +def parsefolder(folder, is_classification=False): + folder = _patch_sep(folder).strip().rstrip("/") if not os.path.exists(folder): raise Exception(f"folder does not exist. {folder}") files = _list_files(folder) @@ -29,6 +36,8 @@ def parsefolder(folder): if not _map_annotations_to_images_1to1(images, annotations): annotations = _loadAnnotations(folder, annotations) _map_annotations_to_images_1tomany(images, annotations) + if is_classification: + _infer_classification_labels_from_folders(images) return { "location": folder, "images": images, @@ -53,7 +62,8 @@ def _list_files(folder): for root, dirs, files in os.walk(folder): for file in files: file_path = os.path.join(root, file) - filedescriptors.append(_describe_file(file_path.split(folder)[1])) + rel = os.path.relpath(file_path, folder) + filedescriptors.append(_describe_file(f"/{rel}")) filedescriptors = sorted(filedescriptors, key=lambda x: _alphanumkey(x["file"])) return filedescriptors @@ -64,6 +74,7 @@ def _add_indices(files): def _describe_file(f): + f = _patch_sep(f) name = f.split("/")[-1] dirname = os.path.dirname(f) fullkey, extension = os.path.splitext(f) @@ -100,45 +111,123 @@ def _map_annotations_to_images_1to1(images, annotations): def _map_annotations_to_images_1tomany(images, annotationFiles): - annotationsByDirname = _list_map(annotationFiles, "dirname") + image_path_to_annotation_files = _build_image_to_annotationfile_index(annotationFiles) imgRefMap, annotationMap = _build_image_and_annotation_maps(annotationFiles) for image in tqdm(images): - dirname = image["dirname"] - annotationsInSameDir = annotationsByDirname.get(dirname, []) - if annotationsInSameDir: - for annotationFile in annotationsInSameDir: - format = annotationFile["parsedType"] - filtered_annotations = _filterIndividualAnnotations( - image, annotationFile, format, imgRefMap, annotationMap - ) - if filtered_annotations: - image["annotationfile"] = filtered_annotations - break + # Get candidate annotation files for this image + rel_path = image["file"].lstrip("/") + candidate_annotations = ( + image_path_to_annotation_files.get(rel_path, []) + or image_path_to_annotation_files.get(image["name"], []) + or image_path_to_annotation_files.get(image["key"], []) + or annotationFiles # Fallback to all files for non-COCO formats + ) + + for annotationFile in candidate_annotations: + format = annotationFile["parsedType"] + filtered_annotations = _filterIndividualAnnotations(image, annotationFile, format, imgRefMap, annotationMap) + if filtered_annotations: + image["annotationfile"] = filtered_annotations + break + + +def _build_image_to_annotationfile_index(annotationFiles): + """Create an index mapping possible image path keys to annotation files that reference them. + + Keys include full relative path, basename, and stem to improve robustness across + different dataset layouts. Supports coco, createml, csv, multilabel_csv, jsonl. + """ + index = defaultdict(list) + for annotationFile in annotationFiles: + parsedType = annotationFile.get("parsedType") + parsed = annotationFile.get("parsed") + if not parsedType or parsed is None: + continue + + if parsedType == "coco": + for imageRef in parsed.get("images", []): + file_name = _patch_sep(imageRef.get("file_name", "")).lstrip("/") + if not file_name: + continue + basename = os.path.basename(file_name) + stem = os.path.splitext(basename)[0] + index[file_name].append(annotationFile) + index[basename].append(annotationFile) + index[stem].append(annotationFile) + + elif parsedType == "createml": + for entry in parsed: + image_name = entry.get("image") + if not image_name: + continue + index[image_name].append(annotationFile) + + elif parsedType == "csv": + for ld in parsed.get("lines", []): + image_name = ld.get("file_name") + if not image_name: + continue + index[image_name].append(annotationFile) + + elif parsedType == "multilabel_csv": + for row in parsed.get("rows", []): + image_name = row.get("file_name") + if not image_name: + continue + index[image_name].append(annotationFile) + + elif parsedType == "jsonl": + for entry in parsed: + image_name = entry.get("image") + if not image_name: + continue + index[image_name].append(annotationFile) + + return index def _build_image_and_annotation_maps(annotationFiles): imgRefMap = {} annotationMap = defaultdict(list) for annFile in annotationFiles: - filename, dirname, parsed, parsedType = ( + filename, parsed, parsedType = ( annFile["file"], - annFile["dirname"], annFile["parsed"], annFile["parsedType"], ) if parsedType == "coco": for imageRef in parsed["images"]: - imgRefMap[f"{filename}/{imageRef['file_name']}"] = imageRef + # Normalize and index by multiple forms to improve matching robustness + file_name = _patch_sep(imageRef["file_name"]).lstrip("/") + basename = os.path.basename(file_name) + stem = os.path.splitext(basename)[0] + + # Prefer full relative path, but also allow basename and stem + imgRefMap.update( + { + f"{filename}/{file_name}": imageRef, + f"{filename}/{basename}": imageRef, + f"{filename}/{stem}": imageRef, + } + ) for annotation in parsed["annotations"]: - annotationMap[f"{dirname}/{annotation['image_id']}"].append(annotation) + annotationMap[f"{filename}/{annotation['image_id']}"].append(annotation) return imgRefMap, annotationMap def _filterIndividualAnnotations(image, annotation, format, imgRefMap, annotationMap): parsed = annotation["parsed"] if format == "coco": - imgReference = imgRefMap.get(f"{annotation['file']}/{image['name']}") + rel_path = image["file"].lstrip("/") + imgReference = ( + # Try matching by full relative path first + imgRefMap.get(f"{annotation['file']}/{rel_path}") + # Fallback: basename with extension + or imgRefMap.get(f"{annotation['file']}/{image['name']}") + # Fallback: stem (no extension) + or imgRefMap.get(f"{annotation['file']}/{image['key']}") + ) if imgReference: # workaround to make Annotations.js correctly identify this as coco in the backend fake_annotation = { @@ -150,7 +239,7 @@ def _filterIndividualAnnotations(image, annotation, format, imgRefMap, annotatio "iscrowd": 0, } _annotation = {"name": "annotation.coco.json"} - annotations_for_image = annotationMap.get(f"{image['dirname']}/{imgReference['id']}", []) + annotations_for_image = annotationMap.get(f"{annotation['file']}/{imgReference['id']}", []) _annotation["rawText"] = json.dumps( { "info": parsed["info"], @@ -183,6 +272,13 @@ def _filterIndividualAnnotations(image, annotation, format, imgRefMap, annotatio return _annotation else: return None + elif format == "multilabel_csv": + rows = [r for r in parsed["rows"] if r["file_name"] == image["name"]] + if rows: + labels = rows[0]["labels"] + return {"type": "classification_multilabel", "labels": labels} + else: + return None elif format == "jsonl": jsonlLines = [json.dumps(line) for line in parsed if line["image"] == image["name"]] if jsonlLines: @@ -207,8 +303,9 @@ def _loadAnnotations(folder, annotations): ann["parsed"] = _read_jsonl(f"{folder}{ann['file']}") ann["parsedType"] = "jsonl" elif extension == ".csv": - ann["parsedType"] = "csv" - ann["parsed"] = _parseAnnotationCSV(f"{folder}{ann['file']}") + parsed = _parseAnnotationCSV(f"{folder}{ann['file']}") + ann["parsed"] = parsed + ann["parsedType"] = parsed.get("type", "csv") return annotations @@ -230,10 +327,20 @@ def _parseAnnotationCSV(filename): # TODO: use a proper CSV library? with open(filename) as f: lines = f.readlines() - headers = lines[0] + headers = [h.strip() for h in lines[0].split(",")] + # Multi-label classification csv typically named _classes.csv + if os.path.basename(filename) == "_classes.csv": + parsed_lines = [] + for line in lines[1:]: + parts = [p.strip() for p in line.split(",")] + file_name = parts[0] + labels = [headers[i] for i, v in enumerate(parts[1:], start=1) if v == "1"] + parsed_lines.append({"file_name": file_name, "labels": labels}) + return {"type": "multilabel_csv", "rows": parsed_lines, "headers": headers} + header_line = lines[0] lines = [{"file_name": ld.split(",")[0].strip(), "line": ld} for ld in lines[1:]] return { - "headers": headers, + "headers": header_line, "lines": lines, } @@ -285,8 +392,14 @@ def _decide_split(images): i["split"] = "train" -def _list_map(my_list, key): - d = {} - for i in my_list: - d.setdefault(i[key], []).append(i) - return d +def _infer_classification_labels_from_folders(images): + for image in images: + if image.get("annotationfile"): + continue + dirname = image.get("dirname", "").strip("/") + if not dirname or dirname == ".": + # Skip images in root directory or invalid paths + continue + class_name = os.path.basename(dirname) + if class_name and class_name != ".": + image["annotationfile"] = {"classification_label": class_name, "type": "classification_folder"} diff --git a/roboflow/util/general.py b/roboflow/util/general.py index fa6a29dd..9368d7a2 100644 --- a/roboflow/util/general.py +++ b/roboflow/util/general.py @@ -1,4 +1,12 @@ +import os import sys +import time +import zipfile +from random import random + +from tqdm import tqdm + +from roboflow.config import TQDM_DISABLE def write_line(line): @@ -13,8 +21,16 @@ def __init__(self, max_retries, retry_on): self.retry_on = retry_on self.retries = 0 + def backoff(self): + """ + Backoff for a random time based on number of retries. + """ + base_t_ms = 100 + max_t_ms = 30000 + sleep_ms = random() * min(max_t_ms, base_t_ms * 2**self.retries) + time.sleep(int(sleep_ms) / 1000) + def __call__(self, func, *args, **kwargs): - self.retries = 0 retry_on = self.retry_on if not retry_on: retry_on = (Exception,) @@ -24,8 +40,28 @@ def __call__(self, func, *args, **kwargs): return func(*args, **kwargs) except BaseException as e: if isinstance(e, retry_on): - self.retries += 1 - if self.retries > self.max_retries: + if self.retries >= self.max_retries: raise + self.backoff() + self.retries += 1 else: raise + + +def extract_zip(location: str, desc: str = "Extracting"): + """Extract ``roboflow.zip`` inside *location* and remove the archive. + + Args: + location: Directory containing ``roboflow.zip``. + desc: Description shown in the tqdm progress bar. + """ + zip_path = os.path.join(location, "roboflow.zip") + tqdm_desc = None if TQDM_DISABLE else desc + with zipfile.ZipFile(zip_path, "r") as zip_ref: + for member in tqdm(zip_ref.infolist(), desc=tqdm_desc): + try: + zip_ref.extract(member, location) + except zipfile.error: + raise RuntimeError("Error unzipping download") + + os.remove(zip_path) diff --git a/roboflow/util/image_utils.py b/roboflow/util/image_utils.py index af071eee..6e159df9 100644 --- a/roboflow/util/image_utils.py +++ b/roboflow/util/image_utils.py @@ -1,12 +1,24 @@ +# Standard library imports import base64 import io import os import urllib +# Third-party imports +import pillow_avif # type: ignore[import-untyped] import requests import yaml from PIL import Image +# pi-heif requires Python 3.10+ +try: + import pi_heif # type: ignore[import-untyped,import-not-found] + + pi_heif.register_heif_opener(thumbnails=False) # Register for HEIF/HEIC +except ImportError: + pass +pillow_avif = pillow_avif # Reference pillow_avif to not remove import by accident + def check_image_path(image_path): """ @@ -74,9 +86,18 @@ def validate_image_path(image_path): def file2jpeg(image_path): import cv2 + # OpenCV will handle standard formats efficiently img = cv2.imread(image_path) - image = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) - pilImage = Image.fromarray(image) + if img is not None: + # Convert BGR to RGB for PIL + image = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + pilImage = Image.fromarray(image) + else: + # If OpenCV fails, the format might be HEIC/AVIF which are handled by PIL + pilImage = Image.open(image_path) + if pilImage.mode != "RGB": + pilImage = pilImage.convert("RGB") + buffered = io.BytesIO() pilImage.save(buffered, quality=100, format="JPEG") return buffered.getvalue() @@ -86,8 +107,11 @@ def load_labelmap(f): if f.lower().endswith(".yaml") or f.lower().endswith(".yml"): with open(f) as file: data = yaml.safe_load(file) - if "names" in data: - return {i: name for i, name in enumerate(data["names"])} + names = data.get("names", []) + if isinstance(names, dict): + return {int(k): v for k, v in names.items()} + else: + return {i: name for i, name in enumerate(names)} else: with open(f) as file: lines = [line for line in file.readlines() if line.strip()] diff --git a/roboflow/util/model_processor.py b/roboflow/util/model_processor.py new file mode 100644 index 00000000..48fc85be --- /dev/null +++ b/roboflow/util/model_processor.py @@ -0,0 +1,1290 @@ +"""Packaging of custom model weights for Roboflow upload. + +The public, non-interactive entry point is :func:`package_custom_weights`. It +only builds the upload archive; it never prompts, prints, or uploads, so it is +safe to call from servers and other headless environments (for example the +Roboflow MCP server):: + + from roboflow.util.model_processor import package_custom_weights + + bundle = package_custom_weights("yolov8n", "runs/detect/train") + try: + ... # upload bundle.archive_path + finally: + bundle.cleanup() + +Expected, user-correctable failures raise :class:`ModelPackagingError` +subclasses; anything else escaping these helpers is a bug. + +The legacy :func:`process` entry point and the ``Version.deploy`` / +``Workspace.deploy_model`` flows wrap the packaging step with +:func:`package_custom_weights_interactive`, which preserves the historical +print-and-confirm CLI behavior. +""" + +from __future__ import annotations + +import json +import math +import os +import shutil +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass +from importlib import import_module +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any + +import yaml + +from roboflow.config import ( + TASK_CLS, + TASK_DET, + TASK_OBB, + TASK_POSE, + TASK_SEG, + TASK_SEM, + TYPE_CLASSICATION, + TYPE_INSTANCE_SEGMENTATION, + TYPE_KEYPOINT_DETECTION, + TYPE_OBJECT_DETECTION, + TYPE_SEMANTIC_SEGMENTATION, +) +from roboflow.util.versions import get_wrong_dependencies_versions, normalize_yolo_model_type + +SUPPORTED_MODELS = ( + "yolov5", + "yolov7", + "yolov7-seg", + "yolov8", + "yolov9", + "yolov10", + "yolov11", + "yolov12", + "yolo26", + "yolonas", + "paligemma", + "paligemma2", + "florence-2", + "rfdetr", +) + +SUPPORTED_HUGGINGFACE_TYPES = ( + "florence-2-base", + "florence-2-large", + "paligemma-3b-pt-224", + "paligemma-3b-pt-448", + "paligemma-3b-pt-896", + "paligemma2-3b-pt-224", + "paligemma2-3b-pt-448", + "paligemma2-3b-pt-896", + "paligemma2-3b-pt-224-peft", + "paligemma2-3b-pt-448-peft", + "paligemma2-3b-pt-896-peft", +) + +# Minimum rf-detr release shipping ``RFDETR.export_for_roboflow`` (used to rebuild +# an upload bundle from a raw PyTorch-Lightning checkpoint). +RFDETR_MIN_VERSION = "1.8.0" + +# rf-detr model_type -> RFDETR subclass name. Single source of truth for both the +# supported-type check and the ``from_checkpoint`` fallback (used when a raw +# checkpoint lacks the metadata rf-detr needs to infer its own class). +_RFDETR_MODEL_TYPE_TO_CLASS = { + # Detection + "rfdetr-base": "RFDETRBase", + "rfdetr-nano": "RFDETRNano", + "rfdetr-small": "RFDETRSmall", + "rfdetr-medium": "RFDETRMedium", + "rfdetr-large": "RFDETRLarge", + "rfdetr-xlarge": "RFDETRXLarge", + "rfdetr-2xlarge": "RFDETR2XLarge", + # Segmentation + "rfdetr-seg-nano": "RFDETRSegNano", + "rfdetr-seg-small": "RFDETRSegSmall", + "rfdetr-seg-medium": "RFDETRSegMedium", + "rfdetr-seg-large": "RFDETRSegLarge", + "rfdetr-seg-xlarge": "RFDETRSegXLarge", + "rfdetr-seg-2xlarge": "RFDETRSeg2XLarge", + # Keypoint detection + "rfdetr-keypoint-preview": "RFDETRKeypointPreview", +} + +SUPPORTED_RFDETR_TYPES = tuple(_RFDETR_MODEL_TYPE_TO_CLASS) + +DEFAULT_WEIGHTS_FILENAME = "weights/best.pt" + +# YOLO families Roboflow rejects without a size suffix (e.g. `yolov8` must be +# `yolov8n`/`yolov8s`/...). Legacy yolov5/7/9 go through the opt.yaml path and are +# intentionally excluded. +ULTRALYTICS_YOLO_FAMILIES = ("yolov8", "yolov10", "yolov11", "yolov12", "yolo26") + +# Canonical (depth_multiple, width_multiple) -> size letter for the classic YOLO +# scaling (v5/v8/v9/v10). Newer families (v11+) instead store an explicit ``scale`` +# letter in the model yaml, which is read first. +YOLO_DEPTH_WIDTH_TO_SIZE = { + (0.33, 0.25): "n", + (0.33, 0.50): "s", + (0.67, 0.75): "m", + (0.67, 1.00): "b", + (1.00, 1.00): "l", + (1.00, 1.25): "x", +} + +# Position-encoding grid size (DINOv2 tokens per side) each *known* RF-DETR variant +# is built with, mirroring rfdetr/config.py. Roboflow reconstructs the architecture +# from the model_type at the variant's default resolution, so a checkpoint trained at +# that default must match or state_dict loading fails on the backbone +# position_embeddings. Variants absent here (e.g. detection xlarge/2xlarge, which have +# no standard config) are not grid-checked. A checkpoint trained at a custom +# resolution may not match any entry; that case warns rather than blocks. +RFDETR_POSITIONAL_ENCODING_SIZE = { + "rfdetr-nano": 24, + "rfdetr-small": 32, + "rfdetr-medium": 36, + "rfdetr-base": 37, + "rfdetr-large": 44, + "rfdetr-seg-nano": 26, + "rfdetr-seg-small": 32, + "rfdetr-seg-medium": 36, + "rfdetr-seg-large": 42, + "rfdetr-seg-xlarge": 52, + "rfdetr-seg-2xlarge": 64, + # Keypoint (576 / patch_size 12 = 48) + "rfdetr-keypoint-preview": 48, +} + + +class ModelPackagingError(Exception): + """Custom weights could not be packaged for a user-correctable reason. + + Consumers can treat any instance of this class as an expected input problem + (bad model_type, missing files, mismatched metadata, ...) and surface the + message to the user. Exceptions that are not ModelPackagingError indicate + bugs and are deliberately not wrapped. + """ + + +class UnsupportedModelError(ModelPackagingError, ValueError): + """The model_type is not supported for custom weights upload.""" + + +class TaskMismatchError(ModelPackagingError, ValueError): + """The model_type's task conflicts with the checkpoint or the project type.""" + + +class MissingFileError(ModelPackagingError, FileNotFoundError): + """A file required for packaging was not found.""" + + +class MissingDependencyError(ModelPackagingError, RuntimeError): + """A Python package required to package these weights is not installed.""" + + +class DependencyMismatchError(ModelPackagingError, RuntimeError): + """An installed dependency version differs from the recommended one. + + Retry with ``allow_dependency_mismatch=True`` to package anyway. + """ + + retry_flag = "allow_dependency_mismatch" + + def __init__(self, message: str, *, dependency: str, required: str, installed: str): + super().__init__(message) + self.dependency = dependency + self.required = required + self.installed = installed + + +class SizeMismatchError(ModelPackagingError, ValueError): + """The declared model size/variant conflicts with the checkpoint architecture. + + Retry with ``allow_size_mismatch=True`` to package the requested model_type + as-is. + """ + + retry_flag = "allow_size_mismatch" + + def __init__(self, message: str, *, requested: str, detected: str | None = None): + super().__init__(message) + self.requested = requested + self.detected = detected + + +@dataclass(frozen=True) +class ModelUploadBundle: + """Packaged archive ready to upload through the Roboflow API. + + ``model_type`` is the resolved type (it may differ from the requested one, + e.g. ``yolov8`` filled in as ``yolov8n`` from the checkpoint architecture). + ``owns_build_dir`` is True when :func:`package_custom_weights` created a + temporary build directory; call :meth:`cleanup` once the archive has been + consumed. + """ + + archive_path: Path + build_dir: Path + model_type: str + warnings: tuple[str, ...] = () + owns_build_dir: bool = False + + @property + def size_bytes(self) -> int: + return self.archive_path.stat().st_size + + def cleanup(self) -> None: + """Remove the build directory if this bundle created it (no-op otherwise).""" + if self.owns_build_dir: + shutil.rmtree(self.build_dir, ignore_errors=True) + + +def task_of_model_type(model_type: str) -> str: + """Canonical task for a deploy model_type string. + + Non-detect tasks double as the model_type suffix token + (e.g. 'yolov11-seg' -> TASK_SEG). Plain 'yolov11' / 'rfdetr-base' -> TASK_DET. + + Keypoint/pose models may spell the token as either 'pose' (Ultralytics) or + 'keypoint' (rf-detr, e.g. 'rfdetr-keypoint-preview'); both map to TASK_POSE. + """ + s = model_type.lower() + if "keypoint" in s: + return TASK_POSE + for task in (TASK_SEM, TASK_SEG, TASK_POSE, TASK_CLS, TASK_OBB): + if task in s: + return task + return TASK_DET + + +def _checkpoint_args_as_dict(raw_args: Any) -> dict[str, Any]: + """Coerce a checkpoint's ``args`` (dict, argparse.Namespace, or None) to a dict. + + A corrupt checkpoint may store ``args`` as a scalar or list; those have no + meaningful attributes, so treat them as no-args rather than letting + ``vars()`` raise a bare ``TypeError`` outside the ModelPackagingError contract. + """ + if isinstance(raw_args, dict): + return raw_args + if hasattr(raw_args, "__dict__"): + return dict(vars(raw_args)) + return {} + + +def _resolve_within_source(source_dir: Path, filename: str) -> Path: + """Resolve ``filename`` against ``source_dir``, refusing to escape it. + + ``filename`` is documented as relative to ``model_path`` and is forwarded + verbatim by hosted callers (the MCP server). An absolute path or ``..`` + segments would let a caller read weights from outside the model directory, + so reject both instead of silently packaging a file the caller never + intended. ``source_dir`` is already resolved; resolving the join collapses + ``..`` and follows symlinks before the containment check. + + Absoluteness is tested under both OS conventions, not just the host's: the + hosted MCP forwards caller-supplied paths, so a POSIX absolute like + ``/etc/passwd`` must be rejected even when packaging happens to run on + Windows (where ``Path.is_absolute()`` alone would miss it, and vice versa). + """ + if PurePosixPath(filename).is_absolute() or PureWindowsPath(filename).is_absolute(): + raise ModelPackagingError(f"filename '{filename}' must be a path relative to model_path, not an absolute path.") + resolved = (source_dir / filename).resolve() + if resolved != source_dir and source_dir not in resolved.parents: + raise ModelPackagingError( + f"filename '{filename}' resolves outside model_path '{source_dir}'. " + "It must point to a checkpoint inside the model directory." + ) + # '' / '.' resolve to model_path itself and a subdirectory passes the + # containment check; all would reach torch.load() and leak a raw + # IsADirectoryError outside the ModelPackagingError contract. + if resolved.is_dir(): + raise ModelPackagingError(f"filename '{filename}' must point to a checkpoint file, not a directory.") + return resolved + + +def validate_model_type_for_project(model_type: str, project_type: str, project_id: str) -> None: + """Raise TaskMismatchError if model_type's task doesn't match the Roboflow project type.""" + expected = { + TYPE_OBJECT_DETECTION: TASK_DET, + TYPE_INSTANCE_SEGMENTATION: TASK_SEG, + TYPE_SEMANTIC_SEGMENTATION: TASK_SEM, + TYPE_KEYPOINT_DETECTION: TASK_POSE, + TYPE_CLASSICATION: TASK_CLS, + }.get(project_type) + if expected is None: + return + actual = task_of_model_type(model_type) + if actual != expected: + raise TaskMismatchError( + f"Project '{project_id}' is type '{project_type}' (task '{expected}') " + f"but model_type '{model_type}' implies task '{actual}'." + ) + + +def package_custom_weights( + model_type: str, + model_path: str, + filename: str = DEFAULT_WEIGHTS_FILENAME, + *, + build_dir: str | Path | None = None, + allow_dependency_mismatch: bool = False, + allow_size_mismatch: bool = False, +) -> ModelUploadBundle: + """Package locally trained custom weights into a Roboflow upload archive. + + This is the public packaging entry point. It is non-interactive and free of + side effects on ``model_path``: it never prompts, prints, or writes into the + source directory. Heavy dependencies (torch, ultralytics) are imported + lazily, only for the model families that need them. + + Args: + model_type: Roboflow model type (e.g. "yolov8n", "rfdetr-base"). + model_path: Directory containing the trained model artifacts. + filename: Weights file path, relative to ``model_path``. + build_dir: Directory to write intermediate artifacts and the final + archive into. Defaults to a fresh temporary directory owned by the + returned bundle; call ``bundle.cleanup()`` when done. + allow_dependency_mismatch: Record a warning instead of raising + DependencyMismatchError when an installed dependency version is not + the recommended one. + allow_size_mismatch: Record a warning instead of raising + SizeMismatchError when the declared model size/variant conflicts + with the checkpoint architecture. + + Returns: + ModelUploadBundle with the archive path, the resolved model_type, and + any warnings collected while packaging. + + Raises: + ModelPackagingError: (or a subclass) for user-correctable problems. + """ + normalized_model_type = normalize_yolo_model_type(model_type.strip()) + source_dir = Path(model_path).expanduser().resolve() + if not source_dir.is_dir(): + raise MissingFileError(f"Model path '{model_path}' does not exist or is not a directory.") + _resolve_within_source(source_dir, filename) + + owns_build_dir = build_dir is None + if build_dir is None: + build_path = Path(tempfile.mkdtemp(prefix="roboflow-package-")) + else: + build_path = Path(build_dir).expanduser().resolve() + build_path.mkdir(parents=True, exist_ok=True) + + try: + archive_path, resolved_model_type, warnings = _process_model( + model_type=normalized_model_type, + model_path=source_dir, + filename=filename, + build_dir=build_path, + allow_dependency_mismatch=allow_dependency_mismatch, + allow_size_mismatch=allow_size_mismatch, + ) + except BaseException: + if owns_build_dir: + shutil.rmtree(build_path, ignore_errors=True) + raise + + return ModelUploadBundle( + archive_path=archive_path, + build_dir=build_path, + model_type=resolved_model_type, + warnings=tuple(warnings), + owns_build_dir=owns_build_dir, + ) + + +def package_custom_weights_interactive( + model_type: str, + model_path: str, + filename: str = DEFAULT_WEIGHTS_FILENAME, + *, + build_dir: str | Path | None = None, +) -> ModelUploadBundle: + """Package weights with the historical interactive SDK behavior. + + Used by ``Version.deploy`` and ``Workspace.deploy_model``: warnings are + printed, and dependency/size mismatches ask for confirmation before + retrying with the corresponding override. Declining re-raises the error. + """ + allow_dependency_mismatch = False + allow_size_mismatch = False + while True: + try: + bundle = package_custom_weights( + model_type, + model_path, + filename, + build_dir=build_dir, + allow_dependency_mismatch=allow_dependency_mismatch, + allow_size_mismatch=allow_size_mismatch, + ) + except (DependencyMismatchError, SizeMismatchError) as error: + print(error) + answer = input("Would you like to continue anyway? y/n: ") + if answer.lower() != "y": + raise + if isinstance(error, DependencyMismatchError): + allow_dependency_mismatch = True + else: + allow_size_mismatch = True + continue + for warning in bundle.warnings: + print(warning) + return bundle + + +def process(model_type: str, model_path: str, filename: str) -> tuple[str, str]: + """Legacy packaging entry point, kept for backwards compatibility. + + Preserves the historical contract end to end: packages into ``model_path`` + (intermediate artifacts and the final archive land there), prints packaging + warnings, asks for confirmation on dependency/size mismatches, and returns + ``(archive_file_name, resolved_model_type)``. Headless code should call + :func:`package_custom_weights` instead. + """ + bundle = package_custom_weights_interactive(model_type, model_path, filename, build_dir=model_path) + return bundle.archive_path.name, bundle.model_type + + +def _process_model( + *, + model_type: str, + model_path: Path, + filename: str, + build_dir: Path, + allow_dependency_mismatch: bool, + allow_size_mismatch: bool, +) -> tuple[Path, str, list[str]]: + if not model_type.startswith(SUPPORTED_MODELS): + raise UnsupportedModelError( + f"Model type '{model_type}' is not supported for custom weights upload. " + f"It must start with a supported family: {', '.join(SUPPORTED_MODELS)}." + ) + + if 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) + if model_type.startswith("rfdetr"): + return _process_rfdetr(model_type, model_path, filename, build_dir, allow_size_mismatch) + return _process_yolo( + model_type, + model_path, + filename, + build_dir, + allow_dependency_mismatch, + allow_size_mismatch, + ) + + +def _import_required_module(module_name: str, install_hint: str) -> Any: + try: + return import_module(module_name) + except ImportError as exc: + raise MissingDependencyError( + f"The '{module_name}' Python package is required to package these " + f"custom weights. Install it with `{install_hint}`." + ) from exc + + +def _check_dependency_version( + *, + dependency: str, + operator: str, + required_version: str, + allow_mismatch: bool, + warnings: list[str], +) -> None: + mismatches = get_wrong_dependencies_versions([(dependency, operator, required_version)]) + if not mismatches: + return + _, _, _, installed = mismatches[0] + message = ( + f"{dependency}{operator}{required_version} is recommended for this " + f"upload, but {dependency} {installed} is installed." + ) + if allow_mismatch: + warnings.append(message) + return + raise DependencyMismatchError( + f"{message} Retry with allow_dependency_mismatch=True to package with the " + f"installed version, or `pip install {dependency}{operator}{required_version}`.", + dependency=dependency, + required=f"{dependency}{operator}{required_version}", + installed=installed, + ) + + +def _detect_yolo_task(model_instance: Any) -> str | None: + """Detect the training task of an Ultralytics model instance via its class name.""" + if model_instance is None: + return None + return { + "DetectionModel": TASK_DET, + "SegmentationModel": TASK_SEG, + "SemanticSegmentationModel": TASK_SEM, + "PoseModel": TASK_POSE, + "ClassificationModel": TASK_CLS, + "OBBModel": TASK_OBB, + }.get(type(model_instance).__name__) + + +def _class_names_from_model_instance(model_instance: Any) -> list[str]: + names = getattr(model_instance, "names", None) + if isinstance(names, list): + return names + if isinstance(names, dict): + return [name for _, name in sorted(names.items(), key=lambda item: item[0])] + raise ModelPackagingError("Could not extract class names from the model checkpoint.") + + +def _filtered_args(args: Any) -> dict[str, Any]: + # A corrupt checkpoint may store args as a scalar/None; coerce via the shared + # helper so it becomes {} rather than raising a raw TypeError from vars(). + return {k: v for k, v in _checkpoint_args_as_dict(args).items() if k in {"model", "imgsz", "batch"}} + + +def _load_checkpoint(torch_module: Any, checkpoint_path: Path, *, map_location: str | None = None) -> Any: + kwargs: dict[str, Any] = {"weights_only": False} + if map_location is not None: + kwargs["map_location"] = map_location + return torch_module.load(checkpoint_path, **kwargs) + + +def _legacy_yolo_args(opts: dict[str, Any], opt_path: Path) -> dict[str, Any]: + """Return required legacy YOLO upload args from opt.yaml.""" + if "imgsz" in opts: + image_size = opts["imgsz"] + elif "img_size" in opts: + image_size = opts["img_size"] + else: + raise ModelPackagingError(f"{opt_path} is missing required key 'imgsz' or 'img_size'.") + if "batch_size" not in opts: + raise ModelPackagingError(f"{opt_path} is missing required key 'batch_size'.") + return {"imgsz": image_size, "batch": opts["batch_size"]} + + +def _infer_yolo_size(model_instance: Any) -> str | None: + """Infer a YOLO size letter (n/s/m/l/x/...) from a loaded checkpoint. + + Prefers an explicit ``scale`` letter in the model yaml (set by newer + Ultralytics), then maps the ``(depth_multiple, width_multiple)`` pair used by + the classic scaling. Returns None when neither signal is present. + """ + yaml_cfg = getattr(model_instance, "yaml", None) or {} + scale = yaml_cfg.get("scale") + if isinstance(scale, str) and len(scale) == 1 and scale.isalpha(): + return scale.lower() + + depth = yaml_cfg.get("depth_multiple") + width = yaml_cfg.get("width_multiple") + if isinstance(depth, (int, float)) and isinstance(width, (int, float)): + for (ref_depth, ref_width), letter in YOLO_DEPTH_WIDTH_TO_SIZE.items(): + if abs(depth - ref_depth) < 1e-6 and abs(width - ref_width) < 1e-6: + return letter + return None + + +def _resolve_yolo_size( + model_type: str, + model_instance: Any, + warnings: list[str], + allow_mismatch: bool = False, +) -> str: + """Fill in or check a YOLO model_type's size suffix against the checkpoint. + + Roboflow rejects bare family names (e.g. ``yolov8``) with an + ``InvalidModelTypeException`` because it needs the model size, and a size that + disagrees with the weights fails conversion. A *missing* size is inferred and + filled in. A *supplied* size that conflicts with the inferred one raises so the + caller can confirm β€” unless ``allow_mismatch`` is set, in which case the + supplied size is packaged as-is with a warning. A user size is also kept when + the size cannot be inferred. Returns the resolved model_type. + """ + core = model_type.lower().split("-", 1)[0] + family = next((f for f in ULTRALYTICS_YOLO_FAMILIES if core.startswith(f)), None) + if family is None: + return model_type + + inferred = _infer_yolo_size(model_instance) + provided = core[len(family) :] + task_suffix = model_type[len(core) :] + + if inferred is None: + if not provided: + if allow_mismatch: + warnings.append( + f"Could not infer a size for '{model_type}' from the checkpoint; " + f"uploading the bare family name as requested. Roboflow may reject it " + f"if it requires an explicit size." + ) + return model_type + raise SizeMismatchError( + f"model_type '{model_type}' is missing a size suffix and the size " + f"could not be inferred from the checkpoint. Specify it explicitly, " + f"e.g. '{family}n', '{family}s', '{family}m', '{family}l', '{family}x'.", + requested=model_type, + ) + return model_type + + if not provided: + warnings.append( + f"Inferred model size '{family}{inferred}' from the checkpoint " + f"architecture (model_type was '{model_type}')." + ) + return f"{family}{inferred}{task_suffix}" + + if provided == inferred: + return model_type + + if allow_mismatch: + warnings.append( + f"model_type '{model_type}' declares size '{provided}', but the checkpoint " + f"architecture is '{family}{inferred}'. Packaging as '{model_type}' as requested." + ) + return model_type + + raise SizeMismatchError( + f"You specified model_type '{model_type}' (size '{provided}'), but the " + f"checkpoint architecture is '{family}{inferred}'. They don't match, so " + f"Roboflow's weight conversion would fail. Upload as '{family}{inferred}" + f"{task_suffix}', or set allow_size_mismatch=True to upload " + f"'{model_type}' exactly as specified.", + requested=model_type, + detected=f"{family}{inferred}{task_suffix}", + ) + + +def _require_model_attr(model_instance: Any, attr: str, model_type: str) -> Any: + """Return ``model_instance.`` or raise a ModelPackagingError. + + Roboflow's server-side conversion needs these fields; a stripped checkpoint + missing one would otherwise raise a raw ``AttributeError`` outside the + ModelPackagingError contract (an opaque 500 for hosted callers). + """ + value = getattr(model_instance, attr, None) + if value is None: + raise ModelPackagingError( + f"The {model_type} checkpoint's model is missing '{attr}'; it does not look " + "like a complete Ultralytics training checkpoint. Re-export it from your training run." + ) + return value + + +def _require_checkpoint_field(checkpoint: Any, key: str, model_type: str) -> Any: + """Return ``checkpoint[key]`` or raise a ModelPackagingError (see _require_model_attr).""" + if not isinstance(checkpoint, dict) or key not in checkpoint: + raise ModelPackagingError( + f"The {model_type} checkpoint is missing '{key}'; it does not look like a " + "complete Ultralytics training checkpoint. Re-export it from your training run." + ) + return checkpoint[key] + + +def _process_yolo( + model_type: str, + model_path: Path, + filename: str, + build_dir: Path, + allow_dependency_mismatch: bool, + allow_size_mismatch: bool, +) -> tuple[Path, str, list[str]]: + warnings: list[str] = [] + torch = _import_required_module("torch", "pip install torch") + ultralytics = None + + if "yolov8" in model_type: + ultralytics = _import_required_module("ultralytics", "pip install ultralytics==8.0.196") + _check_dependency_version( + dependency="ultralytics", + operator="==", + required_version="8.0.196", + allow_mismatch=allow_dependency_mismatch, + warnings=warnings, + ) + elif "yolov10" in model_type: + ultralytics = _import_required_module("ultralytics", "pip install ultralytics") + elif "yolov11" in model_type: + ultralytics = _import_required_module("ultralytics", "pip install 'ultralytics>=8.3.0'") + _check_dependency_version( + dependency="ultralytics", + operator=">=", + required_version="8.3.0", + allow_mismatch=allow_dependency_mismatch, + warnings=warnings, + ) + elif "yolov12" in model_type: + ultralytics = _import_required_module( + "ultralytics", + "pip install git+https://github.com/sunsmarterjie/yolov12.git", + ) + warnings.append( + "YOLOv12 uploads must use the Ultralytics fork from " + "https://github.com/sunsmarterjie/yolov12 or a Roboflow-trained model." + ) + _check_dependency_version( + dependency="ultralytics", + operator="==", + required_version="8.3.63", + allow_mismatch=allow_dependency_mismatch, + warnings=warnings, + ) + elif "yolo26" in model_type: + ultralytics = _import_required_module("ultralytics", "pip install ultralytics") + + checkpoint_path = model_path / filename + if not checkpoint_path.exists(): + raise MissingFileError(f"Model weights file '{checkpoint_path}' was not found.") + + checkpoint = _load_checkpoint(torch, checkpoint_path) + if not isinstance(checkpoint, dict): + raise ModelPackagingError(f"Model weights file '{checkpoint_path}' is not a supported checkpoint dictionary.") + model_instance = checkpoint.get("model") or checkpoint.get("ema") + if model_instance is None: + raise ModelPackagingError("Could not find a 'model' or 'ema' entry in the checkpoint.") + + model_type = _resolve_yolo_size(model_type, model_instance, warnings, allow_size_mismatch) + + detected_task = _detect_yolo_task(model_instance) + if detected_task: + existing_task = task_of_model_type(model_type) + if existing_task == TASK_DET and detected_task != TASK_DET: + model_type = f"{model_type}-{detected_task}" + elif existing_task != detected_task: + raise TaskMismatchError( + f"model_type '{model_type}' implies task '{existing_task}' but the " + f".pt file is a '{detected_task}' checkpoint. Use a matching model_type." + ) + + class_names = _class_names_from_model_instance(model_instance) + if any(name in model_type for name in ULTRALYTICS_YOLO_FAMILIES): + if ultralytics is None: + ultralytics = _import_required_module("ultralytics", "pip install ultralytics") + model_yaml = _require_model_attr(model_instance, "yaml", model_type) + if ( + "-cls" in model_type + or model_type.startswith("yolov10") + or model_type.startswith("yolov11") + or model_type.startswith("yolov12") + or model_type.startswith("yolo26") + ): + if not isinstance(model_yaml, dict) or "nc" not in model_yaml: + raise ModelPackagingError( + f"The {model_type} checkpoint's model config (model.yaml) is missing 'nc'; " + "it does not look like a complete Ultralytics training checkpoint." + ) + nc = model_yaml["nc"] + args = _require_checkpoint_field(checkpoint, "train_args", model_type) + else: + nc = _require_model_attr(model_instance, "nc", model_type) + args = _require_model_attr(model_instance, "args", model_type) + model_artifacts: dict[str, Any] = { + "names": class_names, + "yaml": model_yaml, + "nc": nc, + "args": _filtered_args(args), + "ultralytics_version": ultralytics.__version__, + "model_type": model_type, + } + else: + # yolov5 / yolov7 / yolov9 read their upload args from opt.yaml + opt_path = model_path / "opt.yaml" + if not opt_path.exists(): + raise MissingFileError(f"You must provide an opt.yaml file at '{opt_path}' for {model_type} uploads.") + with opt_path.open() as stream: + opts = yaml.safe_load(stream) or {} + model_artifacts = { + "names": class_names, + "nc": _require_model_attr(model_instance, "nc", model_type), + "args": _legacy_yolo_args(opts, opt_path), + "model_type": model_type, + } + if hasattr(model_instance, "yaml"): + model_artifacts["yaml"] = model_instance.yaml + + (build_dir / "model_artifacts.json").write_text(json.dumps(model_artifacts)) + torch.save(model_instance.state_dict(), build_dir / "state_dict.pt") + + archive_path = build_dir / "roboflow_deploy.zip" + _write_zip( + archive_path, + [ + (model_path / "results.csv", "results.csv", False), + (model_path / "results.png", "results.png", False), + (build_dir / "model_artifacts.json", "model_artifacts.json", True), + (build_dir / "state_dict.pt", "state_dict.pt", True), + ], + ) + return archive_path, model_type, warnings + + +def _detect_rfdetr_task(checkpoint: Any) -> str | None: + """Detect the training task of an rf-detr checkpoint. + + rf-detr supports weight upload for detection, instance segmentation, and + keypoint detection. Modern checkpoints (rf-detr v1.7+) store the Python + class name at `checkpoint["model_name"]` (e.g. 'RFDETRNano' vs + 'RFDETRSegNano' vs 'RFDETRKeypointPreview'). + + The deploy bundle written by rf-detr's `export_for_roboflow` only serialises + `{"model", "args"}` β€” it drops `model_name` β€” so detection must also work + from `args`: keypoint checkpoints carry a non-empty `args.num_keypoints_per_class`, + and detection/segmentation checkpoints carry `args.segmentation_head: bool`. + """ + if not isinstance(checkpoint, dict): + return None + model_name = checkpoint.get("model_name") + if isinstance(model_name, str): + name = model_name.lower() + if "keypoint" in name: + return TASK_POSE + return TASK_SEG if TASK_SEG in name else TASK_DET + raw_args = checkpoint.get("args") + if raw_args is None: + return None + args = _checkpoint_args_as_dict(raw_args) + # Keypoint checkpoints carry num_keypoints_per_class; classify them as pose so it agrees + # with task_of_model_type('rfdetr-keypoint-preview') == TASK_POSE and the upload proceeds. + if args.get("num_keypoints_per_class"): + return TASK_POSE + segmentation_head = args.get("segmentation_head") + if segmentation_head is True: + return TASK_SEG + if segmentation_head is False: + return TASK_DET + return None + + +def _rfdetr_checkpoint_pe_size(checkpoint: Any) -> int | None: + """Return an RF-DETR checkpoint's position-encoding grid size (tokens per side). + + Prefers the explicit ``positional_encoding_size`` arg, then ``resolution // + patch_size``, then derives it from the backbone ``position_embeddings`` tensor + (``gridΒ² + 1`` tokens). Returns None when the geometry cannot be determined. + """ + if not isinstance(checkpoint, dict): + return None + args = _checkpoint_args_as_dict(checkpoint.get("args")) + + pe = args.get("positional_encoding_size") + if isinstance(pe, int) and pe > 0: + return pe + resolution = args.get("resolution") + patch_size = args.get("patch_size") + if isinstance(resolution, int) and isinstance(patch_size, int) and patch_size > 0: + return resolution // patch_size + + state_dict = checkpoint.get("model") + if isinstance(state_dict, dict): + for key, tensor in state_dict.items(): + if not key.endswith("position_embeddings"): + continue + shape = getattr(tensor, "shape", None) + if shape is not None and len(shape) == 3: + grid = math.isqrt(int(shape[1]) - 1) + if grid > 0 and grid * grid == int(shape[1]) - 1: + return grid + return None + + +def _resolve_rfdetr_variant( + model_type: str, + checkpoint: Any, + warnings: list[str], + allow_mismatch: bool = False, +) -> str: + """Check an RF-DETR model_type's size variant against the checkpoint geometry. + + Roboflow rebuilds the architecture from ``model_type`` at the variant's default + resolution before loading the weights, so a variant whose position-encoding grid + differs from the checkpoint fails conversion with a ``position_embeddings`` size + mismatch. Two cases: + + * The checkpoint's grid matches a *different* known variant (e.g. uploaded as + ``rfdetr-seg-nano`` but the grid is ``rfdetr-seg-small``) β€” a high-confidence + mislabel. Raise, naming the variant that fits, so the caller can confirm. + * The grid matches *no* known variant β€” likely a custom training resolution. We + cannot tell whether the backend supports it, so warn and proceed rather than + block a possibly-valid upload. + + ``allow_mismatch`` always proceeds with the requested variant (with a warning). + The detection-vs-segmentation task is handled separately and is not changed + here. Returns the resolved model_type. + """ + expected = RFDETR_POSITIONAL_ENCODING_SIZE.get(model_type) + actual = _rfdetr_checkpoint_pe_size(checkpoint) + if actual is None or expected is None or actual == expected: + return model_type + + task = task_of_model_type(model_type) + match = next( + ( + name + for name, grid in RFDETR_POSITIONAL_ENCODING_SIZE.items() + if grid == actual and task_of_model_type(name) == task + ), + None, + ) + + if match is not None and not allow_mismatch: + raise SizeMismatchError( + f"You specified model_type '{model_type}' (a {expected}x{expected} " + f"position-encoding grid), but the checkpoint was trained with " + f"{actual}x{actual}, which matches '{match}'. They don't match, so " + f"Roboflow's weight conversion would fail to load the backbone position " + f"embeddings. Upload as '{match}', or set allow_size_mismatch=True to " + f"upload '{model_type}' exactly as specified.", + requested=model_type, + detected=match, + ) + + if match is None: + warnings.append( + f"model_type '{model_type}' expects a {expected}x{expected} position-encoding " + f"grid, but the checkpoint is {actual}x{actual} and matches no known RF-DETR " + f"variant (it may use a custom training resolution). Packaging as " + f"'{model_type}'; Roboflow's conversion may reject it if it rebuilds at the " + f"variant's default resolution." + ) + else: + warnings.append( + f"model_type '{model_type}' expects a {expected}x{expected} grid, but the " + f"checkpoint is {actual}x{actual} (matches '{match}'). Packaging as " + f"'{model_type}' as requested." + ) + return model_type + + +def _find_rfdetr_checkpoint(model_path: Path, filename: str, warnings: list[str]) -> Path: + """Locate the rf-detr checkpoint. + + An explicitly-requested ``filename`` (anything other than the default) must + exist: falling back to a different checkpoint on a typo would silently + package the wrong weights. Only the default path falls back to discovering + the first top-level .pt/.pth file (sorted for determinism), preserving how + rf-detr uploads located the checkpoint before ``filename`` was honored. + """ + requested_file = model_path / filename + if requested_file.exists(): + return requested_file + + if filename != DEFAULT_WEIGHTS_FILENAME: + raise MissingFileError( + f"RF-DETR weights file '{requested_file}' was not found. Set filename to the " + f"checkpoint's exact .pt or .pth path relative to model_path." + ) + + discovered = sorted(path for path in model_path.iterdir() if path.is_file() and path.suffix in {".pt", ".pth"}) + if not discovered: + raise MissingFileError( + f"No .pt or .pth checkpoint found in '{model_path}' (and '{requested_file}' does not exist)." + ) + if len(discovered) > 1: + others = ", ".join(path.name for path in discovered) + warnings.append( + f"Weights file '{requested_file}' was not found and '{model_path}' holds multiple " + f"checkpoints ({others}); packaging '{discovered[0].name}'. Set filename to pick a " + "specific checkpoint if that is not the one you want." + ) + else: + warnings.append( + f"Weights file '{requested_file}' was not found; using discovered checkpoint " + f"'{discovered[0].name}' instead." + ) + return discovered[0] + + +def _write_rfdetr_class_names(model_path: Path, build_dir: Path, checkpoint: Any) -> Path: + class_names_path = model_path / "class_names.txt" + if class_names_path.exists(): + class_names = class_names_path.read_text().splitlines() + else: + raw_args = checkpoint.get("args") if isinstance(checkpoint, dict) else None + class_names = _checkpoint_args_as_dict(raw_args).get("class_names") or [] + if not class_names: + raise MissingFileError( + f"No class_names.txt file found in '{model_path}', and the RF-DETR " + "checkpoint does not include args with class_names. This should only " + "happen on rfdetr models trained before version 1.1.0. Create " + "class_names.txt with one class per line or retrain with a newer " + "rfdetr library." + ) + + if "background_class83422" not in class_names: + class_names = ["background_class83422", *class_names] + output_path = build_dir / "class_names.txt" + output_path.write_text("\n".join(class_names) + "\n") + return output_path + + +def _is_ptl_checkpoint(checkpoint: Any) -> bool: + """True if the checkpoint is a raw PyTorch-Lightning rf-detr checkpoint dict.""" + return isinstance(checkpoint, dict) and "pytorch-lightning_version" in checkpoint + + +def _require_rfdetr() -> Any: + """Import ``rfdetr`` and verify it ships the upload-bundle helpers. + + Raises :class:`MissingDependencyError` (a ModelPackagingError, so callers see + an actionable 400 rather than an opaque server error) when ``rfdetr`` is + missing or too old to export a Roboflow upload bundle. + """ + try: + import rfdetr + except ImportError as exc: + raise MissingDependencyError( + "The 'rfdetr' package is required to package raw PyTorch-Lightning rf-detr " + f"checkpoints. Install it with `pip install 'rfdetr>={RFDETR_MIN_VERSION}'`." + ) from exc + + if not hasattr(rfdetr.RFDETR, "export_for_roboflow"): + raise MissingDependencyError( + "The installed 'rfdetr' is too old to package raw PyTorch-Lightning rf-detr " + f"checkpoints. Upgrade it with `pip install --upgrade 'rfdetr>={RFDETR_MIN_VERSION}'`." + ) + return rfdetr + + +def _process_rfdetr( + model_type: str, + model_path: Path, + filename: str, + build_dir: Path, + allow_size_mismatch: bool, +) -> tuple[Path, str, list[str]]: + if model_type not in SUPPORTED_RFDETR_TYPES: + raise UnsupportedModelError( + f"Model type '{model_type}' is not supported for RF-DETR upload. " + f"Supported types are: {', '.join(SUPPORTED_RFDETR_TYPES)}." + ) + torch = _import_required_module("torch", "pip install torch") + warnings: list[str] = [] + + checkpoint_path = _find_rfdetr_checkpoint(model_path, filename, warnings) + checkpoint = _load_checkpoint(torch, checkpoint_path, map_location="cpu") + + # Task detection + mismatch runs for every checkpoint shape, so a checkpoint whose + # task disagrees with model_type (e.g. a keypoint checkpoint uploaded as 'rfdetr-base') + # is rejected instead of packaged under the wrong task. + detected_task = _detect_rfdetr_task(checkpoint) + if detected_task and detected_task != task_of_model_type(model_type): + raise TaskMismatchError( + f"model_type '{model_type}' implies task '{task_of_model_type(model_type)}', " + f"but the checkpoint is a '{detected_task}' RF-DETR model. Use a matching model_type." + ) + + if _is_ptl_checkpoint(checkpoint): + # Raw PyTorch-Lightning checkpoint: let rf-detr rebuild a proper upload + # bundle (weights.pt with args.resolution + class_names.txt) into build_dir, + # so the caller's model_path is never mutated. + rfdetr = _require_rfdetr() + try: + model = rfdetr.RFDETR.from_checkpoint(str(checkpoint_path)) + except ValueError: + # Checkpoint lacks model_name/pretrain_weights signals; fall back to the + # already-validated model_type to pick the RFDETR subclass. + model_cls = getattr(rfdetr, _RFDETR_MODEL_TYPE_TO_CLASS[model_type]) + model = model_cls(pretrain_weights=str(checkpoint_path)) + model.export_for_roboflow(str(build_dir)) # writes weights.pt + class_names.txt + else: + # Roboflow's server-side RF-DETR conversion reads checkpoint["args"] (the + # class names, class count, and model config). A bare inference state_dict β€” + # e.g. {"model": } with nothing else β€” would otherwise package and + # upload fine, then fail conversion with an opaque KeyError: 'args'. Catch it + # here so the caller gets an actionable error before uploading. + if not isinstance(checkpoint, dict) or checkpoint.get("args") is None: + raise ModelPackagingError( + f"The RF-DETR checkpoint '{checkpoint_path.name}' is missing its 'args' " + "metadata; it looks like a bare inference state_dict. Roboflow's weight " + "conversion needs the full training checkpoint (args carries the class " + "names, class count, and model config). Re-export the checkpoint from your " + "training run, or download the deploy checkpoint from Roboflow." + ) + + model_type = _resolve_rfdetr_variant(model_type, checkpoint, warnings, allow_size_mismatch) + + weights_dest = build_dir / "weights.pt" + # In the legacy deploy flow build_dir is model_path, so a checkpoint already + # named weights.pt is its own destination; copying would raise SameFileError. + if checkpoint_path.resolve() != weights_dest.resolve(): + shutil.copy(checkpoint_path, weights_dest) + _write_rfdetr_class_names(model_path, build_dir, checkpoint) + + archive_path = build_dir / "roboflow_deploy.zip" + _write_zip( + archive_path, + [ + (build_dir / "weights.pt", "weights.pt", True), + (model_path / "results.csv", "results.csv", False), + (model_path / "results.png", "results.png", False), + (model_path / "model_artifacts.json", "model_artifacts.json", False), + (build_dir / "class_names.txt", "class_names.txt", False), + ], + ) + return archive_path, model_type, warnings + + +def _process_huggingface( + model_type: str, + model_path: Path, + build_dir: Path, +) -> tuple[Path, str, list[str]]: + if model_type not in SUPPORTED_HUGGINGFACE_TYPES: + raise UnsupportedModelError( + f"Model type '{model_type}' is not supported for this type of upload. " + f"Supported types are: {', '.join(SUPPORTED_HUGGINGFACE_TYPES)}." + ) + + model_files = [path for path in model_path.iterdir() if path.is_file()] + safetensors_files = [path for path in model_files if path.suffix == ".safetensors"] + npz_file = next((path for path in model_files if path.suffix == ".npz"), None) + if safetensors_files: + required = { + "preprocessor_config.json", + "special_tokens_map.json", + "tokenizer_config.json", + "tokenizer.json", + } + missing = sorted(required - {path.name for path in model_files}) + if missing: + raise MissingFileError(f"Missing files required for a PyTorch {model_type} upload: {', '.join(missing)}.") + files_to_deploy = model_files + elif npz_file is not None: + files_to_deploy = [npz_file] + else: + raise MissingFileError(f"No .npz or .safetensors model file found in '{model_path}'.") + + archive_path = build_dir / "roboflow_deploy.tar" + with tarfile.open(archive_path, "w") as tar: + for path in files_to_deploy: + tar.add(path, arcname=path.name) + return archive_path, model_type, [] + + +def _process_yolonas( + model_type: str, + model_path: Path, + filename: str, + build_dir: Path, +) -> tuple[Path, str, list[str]]: + if model_type != "yolonas": + raise UnsupportedModelError( + f"Model type '{model_type}' is not supported for YOLO-NAS upload. The only " + "supported YOLO-NAS type is 'yolonas'; the architecture size goes in opt.yaml " + "as 'architecture: yolo_nas_s' (or _m / _l)." + ) + torch = _import_required_module("torch", "pip install torch") + weights_path = model_path / filename + if not weights_path.exists(): + raise MissingFileError(f"Model weights file '{weights_path}' was not found.") + + checkpoint = _load_checkpoint(torch, weights_path, map_location="cpu") + # A SuperGradients YOLO-NAS checkpoint carries processing_params.class_names. + # A bare state_dict (e.g. torch.save(net.state_dict())) lacks it and would + # otherwise raise a raw KeyError/TypeError instead of an actionable error. + processing_params = checkpoint.get("processing_params") if isinstance(checkpoint, dict) else None + class_names = processing_params.get("class_names") if isinstance(processing_params, dict) else None + if not class_names: + raise ModelPackagingError( + f"The YOLO-NAS checkpoint '{weights_path.name}' is missing " + "processing_params.class_names; it looks like a bare state_dict. Provide the " + "full training checkpoint saved by SuperGradients." + ) + opt_path = model_path / "opt.yaml" + if not opt_path.exists(): + raise MissingFileError( + f"You must create an opt.yaml file at '{opt_path}' of the format:\n" + f"imgsz: \n" + f"batch_size: \n" + f"architecture: \n" + ) + with opt_path.open() as stream: + opts = yaml.safe_load(stream) or {} + missing = [key for key in ("imgsz", "batch_size", "architecture") if key not in opts] + if missing: + raise ModelPackagingError(f"{opt_path} lacks required keys: {', '.join(missing)}.") + + model_artifacts = { + "names": class_names, + "nc": len(class_names), + "args": { + "imgsz": opts["imgsz"], + "batch": opts["batch_size"], + "architecture": opts["architecture"], + }, + "model_type": model_type, + } + (build_dir / "model_artifacts.json").write_text(json.dumps(model_artifacts)) + shutil.copy(weights_path, build_dir / "state_dict.pt") + + archive_path = build_dir / "roboflow_deploy.zip" + _write_zip( + archive_path, + [ + (model_path / "results.json", "results.json", False), + (model_path / "results.png", "results.png", False), + (build_dir / "model_artifacts.json", "model_artifacts.json", True), + (build_dir / "state_dict.pt", "state_dict.pt", True), + ], + ) + return archive_path, model_type, [] + + +def _write_zip( + archive_path: Path, + files: list[tuple[Path, str, bool]], +) -> None: + with zipfile.ZipFile(archive_path, "w") as zip_file: + for path, arcname, required in files: + if path.exists(): + zip_file.write(path, arcname=arcname, compress_type=zipfile.ZIP_DEFLATED) + elif required: + raise MissingFileError(f"Required upload artifact '{path}' was not found.") + + +def get_classnames_txt_for_rfdetr(model_path: str, pt_file: str, checkpoint=None): + """Legacy rf-detr class-names helper, kept for backwards compatibility. + + Writes (and mutates) ``class_names.txt`` inside ``model_path``. The packaging + flow uses :func:`_write_rfdetr_class_names` instead, which leaves the source + directory untouched. + """ + class_names_path = os.path.join(model_path, "class_names.txt") + if os.path.exists(class_names_path): + maybe_prepend_dummy_class(class_names_path) + return class_names_path + + if checkpoint is None: + import torch + + checkpoint = torch.load(os.path.join(model_path, pt_file), map_location="cpu", weights_only=False) + raw_args = checkpoint["args"] + # args may be a plain dict in some checkpoints + args = raw_args if isinstance(raw_args, dict) else vars(raw_args) + if "class_names" in args: + with open(class_names_path, "w") as f: + for class_name in args["class_names"]: + f.write(class_name + "\n") + maybe_prepend_dummy_class(class_names_path) + return class_names_path + + raise MissingFileError( + f"No class_names.txt file found in model path {model_path}.\n" + f"This should only happen on rfdetr models trained before version 1.1.0.\n" + f"Please re-train your model with the latest version of the rfdetr library, or\n" + f"please create a class_names.txt file in the model path with the class names\n" + f"in new lines in the order of the classes in the model.\n" + ) + + +def maybe_prepend_dummy_class(class_name_file: str): + with open(class_name_file) as f: + class_names = f.readlines() + + dummy_class = "background_class83422\n" + if dummy_class not in class_names: + class_names.insert(0, dummy_class) + with open(class_name_file, "w") as f: + f.writelines(class_names) diff --git a/roboflow/util/prediction.py b/roboflow/util/prediction.py index d4740e58..77d4cd73 100644 --- a/roboflow/util/prediction.py +++ b/roboflow/util/prediction.py @@ -10,6 +10,7 @@ from roboflow.config import ( CLASSIFICATION_MODEL, INSTANCE_SEGMENTATION_MODEL, + KEYPOINT_DETECTION_MODEL, OBJECT_DETECTION_MODEL, PREDICTION_OBJECT, SEMANTIC_SEGMENTATION_MODEL, @@ -57,7 +58,7 @@ def plot_annotation(axes, prediction=None, stroke=1, transparency=60, colors=Non prediction = prediction or {} stroke_color = "r" - if prediction["prediction_type"] == OBJECT_DETECTION_MODEL: + if prediction["prediction_type"] in (OBJECT_DETECTION_MODEL, KEYPOINT_DETECTION_MODEL): if prediction["class"] in colors.keys(): stroke_color = colors[prediction["class"]] @@ -158,7 +159,7 @@ def save(self, output_path="predictions.jpg", stroke=2, transparency=60): image = self.__load_image() stroke_color = (255, 0, 0) - if self["prediction_type"] == OBJECT_DETECTION_MODEL: + if self["prediction_type"] in (OBJECT_DETECTION_MODEL, KEYPOINT_DETECTION_MODEL): # Get different dimensions/coordinates x = self["x"] y = self["y"] @@ -346,7 +347,7 @@ def save(self, output_path="predictions.jpg", stroke=2): # Iterate through predictions and add prediction to image for prediction in self.predictions: # Check what type of prediction it is - if self.base_prediction_type == OBJECT_DETECTION_MODEL: + if self.base_prediction_type in (OBJECT_DETECTION_MODEL, KEYPOINT_DETECTION_MODEL): # Get different dimensions/coordinates x = prediction["x"] y = prediction["y"] @@ -509,7 +510,7 @@ def create_prediction_group(json_response, image_path, prediction_type, image_di colors = {} if colors is None else colors prediction_list = [] - if prediction_type in [OBJECT_DETECTION_MODEL, INSTANCE_SEGMENTATION_MODEL]: + if prediction_type in [OBJECT_DETECTION_MODEL, INSTANCE_SEGMENTATION_MODEL, KEYPOINT_DETECTION_MODEL]: for prediction in json_response["predictions"]: prediction = Prediction( prediction, diff --git a/roboflow/util/train_recipe.py b/roboflow/util/train_recipe.py new file mode 100644 index 00000000..010b4dd0 --- /dev/null +++ b/roboflow/util/train_recipe.py @@ -0,0 +1,30 @@ +"""Helpers for v2 ``trainRecipe`` payloads. + +``GET .../v2/trainings/recipe`` returns a ready-to-submit ``template``; +callers edit it and submit it via ``rfapi.create_training_v2``. The server +dense-fills omitted defaults server-side. +""" + +from __future__ import annotations + +import copy +from typing import Any, Dict + + +def fold_epochs_into_recipe(recipe: Dict[str, Any], epochs: int) -> Dict[str, Any]: + """Return a copy of *recipe* with *epochs* folded into its hyperparameters. + + The server dense-fills a submitted recipe's hyperparameters (including + a default ``epochs``) and resolves them ahead of the request body's + top-level ``epochs``, so a top-level value submitted alongside a recipe + would otherwise be silently ignored. + + An ``"epochs"`` already set in the recipe's hyperparameters wins; the + ``hyperparameters`` key is created when a hand-written recipe omits it. + The input recipe is not mutated. + """ + folded = copy.deepcopy(recipe) + hyperparameters = dict(folded.get("hyperparameters") or {}) + hyperparameters.setdefault("epochs", epochs) + folded["hyperparameters"] = hyperparameters + return folded diff --git a/roboflow/util/versions.py b/roboflow/util/versions.py index b43ff79d..f07bf006 100644 --- a/roboflow/util/versions.py +++ b/roboflow/util/versions.py @@ -34,7 +34,7 @@ def get_wrong_dependencies_versions( module = import_module(dependency) module_version = module.__version__ if order not in order_funcs: - raise ValueError(f"order={order} not supported, please use" f" `{', '.join(order_funcs.keys())}`") + raise ValueError(f"order={order} not supported, please use `{', '.join(order_funcs.keys())}`") is_okay = order_funcs[order](Version(module_version), Version(version)) if not is_okay: @@ -53,7 +53,7 @@ def print_warn_for_wrong_dependencies_versions( f" {dependency}{order}{version}`" ) if ask_to_continue: - answer = input(f"Would you like to continue with the wrong version of {dependency}?" " y/n: ") + answer = input(f"Would you like to continue with the wrong version of {dependency}? y/n: ") if answer.lower() != "y": sys.exit(1) @@ -89,3 +89,46 @@ def _wrapper(*args, **kwargs): return _wrapper return _inner + + +def normalize_yolo_model_type(model_type: str) -> str: + model_type = model_type.replace("yolo11", "yolov11") + model_type = model_type.replace("yolo12", "yolov12") + return model_type + + +def get_model_format(model_type: str) -> str: + """ + Get the model format for a given model type. + Args: + model_type (str): The model type to get the format for. + + Returns: + str: The model format. + + Example: + >>> get_model_format("yolov5v6n") + "yolov5pytorch" + >>> get_model_format("rfdetr-nano") + "coco" + >>> get_model_format("yolov11n") + "yolov5pytorch" + """ + # Prefixes extrated from modelRegistry.js in roboflow. + model_formats = { + "yolo": "yolov5pytorch", + "pali": "jsonl", + "flor": "jsonl", + "qwen": "jsonl", + "smol": "jsonl", + "vit-b": "folder", + "resn": "folder", + "rfdetr": "coco", + "rf-detr": "coco", + "deep": "png-mask-semantic", + } + + for prefix, format in model_formats.items(): + if prefix in model_type: + return format + return "yolov5pytorch" diff --git a/scripts/generateAzureSasUrls.sh b/scripts/generateAzureSasUrls.sh new file mode 100755 index 00000000..4fc7232b --- /dev/null +++ b/scripts/generateAzureSasUrls.sh @@ -0,0 +1,179 @@ +#!/bin/bash +# Script to generate Azure Blob Storage SAS URLs for image files in JSONL format +# requires az cli installed and logged in https://learn.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest +# Usage: ./generateAzureSasUrls.sh [output-file] [expiration-hours] [parallel-jobs] +# Example: ./generateAzureSasUrls.sh https://myaccount.blob.core.windows.net/mycontainer output.jsonl 6 8 +# Or with curl: +# curl -fsSL https://raw.githubusercontent.com/roboflow/roboflow-python/main/scripts/generateAzureSasUrls.sh | bash -s -- https://myaccount.blob.core.windows.net/mycontainer output.jsonl + +set -e + +# Check if container URL is provided +if [ -z "$1" ]; then + echo "Error: Azure container URL is required" + echo "Usage: $0 [output-file] [expiration-hours] [parallel-jobs]" + echo "Example: $0 https://myaccount.blob.core.windows.net/mycontainer output.jsonl 6 8" + exit 1 +fi + +CONTAINER_URL="$1" +OUTPUT_FILE="${2:-signed_urls.jsonl}" +EXPIRATION_HOURS="${3:-6}" # Default: 6 hours +PARALLEL_JOBS="${4:-20}" # Default: 20 parallel jobs + +# Remove trailing slash from container URL if present +CONTAINER_URL="${CONTAINER_URL%/}" + +# Extract storage account and container from URL +STORAGE_ACCOUNT=$(echo "$CONTAINER_URL" | sed -E 's|https://([^.]+)\.blob\.core\.windows\.net/.*|\1|') +CONTAINER=$(echo "$CONTAINER_URL" | sed -E 's|https://[^/]+/([^/]+).*|\1|') + +# Optional: Extract path prefix if provided in URL +PATH_PREFIX=$(echo "$CONTAINER_URL" | sed -E 's|https://[^/]+/[^/]+/?(.*)|/\1|' | sed 's|^//$||') +if [ "$PATH_PREFIX" = "/" ]; then + PATH_PREFIX="" +fi + +# Image file extensions to include (regex pattern for grep) +IMAGE_PATTERN='\.(jpg|jpeg|png|gif|bmp|webp|tiff|tif|svg)$' + +# Calculate expiry time in UTC (cross-platform compatible) +if date --version >/dev/null 2>&1; then + # GNU date (Linux) + EXPIRY=$(date -u -d "+${EXPIRATION_HOURS} hours" '+%Y-%m-%dT%H:%MZ') +else + # BSD date (macOS) + EXPIRY=$(date -u -v+${EXPIRATION_HOURS}H '+%Y-%m-%dT%H:%MZ') +fi + +# Function to process a single blob +process_blob() { + local blob_name="$1" + local storage_account="$2" + local container="$3" + local expiry="$4" + + # Generate SAS token for the specific blob (redirect stderr to suppress warnings) + local sas_token=$(az storage blob generate-sas \ + --account-name "$storage_account" \ + --container-name "$container" \ + --name "$blob_name" \ + --permissions r \ + --expiry "$expiry" \ + --https-only \ + --auth-mode key \ + --output tsv 2>/dev/null) + + if [ $? -eq 0 ]; then + # Construct the full URL with SAS token + local signed_url="https://${storage_account}.blob.core.windows.net/${container}/${blob_name}?${sas_token}" + + # Create name with full path using double underscores instead of slashes + local name_with_path=$(echo "$blob_name" | sed 's|/|__|g') + + # Output JSONL + echo "{\"name\": \"$name_with_path\", \"url\": \"$signed_url\"}" + fi +} + +# Alternative function using connection string or SAS token at account level +process_blob_with_connection() { + local blob_name="$1" + local storage_account="$2" + local container="$3" + local expiry="$4" + + # Generate SAS token using connection string if AZURE_STORAGE_CONNECTION_STRING is set + if [ -n "$AZURE_STORAGE_CONNECTION_STRING" ]; then + local sas_token=$(az storage blob generate-sas \ + --connection-string "$AZURE_STORAGE_CONNECTION_STRING" \ + --container-name "$container" \ + --name "$blob_name" \ + --permissions r \ + --expiry "$expiry" \ + --https-only \ + --output tsv 2>/dev/null) + else + # Use account key if available + local sas_token=$(az storage blob generate-sas \ + --account-name "$storage_account" \ + --container-name "$container" \ + --name "$blob_name" \ + --permissions r \ + --expiry "$expiry" \ + --https-only \ + --output tsv 2>/dev/null) + fi + + if [ $? -eq 0 ]; then + local signed_url="https://${storage_account}.blob.core.windows.net/${container}/${blob_name}?${sas_token}" + local name_with_path=$(echo "$blob_name" | sed 's|/|__|g') + echo "{\"name\": \"$name_with_path\", \"url\": \"$signed_url\"}" + fi +} + +# Check if user is logged in to Azure CLI +if ! az account show &>/dev/null; then + echo "Error: Not logged in to Azure CLI. Please run 'az login' first." + exit 1 +fi + +# Export function and variables for xargs +export -f process_blob process_blob_with_connection +export STORAGE_ACCOUNT CONTAINER EXPIRY +export AZURE_STORAGE_CONNECTION_STRING + +echo "Listing blobs from container: $CONTAINER in account: $STORAGE_ACCOUNT..." +if [ -n "$PATH_PREFIX" ]; then + echo "Using path prefix: $PATH_PREFIX" +fi + +# Get list of all blobs, filter for images, and process in parallel +# Create a temporary file for the blob list to avoid stdin issues +BLOB_LIST=$(mktemp) +trap "rm -f $BLOB_LIST" EXIT + +if [ -n "$PATH_PREFIX" ]; then + # List blobs with prefix + az storage blob list \ + --account-name "$STORAGE_ACCOUNT" \ + --container-name "$CONTAINER" \ + --prefix "$PATH_PREFIX" \ + --auth-mode key \ + --query "[].name" \ + --output tsv 2>/dev/null | grep -iE "$IMAGE_PATTERN" > "$BLOB_LIST" +else + # List all blobs in container + az storage blob list \ + --account-name "$STORAGE_ACCOUNT" \ + --container-name "$CONTAINER" \ + --auth-mode key \ + --query "[].name" \ + --output tsv 2>/dev/null | grep -iE "$IMAGE_PATTERN" > "$BLOB_LIST" +fi + +# Process blobs in parallel using background jobs +: > "$OUTPUT_FILE" # Clear output file +COUNT=0 + +while IFS= read -r blob_name; do + # Process blob in background + process_blob "$blob_name" "$STORAGE_ACCOUNT" "$CONTAINER" "$EXPIRY" >> "$OUTPUT_FILE" & + + # Limit concurrent jobs + ((COUNT++)) + if [ $((COUNT % PARALLEL_JOBS)) -eq 0 ]; then + wait # Wait for current batch to complete + fi +done < "$BLOB_LIST" + +# Wait for any remaining jobs +wait + +# Display the results +cat "$OUTPUT_FILE" + +echo "" +echo "Done! SAS URLs written to $OUTPUT_FILE" +echo "Total images processed: $(wc -l < "$OUTPUT_FILE" 2>/dev/null || echo 0)" +echo "SAS tokens valid until: $EXPIRY" diff --git a/scripts/generateGCSSignedUrls.sh b/scripts/generateGCSSignedUrls.sh new file mode 100644 index 00000000..fd44f5a5 --- /dev/null +++ b/scripts/generateGCSSignedUrls.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +# Script to generate GCS signed URLs for image files in JSONL format +# Usage: ./listgcs.sh [output-file] [expiration-seconds] [parallel-jobs] + +set -e + +# Check if GCS path is provided +if [ -z "$1" ]; then + echo "Error: GCS path is required" + echo "Usage: $0 [output-file] [expiration-seconds] [parallel-jobs]" + echo "Example: $0 gs://my-bucket/images/ output.jsonl 21600 8" + exit 1 +fi + +GCS_PATH="$1" +OUTPUT_FILE="${2:-signed_urls.jsonl}" +EXPIRATION_SECONDS="${3:-21600}" # Default: 6 hours +PARALLEL_JOBS="${4:-20}" # Default: 20 parallel jobs + +# Remove trailing slash from GCS path if present +GCS_PATH="${GCS_PATH%/}" + +# Convert seconds to duration format for gcloud (e.g., 21600s) +EXPIRATION="${EXPIRATION_SECONDS}s" + +# Image file extensions to include (regex pattern for grep) +IMAGE_PATTERN='\.(jpg|jpeg|png|gif|bmp|webp|tiff|tif|svg)$' + +# Function to find an appropriate service account +find_service_account() { + # First, try to get the default compute service account for the current project + local project_id=$(gcloud config get-value project 2>/dev/null) + if [ -n "$project_id" ]; then + local compute_sa="${project_id}-compute@developer.gserviceaccount.com" + if gcloud iam service-accounts describe "$compute_sa" >/dev/null 2>&1; then + echo "$compute_sa" + return 0 + fi + fi + + # If that doesn't work, try to find any service account in the project + local sa_list=$(gcloud iam service-accounts list --format="value(email)" --limit=1 2>/dev/null) + if [ -n "$sa_list" ]; then + echo "$sa_list" | head -n 1 + return 0 + fi + + return 1 +} + +# Try to find a service account to use +SERVICE_ACCOUNT=$(find_service_account) +if [ -z "$SERVICE_ACCOUNT" ]; then + echo "Warning: No service account found. Attempting to sign URLs without impersonation." + echo "If this fails, you may need to:" + echo "1. Authenticate with a service account: gcloud auth activate-service-account --key-file=key.json" + echo "2. Or ensure you have appropriate service accounts in your project" + echo "" +fi + +# Function to process a single file +process_file() { + local object="$1" + local service_account="$2" + local expiration="$3" + + # Create signed URL using gcloud storage sign-url + local signed_url_output + if [ -n "$service_account" ]; then + signed_url_output=$(gcloud storage sign-url --http-verb=GET --duration="$expiration" --impersonate-service-account="$service_account" "$object" 2>/dev/null) + else + signed_url_output=$(gcloud storage sign-url --http-verb=GET --duration="$expiration" "$object" 2>/dev/null) + fi + + if [ $? -eq 0 ] && [ -n "$signed_url_output" ]; then + # Extract just the signed_url from the YAML output + local signed_url=$(echo "$signed_url_output" | grep "signed_url:" | sed 's/signed_url: //') + + if [ -n "$signed_url" ]; then + # Extract the path after the bucket name and convert slashes to double underscores + local path_part=$(echo "$object" | sed 's|gs://[^/]*/||') + local name_with_path=$(echo "$path_part" | sed 's|/|__|g') + + # Output JSONL + echo "{\"name\": \"$name_with_path\", \"url\": \"$signed_url\"}" + fi + fi +} + +# Export function and variables for xargs +export -f process_file +export SERVICE_ACCOUNT +export EXPIRATION + +echo "Listing files from $GCS_PATH..." + +# Get list of all files, filter for images, and process in parallel +gsutil ls -r "$GCS_PATH" 2>/dev/null | \ + grep -v '/$' | \ + grep -v ':$' | \ + grep -iE "$IMAGE_PATTERN" | \ + xargs -I {} -P "$PARALLEL_JOBS" bash -c 'process_file "$@"' _ {} "$SERVICE_ACCOUNT" "$EXPIRATION" | \ + tee "$OUTPUT_FILE" + +echo "" +echo "Done! Signed URLs written to $OUTPUT_FILE" +echo "Total images processed: $(wc -l < "$OUTPUT_FILE")" diff --git a/scripts/generateS3SignedUrls.sh b/scripts/generateS3SignedUrls.sh new file mode 100644 index 00000000..9391ee2f --- /dev/null +++ b/scripts/generateS3SignedUrls.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# Script to generate S3 signed URLs for image files in JSONL format +# Usage: ./generateS3SignedUrls.sh [output-file] [expiration-seconds] [parallel-jobs] +# Or with curl: +# curl -fsSL https://raw.githubusercontent.com/roboflow/roboflow-python/main/scripts/generateS3SignedUrls.sh | bash -s -- s3://bucket/path output.jsonl + +set -e + +# Check if S3 path is provided +if [ -z "$1" ]; then + echo "Error: S3 path is required" + echo "Usage: $0 [output-file] [expiration-seconds] [parallel-jobs]" + echo "Example: $0 s3://my-bucket/images/ output.jsonl 3600 8" + exit 1 +fi + +S3_PATH="$1" +OUTPUT_FILE="${2:-signed_urls.jsonl}" +EXPIRATION="${3:-21600}" # Default: 6 hours +PARALLEL_JOBS="${4:-20}" # Default: 20 parallel jobs + +# Remove trailing slash from S3 path if present +S3_PATH="${S3_PATH%/}" + +# Extract bucket name from S3_PATH +BUCKET=$(echo "$S3_PATH" | sed 's|s3://||' | cut -d'/' -f1) + +# Image file extensions to include (regex pattern for grep) +IMAGE_PATTERN='\.(jpg|jpeg|png|gif|bmp|webp|tiff|tif|svg)$' + +# Function to process a single file +process_file() { + local file_path="$1" + local bucket="$2" + local expiration="$3" + + # Construct full S3 URI + local s3_uri="s3://${bucket}/${file_path}" + + # Generate signed URL + local signed_url=$(aws s3 presign "$s3_uri" --expires-in "$expiration" 2>/dev/null) + + if [ $? -eq 0 ]; then + # Create name with full path using double underscores instead of slashes + local name_with_path=$(echo "$file_path" | sed 's|/|__|g') + + # Output JSONL + echo "{\"name\": \"$name_with_path\", \"url\": \"$signed_url\"}" + fi +} + +# Export function and variables for xargs +export -f process_file +export BUCKET +export EXPIRATION + +echo "Listing files from $S3_PATH..." + +# Get list of all files, filter for images, and process in parallel +aws s3 ls "$S3_PATH/" --recursive | \ + awk '{print $4}' | \ + grep -iE "$IMAGE_PATTERN" | \ + xargs -I {} -P "$PARALLEL_JOBS" bash -c 'process_file "$@"' _ {} "$BUCKET" "$EXPIRATION" | \ + tee "$OUTPUT_FILE" + +echo "" +echo "Done! Signed URLs written to $OUTPUT_FILE" +echo "Total images processed: $(wc -l < "$OUTPUT_FILE")" diff --git a/setup.py b/setup.py index c509268e..85f671ab 100644 --- a/setup.py +++ b/setup.py @@ -31,10 +31,9 @@ extras_require={ "desktop": ["opencv-python==4.8.0.74"], "dev": [ - "mypy<1.11.0", + "mypy", "responses", "ruff", - "twine", "types-pyyaml", "types-requests", "types-setuptools", @@ -52,5 +51,5 @@ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ], - python_requires=">=3.8", + python_requires=">=3.10", ) diff --git a/setup_slim.py b/setup_slim.py new file mode 100644 index 00000000..1b2dcc10 --- /dev/null +++ b/setup_slim.py @@ -0,0 +1,53 @@ +import re + +import setuptools +from setuptools import find_packages + +with open("./roboflow/__init__.py") as f: + content = f.read() +_search_version = re.search(r'__version__\s*=\s*[\'"]([^\'"]*)[\'"]', content) +assert _search_version +version = _search_version.group(1) + + +with open("README.md") as fh: + long_description = fh.read() + +with open("requirements-slim.txt") as fh: + install_requires = fh.read().split("\n") + +setuptools.setup( + name="roboflow-slim", + version=version, + author="Roboflow", + author_email="support@roboflow.com", + description="Lightweight Roboflow SDK for vision events, workspace management, and CLI", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/roboflow-ai/roboflow-python", + install_requires=install_requires, + packages=find_packages(exclude=("tests",)), + extras_require={ + "dev": [ + "mypy", + "responses", + "ruff", + "types-pyyaml", + "types-requests", + "types-setuptools", + "types-tqdm", + "wheel", + ], + }, + entry_points={ + "console_scripts": [ + "roboflow=roboflow.roboflowpy:main", + ], + }, + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + ], + python_requires=">=3.10", +) diff --git a/tests/adapters/__init__.py b/tests/adapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/adapters/test_rfapi_model_evals.py b/tests/adapters/test_rfapi_model_evals.py new file mode 100644 index 00000000..41cc6942 --- /dev/null +++ b/tests/adapters/test_rfapi_model_evals.py @@ -0,0 +1,224 @@ +"""Unit tests for the model-eval rfapi helpers (`/{ws}/model-evals/...`).""" + +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +from roboflow.adapters import rfapi +from roboflow.config import API_URL + + +def _resp(status: int, body): + """Build a mock requests.Response double for the given status + JSON body.""" + mock = MagicMock(status_code=status) + mock.json.return_value = body + mock.text = repr(body) + return mock + + +class TestListModelEvals(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success_no_filters(self, mock_get): + mock_get.return_value = _resp(200, {"evals": [{"id": "e1", "status": "done"}]}) + + result = rfapi.list_model_evals("k", "ws") + + self.assertEqual(result, {"evals": [{"id": "e1", "status": "done"}]}) + url = mock_get.call_args[0][0] + params = mock_get.call_args.kwargs["params"] + self.assertEqual(url, f"{API_URL}/ws/model-evals") + self.assertEqual(params, {"api_key": "k"}) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_success_with_filters(self, mock_get): + mock_get.return_value = _resp(200, {"evals": []}) + + rfapi.list_model_evals("k", "ws", project="p1", version=3, model="m1", status="done", limit=10) + + params = mock_get.call_args.kwargs["params"] + self.assertEqual( + params, + { + "api_key": "k", + "project": "p1", + "version": 3, + "model": "m1", + "status": "done", + "limit": 10, + }, + ) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_omits_none_filters(self, mock_get): + mock_get.return_value = _resp(200, {"evals": []}) + + rfapi.list_model_evals("k", "ws", status="done", limit=None) + + params = mock_get.call_args.kwargs["params"] + self.assertNotIn("limit", params) + self.assertEqual(params["status"], "done") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_404_raises_not_found(self, mock_get): + mock_get.return_value = _resp(404, {"error": "model_eval_not_found", "message": "nope"}) + + with self.assertRaises(rfapi.ModelEvalNotFoundError) as ctx: + rfapi.list_model_evals("k", "ws") + self.assertIn("nope", str(ctx.exception)) + + +class TestGetModelEval(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + mock_get.return_value = _resp(200, {"id": "e1", "status": "done", "summary": {"mAP": 0.9}}) + + result = rfapi.get_model_eval("k", "ws", "e1") + + self.assertEqual(result["summary"]["mAP"], 0.9) + url = mock_get.call_args[0][0] + self.assertEqual(url, f"{API_URL}/ws/model-evals/e1") + + +class TestPanelEndpoints(unittest.TestCase): + """Each panel endpoint forwards path + params correctly.""" + + @patch("roboflow.adapters.rfapi.requests.get") + def test_map_results_url(self, mock_get): + mock_get.return_value = _resp(200, {"splits": {}}) + + rfapi.get_model_eval_map_results("k", "ws", "e1") + + url = mock_get.call_args[0][0] + self.assertEqual(url, f"{API_URL}/ws/model-evals/e1/map-results") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_confidence_sweep_url(self, mock_get): + mock_get.return_value = _resp(200, {"splits": {}}) + + rfapi.get_model_eval_confidence_sweep("k", "ws", "e1") + + url = mock_get.call_args[0][0] + self.assertEqual(url, f"{API_URL}/ws/model-evals/e1/confidence-sweep") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_performance_by_class_passes_split(self, mock_get): + mock_get.return_value = _resp(200, {"split": "valid", "classes": []}) + + rfapi.get_model_eval_performance_by_class("k", "ws", "e1", split="valid") + + params = mock_get.call_args.kwargs["params"] + self.assertEqual(params["split"], "valid") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_confusion_matrix_passes_params(self, mock_get): + mock_get.return_value = _resp(200, {"matrix": []}) + + rfapi.get_model_eval_confusion_matrix("k", "ws", "e1", split="test", confidence=30) + + params = mock_get.call_args.kwargs["params"] + self.assertEqual(params["split"], "test") + self.assertEqual(params["confidence"], 30) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_image_predictions_pagination(self, mock_get): + mock_get.return_value = _resp(200, {"images": []}) + + rfapi.get_model_eval_image_predictions("k", "ws", "e1", split="test", confidence=20, limit=50, offset=100) + + params = mock_get.call_args.kwargs["params"] + self.assertEqual(params["limit"], 50) + self.assertEqual(params["offset"], 100) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_recommendations_url(self, mock_get): + mock_get.return_value = _resp(200, {"recommendations": []}) + + rfapi.get_model_eval_recommendations("k", "ws", "e1") + + url = mock_get.call_args[0][0] + self.assertEqual(url, f"{API_URL}/ws/model-evals/e1/recommendations") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_vector_analysis_passes_confidence(self, mock_get): + mock_get.return_value = _resp(200, {"clusters": []}) + + rfapi.get_model_eval_vector_analysis("k", "ws", "e1", confidence=25) + + params = mock_get.call_args.kwargs["params"] + self.assertEqual(params["confidence"], 25) + + +class TestErrorMapping(unittest.TestCase): + """Typed errors are routed to the right exception subclass.""" + + @patch("roboflow.adapters.rfapi.requests.get") + def test_404_flat_envelope(self, mock_get): + # Server returns the flat shape: {"error": "code", "message": "..."} + mock_get.return_value = _resp(404, {"error": "model_eval_not_found", "message": "Eval 'x' not found"}) + + with self.assertRaises(rfapi.ModelEvalNotFoundError) as ctx: + rfapi.get_model_eval("k", "ws", "x") + self.assertIn("Eval 'x' not found", str(ctx.exception)) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_404_status_code_fallback(self, mock_get): + # No `error` field at all β€” fall back to the status code mapping. + mock_get.return_value = _resp(404, {"message": "something went wrong"}) + + with self.assertRaises(rfapi.ModelEvalNotFoundError): + rfapi.get_model_eval("k", "ws", "x") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_409_not_done(self, mock_get): + mock_get.return_value = _resp(409, {"error": "model_eval_not_done", "message": "Eval still running"}) + + with self.assertRaises(rfapi.ModelEvalNotDoneError): + rfapi.get_model_eval_map_results("k", "ws", "x") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_400_invalid_split(self, mock_get): + mock_get.return_value = _resp(400, {"error": "invalid_split", "message": "Invalid split"}) + + with self.assertRaises(rfapi.InvalidSplitError): + rfapi.get_model_eval_performance_by_class("k", "ws", "x", split="all") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_400_invalid_confidence(self, mock_get): + mock_get.return_value = _resp(400, {"error": "invalid_confidence", "message": "out of range"}) + + with self.assertRaises(rfapi.InvalidConfidenceError): + rfapi.get_model_eval_confusion_matrix("k", "ws", "x", confidence=200) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_unknown_404_falls_back_to_not_found(self, mock_get): + # 404 without a recognised code still maps by status code (forward-compat). + mock_get.return_value = _resp(404, {"error": "some_new_code", "message": "?"}) + + with self.assertRaises(rfapi.ModelEvalNotFoundError): + rfapi.get_model_eval("k", "ws", "x") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_unknown_500_raises_generic_roboflow_error(self, mock_get): + mock_get.return_value = _resp(500, {"error": "server_oops", "message": "boom"}) + + with self.assertRaises(rfapi.RoboflowError) as ctx: + rfapi.get_model_eval("k", "ws", "x") + # Not one of the typed subclasses + self.assertNotIsInstance(ctx.exception, rfapi.ModelEvalNotFoundError) + self.assertNotIsInstance(ctx.exception, rfapi.ModelEvalNotDoneError) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_non_json_body_falls_back_to_text(self, mock_get): + # Some misbehaving proxies return HTML 502s β€” make sure we don't crash. + bad = MagicMock(status_code=502, text="Bad Gateway") + bad.json.side_effect = ValueError("not JSON") + mock_get.return_value = bad + + with self.assertRaises(rfapi.RoboflowError) as ctx: + rfapi.get_model_eval("k", "ws", "x") + self.assertIn("Bad Gateway", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/adapters/test_rfapi_phase2.py b/tests/adapters/test_rfapi_phase2.py new file mode 100644 index 00000000..dc03e800 --- /dev/null +++ b/tests/adapters/test_rfapi_phase2.py @@ -0,0 +1,911 @@ +"""Unit tests for Phase 2 rfapi functions.""" + +import json +import unittest +from unittest.mock import MagicMock, patch + +from roboflow.adapters.rfapi import _normalize_workflow_config + + +class TestListBatches(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import list_batches + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"batches": [{"id": "b1"}]}) + result = list_batches("key", "ws", "proj") + self.assertEqual(result, {"batches": [{"id": "b1"}]}) + mock_get.assert_called_once() + self.assertIn("/ws/proj/batches", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, list_batches + + mock_get.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + list_batches("key", "ws", "proj") + + +class TestGetBatch(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import get_batch + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"batch": {"id": "b1"}}) + result = get_batch("key", "ws", "proj", "b1") + self.assertEqual(result, {"batch": {"id": "b1"}}) + self.assertIn("/ws/proj/batches/b1", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, get_batch + + mock_get.return_value = MagicMock(status_code=500, text="Server error") + with self.assertRaises(RoboflowError): + get_batch("key", "ws", "proj", "b1") + + +class TestListAnnotationJobs(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import list_annotation_jobs + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"jobs": []}) + result = list_annotation_jobs("key", "ws", "proj") + self.assertEqual(result, {"jobs": []}) + self.assertIn("/ws/proj/jobs", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, list_annotation_jobs + + mock_get.return_value = MagicMock(status_code=403, text="Forbidden") + with self.assertRaises(RoboflowError): + list_annotation_jobs("key", "ws", "proj") + + +class TestGetAnnotationJob(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import get_annotation_job + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"job": {"id": "j1", "name": "job1"}}) + result = get_annotation_job("key", "ws", "proj", "j1") + self.assertEqual(result["job"]["id"], "j1") + self.assertIn("/ws/proj/jobs/j1", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, get_annotation_job + + mock_get.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + get_annotation_job("key", "ws", "proj", "j1") + + +class TestCreateAnnotationJob(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import create_annotation_job + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"job": {"id": "j2"}}) + result = create_annotation_job("key", "ws", "proj", name="my-job", batch_id="b1") + self.assertEqual(result["job"]["id"], "j2") + # Verify URL and payload + call_args = mock_post.call_args + self.assertIn("/ws/proj/jobs", call_args[0][0]) + payload = call_args[1]["json"] + self.assertEqual(payload["name"], "my-job") + self.assertEqual(payload["batchId"], "b1") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_success_200(self, mock_post): + from roboflow.adapters.rfapi import create_annotation_job + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"job": {"id": "j3"}}) + result = create_annotation_job("key", "ws", "proj", name="my-job") + self.assertEqual(result["job"]["id"], "j3") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_with_assignees(self, mock_post): + from roboflow.adapters.rfapi import create_annotation_job + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"job": {"id": "j4"}}) + create_annotation_job("key", "ws", "proj", name="j", assignees=["a@b.com"]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["assignees"], ["a@b.com"]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, create_annotation_job + + mock_post.return_value = MagicMock(status_code=400, text="Bad request") + with self.assertRaises(RoboflowError): + create_annotation_job("key", "ws", "proj", name="j") + + +class TestListFolders(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import list_folders + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"groups": []}) + result = list_folders("key", "ws") + self.assertEqual(result, {"groups": []}) + mock_get.assert_called_once() + self.assertIn("/ws/groups", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, list_folders + + mock_get.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + list_folders("key", "ws") + + +class TestGetFolder(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import get_folder + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"group": {"id": "g1", "name": "Folder1"}}) + result = get_folder("key", "ws", "g1") + self.assertEqual(result["group"]["id"], "g1") + call_kwargs = mock_get.call_args[1] + self.assertEqual(call_kwargs["params"]["groupId"], "g1") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, get_folder + + mock_get.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + get_folder("key", "ws", "g1") + + +class TestCreateFolder(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import create_folder + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"group": {"id": "g2"}}) + result = create_folder("key", "ws", "NewFolder") + self.assertEqual(result["group"]["id"], "g2") + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["name"], "NewFolder") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_with_parent_and_projects(self, mock_post): + from roboflow.adapters.rfapi import create_folder + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"group": {"id": "g3"}}) + create_folder("key", "ws", "Sub", parent_id="g1", project_ids=["p1", "p2"]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["parent_id"], "g1") + self.assertEqual(payload["projects"], ["p1", "p2"]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, create_folder + + mock_post.return_value = MagicMock(status_code=400, text="Bad request") + with self.assertRaises(RoboflowError): + create_folder("key", "ws", "BadFolder") + + +class TestUpdateFolder(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import update_folder + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"status": "ok"}) + result = update_folder("key", "ws", "g1", name="Renamed") + self.assertEqual(result["status"], "ok") + self.assertIn("/ws/groups/g1", mock_post.call_args[0][0]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["name"], "Renamed") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, update_folder + + mock_post.return_value = MagicMock(status_code=500, text="Server error") + with self.assertRaises(RoboflowError): + update_folder("key", "ws", "g1", name="X") + + +class TestDeleteFolder(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.delete") + def test_success(self, mock_delete): + from roboflow.adapters.rfapi import delete_folder + + mock_delete.return_value = MagicMock(status_code=200, json=lambda: {"status": "deleted"}) + result = delete_folder("key", "ws", "g1") + self.assertEqual(result["status"], "deleted") + self.assertIn("/ws/groups/g1", mock_delete.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.delete") + def test_error(self, mock_delete): + from roboflow.adapters.rfapi import RoboflowError, delete_folder + + mock_delete.return_value = MagicMock(status_code=403, text="Forbidden") + with self.assertRaises(RoboflowError): + delete_folder("key", "ws", "g1") + + +class TestListWorkflows(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import list_workflows + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"workflows": [{"name": "wf1"}]}) + result = list_workflows("key", "ws") + self.assertEqual(len(result["workflows"]), 1) + self.assertIn("/ws/workflows", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, list_workflows + + mock_get.return_value = MagicMock(status_code=500, text="Error") + with self.assertRaises(RoboflowError): + list_workflows("key", "ws") + + +class TestGetWorkflow(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import get_workflow + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"workflow": {"url": "wf1"}}) + result = get_workflow("key", "ws", "wf1") + self.assertEqual(result["workflow"]["url"], "wf1") + self.assertIn("/ws/workflows/wf1", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, get_workflow + + mock_get.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + get_workflow("key", "ws", "wf1") + + +class TestCreateWorkflow(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import create_workflow + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"workflow": {"url": "new-wf"}}) + result = create_workflow("key", "ws", name="New Workflow") + self.assertEqual(result["workflow"]["url"], "new-wf") + self.assertIn("/ws/createWorkflow", mock_post.call_args[0][0]) + # Params are passed as query-string params, not JSON body + params = mock_post.call_args[1]["params"] + self.assertEqual(params["name"], "New Workflow") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_auto_generates_url_slug(self, mock_post): + from roboflow.adapters.rfapi import create_workflow + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"workflow": {"url": "my-workflow"}}) + create_workflow("key", "ws", name="My Workflow") + params = mock_post.call_args[1]["params"] + self.assertEqual(params["url"], "my-workflow") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_with_config_and_template(self, mock_post): + from roboflow.adapters.rfapi import create_workflow + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"workflow": {"url": "wf2"}}) + create_workflow("key", "ws", name="WF2", url="wf2", config='{"a":1}', template='{"b":2}') + params = mock_post.call_args[1]["params"] + self.assertEqual(params["url"], "wf2") + self.assertEqual(params["config"], '{"a":1}') + self.assertEqual(params["template"], '{"b":2}') + + @patch("roboflow.adapters.rfapi.requests.post") + def test_config_dict_serialized_to_string(self, mock_post): + from roboflow.adapters.rfapi import create_workflow + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"workflow": {"url": "wf3"}}) + create_workflow("key", "ws", name="WF3", config={"a": 1}, template={"b": 2}) + params = mock_post.call_args[1]["params"] + # config and template must be strings per the API + self.assertIsInstance(params["config"], str) + self.assertIsInstance(params["template"], str) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_defaults_config_and_template(self, mock_post): + from roboflow.adapters.rfapi import create_workflow + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"workflow": {"url": "wf4"}}) + create_workflow("key", "ws", name="WF4") + params = mock_post.call_args[1]["params"] + self.assertEqual(params["config"], "{}") + self.assertEqual(params["template"], "{}") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, create_workflow + + mock_post.return_value = MagicMock(status_code=400, text="Bad request") + with self.assertRaises(RoboflowError): + create_workflow("key", "ws", name="Bad") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_bare_spec_dict_is_auto_wrapped(self, mock_post): + """Docs-shaped workflow definitions get wrapped in {"specification": ...} + so they match the backend's stored format and the inference server's + expectation. See `_normalize_workflow_config`.""" + import json as _json + + from roboflow.adapters.rfapi import create_workflow + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"workflow": {"url": "wf"}}) + bare = {"version": "1.0", "inputs": [], "steps": [], "outputs": []} + create_workflow("key", "ws", name="WF", config=bare) + sent_config = _json.loads(mock_post.call_args[1]["params"]["config"]) + self.assertEqual(sent_config, {"specification": bare}) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_already_wrapped_config_is_not_double_wrapped(self, mock_post): + import json as _json + + from roboflow.adapters.rfapi import create_workflow + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"workflow": {"url": "wf"}}) + wrapped = {"specification": {"version": "1.0", "inputs": [], "steps": [], "outputs": []}} + create_workflow("key", "ws", name="WF", config=wrapped) + sent_config = _json.loads(mock_post.call_args[1]["params"]["config"]) + self.assertEqual(sent_config, wrapped) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_bare_spec_json_string_is_auto_wrapped(self, mock_post): + """JSON strings are parsed, wrapped if bare, and re-serialized.""" + import json as _json + + from roboflow.adapters.rfapi import create_workflow + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"workflow": {"url": "wf"}}) + bare_str = '{"version": "1.0", "steps": []}' + create_workflow("key", "ws", name="WF", config=bare_str) + sent_config = _json.loads(mock_post.call_args[1]["params"]["config"]) + self.assertEqual(sent_config, {"specification": {"version": "1.0", "steps": []}}) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_non_workflow_dict_is_not_wrapped(self, mock_post): + """Dicts that don't look like a workflow spec (no version/inputs/steps/outputs) + are passed through unchanged to avoid second-guessing custom payloads.""" + import json as _json + + from roboflow.adapters.rfapi import create_workflow + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"workflow": {"url": "wf"}}) + create_workflow("key", "ws", name="WF", config={"a": 1}) + sent_config = _json.loads(mock_post.call_args[1]["params"]["config"]) + self.assertEqual(sent_config, {"a": 1}) + + +class TestNormalizeWorkflowConfig(unittest.TestCase): + """Direct unit tests for the private ``_normalize_workflow_config`` helper. + + Imported from the private API intentionally β€” whitebox tests lock the + behavior contract that ``create_workflow``/``update_workflow`` rely on. + """ + + def test_none_returns_empty_object(self): + self.assertEqual(_normalize_workflow_config(None), "{}") + + def test_empty_dict_serialized_to_empty_json(self): + # Empty dict has no workflow keys, so it falls through the wrap check + # and serializes to ``"{}"`` β€” coincidentally matching the legacy + # ``None -> "{}"`` default. + self.assertEqual(_normalize_workflow_config({}), "{}") + + def test_string_without_workflow_keys_preserved_byte_for_byte(self): + self.assertEqual(_normalize_workflow_config('{"a":1}'), '{"a":1}') + + def test_non_json_string_passthrough(self): + self.assertEqual(_normalize_workflow_config("not json"), "not json") + + def test_already_wrapped_json_string_preserved_byte_for_byte(self): + wrapped = '{"specification": {"version": "1.0"}}' + self.assertEqual(_normalize_workflow_config(wrapped), wrapped) + + def test_partial_workflow_dict_is_wrapped(self): + # Single workflow-shaped key at top level is enough to classify as a + # bare spec; users often build definitions incrementally. + result = _normalize_workflow_config({"steps": [{"id": "s1"}]}) + self.assertEqual(json.loads(result), {"specification": {"steps": [{"id": "s1"}]}}) + + def test_json_array_input_preserved(self): + # ``isinstance(parsed, dict)`` guards against calling ``.keys()`` on + # non-dict JSON; pinning the no-wrap behavior here protects that. + self.assertEqual(_normalize_workflow_config("[1,2,3]"), "[1,2,3]") + + def test_json_scalar_inputs_preserved(self): + self.assertEqual(_normalize_workflow_config("42"), "42") + self.assertEqual(_normalize_workflow_config("true"), "true") + self.assertEqual(_normalize_workflow_config("null"), "null") + + def test_utf8_bom_stripped_before_parse(self): + # Windows editors frequently prepend a UTF-8 BOM. Without the strip, + # ``json.loads`` raises and the raw (unwrapped) string would ship β€” + # reproducing the exact 502 this PR is meant to fix. + bom_str = '\ufeff{"version":"1.0","steps":[]}' + result = _normalize_workflow_config(bom_str) + self.assertEqual(json.loads(result), {"specification": {"version": "1.0", "steps": []}}) + + def test_utf8_bom_stripped_when_already_wrapped(self): + # Already-wrapped JSON saved from a Windows editor would otherwise + # ship the BOM through to the backend, where the inference server's + # ``json.loads`` rejects it ("Unexpected UTF-8 BOM") \u2014 same 502 in + # a different shape. + bom_wrapped = '\ufeff{"specification": {"version": "1.0"}}' + self.assertEqual( + _normalize_workflow_config(bom_wrapped), + '{"specification": {"version": "1.0"}}', + ) + + def test_utf8_bom_stripped_for_non_workflow_dict_string(self): + # A custom JSON payload (not a workflow spec) with a leading BOM + # also gets the BOM removed so the backend stores parseable JSON. + bom_custom = '\ufeff{"a":1}' + self.assertEqual(_normalize_workflow_config(bom_custom), '{"a":1}') + + def test_utf8_bom_stripped_for_non_json_string(self): + # Non-JSON string with a BOM: still strip the BOM, since shipping + # it verbatim has no upside and would only produce a downstream + # decode error if anything ever tries to parse it. + self.assertEqual(_normalize_workflow_config("\ufeffnot json"), "not json") + + def test_wrapped_output_uses_compact_separators(self): + # Matches the shape the web UI writes via ``JSON.stringify``, so + # Firestore audit/diff tooling sees SDK- and UI-written rows as + # byte-identical when the logical content matches. + result = _normalize_workflow_config({"version": "1.0", "steps": []}) + self.assertEqual(result, '{"specification":{"version":"1.0","steps":[]}}') + + +class TestUpdateWorkflow(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import update_workflow + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"status": "ok"}) + result = update_workflow( + "key", "ws", workflow_id="id-1", workflow_name="WF1", workflow_url="wf1", config={"steps": [1]} + ) + self.assertEqual(result["status"], "ok") + self.assertIn("/ws/updateWorkflow", mock_post.call_args[0][0]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["id"], "id-1") + self.assertEqual(payload["name"], "WF1") + self.assertEqual(payload["url"], "wf1") + # config dict should be serialized to string + self.assertIsInstance(payload["config"], str) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_config_string_passthrough(self, mock_post): + from roboflow.adapters.rfapi import update_workflow + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"status": "ok"}) + update_workflow("key", "ws", workflow_id="id-1", workflow_name="WF1", workflow_url="wf1", config='{"a":1}') + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["config"], '{"a":1}') + + @patch("roboflow.adapters.rfapi.requests.post") + def test_bare_spec_dict_is_auto_wrapped_on_update(self, mock_post): + import json as _json + + from roboflow.adapters.rfapi import update_workflow + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"status": "ok"}) + bare = {"version": "1.0", "inputs": [], "steps": [], "outputs": []} + update_workflow("key", "ws", workflow_id="id-1", workflow_name="WF1", workflow_url="wf1", config=bare) + sent_config = _json.loads(mock_post.call_args[1]["json"]["config"]) + self.assertEqual(sent_config, {"specification": bare}) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, update_workflow + + mock_post.return_value = MagicMock(status_code=500, text="Server error") + with self.assertRaises(RoboflowError): + update_workflow("key", "ws", workflow_id="id-1", workflow_name="WF1", workflow_url="wf1", config="{}") + + +class TestListWorkflowVersions(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import list_workflow_versions + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"versions": [{"id": "v1"}]}) + result = list_workflow_versions("key", "ws", "wf1") + self.assertEqual(len(result["versions"]), 1) + self.assertIn("/ws/workflows/wf1/versions", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, list_workflow_versions + + mock_get.return_value = MagicMock(status_code=500, text="Error") + with self.assertRaises(RoboflowError): + list_workflow_versions("key", "ws", "wf1") + + +class TestForkProject(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success_with_url(self, mock_post): + from roboflow.adapters.rfapi import fork_project + + mock_post.return_value = MagicMock(status_code=202, json=lambda: {"taskId": "task-1", "url": "poll"}) + + result = fork_project("key", "target-ws", url="source-ws/source-project") + + self.assertEqual(result["taskId"], "task-1") + self.assertIn("/target-ws/projects/fork", mock_post.call_args[0][0]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload, {"url": "source-ws/source-project"}) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_success_with_explicit_source_slug(self, mock_post): + from roboflow.adapters.rfapi import fork_project + + mock_post.return_value = MagicMock(status_code=202, json=lambda: {"taskId": "task-1", "url": "poll"}) + + fork_project( + "key", + "target-ws", + source_project_slug="source-project", + ) + + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload, {"source_project": "source-project"}) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, fork_project + + mock_post.return_value = MagicMock(status_code=403, ok=False, text="Forbidden") + with self.assertRaises(RoboflowError): + fork_project("key", "ws", url="source-ws/source-project") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_any_2xx_accepted(self, mock_post): + """#8 β€” accept any 2xx so the SDK doesn't break if the backend ever + returns 200 (sync result) or 201 (created) instead of 202. + """ + from roboflow.adapters.rfapi import fork_project + + for code in (200, 201, 202, 204): + mock_post.return_value = MagicMock( + status_code=code, + ok=200 <= code < 300, + json=lambda: {"taskId": "t", "url": "u"}, + ) + result = fork_project("key", "ws", url="source-ws/source-project") + self.assertEqual(result["taskId"], "t") + + +class TestGetAsyncTask(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import get_async_task + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"taskId": "task-1", "status": "running"}) + + result = get_async_task("key", "ws", "task-1") + + self.assertEqual(result["status"], "running") + self.assertIn("/ws/asynctasks/task-1", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_malformed_task_id_is_url_encoded(self, mock_get): + """A task_id containing path/query/fragment characters must not + silently mutate the request path. Each unsafe char is percent-encoded + by ``urllib.parse.quote(..., safe="")``.""" + from roboflow.adapters.rfapi import get_async_task + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"taskId": "x", "status": "running"}) + + # Task ids containing `/`, `?`, `#`, `..`, spaces, and a literal `&` + # used to land in the path verbatim β€” leaking the api_key with a + # forged path and confusing the router. With the fix in place each + # unsafe character is percent-encoded. + get_async_task("key", "ws", "../task?secret=1#frag&x") + + called_url = mock_get.call_args[0][0] + # Slash, dot, question mark, hash, ampersand, and space are all encoded. + self.assertIn("/ws/asynctasks/", called_url) + self.assertIn("%2F", called_url) # `/` + self.assertIn("%3F", called_url) # `?` + self.assertIn("%23", called_url) # `#` + self.assertIn("%26", called_url) # `&` + # Path doesn't end with the bare task id segments. + self.assertNotIn("/asynctasks/../task", called_url) + self.assertNotIn("?secret=1", called_url.split("/asynctasks/", 1)[1]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_get_async_task_at_uses_supplied_url(self, mock_get): + """``get_async_task_at`` hits the server-supplied polling URL + verbatim (modulo the api_key query param), so polling stays on the + host the task lives on even if it differs from ``API_URL``.""" + from roboflow.adapters.rfapi import get_async_task_at + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"taskId": "task-1", "status": "completed"}) + result = get_async_task_at("key", "https://other.host/ws/asynctasks/task-1") + self.assertEqual(result["status"], "completed") + self.assertEqual(mock_get.call_args[0][0], "https://other.host/ws/asynctasks/task-1") + self.assertEqual(mock_get.call_args[1]["params"], {"api_key": "key"}) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, get_async_task + + mock_get.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + get_async_task("key", "ws", "missing") + + +class TestGetAsyncTaskAt(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_polling_url_used_verbatim(self, mock_get): + """When the server returns a fully-qualified polling URL, the SDK must + hit it as-is (potentially on a different host than ``API_URL``) and + only attach the ``api_key`` query param. + """ + from roboflow.adapters.rfapi import get_async_task_at + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"taskId": "task-1", "status": "running"}) + + polling_url = "https://localapi.roboflow.one/ws/asynctasks/task-1" + result = get_async_task_at("api-key", polling_url) + + self.assertEqual(result["status"], "running") + # URL passed through unchanged. + self.assertEqual(mock_get.call_args[0][0], polling_url) + # api_key tacked on as a param. + self.assertEqual(mock_get.call_args[1]["params"], {"api_key": "api-key"}) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error_on_non_200(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, get_async_task_at + + mock_get.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + get_async_task_at("key", "https://api.roboflow.com/ws/asynctasks/missing") + + +class TestForkWorkflow(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import fork_workflow + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"workflow": {"url": "forked"}}) + result = fork_workflow("key", "target-ws", source_workspace="src-ws", source_workflow="wf1") + self.assertEqual(result["workflow"]["url"], "forked") + self.assertIn("/target-ws/forkWorkflow", mock_post.call_args[0][0]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["source_workspace"], "src-ws") + self.assertEqual(payload["source_workflow"], "wf1") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_success_200(self, mock_post): + from roboflow.adapters.rfapi import fork_workflow + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"workflow": {"url": "forked2"}}) + result = fork_workflow("key", "ws", source_workspace="src-ws", source_workflow="wf2") + self.assertEqual(result["workflow"]["url"], "forked2") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_with_name_and_url(self, mock_post): + from roboflow.adapters.rfapi import fork_workflow + + mock_post.return_value = MagicMock(status_code=201, json=lambda: {"workflow": {"url": "custom-fork"}}) + fork_workflow( + "key", "ws", source_workspace="src-ws", source_workflow="wf1", name="Custom Fork", url="custom-fork" + ) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["name"], "Custom Fork") + self.assertEqual(payload["url"], "custom-fork") + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, fork_workflow + + mock_post.return_value = MagicMock(status_code=403, text="Forbidden") + with self.assertRaises(RoboflowError): + fork_workflow("key", "ws", source_workspace="src-ws", source_workflow="wf1") + + +class TestGetBillingUsage(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import get_billing_usage + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"usage": {"credits": 100}}) + result = get_billing_usage("key", "ws") + self.assertEqual(result["usage"]["credits"], 100) + self.assertIn("/ws/billing-usage-report", mock_post.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, get_billing_usage + + mock_post.return_value = MagicMock(status_code=403, text="Forbidden") + with self.assertRaises(RoboflowError): + get_billing_usage("key", "ws") + + +class TestGetPlanInfo(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import get_plan_info + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"plan": "starter", "limit": 1000}) + result = get_plan_info("key") + self.assertEqual(result["plan"], "starter") + self.assertIn("/usage/plan", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, get_plan_info + + mock_get.return_value = MagicMock(status_code=401, text="Unauthorized") + with self.assertRaises(RoboflowError): + get_plan_info("key") + + +class TestGetLabelingStats(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import get_labeling_stats + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"stats": {"labeled": 50}}) + result = get_labeling_stats("key", "ws") + self.assertEqual(result["stats"]["labeled"], 50) + self.assertIn("/ws/stats", mock_get.call_args[0][0]) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, get_labeling_stats + + mock_get.return_value = MagicMock(status_code=500, text="Error") + with self.assertRaises(RoboflowError): + get_labeling_stats("key", "ws") + + +class TestGetVideoJobStatus(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import get_video_job_status + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"status": "completed", "progress": 1.0}) + result = get_video_job_status("key", "job-123") + self.assertEqual(result["status"], "completed") + call_kwargs = mock_get.call_args[1] + self.assertEqual(call_kwargs["params"]["job_id"], "job-123") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, get_video_job_status + + mock_get.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + get_video_job_status("key", "job-123") + + +class TestSearchUniverse(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.get") + def test_success(self, mock_get): + from roboflow.adapters.rfapi import search_universe + + mock_get.return_value = MagicMock( + status_code=200, json=lambda: {"results": [{"name": "cats-dataset"}], "total": 1} + ) + result = search_universe("cats") + self.assertEqual(result["total"], 1) + call_kwargs = mock_get.call_args[1] + self.assertEqual(call_kwargs["params"]["q"], "cats") + + @patch("roboflow.adapters.rfapi.requests.get") + def test_with_type_and_limit(self, mock_get): + from roboflow.adapters.rfapi import search_universe + + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"results": [], "total": 0}) + search_universe("dogs", project_type="model", limit=5, page=2) + call_kwargs = mock_get.call_args[1] + self.assertEqual(call_kwargs["params"]["type"], "model") + self.assertEqual(call_kwargs["params"]["limit"], 5) + self.assertEqual(call_kwargs["params"]["page"], 2) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_error(self, mock_get): + from roboflow.adapters.rfapi import RoboflowError, search_universe + + mock_get.return_value = MagicMock(status_code=500, text="Server error") + with self.assertRaises(RoboflowError): + search_universe("query") + + +class TestUpdateImageMetadata(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import update_image_metadata + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"success": True}) + result = update_image_metadata("key", "ws", "img-1", add_tags=["tag1"], remove_tags=["old"]) + self.assertEqual(result, {"success": True}) + mock_post.assert_called_once() + self.assertIn("/ws/images/img-1/metadata", mock_post.call_args[0][0]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["addTags"], ["tag1"]) + self.assertEqual(payload["removeTags"], ["old"]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_only_sends_provided_fields(self, mock_post): + from roboflow.adapters.rfapi import update_image_metadata + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"success": True}) + update_image_metadata("key", "ws", "img-1", add_tags=["foo"]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload, {"addTags": ["foo"]}) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_metadata_and_tags(self, mock_post): + from roboflow.adapters.rfapi import update_image_metadata + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"success": True}) + update_image_metadata("key", "ws", "img-1", metadata={"cam": "1"}, add_tags=["review"]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload["metadata"], {"cam": "1"}) + self.assertEqual(payload["addTags"], ["review"]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error_404(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, update_image_metadata + + mock_post.return_value = MagicMock(status_code=404, text="Not found") + with self.assertRaises(RoboflowError): + update_image_metadata("key", "ws", "img-1", add_tags=["x"]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_image_id_url_encoded(self, mock_post): + from roboflow.adapters.rfapi import update_image_metadata + + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"success": True}) + update_image_metadata("key", "ws", "img/1", add_tags=["a"]) + called_url = mock_post.call_args[0][0] + self.assertIn("/ws/images/img%2F1/metadata", called_url) + self.assertNotIn("/ws/images/img/1/metadata", called_url) + + +class TestBatchUpdateImageMetadata(unittest.TestCase): + @patch("roboflow.adapters.rfapi.requests.post") + def test_success(self, mock_post): + from roboflow.adapters.rfapi import batch_update_image_metadata + + updates = [{"imageId": "img-1", "addTags": ["t1"]}, {"imageId": "img-2", "metadata": {"k": "v"}}] + mock_post.return_value = MagicMock(status_code=202, json=lambda: {"taskId": "t1", "url": "poll-url"}) + result = batch_update_image_metadata("key", "ws", updates) + self.assertEqual(result, {"taskId": "t1", "url": "poll-url"}) + mock_post.assert_called_once() + self.assertIn("/ws/images/metadata", mock_post.call_args[0][0]) + payload = mock_post.call_args[1]["json"] + self.assertEqual(payload, {"updates": updates}) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_error_400(self, mock_post): + from roboflow.adapters.rfapi import RoboflowError, batch_update_image_metadata + + mock_post.return_value = MagicMock(status_code=400, text="Bad request") + with self.assertRaises(RoboflowError): + batch_update_image_metadata("key", "ws", [{"imageId": "img-1"}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/annotations/dict_names.yaml b/tests/annotations/dict_names.yaml new file mode 100644 index 00000000..e481edbc --- /dev/null +++ b/tests/annotations/dict_names.yaml @@ -0,0 +1,5 @@ +names: + 0: cat + 1: dog + 2: fish +nc: 3 diff --git a/tests/annotations/keypoint-detection-annotations/MM2A_46_R_T_predictions.json b/tests/annotations/keypoint-detection-annotations/MM2A_46_R_T_predictions.json new file mode 100644 index 00000000..cec83f9b --- /dev/null +++ b/tests/annotations/keypoint-detection-annotations/MM2A_46_R_T_predictions.json @@ -0,0 +1,426 @@ +{ + "inference_id": "4b39e84f-88ce-4d27-880c-57bf949029e7", + "time": 0.05072031899999274, + "image": { + "width": 142, + "height": 327 + }, + "predictions": [ + { + "x": 59.5, + "y": 233.5, + "width": 25.0, + "height": 11.0, + "confidence": 0.763361394405365, + "class": "vertebra", + "class_id": 0, + "detection_id": "500623ad-1ca9-4604-a217-6e057cf3f588", + "keypoints": [ + { + "x": 47.0, + "y": 240.0, + "confidence": 0.9998906850814819, + "class_id": 0, + "class_name": "start" + }, + { + "x": 72.0, + "y": 227.0, + "confidence": 0.9996753931045532, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 48.5, + "y": 210.0, + "width": 25.0, + "height": 10.0, + "confidence": 0.7600339651107788, + "class": "vertebra", + "class_id": 0, + "detection_id": "71b9fcd9-4351-47a2-a583-ad9d5e96b604", + "keypoints": [ + { + "x": 36.0, + "y": 215.0, + "confidence": 0.9999080896377563, + "class_id": 0, + "class_name": "start" + }, + { + "x": 61.0, + "y": 205.0, + "confidence": 0.9991416931152344, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 41.0, + "y": 187.5, + "width": 24.0, + "height": 9.0, + "confidence": 0.742439866065979, + "class": "vertebra", + "class_id": 0, + "detection_id": "b04105e9-767e-4e76-9ee9-aadae1326658", + "keypoints": [ + { + "x": 29.0, + "y": 192.0, + "confidence": 0.9993617534637451, + "class_id": 0, + "class_name": "start" + }, + { + "x": 54.0, + "y": 183.0, + "confidence": 0.9988169074058533, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 56.0, + "y": 80.5, + "width": 20.0, + "height": 7.0, + "confidence": 0.6737987995147705, + "class": "vertebra", + "class_id": 0, + "detection_id": "d519e047-8703-4000-95c7-3f29ffb7c233", + "keypoints": [ + { + "x": 46.0, + "y": 77.0, + "confidence": 0.9988997578620911, + "class_id": 0, + "class_name": "start" + }, + { + "x": 66.0, + "y": 85.0, + "confidence": 0.9990716576576233, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 49.0, + "y": 98.0, + "width": 20.0, + "height": 10.0, + "confidence": 0.6587967872619629, + "class": "vertebra", + "class_id": 0, + "detection_id": "b9e46234-d571-4141-8e92-18a9c61cb888", + "keypoints": [ + { + "x": 40.0, + "y": 93.0, + "confidence": 0.9998961687088013, + "class_id": 0, + "class_name": "start" + }, + { + "x": 59.0, + "y": 102.0, + "confidence": 0.9997531175613403, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 69.5, + "y": 259.0, + "width": 31.0, + "height": 8.0, + "confidence": 0.5930185914039612, + "class": "vertebra", + "class_id": 0, + "detection_id": "edf5846c-8858-4dbc-9a86-128340b4ecfd", + "keypoints": [ + { + "x": 55.0, + "y": 262.0, + "confidence": 0.9956279993057251, + "class_id": 0, + "class_name": "start" + }, + { + "x": 85.0, + "y": 256.0, + "confidence": 0.9995435476303101, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 41.0, + "y": 113.0, + "width": 22.0, + "height": 6.0, + "confidence": 0.5826466083526611, + "class": "vertebra", + "class_id": 0, + "detection_id": "e5a09d46-1dda-4957-aa8c-2051febde9dc", + "keypoints": [ + { + "x": 30.0, + "y": 110.0, + "confidence": 0.9999384880065918, + "class_id": 0, + "class_name": "start" + }, + { + "x": 52.0, + "y": 117.0, + "confidence": 0.9998559951782227, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 70.0, + "y": 45.5, + "width": 18.0, + "height": 5.0, + "confidence": 0.49985209107398987, + "class": "vertebra", + "class_id": 0, + "detection_id": "5373c45d-6ab5-474c-bf1b-0f80398e4f50", + "keypoints": [ + { + "x": 61.0, + "y": 43.0, + "confidence": 0.9988017082214355, + "class_id": 0, + "class_name": "start" + }, + { + "x": 80.0, + "y": 48.0, + "confidence": 0.9974247813224792, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 62.5, + "y": 63.5, + "width": 19.0, + "height": 7.0, + "confidence": 0.46164435148239136, + "class": "vertebra", + "class_id": 0, + "detection_id": "93961590-3596-4f86-86ef-3115f27af571", + "keypoints": [ + { + "x": 53.0, + "y": 60.0, + "confidence": 0.9995067715644836, + "class_id": 0, + "class_name": "start" + }, + { + "x": 72.0, + "y": 67.0, + "confidence": 0.9983217716217041, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 35.5, + "y": 168.0, + "width": 25.0, + "height": 6.0, + "confidence": 0.4455893933773041, + "class": "vertebra", + "class_id": 0, + "detection_id": "02949522-1446-4678-b580-37397a6e3544", + "keypoints": [ + { + "x": 23.0, + "y": 171.0, + "confidence": 0.9996205568313599, + "class_id": 0, + "class_name": "start" + }, + { + "x": 48.0, + "y": 165.0, + "confidence": 0.9966169595718384, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 78.0, + "y": 284.0, + "width": 32.0, + "height": 8.0, + "confidence": 0.44538000226020813, + "class": "vertebra", + "class_id": 0, + "detection_id": "2ad879d8-901e-4647-aa58-52d3de28d5fa", + "keypoints": [ + { + "x": 62.0, + "y": 288.0, + "confidence": 0.9988986253738403, + "class_id": 0, + "class_name": "start" + }, + { + "x": 93.0, + "y": 282.0, + "confidence": 0.9989535808563232, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 33.0, + "y": 150.0, + "width": 24.0, + "height": 2.0, + "confidence": 0.28537100553512573, + "class": "vertebra", + "class_id": 0, + "detection_id": "c522b624-ff97-46d7-b90f-4ea04e5ddbbd", + "keypoints": [ + { + "x": 21.0, + "y": 151.0, + "confidence": 0.9995453357696533, + "class_id": 0, + "class_name": "start" + }, + { + "x": 45.0, + "y": 150.0, + "confidence": 0.9993085861206055, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 82.0, + "y": 313.0, + "width": 34.0, + "height": 6.0, + "confidence": 0.2552550435066223, + "class": "vertebra", + "class_id": 0, + "detection_id": "a420b97c-d316-41a6-895e-cd342795af4d", + "keypoints": [ + { + "x": 64.0, + "y": 316.0, + "confidence": 0.9955296516418457, + "class_id": 0, + "class_name": "start" + }, + { + "x": 99.0, + "y": 311.0, + "confidence": 0.9899979829788208, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 37.0, + "y": 126.0, + "width": 24.0, + "height": 6.0, + "confidence": 0.2176252007484436, + "class": "vertebra", + "class_id": 0, + "detection_id": "8600c8bf-c3f6-46c1-a5a6-a602637d0d05", + "keypoints": [ + { + "x": 25.0, + "y": 124.0, + "confidence": 0.9993969798088074, + "class_id": 0, + "class_name": "start" + }, + { + "x": 49.0, + "y": 127.0, + "confidence": 0.9985653758049011, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 35.5, + "y": 132.0, + "width": 23.0, + "height": 4.0, + "confidence": 0.14819690585136414, + "class": "vertebra", + "class_id": 0, + "detection_id": "8bda8ccd-a834-41b3-a40e-848c1fbd4de2", + "keypoints": [ + { + "x": 24.0, + "y": 130.0, + "confidence": 0.9997155666351318, + "class_id": 0, + "class_name": "start" + }, + { + "x": 47.0, + "y": 134.0, + "confidence": 0.9994645118713379, + "class_id": 1, + "class_name": "end" + } + ] + }, + { + "x": 74.0, + "y": 18.0, + "width": 24.0, + "height": 2.0, + "confidence": 0.14375203847885132, + "class": "vertebra", + "class_id": 0, + "detection_id": "fd657847-2461-40f0-8219-8c2c33580153", + "keypoints": [ + { + "x": 62.0, + "y": 18.0, + "confidence": 0.9981837272644043, + "class_id": 0, + "class_name": "start" + }, + { + "x": 85.0, + "y": 19.0, + "confidence": 0.996793806552887, + "class_id": 1, + "class_name": "end" + } + ] + } + ] +} diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/test_annotation_handler.py b/tests/cli/test_annotation_handler.py new file mode 100644 index 00000000..8671ff7b --- /dev/null +++ b/tests/cli/test_annotation_handler.py @@ -0,0 +1,255 @@ +"""Unit tests for roboflow.cli.handlers.annotation.""" + +import io +import json +import sys +import types +import unittest +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestAnnotationParserRegistration(unittest.TestCase): + """Verify the annotation handler registers its subcommands.""" + + def test_annotation_subcommand_exists(self): + result = runner.invoke(app, ["annotation", "batch", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_annotation_batch_get(self): + result = runner.invoke(app, ["annotation", "batch", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_annotation_job_list(self): + result = runner.invoke(app, ["annotation", "job", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_annotation_job_get(self): + result = runner.invoke(app, ["annotation", "job", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_annotation_job_create(self): + result = runner.invoke(app, ["annotation", "job", "create", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestAnnotationStub(unittest.TestCase): + """Verify stub handlers print not-yet-implemented.""" + + def test_stub_prints_message(self): + from roboflow.cli._output import stub as _stub + + args = types.SimpleNamespace(json=False) + + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _stub(args) + self.assertEqual(ctx.exception.code, 1) + finally: + sys.stderr = old + + self.assertIn("not yet implemented", buf.getvalue()) + + def test_stub_json_mode(self): + from roboflow.cli._output import stub as _stub + + args = types.SimpleNamespace(json=True) + + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _stub(args) + self.assertEqual(ctx.exception.code, 1) + finally: + sys.stderr = old + + result = json.loads(buf.getvalue()) + self.assertIn("not yet implemented", result["error"]["message"]) + + +# --------------------------------------------------------------------------- +# Behavior tests (mocked API) +# --------------------------------------------------------------------------- + +_RESOLVE = "roboflow.cli.handlers.annotation._resolve_project_context" + + +class TestBatchList(unittest.TestCase): + """annotation batch list""" + + @patch("roboflow.adapters.rfapi.list_batches") + @patch(_RESOLVE, return_value=("key", "ws", "proj")) + def test_text_output(self, _resolve, mock_api): + mock_api.return_value = {"batches": [{"name": "b1", "id": "1", "status": "annotating", "images": 5}]} + result = runner.invoke(app, ["annotation", "batch", "list", "-p", "ws/proj"]) + self.assertIn("b1", result.output) + + @patch("roboflow.adapters.rfapi.list_batches") + @patch(_RESOLVE, return_value=("key", "ws", "proj")) + def test_json_output(self, _resolve, mock_api): + mock_api.return_value = {"batches": [{"name": "b1", "id": "1"}]} + result = runner.invoke(app, ["--json", "annotation", "batch", "list", "-p", "ws/proj"]) + data = json.loads(result.output) + self.assertIsInstance(data, list) + self.assertEqual(data[0]["name"], "b1") + + @patch(_RESOLVE, return_value=None) + def test_resolve_failure(self, _resolve): + runner.invoke(app, ["annotation", "batch", "list", "-p", "bad"]) + # Should not crash when resolve returns None + + +class TestBatchGet(unittest.TestCase): + """annotation batch get""" + + @patch("roboflow.adapters.rfapi.get_batch") + @patch(_RESOLVE, return_value=("key", "ws", "proj")) + def test_text_output(self, _resolve, mock_api): + mock_api.return_value = {"batch": {"name": "b1", "id": "1", "status": "annotating"}} + result = runner.invoke(app, ["annotation", "batch", "get", "1", "-p", "ws/proj"]) + self.assertIn("b1", result.output) + + @patch("roboflow.adapters.rfapi.get_batch") + @patch(_RESOLVE, return_value=("key", "ws", "proj")) + def test_json_output(self, _resolve, mock_api): + mock_api.return_value = {"batch": {"name": "b1", "id": "1"}} + result = runner.invoke(app, ["--json", "annotation", "batch", "get", "1", "-p", "ws/proj"]) + data = json.loads(result.output) + self.assertIn("batch", data) + + +class TestJobList(unittest.TestCase): + """annotation job list""" + + @patch("roboflow.adapters.rfapi.list_annotation_jobs") + @patch(_RESOLVE, return_value=("key", "ws", "proj")) + def test_text_output(self, _resolve, mock_api): + mock_api.return_value = {"jobs": [{"name": "j1", "id": "10", "status": "active", "assigned_to": "a@b.com"}]} + result = runner.invoke(app, ["annotation", "job", "list", "-p", "ws/proj"]) + self.assertIn("j1", result.output) + + @patch("roboflow.adapters.rfapi.list_annotation_jobs") + @patch(_RESOLVE, return_value=("key", "ws", "proj")) + def test_json_output(self, _resolve, mock_api): + mock_api.return_value = {"jobs": [{"name": "j1", "id": "10"}]} + result = runner.invoke(app, ["--json", "annotation", "job", "list", "-p", "ws/proj"]) + data = json.loads(result.output) + self.assertIsInstance(data, list) + + +class TestJobGet(unittest.TestCase): + """annotation job get""" + + @patch("roboflow.adapters.rfapi.get_annotation_job") + @patch(_RESOLVE, return_value=("key", "ws", "proj")) + def test_text_output(self, _resolve, mock_api): + mock_api.return_value = {"job": {"name": "j1", "id": "10", "status": "active"}} + result = runner.invoke(app, ["annotation", "job", "get", "10", "-p", "ws/proj"]) + self.assertIn("j1", result.output) + + +class TestJobCreate(unittest.TestCase): + """annotation job create""" + + @patch("roboflow.Roboflow") + @patch(_RESOLVE, return_value=("key", "ws", "proj")) + def test_text_output(self, _resolve, mock_rf_cls): + mock_project = MagicMock() + mock_project.create_annotation_job.return_value = {"id": "42", "name": "new-job"} + mock_rf_cls.return_value.workspace.return_value.project.return_value = mock_project + + result = runner.invoke( + app, + [ + "annotation", + "job", + "create", + "-p", + "ws/proj", + "--name", + "new-job", + "--batch", + "b1", + "--num-images", + "5", + "--labeler", + "a@b.com", + "--reviewer", + "c@d.com", + ], + ) + self.assertIn("new-job", result.output) + mock_project.create_annotation_job.assert_called_once_with( + name="new-job", + batch_id="b1", + num_images=5, + labeler_email="a@b.com", + reviewer_email="c@d.com", + ) + + @patch("roboflow.Roboflow") + @patch(_RESOLVE, return_value=("key", "ws", "proj")) + def test_json_output(self, _resolve, mock_rf_cls): + mock_project = MagicMock() + mock_project.create_annotation_job.return_value = {"id": "42", "name": "new-job"} + mock_rf_cls.return_value.workspace.return_value.project.return_value = mock_project + + result = runner.invoke( + app, + [ + "--json", + "annotation", + "job", + "create", + "-p", + "ws/proj", + "--name", + "new-job", + "--batch", + "b1", + "--num-images", + "5", + "--labeler", + "a@b.com", + "--reviewer", + "c@d.com", + ], + ) + data = json.loads(result.output) + self.assertEqual(data["id"], "42") + + def test_create_requires_all_flags(self): + # Missing --reviewer should fail + result = runner.invoke( + app, + [ + "annotation", + "job", + "create", + "-p", + "proj", + "--name", + "j", + "--batch", + "b", + "--num-images", + "1", + "--labeler", + "a@b.com", + ], + ) + self.assertNotEqual(result.exit_code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_api_key.py b/tests/cli/test_api_key.py new file mode 100644 index 00000000..f1489d18 --- /dev/null +++ b/tests/cli/test_api_key.py @@ -0,0 +1,1000 @@ +"""Tests for the api-key CLI handler.""" + +import json +import re +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +# --------------------------------------------------------------------------- +# Registration / --help tests +# --------------------------------------------------------------------------- + + +class TestApiKeyRegistration(unittest.TestCase): + """Verify the api-key group and all subcommands are registered.""" + + def test_api_key_app_exists(self) -> None: + from roboflow.cli.handlers.api_key import api_key_app + + self.assertIsNotNone(api_key_app) + + def test_group_help(self) -> None: + result = runner.invoke(app, ["api-key", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + self.assertIn("api-key", _strip_ansi(result.output).lower()) + + def test_list_help(self) -> None: + result = runner.invoke(app, ["api-key", "list", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + + def test_get_help(self) -> None: + result = runner.invoke(app, ["api-key", "get", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + + def test_publishable_help(self) -> None: + result = runner.invoke(app, ["api-key", "publishable", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + + def test_create_help(self) -> None: + result = runner.invoke(app, ["api-key", "create", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + output = _strip_ansi(result.output).lower() + self.assertIn("scope", output) + self.assertIn("folder", output) + self.assertIn("metadata", output) + self.assertIn("protected", output) + + def test_update_help(self) -> None: + result = runner.invoke(app, ["api-key", "update", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + output = _strip_ansi(result.output).lower() + self.assertIn("name", output) + self.assertIn("scope", output) + self.assertIn("metadata", output) + + def test_protect_help(self) -> None: + result = runner.invoke(app, ["api-key", "protect", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + + def test_disable_help(self) -> None: + result = runner.invoke(app, ["api-key", "disable", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + + def test_revoke_help(self) -> None: + result = runner.invoke(app, ["api-key", "revoke", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +class TestListApiKeys(unittest.TestCase): + @patch("roboflow.adapters.rfapi.list_api_keys") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_list_text(self, _mock_key, _mock_ws, mock_list) -> None: + mock_list.return_value = { + "apiKeys": [ + { + "keyId": "k1", + "name": "My Key", + "prefix": "abc", + "default": True, + "protected": False, + "disabled": False, + } + ] + } + args = Namespace( + json=False, workspace=None, api_key=None, quiet=False, include_disabled=False, include_folders=False + ) + from roboflow.cli.handlers.api_key import _list_keys + + with patch("builtins.print") as mock_print: + _list_keys(args) + printed = mock_print.call_args[0][0] + self.assertIn("My Key", printed) + self.assertIn("k1", printed) + + @patch("roboflow.adapters.rfapi.list_api_keys") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_list_json_emits_full_envelope(self, _mock_key, _mock_ws, mock_list) -> None: + keys = [ + {"keyId": "k1", "name": "My Key", "prefix": "abc", "default": True, "protected": False, "disabled": False} + ] + mock_list.return_value = {"apiKeys": keys, "publishableKey": "rf_myworkspace"} + args = Namespace( + json=True, workspace=None, api_key=None, quiet=False, include_disabled=False, include_folders=False + ) + from roboflow.cli.handlers.api_key import _list_keys + + with patch("builtins.print") as mock_print: + _list_keys(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + # Full server envelope, not a bare array β€” publishableKey must be preserved. + self.assertIsInstance(data, dict) + self.assertEqual(data["apiKeys"][0]["keyId"], "k1") + self.assertEqual(data["publishableKey"], "rf_myworkspace") + + @patch("roboflow.adapters.rfapi.list_api_keys") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_list_passes_include_disabled(self, _mock_key, _mock_ws, mock_list) -> None: + mock_list.return_value = {"apiKeys": []} + args = Namespace( + json=False, workspace=None, api_key=None, quiet=False, include_disabled=True, include_folders=False + ) + from roboflow.cli.handlers.api_key import _list_keys + + with patch("builtins.print"): + _list_keys(args) + mock_list.assert_called_once_with("fake-key", "test-ws", include_disabled=True, include_folders=False) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=None) + def test_list_no_workspace(self, _mock_ws) -> None: + args = Namespace( + json=True, workspace=None, api_key=None, quiet=False, include_disabled=False, include_folders=False + ) + from roboflow.cli.handlers.api_key import _list_keys + + with self.assertRaises(SystemExit) as ctx: + _list_keys(args) + self.assertEqual(ctx.exception.code, 2) + + +# --------------------------------------------------------------------------- +# get +# --------------------------------------------------------------------------- + + +class TestGetApiKey(unittest.TestCase): + @patch("roboflow.adapters.rfapi.get_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_get_text(self, _mock_key, _mock_ws, mock_get) -> None: + mock_get.return_value = { + "apiKey": { + "keyId": "k1", + "name": "My Key", + "prefix": "abc", + "default": False, + "protected": False, + "disabled": False, + } + } + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="k1") + from roboflow.cli.handlers.api_key import _get_key + + with patch("builtins.print") as mock_print: + _get_key(args) + printed = mock_print.call_args[0][0] + self.assertIn("k1", printed) + self.assertIn("My Key", printed) + + @patch("roboflow.adapters.rfapi.get_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_get_json(self, _mock_key, _mock_ws, mock_get) -> None: + payload = {"apiKey": {"keyId": "k1", "name": "My Key"}} + mock_get.return_value = payload + args = Namespace(json=True, workspace=None, api_key=None, quiet=False, key_id="k1") + from roboflow.cli.handlers.api_key import _get_key + + with patch("builtins.print") as mock_print: + _get_key(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertIn("apiKey", data) + + +# --------------------------------------------------------------------------- +# publishable +# --------------------------------------------------------------------------- + + +class TestPublishableKey(unittest.TestCase): + @patch("roboflow.adapters.rfapi.get_publishable_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_publishable_text(self, _mock_key, _mock_ws, mock_pub) -> None: + mock_pub.return_value = {"publishableKey": "rf_myworkspace"} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False) + from roboflow.cli.handlers.api_key import _get_publishable + + with patch("builtins.print") as mock_print: + _get_publishable(args) + printed = mock_print.call_args[0][0] + self.assertIn("rf_myworkspace", printed) + + @patch("roboflow.adapters.rfapi.get_publishable_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_publishable_json(self, _mock_key, _mock_ws, mock_pub) -> None: + mock_pub.return_value = {"publishableKey": "rf_myworkspace"} + args = Namespace(json=True, workspace=None, api_key=None, quiet=False) + from roboflow.cli.handlers.api_key import _get_publishable + + with patch("builtins.print") as mock_print: + _get_publishable(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertEqual(data["publishableKey"], "rf_myworkspace") + + +# --------------------------------------------------------------------------- +# create +# --------------------------------------------------------------------------- + + +class TestCreateApiKey(unittest.TestCase): + @patch("roboflow.adapters.rfapi.create_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_prints_secret_in_text_mode(self, _mock_key, _mock_ws, mock_create) -> None: + mock_create.return_value = {"keyId": "k2", "key": "super-secret-value", "name": "New Key"} + args = Namespace( + json=False, + workspace=None, + api_key=None, + quiet=False, + name="New Key", + scope=None, + folder=None, + protected=False, + ) + from roboflow.cli.handlers.api_key import _create_key + + with patch("builtins.print") as mock_print: + _create_key(args) + printed = mock_print.call_args[0][0] + self.assertIn("super-secret-value", printed) + self.assertIn("WARNING", printed) + + @patch("roboflow.adapters.rfapi.create_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_json_contains_key(self, _mock_key, _mock_ws, mock_create) -> None: + mock_create.return_value = {"keyId": "k2", "key": "super-secret-value", "name": "New Key"} + args = Namespace( + json=True, + workspace=None, + api_key=None, + quiet=False, + name="New Key", + scope=None, + folder=None, + protected=False, + ) + from roboflow.cli.handlers.api_key import _create_key + + with patch("builtins.print") as mock_print: + _create_key(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertEqual(data["key"], "super-secret-value") + self.assertEqual(data["keyId"], "k2") + + @patch("roboflow.adapters.rfapi.create_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_with_scopes_and_folders(self, _mock_key, _mock_ws, mock_create) -> None: + mock_create.return_value = {"keyId": "k3", "key": "s3cr3t", "name": "Scoped Key"} + args = Namespace( + json=False, + workspace=None, + api_key=None, + quiet=False, + name="Scoped Key", + scope=["image:read", "image:annotate"], + folder=["f1", "f2"], + metadata=None, + protected=False, + ) + from roboflow.cli.handlers.api_key import _create_key + + with patch("builtins.print"): + _create_key(args) + mock_create.assert_called_once_with( + "fake-key", + "test-ws", + name="Scoped Key", + scopes=["image:read", "image:annotate"], + folder_ids=["f1", "f2"], + custom_metadata=None, + protected=False, + ) + + @patch("roboflow.adapters.rfapi.create_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_with_metadata(self, _mock_key, _mock_ws, mock_create) -> None: + mock_create.return_value = {"keyId": "k5", "key": "m3ta", "name": "Meta Key"} + args = Namespace( + json=False, + workspace=None, + api_key=None, + quiet=False, + name="Meta Key", + scope=None, + folder=None, + metadata=["team=vision", "env=prod"], + protected=False, + ) + from roboflow.cli.handlers.api_key import _create_key + + with patch("builtins.print"): + _create_key(args) + mock_create.assert_called_once_with( + "fake-key", + "test-ws", + name="Meta Key", + scopes=None, + folder_ids=None, + custom_metadata={"team": "vision", "env": "prod"}, + protected=False, + ) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_invalid_metadata_exits(self, _mock_key, _mock_ws) -> None: + args = Namespace( + json=False, + workspace=None, + api_key=None, + quiet=False, + name="Bad Meta", + scope=None, + folder=None, + metadata=["no-equals-sign"], + protected=False, + ) + from roboflow.cli.handlers.api_key import _create_key + + with self.assertRaises(SystemExit) as ctx: + _create_key(args) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.adapters.rfapi.create_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_no_scopes_passes_none(self, _mock_key, _mock_ws, mock_create) -> None: + mock_create.return_value = {"keyId": "k4", "key": "full-key", "name": "Full Key"} + args = Namespace( + json=False, + workspace=None, + api_key=None, + quiet=False, + name="Full Key", + scope=None, + folder=None, + metadata=None, + protected=False, + ) + from roboflow.cli.handlers.api_key import _create_key + + with patch("builtins.print"): + _create_key(args) + mock_create.assert_called_once_with( + "fake-key", + "test-ws", + name="Full Key", + scopes=None, + folder_ids=None, + custom_metadata=None, + protected=False, + ) + + +class TestCreateApiKeyScopeStates(unittest.TestCase): + """--no-scopes / --full-access three-way scope selection on create.""" + + def _args(self, **overrides): + base = dict( + json=False, + workspace=None, + api_key=None, + quiet=False, + name="K", + scope=None, + no_scopes=False, + full_access=False, + folder=None, + metadata=None, + protected=False, + ) + base.update(overrides) + return Namespace(**base) + + @patch("roboflow.adapters.rfapi.create_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_no_scopes_sends_empty_list(self, _mock_key, _mock_ws, mock_create) -> None: + mock_create.return_value = {"keyId": "k", "key": "s", "name": "K"} + from roboflow.cli.handlers.api_key import _create_key + + with patch("builtins.print"): + _create_key(self._args(no_scopes=True)) + mock_create.assert_called_once_with( + "fake-key", + "test-ws", + name="K", + scopes=[], + folder_ids=None, + custom_metadata=None, + protected=False, + ) + + @patch("roboflow.adapters.rfapi.create_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_full_access_sends_sentinel(self, _mock_key, _mock_ws, mock_create) -> None: + from roboflow.adapters import rfapi + + mock_create.return_value = {"keyId": "k", "key": "s", "name": "K"} + from roboflow.cli.handlers.api_key import _create_key + + with patch("builtins.print"): + _create_key(self._args(full_access=True)) + mock_create.assert_called_once_with( + "fake-key", + "test-ws", + name="K", + scopes=rfapi.FULL_ACCESS, + folder_ids=None, + custom_metadata=None, + protected=False, + ) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_scope_and_no_scopes_conflict_exits_1(self, _mock_key, _mock_ws) -> None: + from roboflow.cli.handlers.api_key import _create_key + + with patch("sys.stderr"): + with self.assertRaises(SystemExit) as ctx: + _create_key(self._args(scope=["image:read"], no_scopes=True)) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_no_scopes_and_full_access_conflict_exits_1(self, _mock_key, _mock_ws) -> None: + from roboflow.cli.handlers.api_key import _create_key + + with patch("sys.stderr"): + with self.assertRaises(SystemExit) as ctx: + _create_key(self._args(no_scopes=True, full_access=True)) + self.assertEqual(ctx.exception.code, 1) + + +# --------------------------------------------------------------------------- +# update +# --------------------------------------------------------------------------- + + +class TestUpdateApiKey(unittest.TestCase): + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_update_name(self, _mock_key, _mock_ws, mock_update) -> None: + mock_update.return_value = {"apiKey": {"keyId": "k1", "name": "Renamed"}} + args = Namespace( + json=True, workspace=None, api_key=None, quiet=False, key_id="k1", name="Renamed", scope=None, metadata=None + ) + from roboflow.cli.handlers.api_key import _update_key + + with patch("builtins.print") as mock_print: + _update_key(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertIn("apiKey", data) + mock_update.assert_called_once_with("fake-key", "test-ws", "k1", name="Renamed") + + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_update_scopes_and_metadata(self, _mock_key, _mock_ws, mock_update) -> None: + mock_update.return_value = {"apiKey": {"keyId": "k1"}} + args = Namespace( + json=False, + workspace=None, + api_key=None, + quiet=False, + key_id="k1", + name=None, + scope=["image:read"], + metadata=["team=vision"], + ) + from roboflow.cli.handlers.api_key import _update_key + + with patch("builtins.print"): + _update_key(args) + mock_update.assert_called_once_with( + "fake-key", + "test-ws", + "k1", + scopes=["image:read"], + custom_metadata={"team": "vision"}, + ) + + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_update_missing_key_exits_3(self, _mock_key, _mock_ws, mock_update) -> None: + from roboflow.adapters.rfapi import RoboflowError + + mock_update.side_effect = RoboflowError("not found", status_code=404) + args = Namespace( + json=False, workspace=None, api_key=None, quiet=False, key_id="nope", name="X", scope=None, metadata=None + ) + from roboflow.cli.handlers.api_key import _update_key + + with self.assertRaises(SystemExit) as ctx: + _update_key(args) + self.assertEqual(ctx.exception.code, 3) + + +# --------------------------------------------------------------------------- +# protect +# --------------------------------------------------------------------------- + + +class TestProtectApiKey(unittest.TestCase): + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_protect_calls_patch_with_protected_true(self, _mock_key, _mock_ws, mock_update) -> None: + mock_update.return_value = {"apiKey": {"keyId": "k1", "protected": True}} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="k1") + from roboflow.cli.handlers.api_key import _protect_key + + with patch("builtins.print"): + _protect_key(args) + mock_update.assert_called_once_with("fake-key", "test-ws", "k1", protected=True) + + +# --------------------------------------------------------------------------- +# disable +# --------------------------------------------------------------------------- + + +class TestDisableApiKey(unittest.TestCase): + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_disable(self, _mock_key, _mock_ws, mock_update) -> None: + mock_update.return_value = {"apiKey": {"keyId": "k1", "disabled": True}} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="k1", enable=False) + from roboflow.cli.handlers.api_key import _disable_key + + with patch("builtins.print") as mock_print: + _disable_key(args) + mock_update.assert_called_once_with("fake-key", "test-ws", "k1", disabled=True) + printed = mock_print.call_args[0][0] + self.assertIn("Disabled", printed) + + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_enable(self, _mock_key, _mock_ws, mock_update) -> None: + mock_update.return_value = {"apiKey": {"keyId": "k1", "disabled": False}} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="k1", enable=True) + from roboflow.cli.handlers.api_key import _disable_key + + with patch("builtins.print") as mock_print: + _disable_key(args) + mock_update.assert_called_once_with("fake-key", "test-ws", "k1", disabled=False) + printed = mock_print.call_args[0][0] + self.assertIn("Enabled", printed) + + +# --------------------------------------------------------------------------- +# revoke +# --------------------------------------------------------------------------- + + +class TestRevokeApiKey(unittest.TestCase): + @patch("roboflow.adapters.rfapi.revoke_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_revoke_with_yes(self, _mock_key, _mock_ws, mock_revoke) -> None: + mock_revoke.return_value = {"status": "revoked", "keyId": "k1"} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="k1", yes=True) + from roboflow.cli.handlers.api_key import _revoke_key + + with patch("builtins.print") as mock_print: + _revoke_key(args) + mock_revoke.assert_called_once_with("fake-key", "test-ws", "k1") + printed = mock_print.call_args[0][0] + self.assertIn("Revoked", printed) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_revoke_without_yes_no_tty_exits(self, _mock_key, _mock_ws) -> None: + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="k1", yes=False) + from roboflow.cli.handlers.api_key import _revoke_key + + with patch("sys.stdin.isatty", return_value=False): + with self.assertRaises(SystemExit) as ctx: + _revoke_key(args) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.adapters.rfapi.revoke_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_revoke_json(self, _mock_key, _mock_ws, mock_revoke) -> None: + mock_revoke.return_value = {"status": "revoked", "keyId": "k1"} + args = Namespace(json=True, workspace=None, api_key=None, quiet=False, key_id="k1", yes=True) + from roboflow.cli.handlers.api_key import _revoke_key + + with patch("builtins.print") as mock_print: + _revoke_key(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertEqual(data["status"], "revoked") + + +# --------------------------------------------------------------------------- +# status-code propagation / exit-code consistency +# --------------------------------------------------------------------------- + + +class TestStatusCodePropagation(unittest.TestCase): + """The rfapi wrappers must attach response.status_code so handlers can branch.""" + + def _make_response(self, status_code, text="error body"): + from unittest.mock import MagicMock + + resp = MagicMock() + resp.ok = status_code < 400 + resp.status_code = status_code + resp.text = text + return resp + + @patch("roboflow.adapters.rfapi.requests.delete") + def test_revoke_attaches_status_code(self, mock_delete) -> None: + from roboflow.adapters.rfapi import RoboflowError, revoke_api_key + + mock_delete.return_value = self._make_response(409, "protected") + with self.assertRaises(RoboflowError) as ctx: + revoke_api_key("fake-key", "test-ws", "k1") + self.assertEqual(ctx.exception.status_code, 409) + + @patch("roboflow.adapters.rfapi.requests.patch") + def test_update_attaches_status_code(self, mock_patch) -> None: + from roboflow.adapters.rfapi import RoboflowError, update_api_key + + mock_patch.return_value = self._make_response(403, "forbidden") + with self.assertRaises(RoboflowError) as ctx: + update_api_key("fake-key", "test-ws", "k1", protected=False) + self.assertEqual(ctx.exception.status_code, 403) + + @patch("roboflow.adapters.rfapi.requests.get") + def test_get_attaches_status_code(self, mock_get) -> None: + from roboflow.adapters.rfapi import RoboflowError, get_api_key + + mock_get.return_value = self._make_response(404, "missing") + with self.assertRaises(RoboflowError) as ctx: + get_api_key("fake-key", "test-ws", "k1") + self.assertEqual(ctx.exception.status_code, 404) + + +class TestErrorBranches(unittest.TestCase): + """End-to-end: status_code now flows through to the right exit code / hint.""" + + @patch("roboflow.adapters.rfapi.revoke_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_revoke_protected_409_hint(self, _mock_key, _mock_ws, mock_revoke) -> None: + from roboflow.adapters.rfapi import RoboflowError + + mock_revoke.side_effect = RoboflowError("Key is protected", status_code=409) + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="k1", yes=True) + from roboflow.cli.handlers.api_key import _revoke_key + + with patch("sys.stderr"): + with self.assertRaises(SystemExit) as ctx: + _revoke_key(args) + # 409 is a non-auth/non-notfound error β†’ exit 1, with the protected-key hint. + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.adapters.rfapi.revoke_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_revoke_missing_exits_3(self, _mock_key, _mock_ws, mock_revoke) -> None: + from roboflow.adapters.rfapi import RoboflowError + + mock_revoke.side_effect = RoboflowError("not found", status_code=404) + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="missing", yes=True) + from roboflow.cli.handlers.api_key import _revoke_key + + with patch("sys.stderr"): + with self.assertRaises(SystemExit) as ctx: + _revoke_key(args) + # Consistent with `get ` β†’ exit 3, not 1. + self.assertEqual(ctx.exception.code, 3) + + @patch("roboflow.adapters.rfapi.get_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_get_missing_exits_3(self, _mock_key, _mock_ws, mock_get) -> None: + from roboflow.adapters.rfapi import RoboflowError + + mock_get.side_effect = RoboflowError("not found", status_code=404) + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="missing") + from roboflow.cli.handlers.api_key import _get_key + + with patch("sys.stderr"): + with self.assertRaises(SystemExit) as ctx: + _get_key(args) + self.assertEqual(ctx.exception.code, 3) + + @patch("roboflow.adapters.rfapi.get_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_get_auth_error_exits_2(self, _mock_key, _mock_ws, mock_get) -> None: + from roboflow.adapters.rfapi import RoboflowError + + mock_get.side_effect = RoboflowError("unauthorized", status_code=401) + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, key_id="k1") + from roboflow.cli.handlers.api_key import _get_key + + with patch("sys.stderr"): + with self.assertRaises(SystemExit) as ctx: + _get_key(args) + self.assertEqual(ctx.exception.code, 2) + + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_disable_protected_409_hint(self, _mock_key, _mock_ws, mock_update) -> None: + from roboflow.adapters.rfapi import RoboflowError + + mock_update.side_effect = RoboflowError("Key is protected", status_code=409) + args = Namespace(json=True, workspace=None, api_key=None, quiet=False, key_id="k1", enable=False) + from roboflow.cli.handlers.api_key import _disable_key + + printed = {} + + def _capture(payload, *a, **k): + printed["json"] = payload + + with patch("builtins.print", side_effect=_capture): + with self.assertRaises(SystemExit) as ctx: + _disable_key(args) + self.assertEqual(ctx.exception.code, 1) + data = json.loads(printed["json"]) + self.assertIn("settings/api", data["error"]["hint"]) + self.assertNotIn("settings/api-keys", data["error"]["hint"]) + + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_disable_plan_feature_403_hint(self, _mock_key, _mock_ws, mock_update) -> None: + from roboflow.adapters.rfapi import RoboflowError + + mock_update.side_effect = RoboflowError("forbidden", status_code=403) + args = Namespace(json=True, workspace=None, api_key=None, quiet=False, key_id="k1", enable=False) + from roboflow.cli.handlers.api_key import _disable_key + + printed = {} + + def _capture(payload, *a, **k): + printed["json"] = payload + + with patch("builtins.print", side_effect=_capture): + with self.assertRaises(SystemExit) as ctx: + _disable_key(args) + # 403 β†’ plan-feature hint, same as create, exit 1. + self.assertEqual(ctx.exception.code, 1) + data = json.loads(printed["json"]) + self.assertIn("Advanced API Keys", data["error"]["hint"]) + + +class TestUpdateApiKeyScopeAndMetadataStates(unittest.TestCase): + """--no-scopes / --full-access / --clear-metadata explicit states on update.""" + + def _args(self, **overrides): + base = dict( + json=False, + workspace=None, + api_key=None, + quiet=False, + key_id="k1", + name=None, + scope=None, + no_scopes=False, + full_access=False, + metadata=None, + clear_metadata=False, + ) + base.update(overrides) + return Namespace(**base) + + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_update_no_scopes_sends_empty_list(self, _mock_key, _mock_ws, mock_update) -> None: + mock_update.return_value = {"apiKey": {"keyId": "k1"}} + from roboflow.cli.handlers.api_key import _update_key + + with patch("builtins.print"): + _update_key(self._args(no_scopes=True)) + mock_update.assert_called_once_with("fake-key", "test-ws", "k1", scopes=[]) + + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_update_full_access_sends_sentinel(self, _mock_key, _mock_ws, mock_update) -> None: + from roboflow.adapters import rfapi + + mock_update.return_value = {"apiKey": {"keyId": "k1"}} + from roboflow.cli.handlers.api_key import _update_key + + with patch("builtins.print"): + _update_key(self._args(full_access=True)) + mock_update.assert_called_once_with("fake-key", "test-ws", "k1", scopes=rfapi.FULL_ACCESS) + + @patch("roboflow.adapters.rfapi.update_api_key") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_update_clear_metadata_sends_empty_dict(self, _mock_key, _mock_ws, mock_update) -> None: + mock_update.return_value = {"apiKey": {"keyId": "k1"}} + from roboflow.cli.handlers.api_key import _update_key + + with patch("builtins.print"): + _update_key(self._args(clear_metadata=True)) + mock_update.assert_called_once_with("fake-key", "test-ws", "k1", custom_metadata={}) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_update_scope_and_full_access_conflict_exits_1(self, _mock_key, _mock_ws) -> None: + from roboflow.cli.handlers.api_key import _update_key + + with patch("sys.stderr"): + with self.assertRaises(SystemExit) as ctx: + _update_key(self._args(scope=["image:read"], full_access=True)) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_update_metadata_and_clear_metadata_conflict_exits_1(self, _mock_key, _mock_ws) -> None: + from roboflow.cli.handlers.api_key import _update_key + + with patch("sys.stderr"): + with self.assertRaises(SystemExit) as ctx: + _update_key(self._args(metadata=["a=b"], clear_metadata=True)) + self.assertEqual(ctx.exception.code, 1) + + +class TestApiKeyBodySerialization(unittest.TestCase): + """The rfapi wrappers must serialize the three scope/metadata states correctly.""" + + def _ok_response(self, payload=None): + from unittest.mock import MagicMock + + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + resp.json.return_value = payload or {} + return resp + + @patch("roboflow.adapters.rfapi.requests.post") + def test_create_full_access_serializes_null_scopes(self, mock_post) -> None: + from roboflow.adapters.rfapi import FULL_ACCESS, create_api_key + + mock_post.return_value = self._ok_response() + create_api_key("fake-key", "test-ws", name="K", scopes=FULL_ACCESS) + body = mock_post.call_args.kwargs["json"] + self.assertIn("scopes", body) + self.assertIsNone(body["scopes"]) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_create_empty_scopes_serializes_empty_list(self, mock_post) -> None: + from roboflow.adapters.rfapi import create_api_key + + mock_post.return_value = self._ok_response() + create_api_key("fake-key", "test-ws", name="K", scopes=[]) + body = mock_post.call_args.kwargs["json"] + self.assertEqual(body["scopes"], []) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_create_omitted_scopes_absent_from_body(self, mock_post) -> None: + from roboflow.adapters.rfapi import create_api_key + + mock_post.return_value = self._ok_response() + create_api_key("fake-key", "test-ws", name="K", scopes=None) + body = mock_post.call_args.kwargs["json"] + self.assertNotIn("scopes", body) + + @patch("roboflow.adapters.rfapi.requests.patch") + def test_update_full_access_serializes_null_scopes(self, mock_patch) -> None: + from roboflow.adapters.rfapi import FULL_ACCESS, update_api_key + + mock_patch.return_value = self._ok_response() + update_api_key("fake-key", "test-ws", "k1", scopes=FULL_ACCESS) + body = mock_patch.call_args.kwargs["json"] + self.assertIn("scopes", body) + self.assertIsNone(body["scopes"]) + + @patch("roboflow.adapters.rfapi.requests.patch") + def test_update_empty_scopes_serializes_empty_list(self, mock_patch) -> None: + from roboflow.adapters.rfapi import update_api_key + + mock_patch.return_value = self._ok_response() + update_api_key("fake-key", "test-ws", "k1", scopes=[]) + body = mock_patch.call_args.kwargs["json"] + self.assertEqual(body["scopes"], []) + + @patch("roboflow.adapters.rfapi.requests.patch") + def test_update_clear_metadata_serializes_empty_dict(self, mock_patch) -> None: + from roboflow.adapters.rfapi import update_api_key + + mock_patch.return_value = self._ok_response() + update_api_key("fake-key", "test-ws", "k1", custom_metadata={}) + body = mock_patch.call_args.kwargs["json"] + # The Pythonic `custom_metadata` kwarg serializes as the canonical camelCase + # `customMetadata` wire field (consistent with `folderIds`). + self.assertIn("customMetadata", body) + self.assertEqual(body["customMetadata"], {}) + self.assertNotIn("custom_metadata", body) + + @patch("roboflow.adapters.rfapi.requests.post") + def test_create_metadata_serializes_camelcase(self, mock_post) -> None: + from roboflow.adapters.rfapi import create_api_key + + mock_post.return_value = self._ok_response() + create_api_key("fake-key", "test-ws", name="K", custom_metadata={"env": "prod"}) + body = mock_post.call_args.kwargs["json"] + self.assertEqual(body["customMetadata"], {"env": "prod"}) + self.assertNotIn("custom_metadata", body) + + @patch("roboflow.adapters.rfapi.requests.patch") + def test_update_none_scopes_absent_from_body(self, mock_patch) -> None: + from roboflow.adapters.rfapi import update_api_key + + mock_patch.return_value = self._ok_response() + update_api_key("fake-key", "test-ws", "k1", name="X", scopes=None) + body = mock_patch.call_args.kwargs["json"] + self.assertNotIn("scopes", body) + self.assertEqual(body["name"], "X") + + +class TestApiKeyNewFlagsHelp(unittest.TestCase): + """The new flags must be discoverable in --help.""" + + def test_create_help_lists_new_flags(self) -> None: + result = runner.invoke(app, ["api-key", "create", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + output = _strip_ansi(result.output).lower() + self.assertIn("--no-scopes", output) + self.assertIn("--full-access", output) + + def test_update_help_lists_new_flags(self) -> None: + result = runner.invoke(app, ["api-key", "update", "--help"]) + self.assertEqual(result.exit_code, 0, result.output) + output = _strip_ansi(result.output).lower() + self.assertIn("--no-scopes", output) + self.assertIn("--full-access", output) + self.assertIn("--clear-metadata", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_asynctasks_handler.py b/tests/cli/test_asynctasks_handler.py new file mode 100644 index 00000000..688f27fd --- /dev/null +++ b/tests/cli/test_asynctasks_handler.py @@ -0,0 +1,197 @@ +"""Tests for the `roboflow asynctasks` CLI handler.""" + +import json +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +def _make_args(**kwargs): + defaults = { + "json": False, + "workspace": "test-ws", + "api_key": "test-key", + "quiet": False, + "task_id": "task-123", + "timeout": 1800, + } + defaults.update(kwargs) + return Namespace(**defaults) + + +class TestAsyncTasksRegistration(unittest.TestCase): + def test_app_exists(self) -> None: + from roboflow.cli.handlers.asynctasks import asynctasks_app + + self.assertIsNotNone(asynctasks_app) + + def test_get_help(self) -> None: + result = runner.invoke(app, ["asynctasks", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_wait_help(self) -> None: + result = runner.invoke(app, ["asynctasks", "wait", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("timeout", result.output.lower()) + + +class TestAsyncTaskGet(unittest.TestCase): + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_get_text(self, _mock_key, mock_get): + from roboflow.cli.handlers.asynctasks import _get_async_task + + mock_get.return_value = { + "taskId": "task-123", + "status": "running", + "progress": {"percent": 42}, + } + args = _make_args() + with patch("builtins.print") as mock_print: + _get_async_task(args) + + mock_get.assert_called_once_with("test-key", "test-ws", "task-123") + printed = mock_print.call_args[0][0] + self.assertIn("task-123", printed) + self.assertIn("running", printed) + + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_get_json(self, _mock_key, mock_get): + from roboflow.cli.handlers.asynctasks import _get_async_task + + payload = { + "taskId": "task-123", + "status": "completed", + "result": {"forked": True, "url": "https://app.roboflow.com/x/y"}, + } + mock_get.return_value = payload + args = _make_args(json=True) + with patch("builtins.print") as mock_print: + _get_async_task(args) + + # Server payload pass-through. + out = json.loads(mock_print.call_args[0][0]) + self.assertEqual(out, payload) + + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_get_404_exits_three(self, _mock_key, mock_get): + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.asynctasks import _get_async_task + + mock_get.side_effect = RoboflowError('{"error":"Async task not found"}') + args = _make_args() + with self.assertRaises(SystemExit) as ctx: + _get_async_task(args) + self.assertEqual(ctx.exception.code, 3) + + +class TestAsyncTaskWait(unittest.TestCase): + @patch("roboflow.core.async_tasks.time.sleep", lambda *_a, **_k: None) + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_wait_until_completed(self, _mock_key, mock_get): + from roboflow.cli.handlers.asynctasks import _wait_async_task + + mock_get.side_effect = [ + {"taskId": "task-1", "status": "pending", "progress": None}, + {"taskId": "task-1", "status": "running", "progress": {"current": 1, "total": 3}}, + {"taskId": "task-1", "status": "completed", "result": {"ok": True}}, + ] + args = _make_args(task_id="task-1") + with patch("builtins.print") as mock_print: + _wait_async_task(args) + + self.assertEqual(mock_get.call_count, 3) + printed = mock_print.call_args[0][0] + self.assertIn("completed", printed) + mock_print.assert_any_call("Task progress: 1/3", flush=True) + + @patch("roboflow.core.async_tasks.time.sleep", lambda *_a, **_k: None) + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_wait_until_failed_exits_one(self, _mock_key, mock_get): + from roboflow.cli.handlers.asynctasks import _wait_async_task + + mock_get.return_value = { + "taskId": "task-1", + "status": "failed", + "error": "Source dataset not public", + } + args = _make_args(task_id="task-1") + with self.assertRaises(SystemExit) as ctx: + _wait_async_task(args) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.core.async_tasks.time.sleep", lambda *_a, **_k: None) + @patch("roboflow.core.async_tasks.time.monotonic") + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_wait_timeout_exits_one(self, _mock_key, mock_get, mock_monotonic): + from roboflow.cli.handlers.asynctasks import _wait_async_task + + # Two get_async_task calls, then deadline check trips. + mock_get.return_value = {"taskId": "task-1", "status": "running"} + # monotonic sequence: start, deadline-check-1 (still under), deadline-check-2 (over) + mock_monotonic.side_effect = [0.0, 0.5, 99999.0] + args = _make_args(task_id="task-1", timeout=10) + with self.assertRaises(SystemExit) as ctx: + _wait_async_task(args) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.core.async_tasks.time.sleep", lambda *_a, **_k: None) + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_wait_server_error_exits_three(self, _mock_key, mock_get): + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.asynctasks import _wait_async_task + + mock_get.side_effect = RoboflowError('{"error":"Async task not found"}') + args = _make_args(task_id="task-1") + with self.assertRaises(SystemExit) as ctx: + _wait_async_task(args) + self.assertEqual(ctx.exception.code, 3) + + +class TestPollUntilTerminalCallback(unittest.TestCase): + """#6 β€” `on_update` must fire on the terminal tick too, so callers driving + a progress bar see the final ``current == total`` event before the loop + returns.""" + + @patch("roboflow.core.async_tasks.time.sleep", lambda *_a, **_k: None) + @patch("roboflow.adapters.rfapi.get_async_task") + def test_on_update_called_for_terminal_status(self, mock_get): + from roboflow.core.async_tasks import poll_until_terminal + + mock_get.side_effect = [ + {"taskId": "t", "status": "running", "progress": {"current": 1, "total": 2}}, + {"taskId": "t", "status": "completed", "progress": {"current": 2, "total": 2}}, + ] + seen = [] + result = poll_until_terminal("k", "ws", "t", on_update=seen.append) + + # Both ticks delivered, including the completed one. + self.assertEqual([s["status"] for s in seen], ["running", "completed"]) + self.assertEqual(result["status"], "completed") + + +class TestAsyncTaskNoWorkspace(unittest.TestCase): + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=None) + def test_get_no_workspace_exits_two(self, _mock_resolve): + from roboflow.cli.handlers.asynctasks import _get_async_task + + args = _make_args(workspace=None, api_key=None) + with self.assertRaises(SystemExit) as ctx: + _get_async_task(args) + self.assertEqual(ctx.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_auth.py b/tests/cli/test_auth.py new file mode 100644 index 00000000..0a20dc56 --- /dev/null +++ b/tests/cli/test_auth.py @@ -0,0 +1,99 @@ +"""Tests for the auth CLI handler.""" + +import re +import types +import unittest + +from typer.testing import CliRunner + +from roboflow.cli import app +from roboflow.cli.handlers import auth as auth_module + +runner = CliRunner() + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +class TestAuthRegistration(unittest.TestCase): + """Verify auth handler registers expected subcommands.""" + + def test_auth_app_exists(self) -> None: + from roboflow.cli.handlers.auth import auth_app + + self.assertIsNotNone(auth_app) + + def test_auth_subcommand_exists(self) -> None: + result = runner.invoke(app, ["auth", "status", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_auth_login_exists(self) -> None: + result = runner.invoke(app, ["auth", "login", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_auth_login_help_shows_flags(self) -> None: + result = runner.invoke(app, ["auth", "login", "--help"]) + self.assertEqual(result.exit_code, 0) + output = _strip_ansi(result.output).lower() + self.assertIn("api-key", output) + self.assertIn("force", output) + + def test_auth_set_workspace_exists(self) -> None: + result = runner.invoke(app, ["auth", "set-workspace", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_auth_logout_exists(self) -> None: + result = runner.invoke(app, ["auth", "logout", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_handler_functions_exist(self) -> None: + from roboflow.cli.handlers import auth + + # All handler functions should be importable + self.assertTrue(callable(auth._login)) + self.assertTrue(callable(auth._status)) + self.assertTrue(callable(auth._set_workspace)) + self.assertTrue(callable(auth._logout)) + + def test_mask_key(self) -> None: + from roboflow.cli.handlers.auth import _mask_key + + self.assertEqual(_mask_key("abcdefgh"), "ab****gh") + self.assertEqual(_mask_key("ab"), "****") + self.assertEqual(_mask_key(""), "****") + + +class TestCompletionTip(unittest.TestCase): + """Verify the post-login completion tip honours --json and --quiet.""" + + def _capture(self, args_ns) -> str: # noqa: ANN001 + import io + import sys + + buf = io.StringIO() + prev = sys.stdout + sys.stdout = buf + try: + auth_module._print_completion_tip(args_ns) + finally: + sys.stdout = prev + return buf.getvalue() + + def test_tip_printed_in_normal_mode(self) -> None: + out = self._capture(types.SimpleNamespace(json=False, quiet=False)) + self.assertIn("roboflow completion install", out) + + def test_tip_suppressed_in_json_mode(self) -> None: + out = self._capture(types.SimpleNamespace(json=True, quiet=False)) + self.assertEqual(out, "") + + def test_tip_suppressed_in_quiet_mode(self) -> None: + out = self._capture(types.SimpleNamespace(json=False, quiet=True)) + self.assertEqual(out, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_backwards_compat.py b/tests/cli/test_backwards_compat.py new file mode 100644 index 00000000..fd3f89f9 --- /dev/null +++ b/tests/cli/test_backwards_compat.py @@ -0,0 +1,72 @@ +"""Tests that the roboflowpy.py backwards-compatibility shim works. + +Ensures that existing scripts and integrations that import from the old +monolithic module continue to work after the CLI modularization and +typer migration. +""" + +import unittest + + +class TestRoboflowpyShim(unittest.TestCase): + """Verify the roboflowpy.py shim re-exports work.""" + + def test_main_importable(self) -> None: + from roboflow.roboflowpy import main + + self.assertTrue(callable(main)) + + def test_argparser_importable(self) -> None: + """debugme.py imports _argparser β€” this must not break.""" + from roboflow.roboflowpy import _argparser + + self.assertTrue(callable(_argparser)) + + def test_argparser_returns_object_with_parse_args(self) -> None: + """_argparser() must return an object with parse_args() method.""" + from roboflow.roboflowpy import _argparser + + parser = _argparser() + self.assertIsNotNone(parser) + self.assertTrue(hasattr(parser, "parse_args")) + self.assertTrue(callable(parser.parse_args)) + + def test_argparser_has_print_help(self) -> None: + """The parser should support print_help() for interactive use.""" + from roboflow.roboflowpy import _argparser + + parser = _argparser() + self.assertTrue(hasattr(parser, "print_help")) + + def test_cli_commands_work_via_typer_runner(self) -> None: + """Verify commands execute through typer's CliRunner.""" + from typer.testing import CliRunner + + from roboflow.cli import app + + runner = CliRunner() + + # --version + result = runner.invoke(app, ["--version"]) + self.assertEqual(result.exit_code, 0) + + # --help + result = runner.invoke(app, ["--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("project", result.output) + + # Legacy alias: login --help + result = runner.invoke(app, ["login", "--help"]) + self.assertEqual(result.exit_code, 0) + + # Legacy alias: whoami --help + result = runner.invoke(app, ["whoami", "--help"]) + self.assertEqual(result.exit_code, 0) + + # Legacy alias: download --help + result = runner.invoke(app, ["download", "--help"]) + self.assertEqual(result.exit_code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_batch_handler.py b/tests/cli/test_batch_handler.py new file mode 100644 index 00000000..bfe773d1 --- /dev/null +++ b/tests/cli/test_batch_handler.py @@ -0,0 +1,38 @@ +"""Tests for the batch CLI handler.""" + +import unittest + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestBatchRegistration(unittest.TestCase): + """Verify batch handler registers expected subcommands.""" + + def test_batch_app_exists(self) -> None: + from roboflow.cli.handlers.batch import batch_app + + self.assertIsNotNone(batch_app) + + def test_batch_create_exists(self) -> None: + result = runner.invoke(app, ["batch", "create", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_batch_status_exists(self) -> None: + result = runner.invoke(app, ["batch", "status", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_batch_list_exists(self) -> None: + result = runner.invoke(app, ["batch", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_batch_results_exists(self) -> None: + result = runner.invoke(app, ["batch", "results", "--help"]) + self.assertEqual(result.exit_code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_completion_handler.py b/tests/cli/test_completion_handler.py new file mode 100644 index 00000000..41a57ed6 --- /dev/null +++ b/tests/cli/test_completion_handler.py @@ -0,0 +1,219 @@ +"""Tests for the completion CLI handler. + +Covers script generation and the install flow (which delegates to +``typer.completion.install``). +""" + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +import click +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestCompletionRegistration(unittest.TestCase): + """Verify completion handler registers expected subcommands.""" + + def test_completion_app_exists(self) -> None: + from roboflow.cli.handlers.completion import completion_app + + self.assertIsNotNone(completion_app) + + def test_completion_bash_exists(self) -> None: + result = runner.invoke(app, ["completion", "bash", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_completion_zsh_exists(self) -> None: + result = runner.invoke(app, ["completion", "zsh", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_completion_fish_exists(self) -> None: + result = runner.invoke(app, ["completion", "fish", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_completion_install_exists(self) -> None: + result = runner.invoke(app, ["completion", "install", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestCompletionScriptGeneration(unittest.TestCase): + """Raw script generation paths (`completion bash|zsh|fish`).""" + + def test_bash_script_contains_marker(self) -> None: + result = runner.invoke(app, ["completion", "bash"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("_ROBOFLOW_COMPLETE", result.output) + + def test_zsh_script_contains_marker(self) -> None: + result = runner.invoke(app, ["completion", "zsh"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("_ROBOFLOW_COMPLETE", result.output) + self.assertIn("complete_zsh", result.output) + self.assertNotIn("zsh_complete", result.output) + self.assertIn("compdef", result.output) + + def test_fish_script_contains_marker(self) -> None: + result = runner.invoke(app, ["completion", "fish"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("_ROBOFLOW_COMPLETE", result.output) + + def test_hidden_commands_filtered_from_completion(self) -> None: + """Click filters hidden commands from completion at iteration time. + + Anything registered with ``hidden=True`` (legacy aliases, snake_case + shims, stubbed groups) must not be visible. We replicate Click's + filter without depending on a private symbol. + """ + import typer + + from roboflow.cli import app as rf_app + + click_app = typer.main.get_command(rf_app) + ctx = click.Context(click_app, info_name="roboflow") + visible = { + name + for name in click_app.list_commands(ctx) + if (cmd := click_app.get_command(ctx, name)) is not None and not cmd.hidden + } + hidden_examples = { + "download", + "login", + "whoami", + "upload", + "import", + "search-export", + "upload_model", + "get_workspace_info", + "run_video_inference_api", + "help", + "batch", + } + leaked = hidden_examples & visible + self.assertFalse(leaked, f"Hidden commands leaked into completion: {leaked}") + + def test_bad_completion_invocation_exits_without_traceback(self) -> None: + from roboflow.cli import main + + with mock.patch.dict(os.environ, {"_ROBOFLOW_COMPLETE": "bash_complete"}, clear=False): + os.environ.pop("COMP_WORDS", None) + os.environ.pop("COMP_CWORD", None) + with mock.patch.object(sys, "argv", ["roboflow", "im"]): + with self.assertRaises(SystemExit) as exc: + main() + self.assertEqual(exc.exception.code, 0) + + +class _IsolatedHomeMixin: + """Mixin: isolated $HOME, $SHELL=zsh, and roboflow-on-PATH stub.""" + + def setUp(self) -> None: # type: ignore[override] + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + self.home = Path(self.tmpdir.name) + + # Stub `shutil.which("roboflow")` only β€” Click's BashComplete also + # calls shutil.which("bash") for version detection; don't intercept + # that. + import shutil as _shutil + + real_which = _shutil.which + + def _fake_which(cmd, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 + if cmd == "roboflow": + return "/usr/local/bin/roboflow" + return real_which(cmd, *args, **kwargs) + + self._which_patch = mock.patch.object(_shutil, "which", side_effect=_fake_which) + self._which_patch.start() + self.addCleanup(self._which_patch.stop) + + self._env_patch = mock.patch.dict( + os.environ, + {"HOME": str(self.home), "SHELL": "/bin/zsh"}, + clear=False, + ) + self._env_patch.start() + self.addCleanup(self._env_patch.stop) + + # typer.completion.install reads Path.home() to pick rc/script paths. + self._home_patch = mock.patch.object(Path, "home", return_value=self.home) + self._home_patch.start() + self.addCleanup(self._home_patch.stop) + + +class TestInstall(_IsolatedHomeMixin, unittest.TestCase): + def test_install_zsh_writes_file(self) -> None: + result = runner.invoke(app, ["completion", "install", "--shell", "zsh"]) + self.assertEqual(result.exit_code, 0, msg=result.output) + target = self.home / ".zfunc" / "_roboflow" + self.assertTrue(target.exists()) + self.assertIn("_ROBOFLOW_COMPLETE", target.read_text()) + + def test_install_bash_writes_file(self) -> None: + result = runner.invoke(app, ["completion", "install", "--shell", "bash"]) + self.assertEqual(result.exit_code, 0, msg=result.output) + target = self.home / ".bash_completions" / "roboflow.sh" + self.assertTrue(target.exists()) + + def test_install_bash_appends_source_line_to_bashrc(self) -> None: + runner.invoke(app, ["completion", "install", "--shell", "bash"]) + rc = (self.home / ".bashrc").read_text() + target = self.home / ".bash_completions" / "roboflow.sh" + self.assertTrue( + any(line.lstrip().startswith("source ") and str(target) in line for line in rc.splitlines()), + msg=f"no source line for {target} in {rc!r}", + ) + + def test_install_fish_writes_file(self) -> None: + result = runner.invoke(app, ["completion", "install", "--shell", "fish"]) + self.assertEqual(result.exit_code, 0, msg=result.output) + target = self.home / ".config" / "fish" / "completions" / "roboflow.fish" + self.assertTrue(target.exists()) + + def test_install_unsupported_shell_errors(self) -> None: + result = runner.invoke(app, ["completion", "install", "--shell", "csh"]) + self.assertEqual(result.exit_code, 3, msg=result.output) + + def test_install_missing_binary_hard_errors(self) -> None: + import shutil as _shutil + + with mock.patch.object(_shutil, "which", return_value=None): + result = runner.invoke(app, ["completion", "install", "--shell", "zsh"]) + self.assertEqual(result.exit_code, 1, msg=result.output) + combined = result.output + (result.stderr or "") + self.assertIn("PATH", combined) + + def test_install_idempotent(self) -> None: + first = runner.invoke(app, ["completion", "install", "--shell", "zsh"]) + second = runner.invoke(app, ["completion", "install", "--shell", "zsh"]) + self.assertEqual(first.exit_code, 0) + self.assertEqual(second.exit_code, 0) + self.assertTrue((self.home / ".zfunc" / "_roboflow").exists()) + + def test_install_bash_idempotent_does_not_duplicate_source_line(self) -> None: + runner.invoke(app, ["completion", "install", "--shell", "bash"]) + runner.invoke(app, ["completion", "install", "--shell", "bash"]) + rc = (self.home / ".bashrc").read_text() + target = self.home / ".bash_completions" / "roboflow.sh" + source_lines = [line for line in rc.splitlines() if line.lstrip().startswith("source ") and str(target) in line] + self.assertEqual(len(source_lines), 1, msg=f"unexpected rc: {rc!r}") + + def test_install_json_schema(self) -> None: + result = runner.invoke(app, ["--json", "completion", "install", "--shell", "fish"]) + self.assertEqual(result.exit_code, 0, msg=result.output) + payload = json.loads(result.output) + self.assertEqual(payload["shell"], "fish") + self.assertIn("path", payload) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_deployment_handler.py b/tests/cli/test_deployment_handler.py new file mode 100644 index 00000000..89f746cd --- /dev/null +++ b/tests/cli/test_deployment_handler.py @@ -0,0 +1,87 @@ +"""Tests for the deployment CLI handler.""" + +import argparse +import io +import json +import unittest +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestDeploymentRegistration(unittest.TestCase): + """Verify deployment handler registers expected subcommands.""" + + def test_deployment_app_exists(self) -> None: + from roboflow.cli.handlers.deployment import deployment_app + + self.assertIsNotNone(deployment_app) + + def test_deployment_subcommand_exists(self) -> None: + result = runner.invoke(app, ["deployment", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_deployment_create_canonical(self) -> None: + result = runner.invoke(app, ["deployment", "create", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_deployment_machine_type_canonical(self) -> None: + result = runner.invoke(app, ["deployment", "machine-type", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_deployment_get_exists(self) -> None: + result = runner.invoke(app, ["deployment", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_deployment_delete_exists(self) -> None: + result = runner.invoke(app, ["deployment", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_deployment_usage_canonical(self) -> None: + result = runner.invoke(app, ["deployment", "usage", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestDeploymentErrorWrapping(unittest.TestCase): + """Verify deployment errors produce structured output.""" + + def test_wrapped_error_uses_structured_output(self) -> None: + """Deployment errors should go through output_error, not bare print.""" + from roboflow.cli.handlers.deployment import _wrap + + def _fake_handler(args: object) -> None: + print("401: Unauthorized (invalid api_key)") + raise SystemExit(401) + + ns = argparse.Namespace(json=True, api_key=None, workspace=None, quiet=False) + wrapped = _wrap(_fake_handler) + stderr = io.StringIO() + with patch("sys.stderr", stderr): + with self.assertRaises(SystemExit) as ctx: + wrapped(ns) + self.assertLessEqual(ctx.exception.code, 3) + err_output = stderr.getvalue().strip() + parsed = json.loads(err_output) + self.assertIn("error", parsed) + + def test_wrapped_success_prints_output(self) -> None: + """On success, wrapped func should replay captured stdout.""" + from roboflow.cli.handlers.deployment import _wrap + + def _fake_handler(args: object) -> None: + print('{"machines": []}') + + ns = argparse.Namespace(json=False, api_key=None, workspace=None, quiet=False) + wrapped = _wrap(_fake_handler) + captured = io.StringIO() + with patch("sys.stdout", captured): + wrapped(ns) + self.assertIn('{"machines": []}', captured.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_device_handler.py b/tests/cli/test_device_handler.py new file mode 100644 index 00000000..8fb83e13 --- /dev/null +++ b/tests/cli/test_device_handler.py @@ -0,0 +1,275 @@ +"""Tests for the device CLI handler.""" + +from __future__ import annotations + +import json +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.adapters.devicesapi import ( + DeviceAuthError, + DeviceNotFoundError, + DeviceRateLimitedError, +) +from roboflow.cli import app + +runner = CliRunner() + +WS = "test-ws" +KEY = "fake-key" + + +def _args(**kwargs) -> Namespace: + defaults = {"json": False, "workspace": WS, "api_key": KEY, "quiet": False} + defaults.update(kwargs) + return Namespace(**defaults) + + +class TestDeviceRegistration(unittest.TestCase): + """Subcommands are registered and `--help` works for each.""" + + def test_top_level_help(self) -> None: + result = runner.invoke(app, ["device", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Manage RFDM devices", result.output) + + def test_subcommands(self) -> None: + for verb in ( + "list", + "get", + "create", + "config", + "config-history", + "streams", + "stream", + "logs", + "telemetry", + "events", + ): + with self.subTest(verb=verb): + result = runner.invoke(app, ["device", verb, "--help"]) + self.assertEqual(result.exit_code, 0, msg=result.output) + + +class TestDeviceListHandler(unittest.TestCase): + @patch("roboflow.adapters.devicesapi.list_devices") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value=KEY) + def test_list_text(self, _mk, _mw, mock_list): + mock_list.return_value = { + "data": [ + { + "id": "a", + "name": "Cam A", + "status": "online", + "type": "edge", + "last_heartbeat": "2026-04-30T00:00:00Z", + } + ] + } + from roboflow.cli.handlers.device import _list + + with patch("builtins.print") as mock_print: + _list(_args()) + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + self.assertIn("Cam A", printed) + self.assertIn("online", printed) + + @patch("roboflow.adapters.devicesapi.list_devices") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value=KEY) + def test_list_json(self, _mk, _mw, mock_list): + mock_list.return_value = {"data": [{"id": "a", "name": "Cam A"}]} + from roboflow.cli.handlers.device import _list + + with patch("builtins.print") as mock_print: + _list(_args(json=True)) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertEqual(data["data"][0]["id"], "a") + + +class TestDeviceErrorMapping(unittest.TestCase): + """Adapter exceptions map to documented exit codes.""" + + @patch("roboflow.adapters.devicesapi.get_device") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value=KEY) + def test_404_exits_3(self, _mk, _mw, mock_get): + mock_get.side_effect = DeviceNotFoundError("not found", status_code=404) + from roboflow.cli.handlers.device import _get + + with self.assertRaises(SystemExit) as ctx: + _get(_args(device_id="missing")) + self.assertEqual(ctx.exception.code, 3) + + @patch("roboflow.adapters.devicesapi.get_device") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value=KEY) + def test_401_exits_2(self, _mk, _mw, mock_get): + mock_get.side_effect = DeviceAuthError("nope", status_code=401) + from roboflow.cli.handlers.device import _get + + with self.assertRaises(SystemExit) as ctx: + _get(_args(device_id="x")) + self.assertEqual(ctx.exception.code, 2) + + @patch("roboflow.adapters.devicesapi.get_device_logs") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value=KEY) + def test_429_exits_1_with_hint(self, _mk, _mw, mock_logs): + mock_logs.side_effect = DeviceRateLimitedError("slow down", status_code=429) + from roboflow.cli.handlers.device import _logs + + args = _args( + device_id="x", + start_time=None, + end_time=None, + service=None, + severity=None, + limit=None, + cursor=None, + json=True, + ) + with self.assertRaises(SystemExit) as ctx: + _logs(args) + self.assertEqual(ctx.exception.code, 1) + + +class TestDeviceErrorRedaction(unittest.TestCase): + """Adapter errors that contain ``api_key=...`` must never reach stderr verbatim.""" + + @patch("roboflow.adapters.devicesapi.get_device") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value="secret-key-with-DASH_und_123") + def test_connection_error_strips_api_key_from_url(self, _mk, _mw, mock_get): + # Simulate a requests.ConnectionError surfacing the full URL with the key. + url = "https://api.roboflow.com/test-ws/devices/v2/x?api_key=secret-key-with-DASH_und_123" + mock_get.side_effect = RuntimeError(f"HTTPSConnectionPool: failed to reach {url}") + import sys + from io import StringIO + + from roboflow.cli.handlers.device import _get + + buf = StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit): + _get(_args(device_id="x", json=True)) + finally: + sys.stderr = old + emitted = buf.getvalue() + self.assertNotIn("secret-key-with-DASH_und_123", emitted) + self.assertIn("api_key=***", emitted) + + +class TestDeviceConfigWarning(unittest.TestCase): + """`device config` must remind humans that the output is sensitive β€” but not in JSON mode.""" + + @patch("roboflow.adapters.devicesapi.get_device_config") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value=KEY) + def test_text_mode_prints_warning_to_stderr(self, _mk, _mw, mock_cfg): + mock_cfg.return_value = {"environment_variables": {"SECRET": "x"}, "services": {}} + import sys + from io import StringIO + + from roboflow.cli.handlers.device import _config + + buf = StringIO() + old = sys.stderr + sys.stderr = buf + try: + with patch("builtins.print"): # swallow the JSON dump + _config(_args(device_id="x")) + finally: + sys.stderr = old + warning = buf.getvalue() + self.assertIn("WARNING", warning) + self.assertIn("environment variables", warning) + + @patch("roboflow.adapters.devicesapi.get_device_config") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value=KEY) + def test_json_mode_no_warning_and_no_payload_mutation(self, _mk, _mw, mock_cfg): + payload = {"environment_variables": {"SECRET": "x"}, "services": {"a": 1}} + mock_cfg.return_value = payload + import sys + from io import StringIO + + from roboflow.cli.handlers.device import _config + + buf = StringIO() + old = sys.stderr + sys.stderr = buf + try: + with patch("builtins.print") as mock_print: + _config(_args(device_id="x", json=True)) + finally: + sys.stderr = old + # No warning in JSON mode (machine consumers shouldn't see ad hoc human text). + self.assertEqual(buf.getvalue(), "") + # The dumped JSON must be byte-faithful to the API response β€” never redacted + # by the SDK, since callers depend on round-trip fidelity for backup/restore. + printed = mock_print.call_args[0][0] + self.assertEqual(json.loads(printed), payload) + + +class TestDeviceCreateHandler(unittest.TestCase): + @patch("roboflow.adapters.devicesapi.create_device") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value=KEY) + def test_create_passes_args(self, _mk, _mw, mock_create): + mock_create.return_value = {"deviceId": "d1", "installId": "i1"} + from roboflow.cli.handlers.device import _create + + args = _args( + device_name="Cam 1", + device_type="edge", + workflow_id="wf-1", + tags=["a", "b"], + offline_mode=None, + source_device_id=None, + json=True, + ) + with patch("builtins.print") as mock_print: + _create(args) + kwargs = mock_create.call_args.kwargs + self.assertEqual(kwargs["device_name"], "Cam 1") + self.assertEqual(kwargs["device_type"], "edge") + self.assertEqual(kwargs["tags"], ["a", "b"]) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertEqual(data["deviceId"], "d1") + + +class TestDeviceLogsCsvSerialization(unittest.TestCase): + @patch("roboflow.adapters.devicesapi.get_device_logs") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=WS) + @patch("roboflow.config.load_roboflow_api_key", return_value=KEY) + def test_severity_passed_as_list(self, _mk, _mw, mock_logs): + mock_logs.return_value = {"data": [], "pagination": {}} + from roboflow.cli.handlers.device import _logs + + args = _args( + device_id="x", + start_time=None, + end_time=None, + service=["foo", "bar"], + severity=["INFO"], + limit=None, + cursor=None, + ) + _logs(args) + kwargs = mock_logs.call_args.kwargs + self.assertEqual(kwargs["service"], ["foo", "bar"]) + self.assertEqual(kwargs["severity"], ["INFO"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_discovery.py b/tests/cli/test_discovery.py new file mode 100644 index 00000000..d072f956 --- /dev/null +++ b/tests/cli/test_discovery.py @@ -0,0 +1,129 @@ +"""Tests that the CLI auto-discovery mechanism works correctly. + +Tests use typer.testing.CliRunner instead of argparse internals. +""" + +import re +import unittest + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +class TestCLIDiscovery(unittest.TestCase): + """Verify the CLI app loads and has expected structure.""" + + def test_app_exists(self) -> None: + self.assertIsNotNone(app) + + def test_help_shows_commands(self) -> None: + result = runner.invoke(app, ["--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("project", result.output) + self.assertIn("workspace", result.output) + self.assertIn("image", result.output) + self.assertIn("infer", result.output) + + def test_version_flag(self) -> None: + result = runner.invoke(app, ["--version"]) + self.assertEqual(result.exit_code, 0) + + def test_json_flag(self) -> None: + result = runner.invoke(app, ["--json", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_handlers_package_importable(self) -> None: + import roboflow.cli.handlers + + self.assertIsNotNone(roboflow.cli.handlers) + + def test_output_module_importable(self) -> None: + from roboflow.cli._output import output, output_error + + self.assertTrue(callable(output)) + self.assertTrue(callable(output_error)) + + def test_resolver_module_importable(self) -> None: + from roboflow.cli._resolver import resolve_resource + + self.assertTrue(callable(resolve_resource)) + + def test_table_module_importable(self) -> None: + from roboflow.cli._table import format_table + + self.assertTrue(callable(format_table)) + + def test_compat_module_importable(self) -> None: + from roboflow.cli._compat import ctx_to_args + + self.assertTrue(callable(ctx_to_args)) + + +class TestGlobalFlagPositioning(unittest.TestCase): + """Verify global flags work in any position (typer handles natively).""" + + def test_json_at_start(self) -> None: + result = runner.invoke(app, ["--json", "project", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_json_at_end(self) -> None: + result = runner.invoke(app, ["project", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workspace_long_form(self) -> None: + result = runner.invoke(app, ["--workspace", "test-ws", "project", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_api_key_flag(self) -> None: + result = runner.invoke(app, ["--api-key", "test-key", "project", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestAliases(unittest.TestCase): + """Verify backwards-compat aliases work.""" + + def test_login_alias(self) -> None: + result = runner.invoke(app, ["login", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("login", _strip_ansi(result.output).lower()) + + def test_whoami_alias(self) -> None: + result = runner.invoke(app, ["whoami", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_upload_alias(self) -> None: + result = runner.invoke(app, ["upload", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_import_alias(self) -> None: + result = runner.invoke(app, ["import", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_download_alias(self) -> None: + result = runner.invoke(app, ["download", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("dataseturl", _strip_ansi(result.output).lower()) + + def test_hidden_aliases_not_in_help(self) -> None: + result = runner.invoke(app, ["--help"]) + output = _strip_ansi(result.output) + self.assertNotIn("upload_model", output) + self.assertNotIn("get_workspace_info", output) + self.assertNotIn("run_video_inference_api", output) + + def test_hidden_alias_still_works(self) -> None: + result = runner.invoke(app, ["upload_model", "--help"]) + self.assertEqual(result.exit_code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_eval_handler.py b/tests/cli/test_eval_handler.py new file mode 100644 index 00000000..0d4a4a30 --- /dev/null +++ b/tests/cli/test_eval_handler.py @@ -0,0 +1,413 @@ +"""Tests for the model-eval CLI handler (`roboflow eval ...`).""" + +from __future__ import annotations + +import json +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Registration / discoverability +# --------------------------------------------------------------------------- + + +class TestEvalRegistration(unittest.TestCase): + """`roboflow eval ...` subcommands are registered with valid --help.""" + + def test_eval_app_exists(self) -> None: + from roboflow.cli.handlers.eval import eval_app + + self.assertIsNotNone(eval_app) + + def test_eval_root_help(self) -> None: + result = runner.invoke(app, ["eval", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_each_subcommand_help(self) -> None: + for cmd in [ + "list", + "get", + "map-results", + "confidence-sweep", + "performance-by-class", + "confusion-matrix", + "vector-analysis", + "image-predictions", + "recommendations", + ]: + with self.subTest(cmd=cmd): + result = runner.invoke(app, ["eval", cmd, "--help"]) + self.assertEqual(result.exit_code, 0, f"{cmd} --help failed: {result.output}") + + +# --------------------------------------------------------------------------- +# Helpers β€” every test patches the workspace + key resolver so no IO happens. +# --------------------------------------------------------------------------- + + +def _args(**overrides): + """Build a Namespace matching what ctx_to_args produces, with sane defaults.""" + base = {"json": False, "workspace": "lee-sandbox", "api_key": None, "quiet": False} + base.update(overrides) + return Namespace(**base) + + +# --------------------------------------------------------------------------- +# `eval list` +# --------------------------------------------------------------------------- + + +class TestEvalListHandler(unittest.TestCase): + @patch("roboflow.adapters.rfapi.list_model_evals") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_list_text_calls_adapter_with_filters(self, _key, _ws, mock_list): + mock_list.return_value = { + "evals": [ + { + "id": "e1", + "status": "done", + "project": "my-project-slug", + "versionId": "3", + "modelId": None, + "createdAt": "2025-01-01", + } + ] + } + args = _args( + workspace=None, + project="my-project-slug", + version="3", + model=None, + status="done", + limit=5, + ) + + from roboflow.cli.handlers.eval import _list_evals + + with patch("builtins.print") as mock_print: + _list_evals(args) + + mock_list.assert_called_once_with( + "key", + "lee-sandbox", + project="my-project-slug", + version="3", + model=None, + status="done", + limit=5, + ) + printed = mock_print.call_args[0][0] + self.assertIn("e1", printed) + self.assertIn("done", printed) + + @patch("roboflow.adapters.rfapi.list_model_evals") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_list_json_emits_evals_array(self, _key, _ws, mock_list): + mock_list.return_value = {"evals": [{"id": "e1", "status": "done"}]} + args = _args(workspace=None, json=True, project=None, version=None, model=None, status=None, limit=None) + + from roboflow.cli.handlers.eval import _list_evals + + with patch("builtins.print") as mock_print: + _list_evals(args) + + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertEqual(data, [{"id": "e1", "status": "done"}]) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=None) + def test_list_no_workspace_exits_2(self, _ws): + args = _args(workspace=None, project=None, version=None, model=None, status=None, limit=None) + + from roboflow.cli.handlers.eval import _list_evals + + with self.assertRaises(SystemExit) as ctx: + _list_evals(args) + self.assertEqual(ctx.exception.code, 2) + + +# --------------------------------------------------------------------------- +# `eval get` +# --------------------------------------------------------------------------- + + +class TestEvalGetHandler(unittest.TestCase): + @patch("roboflow.adapters.rfapi.get_model_eval") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_get_text(self, _key, _ws, mock_get): + mock_get.return_value = { + "id": "e1", + "status": "done", + "project": "my-project-slug", + "versionId": "3", + "modelId": "m1", + "createdAt": "2025-01-01", + "summary": {"mAP": 0.91, "precision": 0.85, "recall": 0.8}, + } + args = _args(workspace=None, eval_id="e1") + + from roboflow.cli.handlers.eval import _get_eval + + with patch("builtins.print") as mock_print: + _get_eval(args) + + printed = mock_print.call_args[0][0] + self.assertIn("Eval: e1", printed) + self.assertIn("Status: done", printed) + self.assertIn("mAP=0.91", printed) + mock_get.assert_called_once_with("key", "lee-sandbox", "e1") + + @patch("roboflow.adapters.rfapi.get_model_eval") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_get_404_exits_3(self, _key, _ws, mock_get): + from roboflow.adapters import rfapi + + mock_get.side_effect = rfapi.ModelEvalNotFoundError("not found") + args = _args(workspace=None, eval_id="bad") + + from roboflow.cli.handlers.eval import _get_eval + + with self.assertRaises(SystemExit) as ctx: + _get_eval(args) + self.assertEqual(ctx.exception.code, 3) + + +# --------------------------------------------------------------------------- +# Per-panel handlers β€” each forwards args to the right adapter function. +# --------------------------------------------------------------------------- + + +class TestPanelHandlers(unittest.TestCase): + @patch("roboflow.adapters.rfapi.get_model_eval_map_results") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_map_results_calls_adapter(self, _key, _ws, mock_fn): + mock_fn.return_value = {"splits": {"test": {"map50": 0.9}}} + args = _args(workspace=None, eval_id="e1") + + from roboflow.cli.handlers.eval import _map_results + + with patch("builtins.print"): + _map_results(args) + mock_fn.assert_called_once_with("key", "lee-sandbox", "e1") + + @patch("roboflow.adapters.rfapi.get_model_eval_confidence_sweep") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_confidence_sweep_calls_adapter(self, _key, _ws, mock_fn): + mock_fn.return_value = {"splits": {}} + args = _args(workspace=None, eval_id="e1") + + from roboflow.cli.handlers.eval import _confidence_sweep + + with patch("builtins.print"): + _confidence_sweep(args) + mock_fn.assert_called_once_with("key", "lee-sandbox", "e1") + + @patch("roboflow.adapters.rfapi.get_model_eval_performance_by_class") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_performance_by_class_passes_split(self, _key, _ws, mock_fn): + mock_fn.return_value = {"split": "valid", "classes": [{"className": "car", "map50": 0.9}]} + args = _args(workspace=None, eval_id="e1", split="valid") + + from roboflow.cli.handlers.eval import _performance_by_class + + with patch("builtins.print"): + _performance_by_class(args) + mock_fn.assert_called_once_with("key", "lee-sandbox", "e1", split="valid") + + @patch("roboflow.adapters.rfapi.get_model_eval_performance_by_class") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_performance_by_class_invalid_split_exits_5(self, _key, _ws, mock_fn): + from roboflow.adapters import rfapi + + mock_fn.side_effect = rfapi.InvalidSplitError("no") + args = _args(workspace=None, eval_id="e1", split="all") + + from roboflow.cli.handlers.eval import _performance_by_class + + with self.assertRaises(SystemExit) as ctx: + _performance_by_class(args) + self.assertEqual(ctx.exception.code, 5) + + @patch("roboflow.adapters.rfapi.get_model_eval_confusion_matrix") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_confusion_matrix_passes_args(self, _key, _ws, mock_fn): + mock_fn.return_value = {"matrix": []} + args = _args(workspace=None, eval_id="e1", split="test", confidence=30) + + from roboflow.cli.handlers.eval import _confusion_matrix + + with patch("builtins.print"): + _confusion_matrix(args) + mock_fn.assert_called_once_with("key", "lee-sandbox", "e1", split="test", confidence=30) + + @patch("roboflow.adapters.rfapi.get_model_eval_confusion_matrix") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_confusion_matrix_invalid_confidence_exits_5(self, _key, _ws, mock_fn): + from roboflow.adapters import rfapi + + mock_fn.side_effect = rfapi.InvalidConfidenceError("bad") + args = _args(workspace=None, eval_id="e1", split=None, confidence=999) + + from roboflow.cli.handlers.eval import _confusion_matrix + + with self.assertRaises(SystemExit) as ctx: + _confusion_matrix(args) + self.assertEqual(ctx.exception.code, 5) + + @patch("roboflow.adapters.rfapi.get_model_eval_vector_analysis") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_vector_analysis_calls_adapter(self, _key, _ws, mock_fn): + mock_fn.return_value = {"clusters": []} + args = _args(workspace=None, eval_id="e1", confidence=20) + + from roboflow.cli.handlers.eval import _vector_analysis + + with patch("builtins.print"): + _vector_analysis(args) + mock_fn.assert_called_once_with("key", "lee-sandbox", "e1", confidence=20) + + @patch("roboflow.adapters.rfapi.get_model_eval_image_predictions") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_image_predictions_pagination(self, _key, _ws, mock_fn): + mock_fn.return_value = { + "split": "test", + "confidenceThreshold": 30, + "totalImages": 100, + "offset": 50, + "limit": 10, + "images": [{"imageId": "i1", "imageName": "a.jpg", "split": "test", "stats": {}}], + } + args = _args(workspace=None, eval_id="e1", split="test", confidence=30, limit=10, offset=50) + + from roboflow.cli.handlers.eval import _image_predictions + + with patch("builtins.print") as mock_print: + _image_predictions(args) + mock_fn.assert_called_once_with("key", "lee-sandbox", "e1", split="test", confidence=30, limit=10, offset=50) + printed = mock_print.call_args[0][0] + self.assertIn("a.jpg", printed) + + @patch("roboflow.adapters.rfapi.get_model_eval_image_predictions") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_image_predictions_table_renders_TP_FP_FN_from_camelCase_stats(self, _key, _ws, mock_fn): + # Regression: the public API nests counts under `stats` with camelCase + # keys (`truePositives`/`falsePositives`/`falseNegatives`). Earlier code + # read `stats.tp`/`stats.fp`/`stats.fn` and silently rendered blanks. + mock_fn.return_value = { + "split": "test", + "confidenceThreshold": 0.2, + "totalImages": 1, + "offset": 0, + "limit": 1, + "images": [ + { + "imageId": "i1", + "imageName": "abc.jpg", + "split": "test", + "augmentations": 2, + "stats": { + "truePositives": 7, + "falsePositives": 2, + "falseNegatives": 1, + "precision": 0.78, + "recall": 0.875, + "f1": 0.824, + }, + # The cluster column previously stringified the whole dict; + # we only want the cluster id rendered. + "cluster": {"id": 4, "embedding2D": [1.5, -3.2]}, + } + ], + } + args = _args(workspace=None, eval_id="e1", split="test", confidence=None, limit=None, offset=None) + + from roboflow.cli.handlers.eval import _image_predictions + + with patch("builtins.print") as mock_print: + _image_predictions(args) + printed = mock_print.call_args[0][0] + # TP/FP/FN counts must appear in the rendered table. + self.assertIn("7", printed) # truePositives + self.assertIn("2", printed) # falsePositives + augmentations both = 2; either way it should appear + self.assertIn("1", printed) # falseNegatives + # Cluster rendered as the bare id, not the embedding-bearing dict. + self.assertIn(" 4 ", printed) + self.assertNotIn("embedding2D", printed) + + @patch("roboflow.adapters.rfapi.get_model_eval_recommendations") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_recommendations_calls_adapter(self, _key, _ws, mock_fn): + mock_fn.return_value = {"generated": False} + args = _args(workspace=None, eval_id="e1") + + from roboflow.cli.handlers.eval import _recommendations + + with patch("builtins.print"): + _recommendations(args) + mock_fn.assert_called_once_with("key", "lee-sandbox", "e1") + + @patch("roboflow.adapters.rfapi.get_model_eval_map_results") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="lee-sandbox") + @patch("roboflow.config.load_roboflow_api_key", return_value="key") + def test_panel_409_not_done_exits_4(self, _key, _ws, mock_fn): + from roboflow.adapters import rfapi + + mock_fn.side_effect = rfapi.ModelEvalNotDoneError("running") + args = _args(workspace=None, eval_id="e1") + + from roboflow.cli.handlers.eval import _map_results + + with self.assertRaises(SystemExit) as ctx: + _map_results(args) + self.assertEqual(ctx.exception.code, 4) + + +# --------------------------------------------------------------------------- +# Exit-code mapping helper +# --------------------------------------------------------------------------- + + +class TestExitCodeMapping(unittest.TestCase): + """The handler distinguishes 404/409/400 to give shell scripts useful exit codes.""" + + def test_exit_codes(self) -> None: + from roboflow.adapters import rfapi + from roboflow.cli.handlers.eval import _eval_error_exit_code + + cases = { + rfapi.ModelEvalNotFoundError("x"): 3, + rfapi.ModelEvalNotDoneError("x"): 4, + rfapi.InvalidSplitError("x"): 5, + rfapi.InvalidConfidenceError("x"): 5, + rfapi.RoboflowError("x"): 1, + ValueError("x"): 1, + } + for exc, expected in cases.items(): + with self.subTest(exc=type(exc).__name__): + self.assertEqual(_eval_error_exit_code(exc), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_folder_handler.py b/tests/cli/test_folder_handler.py new file mode 100644 index 00000000..b968426d --- /dev/null +++ b/tests/cli/test_folder_handler.py @@ -0,0 +1,193 @@ +"""Tests for the folder CLI handler.""" + +import json +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestFolderRegistration(unittest.TestCase): + """Verify folder handler registers expected subcommands.""" + + def test_folder_app_exists(self) -> None: + from roboflow.cli.handlers.folder import folder_app + + self.assertIsNotNone(folder_app) + + def test_folder_list_exists(self) -> None: + result = runner.invoke(app, ["folder", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_folder_get_exists(self) -> None: + result = runner.invoke(app, ["folder", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_folder_create_exists(self) -> None: + result = runner.invoke(app, ["folder", "create", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_folder_update_exists(self) -> None: + result = runner.invoke(app, ["folder", "update", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_folder_delete_exists(self) -> None: + result = runner.invoke(app, ["folder", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestFolderListHandler(unittest.TestCase): + """Test folder list command behavior.""" + + @patch("roboflow.adapters.rfapi.list_folders") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_list_folders_text(self, _mock_key, _mock_ws, mock_list): + mock_list.return_value = {"data": [{"name": "Folder1", "id": "f1", "projects": ["p1", "p2"]}]} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False) + + from roboflow.cli.handlers.folder import _list_folders + + with patch("builtins.print") as mock_print: + _list_folders(args) + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + self.assertIn("Folder1", printed) + + @patch("roboflow.adapters.rfapi.list_folders") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_list_folders_json(self, _mock_key, _mock_ws, mock_list): + mock_list.return_value = {"data": [{"name": "Folder1", "id": "f1", "projects": []}]} + args = Namespace(json=True, workspace=None, api_key=None, quiet=False) + + from roboflow.cli.handlers.folder import _list_folders + + with patch("builtins.print") as mock_print: + _list_folders(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertIsInstance(data, list) + self.assertEqual(data[0]["name"], "Folder1") + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=None) + def test_list_folders_no_workspace(self, _mock_ws): + args = Namespace(json=True, workspace=None, api_key=None, quiet=False) + + from roboflow.cli.handlers.folder import _list_folders + + with self.assertRaises(SystemExit) as ctx: + _list_folders(args) + self.assertEqual(ctx.exception.code, 2) + + +class TestFolderGetHandler(unittest.TestCase): + """Test folder get command behavior.""" + + @patch("roboflow.adapters.rfapi.get_folder") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_get_folder_text(self, _mock_key, _mock_ws, mock_get): + mock_get.return_value = {"data": [{"name": "MyFolder", "id": "f1", "projects": []}]} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, folder_id="f1") + + from roboflow.cli.handlers.folder import _get_folder + + with patch("builtins.print") as mock_print: + _get_folder(args) + printed = mock_print.call_args[0][0] + self.assertIn("MyFolder", printed) + + +class TestFolderCreateHandler(unittest.TestCase): + """Test folder create command behavior.""" + + @patch("roboflow.adapters.rfapi.create_folder") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_folder_json(self, _mock_key, _mock_ws, mock_create): + mock_create.return_value = {"id": "new-folder-id"} + args = Namespace( + json=True, workspace=None, api_key=None, quiet=False, name="NewFolder", parent=None, projects=None + ) + + from roboflow.cli.handlers.folder import _create_folder + + with patch("builtins.print") as mock_print: + _create_folder(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertEqual(data["status"], "created") + self.assertEqual(data["id"], "new-folder-id") + + @patch("roboflow.adapters.rfapi.create_folder") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_create_folder_with_projects(self, _mock_key, _mock_ws, mock_create): + mock_create.return_value = {"id": "f2"} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False, name="F", parent="p1", projects="a,b,c") + + from roboflow.cli.handlers.folder import _create_folder + + with patch("builtins.print"): + _create_folder(args) + mock_create.assert_called_once_with("fake-key", "test-ws", "F", parent_id="p1", project_ids=["a", "b", "c"]) + + +class TestFolderUpdateHandler(unittest.TestCase): + """Test folder update command behavior.""" + + @patch("roboflow.adapters.rfapi.update_folder") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_update_folder_json(self, _mock_key, _mock_ws, mock_update): + mock_update.return_value = {} + args = Namespace(json=True, workspace=None, api_key=None, quiet=False, folder_id="f1", name="Renamed") + + from roboflow.cli.handlers.folder import _update_folder + + with patch("builtins.print") as mock_print: + _update_folder(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertEqual(data["status"], "updated") + + +class TestFolderDeleteHandler(unittest.TestCase): + """Test folder delete command behavior.""" + + @patch("roboflow.adapters.rfapi.delete_folder") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_delete_folder_json(self, _mock_key, _mock_ws, mock_delete): + mock_delete.return_value = {} + args = Namespace(json=True, workspace=None, api_key=None, quiet=False, folder_id="f1") + + from roboflow.cli.handlers.folder import _delete_folder + + with patch("builtins.print") as mock_print: + _delete_folder(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertEqual(data["status"], "deleted") + + @patch("roboflow.adapters.rfapi.delete_folder", side_effect=Exception("Not found")) + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_delete_folder_error_json(self, _mock_key, _mock_ws, _mock_delete): + args = Namespace(json=True, workspace=None, api_key=None, quiet=False, folder_id="bad-id") + + from roboflow.cli.handlers.folder import _delete_folder + + with self.assertRaises(SystemExit) as ctx: + _delete_folder(args) + self.assertEqual(ctx.exception.code, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py new file mode 100644 index 00000000..c48bbe31 --- /dev/null +++ b/tests/cli/test_image_handler.py @@ -0,0 +1,747 @@ +"""Unit tests for roboflow.cli.handlers.image.""" + +import io +import json +import os +import sys +import tempfile +import types +import unittest +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +def _make_args(**overrides): + defaults = { + "json": False, + "api_key": "test-key", + "workspace": "test-ws", + "quiet": False, + } + defaults.update(overrides) + return types.SimpleNamespace(**defaults) + + +class TestImageParserRegistration(unittest.TestCase): + """Verify the image handler registers its subcommands.""" + + def test_image_subcommand_exists(self): + result = runner.invoke(app, ["image", "upload", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_image_upload_help(self): + result = runner.invoke(app, ["image", "upload", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("project", result.output.lower()) + + def test_image_get_help(self): + result = runner.invoke(app, ["image", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_image_search_help(self): + result = runner.invoke(app, ["image", "search", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_image_tag_help(self): + result = runner.invoke(app, ["image", "tag", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_image_delete_help(self): + result = runner.invoke(app, ["image", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_image_annotate_help(self): + result = runner.invoke(app, ["image", "annotate", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestImageUploadSingle(unittest.TestCase): + """Test the single-file upload path.""" + + @patch("roboflow.Roboflow") + def test_upload_single_file(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: + f.write(b"fake-image") + tmp = f.name + try: + mock_project = MagicMock() + mock_rf_cls.return_value.workspace.return_value.project.return_value = mock_project + + args = _make_args( + path=tmp, + project="proj", + annotation=None, + split="train", + batch=None, + tag=None, + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + mock_project.single_upload.assert_called_once() + self.assertIn("Uploaded", buf.getvalue()) + finally: + os.unlink(tmp) + + @patch("roboflow.Roboflow") + def test_upload_single_json_mode(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: + f.write(b"fake-image") + tmp = f.name + try: + mock_project = MagicMock() + mock_rf_cls.return_value.workspace.return_value.project.return_value = mock_project + + args = _make_args( + json=True, + path=tmp, + project="proj", + annotation=None, + split="train", + batch=None, + tag=None, + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + result = json.loads(buf.getvalue()) + self.assertEqual(result["status"], "uploaded") + finally: + os.unlink(tmp) + + +class TestImageUploadDirectory(unittest.TestCase): + """Test the directory import path.""" + + @patch("roboflow.Roboflow") + def test_upload_directory(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.TemporaryDirectory() as tmpdir: + # Create some fake images + for name in ["a.jpg", "b.png", "c.txt"]: + with open(os.path.join(tmpdir, name), "w") as f: + f.write("x") + + mock_ws = MagicMock() + mock_rf_cls.return_value.workspace.return_value = mock_ws + + args = _make_args( + json=True, + path=tmpdir, + project="proj", + annotation=None, + split="train", + batch=None, + tag=None, + metadata=None, + concurrency=5, + retries=1, + labelmap=None, + is_prediction=False, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + mock_ws.upload_dataset.assert_called_once() + result = json.loads(buf.getvalue()) + self.assertEqual(result["status"], "imported") + self.assertEqual(result["count"], 2) # .jpg and .png only + + @patch("roboflow.Roboflow") + def test_upload_zip_file_routes_to_directory_handler(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f: + f.write(b"fake zip") + zip_path = f.name + + try: + mock_ws = MagicMock() + mock_ws.upload_dataset.return_value = {"status": "completed", "task_id": "t1"} + mock_project = MagicMock() + mock_rf_cls.return_value.workspace.return_value = mock_ws + mock_ws.project.return_value = mock_project + + args = _make_args( + json=True, + path=zip_path, + project="proj", + annotation=None, + split="train", + batch=None, + tag=None, + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + no_wait=False, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + mock_ws.upload_dataset.assert_called_once() + mock_project.single_upload.assert_not_called() + finally: + os.unlink(zip_path) + + @patch("roboflow.Roboflow") + def test_no_wait_forwarded(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.TemporaryDirectory() as tmpdir: + mock_ws = MagicMock() + mock_ws.upload_dataset.return_value = {"status": "pending", "task_id": "t9"} + mock_rf_cls.return_value.workspace.return_value = mock_ws + + args = _make_args( + json=True, + path=tmpdir, + project="proj", + annotation=None, + split="train", + batch=None, + tag=None, + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + zip_upload=True, + no_wait=True, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + _, kwargs = mock_ws.upload_dataset.call_args + self.assertEqual(kwargs.get("wait"), False) + self.assertEqual(kwargs.get("use_zip_upload"), True) + + @patch("roboflow.Roboflow") + def test_zip_flow_uses_server_result_in_output(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.TemporaryDirectory() as tmpdir: + mock_ws = MagicMock() + mock_ws.upload_dataset.return_value = {"status": "completed", "task_id": "t1"} + mock_rf_cls.return_value.workspace.return_value = mock_ws + + args = _make_args( + json=True, + path=tmpdir, + project="proj", + annotation=None, + split="train", + batch=None, + tag="foo,bar", + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + zip_upload=True, + no_wait=False, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + result = json.loads(buf.getvalue()) + self.assertEqual(result["task_id"], "t1") + self.assertEqual(result["status"], "completed") + + _, kwargs = mock_ws.upload_dataset.call_args + self.assertEqual(kwargs.get("tags"), ["foo", "bar"]) + self.assertEqual(kwargs.get("use_zip_upload"), True) + + @patch("roboflow.Roboflow") + def test_zip_upload_flag_defaults_false(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.TemporaryDirectory() as tmpdir: + mock_ws = MagicMock() + # MagicMock return β†’ not a dict β†’ per-image output branch + mock_ws.upload_dataset.return_value = None + mock_rf_cls.return_value.workspace.return_value = mock_ws + + args = _make_args( + json=True, + path=tmpdir, + project="proj", + annotation=None, + split="train", + batch=None, + tag=None, + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + _, kwargs = mock_ws.upload_dataset.call_args + self.assertEqual(kwargs.get("use_zip_upload"), False) + + @patch("roboflow.Roboflow") + def test_upload_directory_omits_default_split_when_not_explicit(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.TemporaryDirectory() as tmpdir: + mock_ws = MagicMock() + mock_rf_cls.return_value.workspace.return_value = mock_ws + + args = _make_args( + json=True, + path=tmpdir, + project="proj", + annotation=None, + split=None, + batch=None, + tag=None, + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + _, kwargs = mock_ws.upload_dataset.call_args + self.assertIsNone(kwargs.get("split")) + + @patch("roboflow.Roboflow") + def test_upload_directory_forwards_explicit_split(self, mock_rf_cls): + from roboflow.cli.handlers.image import _handle_upload + + with tempfile.TemporaryDirectory() as tmpdir: + mock_ws = MagicMock() + mock_rf_cls.return_value.workspace.return_value = mock_ws + + args = _make_args( + json=True, + path=tmpdir, + project="proj", + annotation=None, + split="valid", + batch=None, + tag=None, + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_upload(args) + finally: + sys.stdout = old + + _, kwargs = mock_ws.upload_dataset.call_args + self.assertEqual(kwargs.get("split"), "valid") + + +class TestImageDelete(unittest.TestCase): + """Test the delete handler.""" + + @patch("roboflow.adapters.rfapi.workspace_delete_images") + def test_delete_images(self, mock_delete_images): + from roboflow.cli.handlers.image import _handle_delete + + mock_delete_images.return_value = {"deleted": 2, "skipped": 0} + + args = _make_args(json=True, image_ids="id1,id2", project="proj") + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_delete(args) + finally: + sys.stdout = old + + mock_delete_images.assert_called_once_with( + api_key="test-key", + workspace_url="test-ws", + image_ids=["id1", "id2"], + ) + result = json.loads(buf.getvalue()) + self.assertEqual(result["deleted"], 2) + + +class TestImageSearch(unittest.TestCase): + """Test the search handler.""" + + @patch("roboflow.adapters.rfapi.workspace_search") + def test_search(self, mock_workspace_search): + from roboflow.cli.handlers.image import _handle_search + + mock_workspace_search.return_value = {"results": [], "total": 0} + + args = _make_args(json=True, query="tag:test", project="proj", limit=10, cursor=None) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_search(args) + finally: + sys.stdout = old + + mock_workspace_search.assert_called_once() + # -p must scope via a `project:` filter prepended to the query. + called_query = mock_workspace_search.call_args.kwargs["query"] + self.assertEqual(called_query, "project:proj tag:test") + result = json.loads(buf.getvalue()) + self.assertEqual(result["total"], 0) + + @patch("roboflow.adapters.rfapi.workspace_search") + def test_search_without_project_is_unscoped(self, mock_workspace_search): + from roboflow.cli.handlers.image import _handle_search + + mock_workspace_search.return_value = {"results": [], "total": 0} + args = _make_args(json=True, query="tag:test", project=None, limit=10, cursor=None) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_search(args) + finally: + sys.stdout = old + + called_query = mock_workspace_search.call_args.kwargs["query"] + self.assertEqual(called_query, "tag:test") + + @patch("roboflow.cli.handlers.search._search") + @patch("roboflow.cli.handlers.image._handle_search") + def test_search_with_project_and_export_scopes_the_export(self, mock_handle_search, mock_search): + # `-p ... --export` must export the project, not silently drop --export. + result = runner.invoke( + app, + ["--workspace", "ws", "--api-key", "k", "image", "search", "tag:test", "-p", "proj", "--export"], + ) + + self.assertEqual(result.exit_code, 0) + mock_handle_search.assert_not_called() + mock_search.assert_called_once() + export_args = mock_search.call_args.args[0] + self.assertTrue(export_args.export) + # Export scopes by the `dataset` (project slug) body param. + self.assertEqual(export_args.dataset, "proj") + + @patch("roboflow.Roboflow") + def test_search_export_forwards_cli_api_key_to_sdk(self, mock_roboflow): + # The export path must honor an explicitly supplied --api-key, not only + # saved/env credentials (CI/agent workflows pass the key directly). + mock_roboflow.return_value = MagicMock() + result = runner.invoke( + app, + ["--workspace", "ws", "--api-key", "MY_KEY", "image", "search", "tag:test", "-p", "proj", "--export"], + ) + + self.assertEqual(result.exit_code, 0) + mock_roboflow.assert_called_once() + self.assertEqual(mock_roboflow.call_args.kwargs.get("api_key"), "MY_KEY") + + @patch("roboflow.cli.handlers.search._search") + @patch("roboflow.cli.handlers.image._handle_search") + def test_search_with_project_no_export_uses_roboql_filter(self, mock_handle_search, mock_search): + result = runner.invoke( + app, + ["--workspace", "ws", "--api-key", "k", "image", "search", "tag:test", "-p", "proj"], + ) + + self.assertEqual(result.exit_code, 0) + mock_search.assert_not_called() + mock_handle_search.assert_called_once() + + +class TestImageAnnotate(unittest.TestCase): + """Test the annotate handler.""" + + @patch("roboflow.adapters.rfapi.save_annotation") + def test_annotate(self, mock_save_annotation): + from roboflow.cli.handlers.image import _handle_annotate + + mock_save_annotation.return_value = {"success": True} + + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w") as f: + f.write("annotation data") + ann_path = f.name + + try: + args = _make_args( + json=True, + image_id="img-1", + project="proj", + annotation_file=ann_path, + annotation_format=None, + labelmap=None, + ) + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_annotate(args) + finally: + sys.stdout = old + + mock_save_annotation.assert_called_once() + result = json.loads(buf.getvalue()) + self.assertEqual(result["status"], "saved") + finally: + os.unlink(ann_path) + + +class TestUploadPathNotFound(unittest.TestCase): + """Test error when path doesn't exist.""" + + def test_nonexistent_path(self): + from roboflow.cli.handlers.image import _handle_upload + + args = _make_args( + path="/nonexistent/path.jpg", + project="proj", + annotation=None, + split="train", + batch=None, + tag=None, + metadata=None, + concurrency=10, + retries=0, + labelmap=None, + is_prediction=False, + ) + + with self.assertRaises(SystemExit): + _handle_upload(args) + + +class TestImageMetadataRegistration(unittest.TestCase): + """Verify the metadata command and tag alias register correctly.""" + + def test_image_metadata_help(self): + result = runner.invoke(app, ["image", "metadata", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("tags", result.output.lower()) + self.assertIn("metadata", result.output.lower()) + + def test_tag_is_alias(self): + result = runner.invoke(app, ["image", "tag", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("tags", result.output.lower()) + self.assertNotIn("project", result.output.lower()) + + +class TestImageMetadataSingle(unittest.TestCase): + """Test the single-image metadata path.""" + + @patch("roboflow.cli._resolver.resolve_ws_and_key", return_value=("test-ws", "test-key")) + @patch("roboflow.adapters.rfapi.update_image_metadata", return_value={"success": True}) + def test_metadata_single(self, mock_update, mock_resolve): + from roboflow.cli.handlers.image import _handle_metadata + + args = _make_args( + image_ids="img-1", + metadata='{"camera": "cam1"}', + remove_metadata=None, + add_tags="review", + remove_tags=None, + poll=False, + timeout=1800, + ) + _handle_metadata(args) + mock_update.assert_called_once_with( + api_key="test-key", + workspace_url="test-ws", + image_id="img-1", + metadata={"camera": "cam1"}, + remove_metadata=None, + add_tags=["review"], + remove_tags=None, + ) + + def test_metadata_invalid_json(self): + from roboflow.cli.handlers.image import _handle_metadata + + args = _make_args( + image_ids="img-1", + metadata="not-json", + remove_metadata=None, + add_tags=None, + remove_tags=None, + poll=False, + timeout=1800, + ) + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit): + _handle_metadata(args) + finally: + sys.stderr = old + self.assertIn("Invalid metadata JSON", buf.getvalue()) + + def test_metadata_nothing_to_do(self): + from roboflow.cli.handlers.image import _handle_metadata + + args = _make_args( + image_ids="img-1", + metadata=None, + remove_metadata=None, + add_tags=None, + remove_tags=None, + poll=False, + timeout=1800, + ) + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit): + _handle_metadata(args) + finally: + sys.stderr = old + self.assertIn("Nothing to update", buf.getvalue()) + + +class TestImageMetadataBatch(unittest.TestCase): + """Test the batch (multi-image) metadata path.""" + + @patch("roboflow.cli._resolver.resolve_ws_and_key", return_value=("test-ws", "test-key")) + @patch( + "roboflow.adapters.rfapi.batch_update_image_metadata", + return_value={"taskId": "t1", "url": "https://api.roboflow.com/test-ws/asynctasks/t1"}, + ) + def test_metadata_batch_no_poll(self, mock_batch, mock_resolve): + from roboflow.cli.handlers.image import _handle_metadata + + args = _make_args( + image_ids="img-1,img-2,img-3", + metadata=None, + remove_metadata=None, + add_tags="review", + remove_tags=None, + poll=False, + timeout=1800, + json=True, + ) + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _handle_metadata(args) + finally: + sys.stdout = old + data = json.loads(buf.getvalue()) + self.assertEqual(data["taskId"], "t1") + self.assertEqual(data["imageCount"], 3) + mock_batch.assert_called_once() + updates = mock_batch.call_args[1]["updates"] + self.assertEqual(len(updates), 3) + self.assertEqual(updates[0]["imageId"], "img-1") + self.assertEqual(updates[0]["addTags"], ["review"]) + + def test_metadata_batch_over_limit(self): + from roboflow.cli.handlers.image import _handle_metadata + + ids = ",".join([f"img-{i}" for i in range(1001)]) + args = _make_args( + image_ids=ids, + metadata=None, + remove_metadata=None, + add_tags="review", + remove_tags=None, + poll=False, + timeout=1800, + ) + with patch("roboflow.cli._resolver.resolve_ws_and_key", return_value=("test-ws", "test-key")): + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit): + _handle_metadata(args) + finally: + sys.stderr = old + self.assertIn("Too many images", buf.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_infer_handler.py b/tests/cli/test_infer_handler.py new file mode 100644 index 00000000..52a53f43 --- /dev/null +++ b/tests/cli/test_infer_handler.py @@ -0,0 +1,211 @@ +"""Unit tests for roboflow.cli.handlers.infer.""" + +import io +import json +import sys +import types +import unittest +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestInferRegister(unittest.TestCase): + """Verify infer handler registers as a top-level command.""" + + def test_register_adds_infer_parser(self) -> None: + result = runner.invoke(app, ["infer", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("model", result.output.lower()) + + def test_infer_help_shows_options(self) -> None: + result = runner.invoke(app, ["infer", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("confidence", result.output.lower()) + self.assertIn("overlap", result.output.lower()) + + +class TestInferHandler(unittest.TestCase): + """Test _infer handler function.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": False, + "api_key": "test-key", + "workspace": "test-ws", + "model": "test-project/1", + "file": "test.jpg", + "confidence": 0.5, + "overlap": 0.5, + "type": "object-detection", + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + @patch("roboflow.models.object_detection.ObjectDetectionModel") + def test_infer_text_output(self, mock_model_cls: MagicMock) -> None: + from roboflow.cli.handlers.infer import _infer + + mock_group = MagicMock() + mock_group.__str__ = lambda self: "detection results" + mock_group.__iter__ = lambda self: iter([]) + mock_model = MagicMock() + mock_model.predict.return_value = mock_group + mock_model_cls.return_value = mock_model + + args = self._make_args() + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _infer(args) + finally: + sys.stdout = old_stdout + + self.assertIn("detection results", buf.getvalue()) + + @patch("roboflow.models.object_detection.ObjectDetectionModel") + def test_infer_json_output(self, mock_model_cls: MagicMock) -> None: + from roboflow.cli.handlers.infer import _infer + + mock_pred = MagicMock() + mock_pred.json.return_value = {"class": "dog", "confidence": 0.9} + mock_group = MagicMock() + mock_group.__iter__ = lambda self: iter([mock_pred]) + mock_model = MagicMock() + mock_model.predict.return_value = mock_group + mock_model_cls.return_value = mock_model + + args = self._make_args(json=True) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _infer(args) + finally: + sys.stdout = old_stdout + + result = json.loads(buf.getvalue()) + self.assertIsInstance(result, list) + self.assertEqual(result[0]["class"], "dog") + + @patch("roboflow.models.object_detection.ObjectDetectionModel") + @patch("roboflow.adapters.rfapi.get_project") + def test_infer_auto_detects_type(self, mock_get_project: MagicMock, mock_model_cls: MagicMock) -> None: + from roboflow.cli.handlers.infer import _infer + + mock_get_project.return_value = {"project": {"type": "object-detection"}} + mock_group = MagicMock() + mock_group.__str__ = lambda self: "results" + mock_group.__iter__ = lambda self: iter([]) + mock_model = MagicMock() + mock_model.predict.return_value = mock_group + mock_model_cls.return_value = mock_model + + args = self._make_args(type=None) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _infer(args) + finally: + sys.stdout = old_stdout + + mock_get_project.assert_called_once() + + @patch("roboflow.config.load_roboflow_api_key", return_value=None) + def test_infer_no_api_key(self, _mock_key: MagicMock) -> None: + from roboflow.cli.handlers.infer import _infer + + args = self._make_args(api_key=None) + with self.assertRaises(SystemExit) as ctx: + _infer(args) + self.assertEqual(ctx.exception.code, 2) + + @patch("roboflow.models.object_detection.ObjectDetectionModel") + def test_infer_confidence_converted_to_percentage(self, mock_model_cls: MagicMock) -> None: + from roboflow.cli.handlers.infer import _infer + + mock_group = MagicMock() + mock_group.__str__ = lambda self: "results" + mock_group.__iter__ = lambda self: iter([]) + mock_model = MagicMock() + mock_model.predict.return_value = mock_group + mock_model_cls.return_value = mock_model + + args = self._make_args(confidence=0.7, overlap=0.3) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _infer(args) + finally: + sys.stdout = old_stdout + + mock_model.predict.assert_called_once_with("test.jpg", confidence=70, overlap=30) + + +class TestInferVLM(unittest.TestCase): + """VLM (text-image-pairs) path returns raw dict passthrough.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": False, + "api_key": "test-key", + "workspace": "test-ws", + "model": "test-project/1", + "file": "https://example.com/img.jpg", + "confidence": 0.5, + "overlap": 0.5, + "type": "text-image-pairs", + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + @patch("roboflow.models.vlm.VLMModel") + def test_infer_vlm_json_passthrough(self, mock_model_cls: MagicMock) -> None: + from roboflow.cli.handlers.infer import _infer + + raw = {"inference_id": "abc", "response": {">": "caption text"}} + mock_model = MagicMock() + mock_model.predict.return_value = raw + mock_model_cls.return_value = mock_model + + args = self._make_args(json=True) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _infer(args) + finally: + sys.stdout = old_stdout + + result = json.loads(buf.getvalue()) + self.assertEqual(result, raw) + + @patch("roboflow.models.vlm.VLMModel") + def test_infer_vlm_skips_confidence_overlap(self, mock_model_cls: MagicMock) -> None: + from roboflow.cli.handlers.infer import _infer + + mock_model = MagicMock() + mock_model.predict.return_value = {"ok": True} + mock_model_cls.return_value = mock_model + + args = self._make_args(confidence=0.7, overlap=0.3) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _infer(args) + finally: + sys.stdout = old_stdout + + mock_model.predict.assert_called_once_with("https://example.com/img.jpg") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_model_handler.py b/tests/cli/test_model_handler.py new file mode 100644 index 00000000..f4ca2f2d --- /dev/null +++ b/tests/cli/test_model_handler.py @@ -0,0 +1,400 @@ +"""Unit tests for roboflow.cli.handlers.model.""" + +import io +import json +import sys +import types +import unittest +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestModelRegister(unittest.TestCase): + """Verify model handler registers expected subcommands.""" + + def test_model_help(self) -> None: + result = runner.invoke(app, ["model", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + self.assertIn("get", result.output) + self.assertIn("upload", result.output) + + def test_model_list_help(self) -> None: + result = runner.invoke(app, ["model", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_model_get_help(self) -> None: + result = runner.invoke(app, ["model", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_model_upload_help(self) -> None: + result = runner.invoke(app, ["model", "upload", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestModelGet(unittest.TestCase): + """Test _get_model handler.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": False, + "api_key": "test-key", + "workspace": "test-ws", + "model_url": "test-ws/test-project", + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + @patch("roboflow.adapters.rfapi.get_project") + def test_get_model_success(self, mock_get_project: MagicMock) -> None: + from roboflow.cli.handlers.model import _get_model + + mock_get_project.return_value = {"project": {"name": "test"}} + + args = self._make_args(json=True) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _get_model(args) + finally: + sys.stdout = old_stdout + + result = json.loads(buf.getvalue()) + self.assertEqual(result["project"]["name"], "test") + + @patch("roboflow.adapters.rfapi.get_version") + def test_get_model_with_version(self, mock_get_version: MagicMock) -> None: + from roboflow.cli.handlers.model import _get_model + + mock_get_version.return_value = {"version": {"id": "test/1"}} + + args = self._make_args(model_url="test-ws/test-project/1", json=True) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _get_model(args) + finally: + sys.stdout = old_stdout + + result = json.loads(buf.getvalue()) + self.assertIn("version", result) + mock_get_version.assert_called_once() + + @patch("roboflow.config.load_roboflow_api_key", return_value=None) + def test_get_model_no_api_key(self, _mock_key: MagicMock) -> None: + from roboflow.cli.handlers.model import _get_model + + args = self._make_args(api_key=None) + with self.assertRaises(SystemExit) as ctx: + _get_model(args) + self.assertEqual(ctx.exception.code, 2) + + +class TestModelUpload(unittest.TestCase): + """Test _upload_model handler.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": False, + "api_key": "test-key", + "workspace": "test-ws", + "project": ["proj1"], + "version_number": 1, + "model_type": "yolov8", + "model_path": "/path/to/model", + "filename": "weights/best.pt", + "model_name": None, + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + @patch("roboflow.Roboflow") + def test_upload_single_version(self, mock_rf_cls: MagicMock) -> None: + from roboflow.cli.handlers.model import _upload_model + + mock_version = MagicMock() + mock_project = MagicMock() + mock_project.version.return_value = mock_version + mock_workspace = MagicMock() + mock_workspace.project.return_value = mock_project + mock_rf = MagicMock() + mock_rf.workspace.return_value = mock_workspace + mock_rf_cls.return_value = mock_rf + + args = self._make_args(json=True) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _upload_model(args) + finally: + sys.stdout = old_stdout + + result = json.loads(buf.getvalue()) + self.assertEqual(result["status"], "uploaded") + mock_version.deploy.assert_called_once_with("yolov8", "/path/to/model", "weights/best.pt") + + @patch("roboflow.Roboflow") + def test_upload_multi_project(self, mock_rf_cls: MagicMock) -> None: + from roboflow.cli.handlers.model import _upload_model + + mock_workspace = MagicMock() + mock_rf = MagicMock() + mock_rf.workspace.return_value = mock_workspace + mock_rf_cls.return_value = mock_rf + + args = self._make_args(project=["proj1", "proj2"], version_number=None, model_name="my-model", json=True) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _upload_model(args) + finally: + sys.stdout = old_stdout + + result = json.loads(buf.getvalue()) + self.assertEqual(result["status"], "uploaded") + mock_workspace.deploy_model.assert_called_once() + + @patch("roboflow.Roboflow") + def test_upload_no_project_errors(self, mock_rf_cls: MagicMock) -> None: + from roboflow.cli.handlers.model import _upload_model + + mock_workspace = MagicMock() + mock_rf = MagicMock() + mock_rf.workspace.return_value = mock_workspace + mock_rf_cls.return_value = mock_rf + + args = self._make_args(project=None, version_number=None) + with self.assertRaises(SystemExit): + _upload_model(args) + + +class TestParseErrorMessage(unittest.TestCase): + """Test _parse_error_message helper (centralized in _output.py).""" + + def test_plain_string(self) -> None: + from roboflow.cli._output import _parse_error_message + + parsed, human = _parse_error_message("something broke") + self.assertIsNone(parsed) + self.assertEqual(human, "something broke") + + def test_json_with_nested_error(self) -> None: + from roboflow.cli._output import _parse_error_message + + raw = '{"error": {"message": "Unsupported request"}}' + parsed, human = _parse_error_message(raw) + self.assertIsNotNone(parsed) + self.assertEqual(human, "Unsupported request") + + def test_json_with_string_error(self) -> None: + from roboflow.cli._output import _parse_error_message + + raw = '{"error": "Not found"}' + parsed, human = _parse_error_message(raw) + self.assertIsNotNone(parsed) + self.assertEqual(human, "Not found") + + +class TestModelListError(unittest.TestCase): + """Test _list_models handles API errors cleanly.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": True, + "api_key": "test-key", + "workspace": "test-ws", + "project": "nonexistent-project", + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + @patch("roboflow.Roboflow") + def test_list_models_project_not_found(self, mock_rf_cls: MagicMock) -> None: + from roboflow.cli.handlers.model import _list_models + + mock_workspace = MagicMock() + mock_workspace.project.side_effect = RuntimeError("Project not found") + mock_rf = MagicMock() + mock_rf.workspace.return_value = mock_workspace + mock_rf_cls.return_value = mock_rf + + args = self._make_args() + buf = io.StringIO() + old_stderr = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _list_models(args) + self.assertEqual(ctx.exception.code, 3) + finally: + sys.stderr = old_stderr + + result = json.loads(buf.getvalue()) + self.assertIn("error", result) + + +class TestModelStarRegister(unittest.TestCase): + """model star subcommand registers.""" + + def test_star_help(self) -> None: + result = runner.invoke(app, ["model", "star", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("nas", result.output.lower()) + + def test_list_help_mentions_group(self) -> None: + result = runner.invoke(app, ["model", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("group", result.output.lower()) + + +class TestModelStar(unittest.TestCase): + """_star_model business logic.""" + + def _args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": True, + "api_key": "test-key", + "workspace": "test-ws", + "model_id": "my-proj-3-nas-gpu-b", + "starred": True, + "quiet": True, + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + def _capture_stdout(self, fn, args): + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + fn(args) + finally: + sys.stdout = old + return buf.getvalue() + + @patch("roboflow.adapters.rfapi.favorite_nas_model") + def test_star_success(self, mock_fav: MagicMock) -> None: + from roboflow.cli.handlers.model import _star_model + + mock_fav.return_value = {"success": True, "model": {"url": "my-proj-3-nas-gpu-b"}} + out = self._capture_stdout(_star_model, self._args()) + + # Bare slug + -w, so workspace comes from args.workspace. + mock_fav.assert_called_once_with("test-key", "test-ws", "my-proj-3-nas-gpu-b", starred=True) + result = json.loads(out) + self.assertTrue(result.get("success")) + + @patch("roboflow.adapters.rfapi.favorite_nas_model") + def test_star_workspace_prefixed_id(self, mock_fav: MagicMock) -> None: + """When the id is `/`, the workspace flag overrides anyway.""" + from roboflow.cli.handlers.model import _star_model + + mock_fav.return_value = {"success": True, "model": {"url": "my-proj-3-nas-gpu-b"}} + self._capture_stdout(_star_model, self._args(model_id="some-ws/my-proj-3-nas-gpu-b")) + + # -w wins over the prefix, id is stripped of the workspace segment. + mock_fav.assert_called_once_with("test-key", "test-ws", "my-proj-3-nas-gpu-b", starred=True) + + @patch("roboflow.adapters.rfapi.favorite_nas_model") + def test_star_workspace_inferred_from_prefix(self, mock_fav: MagicMock) -> None: + """No -w but `/` argument: workspace comes from the prefix.""" + from roboflow.cli.handlers.model import _star_model + + mock_fav.return_value = {"success": True, "model": {"url": "my-proj-3-nas-gpu-b"}} + self._capture_stdout( + _star_model, + self._args(workspace=None, model_id="some-ws/my-proj-3-nas-gpu-b"), + ) + + mock_fav.assert_called_once_with("test-key", "some-ws", "my-proj-3-nas-gpu-b", starred=True) + + @patch("roboflow.adapters.rfapi.favorite_nas_model") + def test_star_unstar_path(self, mock_fav: MagicMock) -> None: + from roboflow.cli.handlers.model import _star_model + + mock_fav.return_value = {"success": True, "model": {"url": "my-proj-3-nas-gpu-b"}} + self._capture_stdout(_star_model, self._args(starred=False)) + + mock_fav.assert_called_once_with("test-key", "test-ws", "my-proj-3-nas-gpu-b", starred=False) + + @patch("roboflow.adapters.rfapi.favorite_nas_model") + def test_star_non_nas_surfaces_hint(self, mock_fav: MagicMock) -> None: + from roboflow.adapters import rfapi + from roboflow.cli.handlers.model import _star_model + + mock_fav.side_effect = rfapi.RoboflowError( + '{"code":"MODEL_NOT_NAS","message":"Starring is only supported for NAS-trained models."}' + ) + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as cm: + _star_model(self._args()) + finally: + sys.stderr = old + self.assertEqual(cm.exception.code, 3) + err = json.loads(buf.getvalue()) + # output_error parses the JSON body; the code surfaces alongside the message. + self.assertEqual(err["error"].get("code"), "MODEL_NOT_NAS") + self.assertIn("NAS-only", err["error"].get("hint", "")) + + +class TestModelListGroupFilter(unittest.TestCase): + """_list_models with --group hits the public /models endpoint.""" + + def _args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": True, + "api_key": "test-key", + "workspace": "test-ws", + "project": "my-project", + "group": None, + "quiet": True, + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + @patch("roboflow.adapters.rfapi.list_project_models") + def test_list_with_group_uses_public_endpoint(self, mock_list: MagicMock) -> None: + from roboflow.cli.handlers.model import _list_models + + mock_list.return_value = [ + { + "url": "my-ws/my-proj-3-nas-gpu-abc", + "modelType": "rfdetr-nas", + "metrics": { + "map50": 87.3, + "map5095": 57.6, + "hardware": "gpu", + "latency": 8.7, + }, + "recommended": True, + } + ] + + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + _list_models(self._args(group="rfdetrNasGroup-3")) + finally: + sys.stdout = old + + mock_list.assert_called_once_with("test-key", "test-ws", "my-project", group="rfdetrNasGroup-3") + rows = json.loads(buf.getvalue()) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["metrics"]["hardware"], "gpu") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py new file mode 100644 index 00000000..ca83157b --- /dev/null +++ b/tests/cli/test_output.py @@ -0,0 +1,166 @@ +"""Unit tests for roboflow.cli._output.""" + +import io +import json +import sys +import types +import unittest + + +class TestOutput(unittest.TestCase): + """Tests for the output() helper.""" + + def _make_args(self, *, json_mode: bool = False) -> types.SimpleNamespace: + return types.SimpleNamespace(json=json_mode) + + def test_json_mode_prints_json(self) -> None: + from roboflow.cli._output import output + + args = self._make_args(json_mode=True) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + output(args, data={"key": "value"}, text="human text") + finally: + sys.stdout = old_stdout + result = json.loads(buf.getvalue()) + self.assertEqual(result, {"key": "value"}) + + def test_text_mode_prints_text(self) -> None: + from roboflow.cli._output import output + + args = self._make_args(json_mode=False) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + output(args, data={"key": "value"}, text="human text") + finally: + sys.stdout = old_stdout + self.assertEqual(buf.getvalue().strip(), "human text") + + def test_text_mode_falls_back_to_json_when_no_text(self) -> None: + from roboflow.cli._output import output + + args = self._make_args(json_mode=False) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + output(args, data={"fallback": True}) + finally: + sys.stdout = old_stdout + result = json.loads(buf.getvalue()) + self.assertTrue(result["fallback"]) + + def test_output_error_json_mode(self) -> None: + from roboflow.cli._output import output_error + + args = self._make_args(json_mode=True) + buf = io.StringIO() + old_stderr = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + output_error(args, "something broke", hint="try again", exit_code=1) + finally: + sys.stderr = old_stderr + self.assertEqual(ctx.exception.code, 1) + result = json.loads(buf.getvalue()) + self.assertEqual(result["error"]["message"], "something broke") + self.assertEqual(result["error"]["hint"], "try again") + + def test_output_error_text_mode(self) -> None: + from roboflow.cli._output import output_error + + args = self._make_args(json_mode=False) + buf = io.StringIO() + old_stderr = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + output_error(args, "not found", exit_code=3) + finally: + sys.stderr = old_stderr + self.assertEqual(ctx.exception.code, 3) + self.assertIn("not found", buf.getvalue()) + + +class TestTable(unittest.TestCase): + """Tests for the format_table() helper.""" + + def test_empty_rows(self) -> None: + from roboflow.cli._table import format_table + + result = format_table([], ["a", "b"]) + self.assertEqual(result, "(no results)") + + def test_basic_table(self) -> None: + from roboflow.cli._table import format_table + + rows = [ + {"name": "proj-a", "type": "object-detection"}, + {"name": "proj-b", "type": "classification"}, + ] + result = format_table(rows, ["name", "type"]) + lines = result.split("\n") + self.assertEqual(len(lines), 4) # header + separator + 2 rows + self.assertIn("NAME", lines[0]) + self.assertIn("TYPE", lines[0]) + self.assertIn("proj-a", lines[2]) + + +class TestResolver(unittest.TestCase): + """Tests for the resource shorthand resolver.""" + + def test_single_segment(self) -> None: + from roboflow.cli._resolver import resolve_resource + + ws, proj, ver = resolve_resource("my-project", workspace_override="default-ws") + self.assertEqual(ws, "default-ws") + self.assertEqual(proj, "my-project") + self.assertIsNone(ver) + + def test_workspace_project(self) -> None: + from roboflow.cli._resolver import resolve_resource + + ws, proj, ver = resolve_resource("my-ws/my-project") + self.assertEqual(ws, "my-ws") + self.assertEqual(proj, "my-project") + self.assertIsNone(ver) + + def test_project_version(self) -> None: + from roboflow.cli._resolver import resolve_resource + + ws, proj, ver = resolve_resource("my-project/3", workspace_override="default-ws") + self.assertEqual(ws, "default-ws") + self.assertEqual(proj, "my-project") + self.assertEqual(ver, 3) + + def test_full_triple(self) -> None: + from roboflow.cli._resolver import resolve_resource + + ws, proj, ver = resolve_resource("my-ws/my-project/42") + self.assertEqual(ws, "my-ws") + self.assertEqual(proj, "my-project") + self.assertEqual(ver, 42) + + def test_no_workspace_raises(self) -> None: + from unittest.mock import patch + + from roboflow.cli._resolver import resolve_resource + + with patch("roboflow.cli._resolver.get_conditional_configuration_variable", return_value=None): + with self.assertRaises(ValueError): + resolve_resource("my-project") # no override, no default + + def test_too_many_segments_raises(self) -> None: + from roboflow.cli._resolver import resolve_resource + + with self.assertRaises(ValueError): + resolve_resource("a/b/c/d") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_project_fork_handler.py b/tests/cli/test_project_fork_handler.py new file mode 100644 index 00000000..d96f7806 --- /dev/null +++ b/tests/cli/test_project_fork_handler.py @@ -0,0 +1,195 @@ +"""Tests for the `roboflow project fork` CLI handler.""" + +import json +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +def _make_args(**kwargs): + """Create a Namespace with CLI defaults and fork-command defaults.""" + defaults = { + "json": False, + "workspace": "test-ws", + "api_key": "test-key", + "quiet": False, + "no_wait": False, + "timeout": 1800, + } + defaults.update(kwargs) + return Namespace(**defaults) + + +class TestProjectForkRegistration(unittest.TestCase): + def test_fork_help_exists(self) -> None: + result = runner.invoke(app, ["project", "fork", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Universe", result.output) + self.assertIn("no", result.output.lower()) + self.assertIn("wait", result.output.lower()) + self.assertIn("timeout", result.output.lower()) + + +class TestForkProjectNoWait(unittest.TestCase): + @patch("roboflow.adapters.rfapi.fork_project") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_url_form_no_wait_text(self, _mock_key, mock_fork): + from roboflow.cli.handlers.project import _fork_project + + mock_fork.return_value = { + "taskId": "task-123", + "url": "https://api.roboflow.com/test-ws/asynctasks/task-123", + } + args = _make_args( + source="https://universe.roboflow.com/ws/proj", + no_wait=True, + ) + with patch("builtins.print") as mock_print: + _fork_project(args) + + mock_fork.assert_called_once_with( + "test-key", + "test-ws", + url="https://universe.roboflow.com/ws/proj", + ) + printed = mock_print.call_args[0][0] + self.assertIn("task-123", printed) + # #10 β€” server-supplied polling URL surfaces so the user can poll later. + self.assertIn("https://api.roboflow.com/test-ws/asynctasks/task-123", printed) + + @patch("roboflow.adapters.rfapi.fork_project") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_shorthand_no_wait_json(self, _mock_key, mock_fork): + from roboflow.cli.handlers.project import _fork_project + + mock_fork.return_value = {"taskId": "task-456", "url": "poll-url"} + args = _make_args(json=True, source="ws/proj", no_wait=True) + with patch("builtins.print") as mock_print: + _fork_project(args) + + mock_fork.assert_called_once_with( + "test-key", + "test-ws", + url="ws/proj", + ) + out = json.loads(mock_print.call_args[0][0]) + # Server response is passed through verbatim. + self.assertEqual(out, {"taskId": "task-456", "url": "poll-url"}) + + +class TestForkProjectWait(unittest.TestCase): + @patch("roboflow.core.async_tasks.time.sleep", lambda *_a, **_k: None) + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.adapters.rfapi.fork_project") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_wait_until_completed_text(self, _mock_key, mock_fork, mock_get): + from roboflow.cli.handlers.project import _fork_project + + # No `url` in the fork response β†’ poll_until_terminal falls back to + # rfapi.get_async_task (which `mock_get` patches). + mock_fork.return_value = {"taskId": "task-1"} + mock_get.side_effect = [ + {"taskId": "task-1", "status": "running", "progress": {"current": 1, "total": 2}}, + { + "taskId": "task-1", + "status": "completed", + "result": { + "forked": True, + "datasetUrl": "license-plates", + "id": "test-ws/license-plates", + "name": "License Plates", + "url": "https://app.roboflow.com/test-ws/license-plates", + }, + }, + ] + args = _make_args(source="ws/proj") + with patch("builtins.print") as mock_print: + _fork_project(args) + + printed = mock_print.call_args[0][0] + self.assertIn("Forked", printed) + self.assertIn("Destination URL", printed) + self.assertIn("https://app.roboflow.com/test-ws/license-plates", printed) + mock_print.assert_any_call("Task progress: 1/2", flush=True) + self.assertEqual(mock_get.call_count, 2) + + @patch("roboflow.core.async_tasks.time.sleep", lambda *_a, **_k: None) + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.adapters.rfapi.fork_project") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_wait_until_completed_json(self, _mock_key, mock_fork, mock_get): + from roboflow.cli.handlers.project import _fork_project + + mock_fork.return_value = {"taskId": "task-1"} + terminal_payload = { + "taskId": "task-1", + "status": "completed", + "result": {"forked": True, "url": "https://app.roboflow.com/x/y"}, + } + mock_get.return_value = terminal_payload + args = _make_args(json=True, source="ws/proj") + with patch("builtins.print") as mock_print: + _fork_project(args) + + # Server payload is passed through unchanged in --json mode. + out = json.loads(mock_print.call_args[0][0]) + self.assertEqual(out, terminal_payload) + + @patch("roboflow.core.async_tasks.time.sleep", lambda *_a, **_k: None) + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.adapters.rfapi.fork_project") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_wait_until_failed_exits_one(self, _mock_key, mock_fork, mock_get): + from roboflow.cli.handlers.project import _fork_project + + mock_fork.return_value = {"taskId": "task-1"} + mock_get.return_value = { + "taskId": "task-1", + "status": "failed", + "error": "Source dataset is not public", + } + args = _make_args(source="ws/proj") + with self.assertRaises(SystemExit) as ctx: + _fork_project(args) + self.assertEqual(ctx.exception.code, 1) + + +class TestForkProjectErrors(unittest.TestCase): + def test_empty_source_exits(self): + from roboflow.cli.handlers.project import _fork_project + + args = _make_args(source="") + with self.assertRaises(SystemExit) as ctx: + _fork_project(args) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.adapters.rfapi.fork_project") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_server_error_passes_through(self, _mock_key, mock_fork): + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.project import _fork_project + + mock_fork.side_effect = RoboflowError('{"error":"You already own that dataset."}') + args = _make_args(source="ws/proj") + with self.assertRaises(SystemExit) as ctx: + _fork_project(args) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=None) + def test_no_workspace_exits_two(self, _mock_resolve): + from roboflow.cli.handlers.project import _fork_project + + args = _make_args(workspace=None, api_key=None, source="ws/proj") + with self.assertRaises(SystemExit) as ctx: + _fork_project(args) + self.assertEqual(ctx.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_project_handler.py b/tests/cli/test_project_handler.py new file mode 100644 index 00000000..129a90a0 --- /dev/null +++ b/tests/cli/test_project_handler.py @@ -0,0 +1,179 @@ +"""Tests for the project CLI handler.""" + +import unittest + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestProjectHandlerRegistration(unittest.TestCase): + """Verify that the project handler registers correctly.""" + + def test_project_list_exists(self) -> None: + result = runner.invoke(app, ["project", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_project_list_help_shows_type(self) -> None: + result = runner.invoke(app, ["project", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("type", result.output.lower()) + + def test_project_get_exists(self) -> None: + result = runner.invoke(app, ["project", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_project_create_exists(self) -> None: + result = runner.invoke(app, ["project", "create", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("type", result.output.lower()) + + def test_project_delete_exists(self) -> None: + result = runner.invoke(app, ["project", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_project_restore_exists(self) -> None: + result = runner.invoke(app, ["project", "restore", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_subcommands_visible(self) -> None: + result = runner.invoke(app, ["project", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + self.assertIn("get", result.output) + self.assertIn("create", result.output) + self.assertIn("delete", result.output) + self.assertIn("restore", result.output) + + +class TestProjectDeleteHandler(unittest.TestCase): + """project delete calls rfapi.delete_project and honors --yes.""" + + def _args(self, project_id="my-ws/my-proj"): + from argparse import Namespace + + return Namespace( + json=False, + workspace=None, + api_key="fake-key", + quiet=False, + project_id=project_id, + yes=True, + ) + + def test_delete_calls_rfapi(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.project import _delete_project + + with patch("roboflow.adapters.rfapi.delete_project", return_value={"deleted": True}) as mock_del: + _delete_project(self._args()) + mock_del.assert_called_once_with("fake-key", "my-ws", "my-proj") + + +class TestProjectRestoreHandler(unittest.TestCase): + """project restore looks up the item in Trash by URL, then restores.""" + + def _args(self, project_id="my-ws/my-proj"): + from argparse import Namespace + + return Namespace( + json=False, + workspace=None, + api_key="fake-key", + quiet=False, + project_id=project_id, + ) + + def test_restore_found(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.project import _restore_project + + trash = {"sections": {"projects": [{"id": "abc123", "url": "my-proj", "name": "My Project"}]}} + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch( + "roboflow.adapters.rfapi.restore_trash_item", + return_value={"restored": True, "type": "project", "id": "abc123"}, + ) as mock_restore, + ): + _restore_project(self._args()) + mock_restore.assert_called_once_with("fake-key", "my-ws", "project", "abc123") + + def test_restore_not_in_trash(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.project import _restore_project + + # Trash doesn't contain this project β€” handler should error without + # calling restore_trash_item. + with ( + patch( + "roboflow.adapters.rfapi.list_trash", + return_value={"sections": {"projects": []}}, + ), + patch("roboflow.adapters.rfapi.restore_trash_item") as mock_restore, + patch("sys.exit"), + ): + _restore_project(self._args()) + mock_restore.assert_not_called() + + +class TestProjectHealthHandler(unittest.TestCase): + """project health calls project.health() via SDK.""" + + def _args(self, project_id="my-project", regenerate=False): + from argparse import Namespace + + return Namespace( + json=False, + workspace="my-ws", + api_key="fake-key", + quiet=False, + project_id=project_id, + regenerate=regenerate, + ) + + def test_health_exists(self) -> None: + result = runner.invoke(app, ["project", "health", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("regenerate", result.output.lower()) + + def test_health_calls_sdk(self) -> None: + from unittest.mock import MagicMock, patch + + from roboflow.cli.handlers.project import _health_project + + mock_project = MagicMock() + mock_project.health.return_value = {"images": 100, "classes": {"cat": 50, "dog": 50}} + + mock_rf = MagicMock() + mock_rf.workspace.return_value.project.return_value = mock_project + + with patch("roboflow.Roboflow", return_value=mock_rf): + _health_project(self._args()) + mock_project.health.assert_called_once_with(regenerate=False) + + def test_health_regenerate(self) -> None: + from unittest.mock import MagicMock, patch + + from roboflow.cli.handlers.project import _health_project + + mock_project = MagicMock() + mock_project.health.return_value = {"images": 100} + + mock_rf = MagicMock() + mock_rf.workspace.return_value.project.return_value = mock_project + + with patch("roboflow.Roboflow", return_value=mock_rf): + _health_project(self._args(regenerate=True)) + mock_project.health.assert_called_once_with(regenerate=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_search_handler.py b/tests/cli/test_search_handler.py new file mode 100644 index 00000000..df2411c8 --- /dev/null +++ b/tests/cli/test_search_handler.py @@ -0,0 +1,33 @@ +"""Tests for the search CLI handler.""" + +import unittest + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestSearchRegistration(unittest.TestCase): + """Verify search handler registers expected subcommands.""" + + def test_search_command_callable(self) -> None: + from roboflow.cli.handlers.search import search_command + + self.assertTrue(callable(search_command)) + + def test_search_subcommand_exists(self) -> None: + result = runner.invoke(app, ["search", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_search_help_shows_options(self) -> None: + result = runner.invoke(app, ["search", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("limit", result.output.lower()) + self.assertIn("cursor", result.output.lower()) + self.assertIn("export", result.output.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_train_handler.py b/tests/cli/test_train_handler.py new file mode 100644 index 00000000..fcb1517b --- /dev/null +++ b/tests/cli/test_train_handler.py @@ -0,0 +1,741 @@ +"""Unit tests for roboflow.cli.handlers.train.""" + +import io +import json +import os +import re +import sys +import types +import unittest +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +class TestTrainRegister(unittest.TestCase): + """Verify train handler registers expected subcommands.""" + + def test_train_help(self) -> None: + result = runner.invoke(app, ["train", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_train_start_help(self) -> None: + result = runner.invoke(app, ["train", "start", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("project", result.output.lower()) + self.assertIn("version", result.output.lower()) + + +class TestTrainStart(unittest.TestCase): + """Test _start handler function.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": False, + "api_key": "test-key", + "workspace": "test-ws", + "project": "my-project", + "version_number": 1, + "model_type": None, + "checkpoint": None, + "speed": None, + "epochs": None, + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + @patch("roboflow.adapters.rfapi.start_version_training") + def test_start_success(self, mock_train: MagicMock) -> None: + from roboflow.cli.handlers.train import _start + + mock_train.return_value = True + + args = self._make_args(json=True) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _start(args) + finally: + sys.stdout = old_stdout + + result = json.loads(buf.getvalue()) + self.assertEqual(result["status"], "training_started") + self.assertEqual(result["project"], "my-project") + self.assertEqual(result["version"], 1) + + @patch("roboflow.adapters.rfapi.start_version_training") + def test_start_with_all_options(self, mock_train: MagicMock) -> None: + from roboflow.cli.handlers.train import _start + + mock_train.return_value = True + + args = self._make_args( + json=True, + model_type="yolov8n", + checkpoint="abc", + speed="fast", + epochs=50, + ) + buf = io.StringIO() + old_stdout = sys.stdout + sys.stdout = buf + try: + _start(args) + finally: + sys.stdout = old_stdout + + mock_train.assert_called_once_with( + "test-key", + "test-ws", + "my-project", + "1", + speed="fast", + checkpoint="abc", + model_type="yolov8n", + epochs=50, + ) + + @patch("roboflow.adapters.rfapi.start_version_training") + def test_start_api_error(self, mock_train: MagicMock) -> None: + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _start + + mock_train.side_effect = RoboflowError("training failed") + + args = self._make_args() + with self.assertRaises(SystemExit) as ctx: + _start(args) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.config.load_roboflow_api_key", return_value=None) + def test_start_no_api_key(self, _mock_key: MagicMock) -> None: + from roboflow.cli.handlers.train import _start + + args = self._make_args(api_key=None) + with self.assertRaises(SystemExit) as ctx: + _start(args) + self.assertEqual(ctx.exception.code, 2) + + @patch("roboflow.adapters.rfapi.start_version_training") + def test_start_json_error_not_double_encoded(self, mock_train: MagicMock) -> None: + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _start + + # Simulate API returning a JSON error string + mock_train.side_effect = RoboflowError('{"error": {"message": "Unsupported request"}}') + + args = self._make_args(json=True) + buf = io.StringIO() + old_stderr = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit): + _start(args) + finally: + sys.stderr = old_stderr + + result = json.loads(buf.getvalue()) + # Should be a parsed object, not a double-encoded JSON string + self.assertIsInstance(result["error"], dict) + self.assertEqual(result["error"]["message"], "Unsupported request") + + +RECIPE_RESPONSE = { + "modelType": "rfdetr-medium", + "family": "rf-detr", + "taskType": "object-detection", + "schema": {"hyperparameters": [{"key": "lr", "type": "float"}]}, + "template": { + "schema_version": 1, + "input": {}, + "online_preprocessing": [], + "online_augmentation": {"splits": ["train"], "steps": []}, + "source_version": {}, + "hyperparameters": {}, + }, + "usage": "...", +} + + +class TestTrainRecipe(unittest.TestCase): + """`train recipe` describe command.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": True, + "api_key": "test-key", + "workspace": "test-ws", + "project": "my-project", + "version_number": 3, + "model_type": "rfdetr-medium", + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + def _capture_stdout(self, fn, args): + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + fn(args) + finally: + sys.stdout = old + return buf.getvalue() + + def test_recipe_help(self) -> None: + result = runner.invoke(app, ["train", "recipe", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("model", result.output.lower()) + + @patch("roboflow.adapters.rfapi.get_train_recipe") + def test_recipe_prints_response_as_json(self, mock_recipe: MagicMock) -> None: + from roboflow.cli.handlers.train import _recipe + + mock_recipe.return_value = RECIPE_RESPONSE + out = self._capture_stdout(_recipe, self._make_args()) + + mock_recipe.assert_called_once_with("test-key", "test-ws", "my-project", "3", model_type="rfdetr-medium") + self.assertEqual(json.loads(out), RECIPE_RESPONSE) + + @patch("roboflow.adapters.rfapi.get_train_recipe") + def test_recipe_via_cli_runner(self, mock_recipe: MagicMock) -> None: + mock_recipe.return_value = RECIPE_RESPONSE + result = runner.invoke( + app, + [ + "--api-key", + "test-key", + "--workspace", + "test-ws", + "train", + "recipe", + "-p", + "my-project", + "-v", + "3", + "-m", + "rfdetr-medium", + ], + ) + self.assertEqual(result.exit_code, 0, msg=result.output) + self.assertEqual(json.loads(result.output), RECIPE_RESPONSE) + + @patch("roboflow.adapters.rfapi.get_train_recipe") + def test_recipe_api_error(self, mock_recipe: MagicMock) -> None: + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _recipe + + mock_recipe.side_effect = RoboflowError("no recipe for model type") + with self.assertRaises(SystemExit) as ctx: + _recipe(self._make_args()) + self.assertEqual(ctx.exception.code, 1) + + @patch("roboflow.config.load_roboflow_api_key", return_value=None) + def test_recipe_no_api_key(self, _mock_key: MagicMock) -> None: + from roboflow.cli.handlers.train import _recipe + + with self.assertRaises(SystemExit) as ctx: + _recipe(self._make_args(api_key=None)) + self.assertEqual(ctx.exception.code, 2) + + +class TestTrainStartV2(unittest.TestCase): + """`train start` with --train-recipe goes through v2 create_training_v2.""" + + def _make_args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": True, + "api_key": "test-key", + "workspace": "test-ws", + "project": "my-project", + "version_number": 3, + "model_type": "rfdetr-medium", + "checkpoint": None, + "speed": None, + "epochs": None, + "train_recipe": None, + "quiet": True, + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + def _capture_stdout(self, fn, args): + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + fn(args) + finally: + sys.stdout = old + return buf.getvalue() + + @patch("roboflow.adapters.rfapi.get_version") + @patch("roboflow.adapters.rfapi.create_training_v2") + @patch("roboflow.adapters.rfapi.get_train_recipe") + def test_start_with_train_recipe_submits_as_is( + self, mock_recipe: MagicMock, mock_create: MagicMock, mock_get_version: MagicMock + ) -> None: + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _start + + mock_create.return_value = {"trainingId": "t-2", "status": "queued"} + mock_get_version.side_effect = RoboflowError("offline") + + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.5}} + args = self._make_args(train_recipe=json.dumps(recipe)) + out = self._capture_stdout(_start, args) + + mock_recipe.assert_not_called() + self.assertEqual(mock_create.call_args.kwargs["train_recipe"], recipe) + self.assertEqual(json.loads(out)["trainingId"], "t-2") + + def test_start_with_invalid_train_recipe_json(self) -> None: + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe="[unterminated") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("Invalid JSON", err["error"]["message"]) + + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_non_object_train_recipe_json(self, mock_create: MagicMock) -> None: + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe="[1]") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("must be a JSON object", err["error"]["message"]) + self.assertIn("list", err["error"]["message"]) + mock_create.assert_not_called() # rejected before any network call + + @patch("roboflow.adapters.rfapi.start_version_training") + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_empty_train_recipe_errors_without_training( + self, mock_create: MagicMock, mock_legacy: MagicMock + ) -> None: + """--train-recipe "" (e.g. an unset shell variable) must error, not + fall through to the legacy endpoint and start a different training.""" + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe="") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("Invalid JSON", err["error"]["message"]) + mock_create.assert_not_called() + mock_legacy.assert_not_called() # the real hazard: no legacy fallback + + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_train_recipe_requires_model_type(self, mock_create: MagicMock) -> None: + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe=json.dumps({"schema_version": 1}), model_type=None) + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("requires a model type", err["error"]["message"]) + mock_create.assert_not_called() # rejected before any network call + + @patch("roboflow.adapters.rfapi.get_version") + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_train_recipe_from_file(self, mock_create: MagicMock, mock_get_version: MagicMock) -> None: + import tempfile + + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _start + + mock_create.return_value = {"trainingId": "t-5", "status": "queued"} + mock_get_version.side_effect = RoboflowError("offline") + + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0003}} + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "train_recipe.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(recipe, f) + args = self._make_args(train_recipe=f"@{path}") + self._capture_stdout(_start, args) + + self.assertEqual(mock_create.call_args.kwargs["train_recipe"], recipe) + + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_missing_train_recipe_file(self, mock_create: MagicMock) -> None: + from roboflow.cli.handlers.train import _start + + args = self._make_args(train_recipe="@/nonexistent/train_recipe.json") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("Cannot read --train-recipe file", err["error"]["message"]) + mock_create.assert_not_called() # rejected before any network call + + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_invalid_json_in_train_recipe_file(self, mock_create: MagicMock) -> None: + import tempfile + + from roboflow.cli.handlers.train import _start + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "train_recipe.json") + with open(path, "w", encoding="utf-8") as f: + f.write("{not json") + args = self._make_args(train_recipe=f"@{path}") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as ctx: + _start(args) + finally: + sys.stderr = old + self.assertEqual(ctx.exception.code, 1) + err = json.loads(buf.getvalue()) + self.assertIn("Invalid JSON in --train-recipe file", err["error"]["message"]) + mock_create.assert_not_called() + + @patch("roboflow.adapters.rfapi.get_version") + @patch("roboflow.adapters.rfapi.create_training_v2") + def test_start_with_train_recipe_and_epochs_folds_epochs( + self, mock_create: MagicMock, mock_get_version: MagicMock + ) -> None: + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.train import _start + + mock_create.return_value = {"trainingId": "t-4", "status": "queued"} + mock_get_version.side_effect = RoboflowError("offline") + + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.5}} + args = self._make_args(train_recipe=json.dumps(recipe), epochs=50) + self._capture_stdout(_start, args) + + create_kwargs = mock_create.call_args.kwargs + self.assertEqual(create_kwargs["train_recipe"]["hyperparameters"], {"lr": 0.5, "epochs": 50}) + self.assertEqual(create_kwargs["epochs"], 50) + + def test_start_help_shows_train_recipe_flag_only(self) -> None: + result = runner.invoke(app, ["train", "start", "--help"]) + self.assertEqual(result.exit_code, 0) + output = _strip_ansi(result.output) + self.assertIn("--train-recipe", output) + self.assertNotIn("--hyperparameters", output) + + +class TestTrainSubcommandsRegister(unittest.TestCase): + """train cancel/stop/results subcommands register correctly.""" + + def test_cancel_help(self) -> None: + result = runner.invoke(app, ["train", "cancel", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("cancel", result.output.lower()) + + def test_stop_help(self) -> None: + result = runner.invoke(app, ["train", "stop", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_results_help(self) -> None: + result = runner.invoke(app, ["train", "results", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_delete_help(self) -> None: + result = runner.invoke(app, ["train", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_list_help(self) -> None: + result = runner.invoke(app, ["train", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_restore_help(self) -> None: + result = runner.invoke(app, ["train", "restore", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestTrainCancelStopResults(unittest.TestCase): + """_cancel / _stop / _results business logic.""" + + def _args(self, **kwargs: object) -> types.SimpleNamespace: + defaults = { + "json": True, + "api_key": "test-key", + "workspace": "test-ws", + "target": "my-project/3", + "continue_if_no_refund": False, + "quiet": True, + } + defaults.update(kwargs) + return types.SimpleNamespace(**defaults) + + def _capture_stdout(self, fn, args): + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + fn(args) + finally: + sys.stdout = old + return buf.getvalue() + + @patch("roboflow.adapters.rfapi.cancel_version_training") + def test_cancel_success(self, mock_cancel: MagicMock) -> None: + from roboflow.cli.handlers.train import _cancel + + mock_cancel.return_value = {"refund": True} + out = self._capture_stdout(_cancel, self._args()) + + mock_cancel.assert_called_once_with("test-key", "test-ws", "my-project", "3", continue_if_no_refund=False) + result = json.loads(out) + self.assertEqual(result["status"], "cancelled") + self.assertEqual(result["project"], "my-project") + self.assertEqual(result["version"], "3") + self.assertTrue(result.get("refund")) + + @patch("roboflow.adapters.rfapi.cancel_version_training") + def test_cancel_409_surfaces_hint(self, mock_cancel: MagicMock) -> None: + from roboflow.adapters import rfapi + from roboflow.cli.handlers.train import _cancel + + mock_cancel.side_effect = rfapi.RoboflowError("Cannot cancel non-running train job.") + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as cm: + _cancel(self._args()) + finally: + sys.stderr = old + self.assertEqual(cm.exception.code, 3) + err = json.loads(buf.getvalue()) + self.assertIn("Cannot cancel", err["error"]["message"]) + self.assertIn("in-flight", err["error"].get("hint", "")) + + @patch("roboflow.adapters.rfapi.stop_version_training") + def test_stop_success(self, mock_stop: MagicMock) -> None: + from roboflow.cli.handlers.train import _stop + + mock_stop.return_value = {"success": True} + out = self._capture_stdout(_stop, self._args()) + + mock_stop.assert_called_once_with("test-key", "test-ws", "my-project", "3") + result = json.loads(out) + self.assertEqual(result["status"], "stop_requested") + + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_success(self, mock_delete: MagicMock) -> None: + from roboflow.cli.handlers.train import _delete + + mock_delete.return_value = {"deleted": True, "type": "training", "trainingId": "t-1", "trash": True} + out = self._capture_stdout(_delete, self._args(training_id="t-1")) + + mock_delete.assert_called_once_with("test-key", "test-ws", "my-project", "3", training_id="t-1") + result = json.loads(out) + self.assertEqual(result["status"], "in_trash") + self.assertTrue(result["trash"]) + + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_in_progress_surfaces_hint(self, mock_delete: MagicMock) -> None: + from roboflow.adapters import rfapi + from roboflow.cli.handlers.train import _delete + + mock_delete.side_effect = rfapi.RoboflowError( + "This training is still in progress. Stop or cancel it before deleting it." + ) + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as cm: + _delete(self._args(training_id="t-1")) + finally: + sys.stderr = old + self.assertEqual(cm.exception.code, 3) + err = json.loads(buf.getvalue()) + self.assertIn("in progress", err["error"]["message"]) + self.assertIn("train stop", err["error"].get("hint", "")) + + @patch("roboflow.adapters.rfapi.delete_version_training") + @patch("roboflow.adapters.rfapi.list_trainings_for_version") + def test_delete_multiple_trainings_hint_points_to_train_list( + self, mock_list: MagicMock, mock_delete: MagicMock + ) -> None: + from roboflow.cli.handlers.train import _delete + + # The sole-run resolution is client-side now: several runs abort the + # delete before any request is made, with the train-list hint. + mock_list.return_value = [{"id": "t-1"}, {"id": "t-2"}] + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit): + _delete(self._args(training_id=None)) + finally: + sys.stderr = old + mock_delete.assert_not_called() + err = json.loads(buf.getvalue()) + self.assertIn("roboflow train list", err["error"].get("hint", "")) + + @patch("roboflow.adapters.rfapi.list_trainings_for_version") + def test_list_enumerates_training_ids(self, mock_list: MagicMock) -> None: + from roboflow.cli.handlers.train import _list + + mock_list.return_value = [ + {"id": "t-1", "status": "finished", "modelType": "yolo26n", "modelIds": ["m1"]}, + {"id": "t-2", "status": "stopped", "modelType": "yolo26n", "modelIds": []}, + ] + out = self._capture_stdout(_list, self._args()) + + mock_list.assert_called_once_with("test-key", "test-ws", "my-project", "3") + result = json.loads(out) + self.assertEqual([t["id"] for t in result["trainings"]], ["t-1", "t-2"]) + self.assertIn("t-1", out) + + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_notes_the_serving_switch(self, mock_delete: MagicMock) -> None: + from roboflow.cli.handlers.train import _delete + + mock_delete.return_value = { + "deleted": True, + "type": "training", + "trainingId": "t-1", + "trash": True, + "versionAliasAction": "repointed", + "versionAliasTarget": "test-ws/next-oldest-model", + } + args = self._args(training_id="t-1") + args.json = False + out = self._capture_stdout(_delete, args) + + self.assertIn("switched to", out) + self.assertIn("next-oldest-model", out) + + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_notes_the_serving_stop_when_no_model_remains(self, mock_delete: MagicMock) -> None: + from roboflow.cli.handlers.train import _delete + + mock_delete.return_value = { + "deleted": True, + "type": "training", + "trainingId": "t-1", + "trash": True, + "versionAliasAction": "deleted", + } + args = self._args(training_id="t-1") + args.json = False + out = self._capture_stdout(_delete, args) + + self.assertIn("stops serving", out) + self.assertIn("my-project/3", out) + + @patch("roboflow.adapters.rfapi.delete_version_training") + def test_delete_blank_training_id_is_a_structured_usage_error(self, mock_delete: MagicMock) -> None: + from roboflow.cli.handlers.train import _delete + + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as cm: + _delete(self._args(training_id=" ")) + finally: + sys.stderr = old + self.assertEqual(cm.exception.code, 2) + mock_delete.assert_not_called() + err = json.loads(buf.getvalue()) + self.assertIn("non-empty", err["error"]["message"]) + self.assertIn("--training-id", err["error"].get("hint", "")) + + def test_restore_blank_training_id_is_a_structured_usage_error(self) -> None: + from roboflow.cli.handlers.train import _restore + + buf = io.StringIO() + old = sys.stderr + sys.stderr = buf + try: + with self.assertRaises(SystemExit) as cm: + _restore(self._args(training_id=" ")) + finally: + sys.stderr = old + self.assertEqual(cm.exception.code, 2) + err = json.loads(buf.getvalue()) + self.assertIn("required", err["error"]["message"]) + self.assertIn("--training-id", err["error"].get("hint", "")) + + @patch("roboflow.adapters.rfapi.restore_trash_item") + def test_restore_success(self, mock_restore: MagicMock) -> None: + from roboflow.cli.handlers.train import _restore + + mock_restore.return_value = {"restored": True} + out = self._capture_stdout(_restore, self._args(training_id="t-1")) + + mock_restore.assert_called_once_with("test-key", "test-ws", "training", "t-1") + result = json.loads(out) + self.assertEqual(result["status"], "restored") + + @patch("roboflow.adapters.rfapi.get_training_results") + def test_results_nas_run(self, mock_get: MagicMock) -> None: + from roboflow.cli.handlers.train import _results + + mock_get.return_value = { + "trainingId": "test-ws/my-project/3", + "status": "finished", + "jobType": "nas", + "modelGroup": "rfdetrNasGroup-3", + "modelCount": 5, + "recommendedByHardware": {"gpu": "my-project-3-nas-gpu-a"}, + "models": [{"modelId": "my-project-3-nas-gpu-a"}], + } + out = self._capture_stdout(_results, self._args()) + result = json.loads(out) + self.assertEqual(result["jobType"], "nas") + self.assertEqual(result["modelCount"], 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_trash_handler.py b/tests/cli/test_trash_handler.py new file mode 100644 index 00000000..996508aa --- /dev/null +++ b/tests/cli/test_trash_handler.py @@ -0,0 +1,141 @@ +"""Tests for the trash CLI handler.""" + +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestTrashRegistration(unittest.TestCase): + """Verify trash handler registers expected subcommands.""" + + def test_trash_app_exists(self) -> None: + from roboflow.cli.handlers.trash import trash_app + + self.assertIsNotNone(trash_app) + + def test_trash_list_exists(self) -> None: + result = runner.invoke(app, ["trash", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_permanent_delete_commands_not_exposed(self) -> None: + # empty / delete immediately are intentionally not available on the + # SDK/CLI β€” they exist only in the web UI. Guard against regression. + empty_result = runner.invoke(app, ["trash", "empty", "--help"]) + self.assertNotEqual(empty_result.exit_code, 0) + delete_result = runner.invoke(app, ["trash", "delete", "--help"]) + self.assertNotEqual(delete_result.exit_code, 0) + + def test_subcommands_visible(self) -> None: + result = runner.invoke(app, ["trash", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + # empty / delete should NOT appear in the command group + self.assertNotIn("empty", result.output.lower()) + + +def _args(**overrides): + base = { + "json": False, + "workspace": None, + "api_key": "fake-key", + "quiet": False, + } + base.update(overrides) + return Namespace(**base) + + +class TestTrashListHandler(unittest.TestCase): + """trash list calls rfapi.list_trash and formats the output.""" + + def test_list_text_output(self) -> None: + from roboflow.cli.handlers.trash import _list_trash + + trash_response = { + "items": [ + { + "type": "project", + "id": "d1", + "name": "My Project", + "deletedAt": "2026-04-01", + "scheduledCleanupAt": "2026-05-01", + "deletedByName": "Alice", + }, + { + "type": "version", + "id": "3", + "name": "v3", + "parentName": "My Project", + "parentUrl": "my-proj", + "deletedAt": "2026-04-02", + "scheduledCleanupAt": "2026-05-02", + "deletedByName": "Bob", + }, + ], + "sections": {}, + } + with ( + patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws"), + patch("roboflow.config.load_roboflow_api_key", return_value="fake-key"), + patch("roboflow.adapters.rfapi.list_trash", return_value=trash_response) as mock_list, + patch("builtins.print") as mock_print, + ): + _list_trash(_args()) + mock_list.assert_called_once_with("fake-key", "test-ws") + mock_print.assert_called_once() + printed = mock_print.call_args[0][0] + self.assertIn("My Project", printed) + self.assertIn("v3", printed) + + def test_list_empty(self) -> None: + from roboflow.cli.handlers.trash import _list_trash + + with ( + patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws"), + patch("roboflow.config.load_roboflow_api_key", return_value="fake-key"), + patch( + "roboflow.adapters.rfapi.list_trash", + return_value={"items": [], "sections": {}}, + ), + patch("builtins.print") as mock_print, + ): + _list_trash(_args()) + printed = mock_print.call_args[0][0] + self.assertIn("empty", printed.lower()) + + +class TestRfapiSurface(unittest.TestCase): + """Guard: rfapi must not expose permanent-delete wrappers.""" + + def test_no_trash_delete_immediately(self) -> None: + from roboflow.adapters import rfapi + + self.assertFalse(hasattr(rfapi, "trash_delete_immediately")) + + def test_no_empty_trash(self) -> None: + from roboflow.adapters import rfapi + + self.assertFalse(hasattr(rfapi, "empty_trash")) + + +class TestWorkspaceSurface(unittest.TestCase): + """Guard: Workspace must not expose permanent-delete helpers.""" + + def test_no_delete_from_trash(self) -> None: + from roboflow.core.workspace import Workspace + + self.assertFalse(hasattr(Workspace, "delete_from_trash")) + + def test_no_empty_trash(self) -> None: + from roboflow.core.workspace import Workspace + + self.assertFalse(hasattr(Workspace, "empty_trash")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_trash_polish.py b/tests/cli/test_trash_polish.py new file mode 100644 index 00000000..d7103b67 --- /dev/null +++ b/tests/cli/test_trash_polish.py @@ -0,0 +1,315 @@ +"""Behavioral tests for the 1.3.8 CLI polish fixes. + +Covers the three findings from the prod CLI shake-down: + 1. Auth errors (HTTP 401) should exit with code 2, not 3. + 2. Destructive commands without ``--yes`` and no TTY should bail with a + hint instead of either hanging on a closed stdin or silently + proceeding when ``--json`` is set. + 3. Re-running ``project|version|workflow delete`` on something already in + Trash should be a no-op success rather than surfacing a misleading + "missing scope" 404 message. +""" + +import io +import sys +import unittest +from argparse import Namespace +from unittest.mock import patch + +from roboflow.adapters import rfapi + + +def _args(**overrides): + base = { + "json": False, + "workspace": "test-ws", + "api_key": "fake-key", + "quiet": False, + "yes": False, + } + base.update(overrides) + return Namespace(**base) + + +# --------------------------------------------------------------------------- +# 1. Auth errors β†’ exit code 2 +# --------------------------------------------------------------------------- + + +class TestAuthExitCode(unittest.TestCase): + """``output_api_error`` must map ``status_code=401`` to exit code 2. + + Before this change every ``rfapi.RoboflowError`` from the trash + endpoints was caught with ``exit_code=3`` (not-found), so scripts / + agents couldn't tell "your key is bad, get a new one" apart from + "this resource doesn't exist." + """ + + def test_401_maps_to_exit_2(self) -> None: + from roboflow.cli._output import output_api_error + + exc = rfapi.RoboflowError("This API key does not exist", status_code=401) + with self.assertRaises(SystemExit) as ctx: + output_api_error(_args(), exc, hint="ignored when 401") + self.assertEqual(ctx.exception.code, 2) + + def test_404_maps_to_exit_3(self) -> None: + from roboflow.cli._output import output_api_error + + exc = rfapi.RoboflowError("Not found", status_code=404) + with self.assertRaises(SystemExit) as ctx: + output_api_error(_args(), exc) + self.assertEqual(ctx.exception.code, 3) + + def test_other_status_maps_to_exit_1(self) -> None: + from roboflow.cli._output import output_api_error + + exc = rfapi.RoboflowError("Server died", status_code=500) + with self.assertRaises(SystemExit) as ctx: + output_api_error(_args(), exc) + self.assertEqual(ctx.exception.code, 1) + + def test_no_status_code_maps_to_exit_1(self) -> None: + # Older `raise RoboflowError(text)` call sites don't set status_code; + # they should default to a generic exit 1, NOT to 3 (which would + # impersonate "not found") and NOT to 2 (which would impersonate + # "auth error"). + from roboflow.cli._output import output_api_error + + exc = rfapi.RoboflowError("ambiguous") + with self.assertRaises(SystemExit) as ctx: + output_api_error(_args(), exc) + self.assertEqual(ctx.exception.code, 1) + + def test_trash_response_attaches_status_code(self) -> None: + # rfapi._raise_for_trash_response is the funnel for every 4xx/5xx + # from the soft-delete endpoints β€” verify it stamps status_code. + class FakeResponse: + status_code = 401 + text = '{"error": "Unauthorized"}' + + def json(self): + return {"error": "Unauthorized"} + + with self.assertRaises(rfapi.RoboflowError) as ctx: + rfapi._raise_for_trash_response(FakeResponse()) + self.assertEqual(ctx.exception.status_code, 401) + self.assertEqual(str(ctx.exception), "Unauthorized") + + +# --------------------------------------------------------------------------- +# 2. Destructive commands gate on --yes OR a TTY (not on --json) +# --------------------------------------------------------------------------- + + +class TestDestructiveConfirm(unittest.TestCase): + """``confirm_destructive`` should: + + * return True when ``--yes`` is set (regardless of ``--json`` or TTY); + * exit cleanly with code 1 when no TTY AND no ``--yes`` (the regression + scenario: ``roboflow project delete X --json < /dev/null`` previously + went through silently because ``--json`` was treated as an implicit + "skip prompt" signal); + * prompt via typer.confirm when on a TTY without ``--yes`` and respect + the user's choice. + """ + + def test_yes_flag_short_circuits(self) -> None: + from roboflow.cli._output import confirm_destructive + + # No TTY, no prompt β€” but --yes is set, so it should still proceed. + with patch.object(sys.stdin, "isatty", return_value=False): + self.assertTrue(confirm_destructive(_args(yes=True), "destroy?")) + + def test_no_tty_no_yes_bails_with_exit_1(self) -> None: + from roboflow.cli._output import confirm_destructive + + with patch.object(sys.stdin, "isatty", return_value=False): + with self.assertRaises(SystemExit) as ctx: + confirm_destructive(_args(yes=False), "destroy?") + self.assertEqual(ctx.exception.code, 1) + + def test_json_alone_does_not_bypass(self) -> None: + # Regression guard for the original bug: --json without --yes + # should NOT short-circuit the destructive guard. + from roboflow.cli._output import confirm_destructive + + with patch.object(sys.stdin, "isatty", return_value=False): + with self.assertRaises(SystemExit) as ctx: + confirm_destructive(_args(yes=False, json=True), "destroy?") + self.assertEqual(ctx.exception.code, 1) + + def test_tty_prompts_and_respects_decline(self) -> None: + from roboflow.cli._output import confirm_destructive + + captured = io.StringIO() + with ( + patch.object(sys.stdin, "isatty", return_value=True), + patch("typer.confirm", return_value=False), + patch("sys.stdout", captured), + ): + self.assertFalse(confirm_destructive(_args(yes=False), "destroy?")) + # Caller doesn't need to re-emit "Cancelled." β€” confirm_destructive + # already calls output() with the cancelled marker. + self.assertIn("Cancelled", captured.getvalue()) + + def test_tty_prompts_and_respects_accept(self) -> None: + from roboflow.cli._output import confirm_destructive + + with patch.object(sys.stdin, "isatty", return_value=True), patch("typer.confirm", return_value=True): + self.assertTrue(confirm_destructive(_args(yes=False), "destroy?")) + + +# --------------------------------------------------------------------------- +# 3. Idempotent re-delete on project/version/workflow +# --------------------------------------------------------------------------- + + +class TestIdempotentDelete(unittest.TestCase): + """When the DELETE call returns 404 because the resource is already in + Trash (the public API's URL filter excludes trashed items), the handler + should probe ``list_trash`` and emit a synthetic success payload with + ``alreadyInTrash: True``. Previously we surfaced the raw 404 with a + misleading "missing scope" hint.""" + + def _trash_payload_with_project(self, slug: str, project_id: str = "p_id"): + return { + "items": [], + "sections": { + "projects": [{"id": project_id, "url": slug, "name": slug}], + "versions": [], + "workflows": [], + }, + } + + def test_project_already_in_trash_returns_success(self) -> None: + from roboflow.cli.handlers.project import _delete_project + + not_found = rfapi.RoboflowError("Endpoint does not exist", status_code=404) + captured = io.StringIO() + + # Resolver works on "ws/slug" shorthand β€” pass an explicit workspace + # via args.workspace and a bare slug via args.project_id. + args = _args(project_id="my-proj", yes=True, json=True) + with ( + patch("roboflow.adapters.rfapi.delete_project", side_effect=not_found), + patch( + "roboflow.adapters.rfapi.list_trash", + return_value=self._trash_payload_with_project("my-proj"), + ), + patch("sys.stdout", captured), + ): + _delete_project(args) + + out = captured.getvalue() + self.assertIn('"alreadyInTrash": true', out) + self.assertIn('"deleted": true', out) + self.assertIn('"trash": true', out) + + def test_project_404_not_in_trash_propagates_error(self) -> None: + # If the slug really doesn't exist (not active, not trashed), + # we should NOT swallow the 404 β€” propagate as exit 3. + from roboflow.cli.handlers.project import _delete_project + + not_found = rfapi.RoboflowError("Endpoint does not exist", status_code=404) + empty_trash = {"items": [], "sections": {"projects": [], "versions": [], "workflows": []}} + + args = _args(project_id="ghost-proj", yes=True, json=True) + with ( + patch("roboflow.adapters.rfapi.delete_project", side_effect=not_found), + patch("roboflow.adapters.rfapi.list_trash", return_value=empty_trash), + patch("sys.stderr", io.StringIO()), + ): + with self.assertRaises(SystemExit) as ctx: + _delete_project(args) + self.assertEqual(ctx.exception.code, 3) + + def test_workflow_already_in_trash_returns_success(self) -> None: + from roboflow.cli.handlers.workflow import _delete_workflow + + not_found = rfapi.RoboflowError("Endpoint does not exist", status_code=404) + trash = { + "items": [], + "sections": { + "projects": [], + "versions": [], + "workflows": [{"id": "wf_id", "url": "my-wf", "name": "My WF"}], + }, + } + captured = io.StringIO() + args = _args(workflow_url="my-wf", yes=True, json=True) + with ( + patch("roboflow.adapters.rfapi.delete_workflow", side_effect=not_found), + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch("sys.stdout", captured), + ): + _delete_workflow(args) + out = captured.getvalue() + self.assertIn('"alreadyInTrash": true', out) + self.assertIn('"workflowId": "wf_id"', out) + + def test_version_already_in_trash_returns_success(self) -> None: + from roboflow.cli.handlers.version import _delete_version + + not_found = rfapi.RoboflowError("Endpoint does not exist", status_code=404) + trash = { + "items": [], + "sections": { + "projects": [], + "versions": [ + { + "id": "1", + "parentUrl": "my-proj", + "parentId": "p_id", + "name": "v1", + } + ], + "workflows": [], + }, + } + captured = io.StringIO() + args = _args(version_ref="my-proj/1", yes=True, json=True) + with ( + patch("roboflow.adapters.rfapi.delete_version", side_effect=not_found), + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch("sys.stdout", captured), + ): + _delete_version(args) + out = captured.getvalue() + self.assertIn('"alreadyInTrash": true', out) + self.assertIn('"version": "1"', out) + + +# --------------------------------------------------------------------------- +# 4. RoboflowError subclasses must preserve their HTTP status_code +# --------------------------------------------------------------------------- + + +class TestRoboflowErrorSubclassStatusCode(unittest.TestCase): + """Regression guard: when ``RoboflowError.__init__`` started accepting + ``status_code``, the subclasses ``ImageUploadError`` and + ``AnnotationSaveError`` were calling ``super().__init__(self.message)`` + without forwarding the second arg. The parent then assigned + ``self.status_code = None``, silently wiping the HTTP code that the + subclass had just set on itself a line earlier.""" + + def test_image_upload_error_preserves_status_code(self) -> None: + exc = rfapi.ImageUploadError("upload blew up", status_code=413) + self.assertEqual(exc.status_code, 413) + self.assertEqual(exc.message, "upload blew up") + self.assertEqual(exc.retries, 0) + + def test_annotation_save_error_preserves_status_code(self) -> None: + exc = rfapi.AnnotationSaveError("annot blew up", status_code=409) + self.assertEqual(exc.status_code, 409) + self.assertEqual(exc.message, "annot blew up") + self.assertEqual(exc.retries, 0) + + def test_subclasses_default_status_code_to_none(self) -> None: + # Existing message-only call sites must keep working. + self.assertIsNone(rfapi.ImageUploadError("just a message").status_code) + self.assertIsNone(rfapi.AnnotationSaveError("just a message").status_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_universe_handler.py b/tests/cli/test_universe_handler.py new file mode 100644 index 00000000..beaa59de --- /dev/null +++ b/tests/cli/test_universe_handler.py @@ -0,0 +1,85 @@ +"""Tests for the universe CLI handler.""" + +import json +import unittest +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestUniverseRegistration(unittest.TestCase): + """Verify universe handler registers expected subcommands.""" + + def test_universe_app_exists(self) -> None: + from roboflow.cli.handlers.universe import universe_app + + self.assertIsNotNone(universe_app) + + def test_universe_search_exists(self) -> None: + result = runner.invoke(app, ["universe", "search", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_universe_search_help_shows_options(self) -> None: + result = runner.invoke(app, ["universe", "search", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("type", result.output.lower()) + self.assertIn("limit", result.output.lower()) + + +class TestUniverseSearch(unittest.TestCase): + """Test universe search handler.""" + + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_search_success(self, _mock_key, mock_search) -> None: + mock_search.return_value = { + "results": [ + {"name": "cats-dataset", "type": "dataset", "images": 1000, "url": "https://example.com/cats"}, + ] + } + result = runner.invoke(app, ["universe", "search", "cats"]) + self.assertIn("cats-dataset", result.output) + + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="my-key") + def test_search_passes_api_key(self, _mock_key, mock_search) -> None: + mock_search.return_value = {"results": []} + runner.invoke(app, ["universe", "search", "cats"]) + mock_search.assert_called_once_with("cats", api_key="my-key", project_type=None, limit=12) + + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="k") + def test_search_passes_custom_limit(self, _mock_key, mock_search) -> None: + mock_search.return_value = {"results": []} + runner.invoke(app, ["universe", "search", "dogs", "--limit", "5"]) + mock_search.assert_called_once_with("dogs", api_key="k", project_type=None, limit=5) + + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_search_json_output(self, _mock_key, mock_search) -> None: + mock_search.return_value = { + "results": [ + {"name": "dogs-dataset", "type": "dataset", "images": 500, "url": "https://example.com/dogs"}, + ] + } + result = runner.invoke(app, ["--json", "universe", "search", "dogs"]) + data = json.loads(result.output) + self.assertIsInstance(data, list) + self.assertEqual(data[0]["name"], "dogs-dataset") + + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_search_api_error_json(self, _mock_key, mock_search) -> None: + from roboflow.adapters.rfapi import RoboflowError + + mock_search.side_effect = RoboflowError("API down") + result = runner.invoke(app, ["--json", "universe", "search", "fail"]) + self.assertNotEqual(result.exit_code, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_version_handler.py b/tests/cli/test_version_handler.py new file mode 100644 index 00000000..068c608e --- /dev/null +++ b/tests/cli/test_version_handler.py @@ -0,0 +1,224 @@ +"""Tests for the version CLI handler.""" + +import json +import tempfile +import unittest +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestVersionHandlerRegistration(unittest.TestCase): + """Verify that the version handler registers correctly.""" + + def test_version_list_exists(self) -> None: + result = runner.invoke(app, ["version", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_version_get_exists(self) -> None: + result = runner.invoke(app, ["version", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_version_download_exists(self) -> None: + result = runner.invoke(app, ["version", "download", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_version_export_exists(self) -> None: + result = runner.invoke(app, ["version", "export", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_version_create_exists(self) -> None: + result = runner.invoke(app, ["version", "create", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_subcommands_visible(self) -> None: + result = runner.invoke(app, ["version", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + self.assertIn("get", result.output) + self.assertIn("download", result.output) + self.assertIn("export", result.output) + self.assertIn("create", result.output) + + +class TestVersionCreate(unittest.TestCase): + """Test version create handler.""" + + def test_create_missing_settings_file(self) -> None: + result = runner.invoke( + app, + ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", "/nonexistent/file.json"], + ) + self.assertNotEqual(result.exit_code, 0) + + def test_create_invalid_json_file(self) -> None: + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("not valid json") + f.flush() + result = runner.invoke( + app, + ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", f.name], + ) + self.assertNotEqual(result.exit_code, 0) + + def test_create_no_api_key(self) -> None: + settings = {"augmentation": {}, "preprocessing": {}} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(settings, f) + f.flush() + with patch("roboflow.config.load_roboflow_api_key", return_value=None): + result = runner.invoke( + app, + ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", f.name], + ) + self.assertNotEqual(result.exit_code, 0) + + def test_create_json_error_output(self) -> None: + result = runner.invoke( + app, + ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", "/nonexistent/file.json"], + ) + self.assertNotEqual(result.exit_code, 0) + + +class TestParseUrl(unittest.TestCase): + """Test the _parse_url helper.""" + + def test_shorthand(self) -> None: + from roboflow.cli.handlers.version import _parse_url + + w, p, v = _parse_url("my-ws/my-project/3") + self.assertEqual(w, "my-ws") + self.assertEqual(p, "my-project") + self.assertEqual(v, "3") + + def test_full_url(self) -> None: + from roboflow.cli.handlers.version import _parse_url + + w, p, v = _parse_url("https://universe.roboflow.com/my-ws/my-project/3") + self.assertEqual(w, "my-ws") + self.assertEqual(p, "my-project") + self.assertEqual(v, "3") + + def test_no_version(self) -> None: + from roboflow.cli.handlers.version import _parse_url + + w, p, v = _parse_url("my-ws/my-project") + self.assertEqual(w, "my-ws") + self.assertEqual(p, "my-project") + self.assertIsNone(v) + + +class TestVersionDeleteRestoreRegistration(unittest.TestCase): + """Verify delete/restore commands register under `version`.""" + + def test_version_delete_exists(self) -> None: + result = runner.invoke(app, ["version", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_version_restore_exists(self) -> None: + result = runner.invoke(app, ["version", "restore", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + +class TestVersionDeleteHandler(unittest.TestCase): + """version delete calls rfapi.delete_version and honors --yes.""" + + def _args(self, version_ref="my-ws/my-proj/3"): + from argparse import Namespace + + return Namespace( + json=False, + workspace=None, + api_key="fake-key", + quiet=False, + version_ref=version_ref, + yes=True, + ) + + def test_delete_calls_rfapi(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.version import _delete_version + + with patch("roboflow.adapters.rfapi.delete_version", return_value={"deleted": True}) as mock_del: + _delete_version(self._args()) + mock_del.assert_called_once_with("fake-key", "my-ws", "my-proj", 3) + + +class TestVersionRestoreHandler(unittest.TestCase): + """version restore looks up by (parentUrl, version id) in Trash.""" + + def _args(self, version_ref="my-ws/my-proj/3"): + from argparse import Namespace + + return Namespace( + json=False, + workspace=None, + api_key="fake-key", + quiet=False, + version_ref=version_ref, + ) + + def test_restore_found(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.version import _restore_version + + trash = { + "sections": { + "versions": [ + { + "id": "3", + "parentId": "proj-id-123", + "parentUrl": "my-proj", + "name": "v3", + } + ] + } + } + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch( + "roboflow.adapters.rfapi.restore_trash_item", + return_value={"restored": True, "type": "version", "id": "3"}, + ) as mock_restore, + ): + _restore_version(self._args()) + mock_restore.assert_called_once_with("fake-key", "my-ws", "version", "3", parent_id="proj-id-123") + + def test_restore_wrong_project_not_found(self) -> None: + from unittest.mock import patch + + from roboflow.cli.handlers.version import _restore_version + + # version id matches but parentUrl doesn't β€” must not restore. + trash = { + "sections": { + "versions": [ + { + "id": "3", + "parentId": "other-id", + "parentUrl": "other-proj", + "name": "v3", + } + ] + } + } + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch("roboflow.adapters.rfapi.restore_trash_item") as mock_restore, + patch("sys.exit"), + ): + _restore_version(self._args()) + mock_restore.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_video_handler.py b/tests/cli/test_video_handler.py new file mode 100644 index 00000000..6deb1bdf --- /dev/null +++ b/tests/cli/test_video_handler.py @@ -0,0 +1,64 @@ +"""Tests for the video CLI handler.""" + +import json +import unittest +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestVideoRegistration(unittest.TestCase): + """Verify video handler registers expected subcommands.""" + + def test_video_app_exists(self) -> None: + from roboflow.cli.handlers.video import video_app + + self.assertIsNotNone(video_app) + + def test_video_infer_exists(self) -> None: + result = runner.invoke(app, ["video", "infer", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_video_status_exists(self) -> None: + result = runner.invoke(app, ["video", "status", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestVideoStatus(unittest.TestCase): + """Test video status handler.""" + + @patch("roboflow.config.load_roboflow_api_key", return_value=None) + def test_status_no_api_key(self, _mock_key) -> None: + result = runner.invoke(app, ["--json", "video", "status", "job-123"]) + self.assertNotEqual(result.exit_code, 0) + + @patch("roboflow.adapters.rfapi.get_video_job_status") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_status_success(self, _mock_key, mock_api) -> None: + mock_api.return_value = {"status": "completed", "progress": "100%"} + result = runner.invoke(app, ["video", "status", "job-abc"]) + self.assertIn("job-abc", result.output) + self.assertIn("completed", result.output) + + @patch("roboflow.adapters.rfapi.get_video_job_status") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_status_json_output(self, _mock_key, mock_api) -> None: + mock_api.return_value = {"status": "processing", "progress": "50%"} + result = runner.invoke(app, ["--json", "video", "status", "job-abc"]) + data = json.loads(result.output) + self.assertEqual(data["status"], "processing") + + @patch("roboflow.adapters.rfapi.get_video_job_status") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_status_passes_job_id_to_api(self, _mock_key, mock_api) -> None: + mock_api.return_value = {"status": "completed"} + runner.invoke(app, ["video", "status", "my-unique-job-777"]) + mock_api.assert_called_once_with("fake-key", "my-unique-job-777") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_workflow_handler.py b/tests/cli/test_workflow_handler.py new file mode 100644 index 00000000..128fb377 --- /dev/null +++ b/tests/cli/test_workflow_handler.py @@ -0,0 +1,463 @@ +"""Tests for the workflow CLI handler.""" + +import json +import os +import tempfile +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +def _make_args(**kwargs): + """Create a Namespace with CLI defaults.""" + defaults = {"json": False, "workspace": "test-ws", "api_key": "test-key", "quiet": False} + defaults.update(kwargs) + return Namespace(**defaults) + + +class TestWorkflowRegistration(unittest.TestCase): + """Verify workflow handler registers expected subcommands.""" + + def test_workflow_app_exists(self) -> None: + from roboflow.cli.handlers.workflow import workflow_app + + self.assertIsNotNone(workflow_app) + + def test_workflow_list_exists(self) -> None: + result = runner.invoke(app, ["workflow", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workflow_get_exists(self) -> None: + result = runner.invoke(app, ["workflow", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workflow_create_exists(self) -> None: + result = runner.invoke(app, ["workflow", "create", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workflow_update_exists(self) -> None: + result = runner.invoke(app, ["workflow", "update", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workflow_version_list_exists(self) -> None: + result = runner.invoke(app, ["workflow", "version", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workflow_fork_exists(self) -> None: + result = runner.invoke(app, ["workflow", "fork", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workflow_delete_exists(self) -> None: + result = runner.invoke(app, ["workflow", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_workflow_restore_exists(self) -> None: + result = runner.invoke(app, ["workflow", "restore", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("trash", result.output.lower()) + + def test_workflow_build_exists(self) -> None: + result = runner.invoke(app, ["workflow", "build", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workflow_run_exists(self) -> None: + result = runner.invoke(app, ["workflow", "run", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workflow_deploy_exists(self) -> None: + result = runner.invoke(app, ["workflow", "deploy", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestWorkflowList(unittest.TestCase): + @patch("roboflow.adapters.rfapi.list_workflows") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_list_workflows_text(self, _mock_key, mock_list): + from roboflow.cli.handlers.workflow import _list_workflows + + mock_list.return_value = { + "workflows": [ + {"name": "My Workflow", "url": "my-workflow", "status": "active"}, + ] + } + args = _make_args() + with patch("builtins.print") as mock_print: + _list_workflows(args) + mock_list.assert_called_once_with("test-key", "test-ws") + printed = mock_print.call_args[0][0] + self.assertIn("My Workflow", printed) + + @patch("roboflow.adapters.rfapi.list_workflows") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_list_workflows_json(self, _mock_key, mock_list): + from roboflow.cli.handlers.workflow import _list_workflows + + mock_list.return_value = { + "workflows": [ + {"name": "WF1", "url": "wf-1", "status": "active"}, + ] + } + args = _make_args(json=True) + with patch("builtins.print") as mock_print: + _list_workflows(args) + out = json.loads(mock_print.call_args[0][0]) + self.assertIsInstance(out, list) + self.assertEqual(out[0]["name"], "WF1") + + @patch("roboflow.adapters.rfapi.list_workflows") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_list_workflows_error(self, _mock_key, mock_list): + from roboflow.adapters.rfapi import RoboflowError + from roboflow.cli.handlers.workflow import _list_workflows + + mock_list.side_effect = RoboflowError("Not found") + args = _make_args() + with self.assertRaises(SystemExit) as ctx: + _list_workflows(args) + self.assertEqual(ctx.exception.code, 3) + + +class TestWorkflowGet(unittest.TestCase): + @patch("roboflow.adapters.rfapi.get_workflow") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_get_workflow_text(self, _mock_key, mock_get): + from roboflow.cli.handlers.workflow import _get_workflow + + mock_get.return_value = { + "workflow": { + "name": "My WF", + "url": "my-wf", + "description": "A test workflow", + "blockCount": 5, + } + } + args = _make_args(workflow_url="my-wf") + with patch("builtins.print") as mock_print: + _get_workflow(args) + mock_get.assert_called_once_with("test-key", "test-ws", "my-wf") + printed = mock_print.call_args[0][0] + self.assertIn("My WF", printed) + self.assertIn("5", printed) + + @patch("roboflow.adapters.rfapi.get_workflow") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_get_workflow_json(self, _mock_key, mock_get): + from roboflow.cli.handlers.workflow import _get_workflow + + mock_get.return_value = {"workflow": {"name": "My WF", "url": "my-wf"}} + args = _make_args(json=True, workflow_url="my-wf") + with patch("builtins.print") as mock_print: + _get_workflow(args) + out = json.loads(mock_print.call_args[0][0]) + self.assertIn("workflow", out) + + +class TestWorkflowCreate(unittest.TestCase): + @patch("roboflow.adapters.rfapi.create_workflow") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_create_workflow_basic(self, _mock_key, mock_create): + from roboflow.cli.handlers.workflow import _create_workflow + + mock_create.return_value = {"name": "New WF", "url": "new-wf"} + args = _make_args(name="New WF", definition=None, description=None) + with patch("builtins.print") as mock_print: + _create_workflow(args) + mock_create.assert_called_once_with("test-key", "test-ws", name="New WF", config="{}", template="{}") + printed = mock_print.call_args[0][0] + self.assertIn("Created workflow", printed) + + @patch("roboflow.adapters.rfapi.create_workflow") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_create_workflow_with_definition(self, _mock_key, mock_create): + from roboflow.cli.handlers.workflow import _create_workflow + + mock_create.return_value = {"name": "New WF", "url": "new-wf"} + defn = {"blocks": [{"type": "input"}]} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(defn, f) + f.flush() + tmp_path = f.name + + try: + args = _make_args(name="New WF", definition=tmp_path, description="A desc") + with patch("builtins.print"): + _create_workflow(args) + mock_create.assert_called_once_with( + "test-key", "test-ws", name="New WF", config=json.dumps(defn), template="{}" + ) + finally: + os.unlink(tmp_path) + + def test_create_workflow_missing_file(self): + from roboflow.cli.handlers.workflow import _create_workflow + + args = _make_args(name="New WF", definition="/nonexistent/file.json", description=None) + with self.assertRaises(SystemExit) as ctx: + _create_workflow(args) + self.assertEqual(ctx.exception.code, 1) + + def test_create_workflow_invalid_json(self): + from roboflow.cli.handlers.workflow import _create_workflow + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("{bad json") + f.flush() + tmp_path = f.name + + try: + args = _make_args(name="New WF", definition=tmp_path, description=None) + with self.assertRaises(SystemExit) as ctx: + _create_workflow(args) + self.assertEqual(ctx.exception.code, 1) + finally: + os.unlink(tmp_path) + + +class TestWorkflowUpdate(unittest.TestCase): + @patch("roboflow.adapters.rfapi.update_workflow") + @patch("roboflow.adapters.rfapi.get_workflow") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_update_workflow(self, _mock_key, mock_get, mock_update): + from roboflow.cli.handlers.workflow import _update_workflow + + mock_get.return_value = {"workflow": {"id": "wf-123", "name": "My WF", "url": "my-wf", "config": "{}"}} + mock_update.return_value = {"url": "my-wf", "status": "updated"} + defn = {"blocks": []} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(defn, f) + f.flush() + tmp_path = f.name + + try: + args = _make_args(workflow_url="my-wf", definition=tmp_path) + with patch("builtins.print") as mock_print: + _update_workflow(args) + mock_get.assert_called_once_with("test-key", "test-ws", "my-wf") + mock_update.assert_called_once_with( + "test-key", + "test-ws", + workflow_id="wf-123", + workflow_name="My WF", + workflow_url="my-wf", + config=json.dumps(defn), + ) + printed = mock_print.call_args[0][0] + self.assertIn("Updated workflow", printed) + finally: + os.unlink(tmp_path) + + @patch("roboflow.adapters.rfapi.update_workflow") + @patch("roboflow.adapters.rfapi.get_workflow") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_update_workflow_no_definition(self, _mock_key, mock_get, mock_update): + """When no --definition is given, existing config is preserved.""" + from roboflow.cli.handlers.workflow import _update_workflow + + mock_get.return_value = { + "workflow": {"id": "wf-123", "name": "My WF", "url": "my-wf", "config": '{"existing": true}'} + } + mock_update.return_value = {"url": "my-wf", "status": "updated"} + args = _make_args(workflow_url="my-wf", definition=None) + with patch("builtins.print"): + _update_workflow(args) + mock_update.assert_called_once_with( + "test-key", + "test-ws", + workflow_id="wf-123", + workflow_name="My WF", + workflow_url="my-wf", + config='{"existing": true}', + ) + + def test_update_workflow_missing_file(self): + from roboflow.cli.handlers.workflow import _update_workflow + + args = _make_args(workflow_url="my-wf", definition="/nonexistent/file.json") + with self.assertRaises(SystemExit) as ctx: + _update_workflow(args) + self.assertEqual(ctx.exception.code, 1) + + +class TestWorkflowVersionList(unittest.TestCase): + @patch("roboflow.adapters.rfapi.list_workflow_versions") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_list_versions(self, _mock_key, mock_versions): + from roboflow.cli.handlers.workflow import _list_workflow_versions + + mock_versions.return_value = { + "versions": [ + {"version": "1", "created": "2026-01-01"}, + {"version": "2", "created": "2026-02-01"}, + ] + } + args = _make_args(workflow_url="my-wf") + with patch("builtins.print") as mock_print: + _list_workflow_versions(args) + mock_versions.assert_called_once_with("test-key", "test-ws", "my-wf") + printed = mock_print.call_args[0][0] + self.assertIn("1", printed) + self.assertIn("2", printed) + + @patch("roboflow.adapters.rfapi.list_workflow_versions") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_list_versions_json(self, _mock_key, mock_versions): + from roboflow.cli.handlers.workflow import _list_workflow_versions + + mock_versions.return_value = {"versions": [{"version": "1", "created": "2026-01-01"}]} + args = _make_args(json=True, workflow_url="my-wf") + with patch("builtins.print") as mock_print: + _list_workflow_versions(args) + out = json.loads(mock_print.call_args[0][0]) + self.assertIsInstance(out, list) + + +class TestWorkflowFork(unittest.TestCase): + @patch("roboflow.adapters.rfapi.fork_workflow") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_fork_workflow_same_workspace(self, _mock_key, mock_fork): + """When workflow_url is just a slug, source_workspace defaults to current ws.""" + from roboflow.cli.handlers.workflow import _fork_workflow + + mock_fork.return_value = {"url": "my-wf-fork", "workflow_url": "my-wf-fork"} + args = _make_args(workflow_url="my-wf") + with patch("builtins.print") as mock_print: + _fork_workflow(args) + mock_fork.assert_called_once_with("test-key", "test-ws", source_workspace="test-ws", source_workflow="my-wf") + printed = mock_print.call_args[0][0] + self.assertIn("Forked workflow", printed) + + @patch("roboflow.adapters.rfapi.fork_workflow") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_fork_workflow_cross_workspace(self, _mock_key, mock_fork): + """When workflow_url is 'other-ws/my-wf', source_workspace is parsed.""" + from roboflow.cli.handlers.workflow import _fork_workflow + + mock_fork.return_value = {"url": "my-wf-fork"} + args = _make_args(workflow_url="other-ws/my-wf") + with patch("builtins.print"): + _fork_workflow(args) + mock_fork.assert_called_once_with("test-key", "test-ws", source_workspace="other-ws", source_workflow="my-wf") + + @patch("roboflow.adapters.rfapi.fork_workflow") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_fork_workflow_json(self, _mock_key, mock_fork): + from roboflow.cli.handlers.workflow import _fork_workflow + + mock_fork.return_value = {"url": "my-wf-fork"} + args = _make_args(json=True, workflow_url="my-wf") + with patch("builtins.print") as mock_print: + _fork_workflow(args) + out = json.loads(mock_print.call_args[0][0]) + self.assertEqual(out["status"], "forked") + self.assertEqual(out["source"], "my-wf") + self.assertEqual(out["new_url"], "my-wf-fork") + + +class TestWorkflowStubs(unittest.TestCase): + def test_build_stub(self): + from roboflow.cli.handlers.workflow import _stub_build + + args = _make_args() + with self.assertRaises(SystemExit): + _stub_build(args) + + def test_run_stub(self): + from roboflow.cli.handlers.workflow import _stub_run + + args = _make_args() + with self.assertRaises(SystemExit): + _stub_run(args) + + def test_deploy_stub(self): + from roboflow.cli.handlers.workflow import _stub_deploy + + args = _make_args() + with self.assertRaises(SystemExit): + _stub_deploy(args) + + +class TestWorkflowNoWorkspace(unittest.TestCase): + """Verify proper error when no workspace is available.""" + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=None) + def test_list_no_workspace(self, _mock_resolve): + from roboflow.cli.handlers.workflow import _list_workflows + + args = _make_args(workspace=None, api_key=None) + with self.assertRaises(SystemExit) as ctx: + _list_workflows(args) + self.assertEqual(ctx.exception.code, 2) + + +class TestWorkflowDeleteHandler(unittest.TestCase): + """workflow delete calls rfapi.delete_workflow and honors --yes.""" + + def test_delete_calls_rfapi(self) -> None: + from roboflow.cli.handlers.workflow import _delete_workflow + + args = _make_args(workflow_url="slow-webhooks", yes=True) + with patch("roboflow.adapters.rfapi.delete_workflow", return_value={"deleted": True}) as mock_del: + _delete_workflow(args) + mock_del.assert_called_once_with("test-key", "test-ws", "slow-webhooks") + + +class TestWorkflowRestoreHandler(unittest.TestCase): + """workflow restore looks up by URL (or id) in Trash, then restores.""" + + def test_restore_found_by_url(self) -> None: + from roboflow.cli.handlers.workflow import _restore_workflow + + trash = {"sections": {"workflows": [{"id": "wf_abc123", "url": "slow-webhooks", "name": "Slow Webhooks"}]}} + args = _make_args(workflow_url="slow-webhooks") + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch( + "roboflow.adapters.rfapi.restore_trash_item", + return_value={"restored": True}, + ) as mock_restore, + ): + _restore_workflow(args) + mock_restore.assert_called_once_with("test-key", "test-ws", "workflow", "wf_abc123") + + def test_restore_found_by_id(self) -> None: + # Callers who pass a Firestore id (e.g. copy/paste from `trash list`) + # still resolve, via the id fallback. + from roboflow.cli.handlers.workflow import _restore_workflow + + trash = {"sections": {"workflows": [{"id": "wf_abc123", "url": "slow-webhooks", "name": "Slow Webhooks"}]}} + args = _make_args(workflow_url="wf_abc123") + with ( + patch("roboflow.adapters.rfapi.list_trash", return_value=trash), + patch( + "roboflow.adapters.rfapi.restore_trash_item", + return_value={"restored": True}, + ) as mock_restore, + ): + _restore_workflow(args) + mock_restore.assert_called_once_with("test-key", "test-ws", "workflow", "wf_abc123") + + def test_restore_not_in_trash(self) -> None: + from roboflow.cli.handlers.workflow import _restore_workflow + + args = _make_args(workflow_url="slow-webhooks") + with ( + patch( + "roboflow.adapters.rfapi.list_trash", + return_value={"sections": {"workflows": []}}, + ), + patch("roboflow.adapters.rfapi.restore_trash_item") as mock_restore, + ): + with self.assertRaises(SystemExit): + _restore_workflow(args) + mock_restore.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/cli/test_workspace.py b/tests/cli/test_workspace.py new file mode 100644 index 00000000..0f9e038f --- /dev/null +++ b/tests/cli/test_workspace.py @@ -0,0 +1,196 @@ +"""Tests for the workspace CLI handler.""" + +import json +import unittest +from argparse import Namespace +from unittest.mock import patch + +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestWorkspaceRegistration(unittest.TestCase): + """Verify workspace handler registers expected subcommands.""" + + def test_workspace_app_exists(self) -> None: + from roboflow.cli.handlers.workspace import workspace_app + + self.assertIsNotNone(workspace_app) + + def test_workspace_list_exists(self) -> None: + result = runner.invoke(app, ["workspace", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workspace_get_exists(self) -> None: + result = runner.invoke(app, ["workspace", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workspace_usage_exists(self) -> None: + result = runner.invoke(app, ["workspace", "usage", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workspace_plan_exists(self) -> None: + result = runner.invoke(app, ["workspace", "plan", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_workspace_stats_exists(self) -> None: + result = runner.invoke(app, ["workspace", "stats", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_handler_functions_exist(self) -> None: + from roboflow.cli.handlers import workspace + + self.assertTrue(callable(workspace._list_workspaces)) + self.assertTrue(callable(workspace._get_workspace)) + self.assertTrue(callable(workspace._workspace_usage)) + self.assertTrue(callable(workspace._workspace_plan)) + self.assertTrue(callable(workspace._workspace_stats)) + + +class TestWorkspaceUsageHandler(unittest.TestCase): + """Test workspace usage command behavior.""" + + @patch("roboflow.adapters.rfapi.get_billing_usage") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_usage_json(self, _mock_key, _mock_ws, mock_usage): + mock_usage.return_value = {"usage": {"inference_calls": 100, "images_uploaded": 50}} + args = Namespace(json=True, workspace=None, api_key=None, quiet=False) + + from roboflow.cli.handlers.workspace import _workspace_usage + + with patch("builtins.print") as mock_print: + _workspace_usage(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertIn("usage", data) + + @patch("roboflow.adapters.rfapi.get_billing_usage") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_usage_text(self, _mock_key, _mock_ws, mock_usage): + mock_usage.return_value = {"usage": {"inference_calls": 100}} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False) + + from roboflow.cli.handlers.workspace import _workspace_usage + + with patch("builtins.print") as mock_print: + _workspace_usage(args) + printed = mock_print.call_args[0][0] + self.assertIn("Billing Usage", printed) + + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value=None) + def test_usage_no_workspace(self, _mock_ws): + args = Namespace(json=True, workspace=None, api_key=None, quiet=False) + + from roboflow.cli.handlers.workspace import _workspace_usage + + with self.assertRaises(SystemExit) as ctx: + _workspace_usage(args) + self.assertEqual(ctx.exception.code, 2) + + +class TestWorkspacePlanHandler(unittest.TestCase): + """Test workspace plan command behavior.""" + + @patch("roboflow.adapters.rfapi.get_plan_info") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_plan_json(self, _mock_key, _mock_ws, mock_plan): + mock_plan.return_value = {"plan": {"name": "Pro", "limit": 10000}} + args = Namespace(json=True, workspace=None, api_key=None, quiet=False) + + from roboflow.cli.handlers.workspace import _workspace_plan + + with patch("builtins.print") as mock_print: + _workspace_plan(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertIn("plan", data) + + @patch("roboflow.adapters.rfapi.get_plan_info") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_plan_text(self, _mock_key, _mock_ws, mock_plan): + mock_plan.return_value = {"plan": {"name": "Pro"}} + args = Namespace(json=False, workspace=None, api_key=None, quiet=False) + + from roboflow.cli.handlers.workspace import _workspace_plan + + with patch("builtins.print") as mock_print: + _workspace_plan(args) + printed = mock_print.call_args[0][0] + self.assertIn("Plan Info", printed) + + +class TestWorkspaceStatsHandler(unittest.TestCase): + """Test workspace stats command behavior.""" + + @patch("roboflow.adapters.rfapi.get_labeling_stats") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_stats_json(self, _mock_key, _mock_ws, mock_stats): + mock_stats.return_value = {"stats": {"total_annotations": 500}} + args = Namespace( + json=True, workspace=None, api_key=None, quiet=False, start_date="2026-01-01", end_date="2026-04-01" + ) + + from roboflow.cli.handlers.workspace import _workspace_stats + + with patch("builtins.print") as mock_print: + _workspace_stats(args) + printed = mock_print.call_args[0][0] + data = json.loads(printed) + self.assertIn("stats", data) + + @patch("roboflow.adapters.rfapi.get_labeling_stats") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_stats_passes_dates(self, _mock_key, _mock_ws, mock_stats): + mock_stats.return_value = {"stats": {"total_annotations": 500}} + args = Namespace( + json=True, workspace=None, api_key=None, quiet=False, start_date="2026-01-01", end_date="2026-04-01" + ) + + from roboflow.cli.handlers.workspace import _workspace_stats + + with patch("builtins.print"): + _workspace_stats(args) + mock_stats.assert_called_once_with("fake-key", "test-ws", start_date="2026-01-01", end_date="2026-04-01") + + @patch("roboflow.adapters.rfapi.get_labeling_stats") + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_stats_text(self, _mock_key, _mock_ws, mock_stats): + mock_stats.return_value = {"stats": {"total_annotations": 500}} + args = Namespace( + json=False, workspace=None, api_key=None, quiet=False, start_date="2026-01-01", end_date="2026-04-01" + ) + + from roboflow.cli.handlers.workspace import _workspace_stats + + with patch("builtins.print") as mock_print: + _workspace_stats(args) + printed = mock_print.call_args[0][0] + self.assertIn("Labeling Stats", printed) + + @patch("roboflow.adapters.rfapi.get_labeling_stats", side_effect=Exception("server error")) + @patch("roboflow.cli._resolver.resolve_default_workspace", return_value="test-ws") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_stats_error_json(self, _mock_key, _mock_ws, _mock_stats): + args = Namespace( + json=True, workspace=None, api_key=None, quiet=False, start_date="2026-01-01", end_date="2026-04-01" + ) + + from roboflow.cli.handlers.workspace import _workspace_stats + + with self.assertRaises(SystemExit) as ctx: + _workspace_stats(args) + self.assertEqual(ctx.exception.code, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/datasets/corrosion-singlelabel-classification/README.dataset.txt b/tests/datasets/corrosion-singlelabel-classification/README.dataset.txt new file mode 100644 index 00000000..890430c0 --- /dev/null +++ b/tests/datasets/corrosion-singlelabel-classification/README.dataset.txt @@ -0,0 +1,5 @@ +# Synthetic Corrosion Dataset > 2022-08-16 10:23am +https://universe.roboflow.com/classification/synthetic-corrosion-dataset + +Provided by Roboflow +License: CC BY 4.0 diff --git a/tests/datasets/corrosion-singlelabel-classification/test/Corrosion/craiyon_082120_rust_on_a_metal_surface_png_jpg.rf.31cec528f5d8ce30bd3d972553a65ae8.jpg b/tests/datasets/corrosion-singlelabel-classification/test/Corrosion/craiyon_082120_rust_on_a_metal_surface_png_jpg.rf.31cec528f5d8ce30bd3d972553a65ae8.jpg new file mode 100644 index 00000000..8c5a7293 Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/test/Corrosion/craiyon_082120_rust_on_a_metal_surface_png_jpg.rf.31cec528f5d8ce30bd3d972553a65ae8.jpg differ diff --git a/tests/datasets/corrosion-singlelabel-classification/test/no-corrosion/craiyon_084611_very_clean_galvanized_pipe_in_an_industrial_setting_png_jpg.rf.0a107e9b94256d2b7cd344469a086cac.jpg b/tests/datasets/corrosion-singlelabel-classification/test/no-corrosion/craiyon_084611_very_clean_galvanized_pipe_in_an_industrial_setting_png_jpg.rf.0a107e9b94256d2b7cd344469a086cac.jpg new file mode 100644 index 00000000..5608fe37 Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/test/no-corrosion/craiyon_084611_very_clean_galvanized_pipe_in_an_industrial_setting_png_jpg.rf.0a107e9b94256d2b7cd344469a086cac.jpg differ diff --git a/tests/datasets/corrosion-singlelabel-classification/train/Corrosion/craiyon_082033_rust_on_a_metal_surface_png_jpg.rf.05b8b2d42e101b838df859f711a320fa.jpg b/tests/datasets/corrosion-singlelabel-classification/train/Corrosion/craiyon_082033_rust_on_a_metal_surface_png_jpg.rf.05b8b2d42e101b838df859f711a320fa.jpg new file mode 100644 index 00000000..4861af44 Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/train/Corrosion/craiyon_082033_rust_on_a_metal_surface_png_jpg.rf.05b8b2d42e101b838df859f711a320fa.jpg differ diff --git a/tests/datasets/corrosion-singlelabel-classification/train/Corrosion/craiyon_082033_rust_on_a_metal_surface_png_jpg.rf.937292ebf95b7cc5575bfe6cfca4123b.jpg b/tests/datasets/corrosion-singlelabel-classification/train/Corrosion/craiyon_082033_rust_on_a_metal_surface_png_jpg.rf.937292ebf95b7cc5575bfe6cfca4123b.jpg new file mode 100644 index 00000000..e9a19060 Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/train/Corrosion/craiyon_082033_rust_on_a_metal_surface_png_jpg.rf.937292ebf95b7cc5575bfe6cfca4123b.jpg differ diff --git a/tests/datasets/corrosion-singlelabel-classification/train/Corrosion/craiyon_082033_rust_on_a_metal_surface_png_jpg.rf.96a16f19477c353e75d7be8251e87dcd.jpg b/tests/datasets/corrosion-singlelabel-classification/train/Corrosion/craiyon_082033_rust_on_a_metal_surface_png_jpg.rf.96a16f19477c353e75d7be8251e87dcd.jpg new file mode 100644 index 00000000..229b7507 Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/train/Corrosion/craiyon_082033_rust_on_a_metal_surface_png_jpg.rf.96a16f19477c353e75d7be8251e87dcd.jpg differ diff --git a/tests/datasets/corrosion-singlelabel-classification/train/no-corrosion/craiyon_084311_very_clean_blue_pipe_joints_in_an_industrial_setting_png_jpg.rf.10bbbd6001a08115564ae8490ea9e6d3.jpg b/tests/datasets/corrosion-singlelabel-classification/train/no-corrosion/craiyon_084311_very_clean_blue_pipe_joints_in_an_industrial_setting_png_jpg.rf.10bbbd6001a08115564ae8490ea9e6d3.jpg new file mode 100644 index 00000000..2a9b7b97 Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/train/no-corrosion/craiyon_084311_very_clean_blue_pipe_joints_in_an_industrial_setting_png_jpg.rf.10bbbd6001a08115564ae8490ea9e6d3.jpg differ diff --git a/tests/datasets/corrosion-singlelabel-classification/train/no-corrosion/craiyon_084311_very_clean_blue_pipe_joints_in_an_industrial_setting_png_jpg.rf.506a655346fbf48c8e74ee28654ba8b4.jpg b/tests/datasets/corrosion-singlelabel-classification/train/no-corrosion/craiyon_084311_very_clean_blue_pipe_joints_in_an_industrial_setting_png_jpg.rf.506a655346fbf48c8e74ee28654ba8b4.jpg new file mode 100644 index 00000000..022a9c8c Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/train/no-corrosion/craiyon_084311_very_clean_blue_pipe_joints_in_an_industrial_setting_png_jpg.rf.506a655346fbf48c8e74ee28654ba8b4.jpg differ diff --git a/tests/datasets/corrosion-singlelabel-classification/train/no-corrosion/craiyon_084311_very_clean_blue_pipe_joints_in_an_industrial_setting_png_jpg.rf.9492ef8c7a00d5644eed0d6fcfae9f7d.jpg b/tests/datasets/corrosion-singlelabel-classification/train/no-corrosion/craiyon_084311_very_clean_blue_pipe_joints_in_an_industrial_setting_png_jpg.rf.9492ef8c7a00d5644eed0d6fcfae9f7d.jpg new file mode 100644 index 00000000..2424aead Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/train/no-corrosion/craiyon_084311_very_clean_blue_pipe_joints_in_an_industrial_setting_png_jpg.rf.9492ef8c7a00d5644eed0d6fcfae9f7d.jpg differ diff --git a/tests/datasets/corrosion-singlelabel-classification/valid/Corrosion/craiyon_083146_rusty_metal_surface_with_paint_chipping_png_jpg.rf.41829ee700e2b2b6d29f8dc3e7c3bd58.jpg b/tests/datasets/corrosion-singlelabel-classification/valid/Corrosion/craiyon_083146_rusty_metal_surface_with_paint_chipping_png_jpg.rf.41829ee700e2b2b6d29f8dc3e7c3bd58.jpg new file mode 100644 index 00000000..dfad029c Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/valid/Corrosion/craiyon_083146_rusty_metal_surface_with_paint_chipping_png_jpg.rf.41829ee700e2b2b6d29f8dc3e7c3bd58.jpg differ diff --git a/tests/datasets/corrosion-singlelabel-classification/valid/no-corrosion/craiyon_084620_very_clean_galvanized_pipe_in_an_industrial_setting_png_jpg.rf.6c6895effcfc4dab36d6b1455357a8fe.jpg b/tests/datasets/corrosion-singlelabel-classification/valid/no-corrosion/craiyon_084620_very_clean_galvanized_pipe_in_an_industrial_setting_png_jpg.rf.6c6895effcfc4dab36d6b1455357a8fe.jpg new file mode 100644 index 00000000..543200c0 Binary files /dev/null and b/tests/datasets/corrosion-singlelabel-classification/valid/no-corrosion/craiyon_084620_very_clean_galvanized_pipe_in_an_industrial_setting_png_jpg.rf.6c6895effcfc4dab36d6b1455357a8fe.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/README.dataset.txt b/tests/datasets/skinproblem-multilabel-classification/README.dataset.txt new file mode 100644 index 00000000..05e23692 --- /dev/null +++ b/tests/datasets/skinproblem-multilabel-classification/README.dataset.txt @@ -0,0 +1,5 @@ +# Skin-Problem-MultiLabel > 2023-12-26 4:26pm +https://universe.roboflow.com/parin-kittipongdaja-vwmn3/skin-problem-multilabel + +Provided by a Roboflow user +License: CC BY 4.0 diff --git a/tests/datasets/skinproblem-multilabel-classification/test/101_jpg.rf.ffb91e580c891eb04b715545274b2469.jpg b/tests/datasets/skinproblem-multilabel-classification/test/101_jpg.rf.ffb91e580c891eb04b715545274b2469.jpg new file mode 100644 index 00000000..6a49be54 Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/test/101_jpg.rf.ffb91e580c891eb04b715545274b2469.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/test/252_jpeg_jpg.rf.fdb8e3a6b21d7ff3e7b5190c7d588778.jpg b/tests/datasets/skinproblem-multilabel-classification/test/252_jpeg_jpg.rf.fdb8e3a6b21d7ff3e7b5190c7d588778.jpg new file mode 100644 index 00000000..7067ce0e Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/test/252_jpeg_jpg.rf.fdb8e3a6b21d7ff3e7b5190c7d588778.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/test/29-Male-South-Korean-Kang-Song_jpg.rf.fde9e1486ef1d8a180a7cb05d9397f22.jpg b/tests/datasets/skinproblem-multilabel-classification/test/29-Male-South-Korean-Kang-Song_jpg.rf.fde9e1486ef1d8a180a7cb05d9397f22.jpg new file mode 100644 index 00000000..823314bb Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/test/29-Male-South-Korean-Kang-Song_jpg.rf.fde9e1486ef1d8a180a7cb05d9397f22.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/test/_classes.csv b/tests/datasets/skinproblem-multilabel-classification/test/_classes.csv new file mode 100644 index 00000000..3a18fc7b --- /dev/null +++ b/tests/datasets/skinproblem-multilabel-classification/test/_classes.csv @@ -0,0 +1,482 @@ +filename, Acne, Blackheads, Dark Spots, Dry Skin, Eye bags, Normal Skin, Oily Skin, Pores, Skin Redness, Wrinkles +berminyak__-57-_JPG_jpg.rf.09d006578441e5bd935aba99d9fa279c.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-615-_jpg.rf.0974d615f40e253b15a9ab7a77ca776d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +122_jpg.rf.02cc5bcc2129c6fdc2a20da323e2c681.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_273_jpg.rf.0809a13957ccb332598fe2ba13d16a8f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +183_jpg.rf.0344fac8ba654383820b1c23bda9316a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +34-Male-South-Korean-Yong-Hwa-Jung_jpg.rf.0890b080a72bfe9e19646adf6fede6a9.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-113-_jpg.rf.07195b316447dea207693be41f414110.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_33_jpg.rf.056b125086c596d71ddba0039aa0f79e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_53_jpg.rf.09de235b8e33981d1ba3000d3424558a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-800-_jpg.rf.0a0dc84728b2b05492d6eb7dee06da29.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-skin_116_jpeg_jpg.rf.03ef3806db34f400c7af77f7c88ec8c0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_41_jpeg_jpg.rf.0a089b9502dbcc1f05ead607d9d3fb1c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-244-_jpeg_jpg.rf.075f1226c512b97ea24dee99c31356b2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-48-_jpeg_jpg.rf.02228d353547bc747af9c3f6649b6f58.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_385_png_jpg.rf.02b61d708148070511e886d5b87147d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +459_jpg.rf.040c35de2599ad94e40701a96bcad9e5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-3-_jpeg_jpg.rf.081a37341a67056838931ebd7a723418.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_401_jpg.rf.09e9c5f067b4ca4037d1da77c8d369ac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +29-Male-South-Korean-Dong-Hyun-Kim_jpg.rf.030c24c24f15c46fdfaea59295a992f5.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-550-_jpeg_jpg.rf.0a1b12dedfb71219c1823817e3f3a174.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_240_jpg.rf.01862d5eacefdbbf9429dbce02e759ee.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +97723bf3c1966b6736962b435dd43bda_jpg.rf.00779e7ad9a28db58209015007708742.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-621-_jpeg_jpg.rf.07e1cea41cef43307f79590096b74884.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_137_jpg.rf.0a6ce97517f50177dd77955204bb4c7d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +352_jpg.rf.0b3b964ea2b8ef64cf76b6822ce634ba.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-829-_jpg.rf.0a3e5e2ad333b394fe274d3b04ee15df.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-781-_jpeg_jpg.rf.075f175ffd01a01743818b3ac53548cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +143_jpg.rf.0779798c2c88a015e893e3625116af00.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +44_jpg.rf.08c5bef920640f31fbe215b94c9a1328.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_183_jpg.rf.0c4d298ba0350baa715a177d992a1a88.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +148_jpg.rf.0de349409b2285a870832f9191de6d4c.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +301_jpg.rf.0c980675eaddfd23bd336723fab8b8c1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +264_jpg.rf.0bbbeaa12414ef4e7b5f33360564a5be.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +81_jpg.rf.0f2bdc989a672a147a5441b97d929620.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +107_jpg.rf.0ff13fd967dabd97ded817dc9e41ed5c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_247_jpg.rf.0f5b79678b96622c2782661484e4cb4c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-273-_jpg.rf.0f566d179d526cbee4af2462e5439494.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle2_109_jpg.rf.1062f913c14d6b1b80feabf8e8ae1a7d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Rosacea-treatment-removal-redness-acne-veins-spider_jpg.rf.1163f3a60404245b0ee7203f5bd80ed0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +kering__-24-_jpg.rf.12454a77f50fd510a4d40cc2c6d2fecc.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-926-_jpeg_jpg.rf.12acc032271fba310d7c2bab24619745.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_37_jpg.rf.118337e49fb89a1cf4e2adbb91219d82.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +a9a346a2-5870-43d7-b11a-9b89352d011f_jpg.rf.12d8b1df83dae3a711ca2e3d828b4fcc.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +dry-111-_jpg.rf.138178cef647e8dc559b273b9f90edd1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +35-Male-South-Korean-Yong-Gyu-Park_jpg.rf.140a14575fcf07c688cef3f8cd65c7ff.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +bbdc7178-c848-4761-a630-e6b8cc3799d3_jpg.rf.1450fdbe48237b480fdcbb826d2e0306.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-75-_jpeg_jpg.rf.14b75dc2ee2947ea7c59df01b0493c43.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +87a52750-9e09-40b1-bf53-5330e653b7a2_jpg.rf.15312b271f8bd0c1f04f7897dc3c3042.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-67-_jpeg_jpg.rf.1546ea3458bd07ec4c99aeb33b3ba792.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +95_jpg.rf.162d626d35bbec7f89fc8bfee84ec43d.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +262_jpeg_jpg.rf.1684166392919f43f5e92c26cfa0d209.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Kering-23-_jpeg_jpg.rf.16a4703e63c9dc0a9bbe17fcc79a5332.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-807-_jpeg_jpg.rf.175f10855297433272eac70b3e256232.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +16_jpg.rf.1897ed6f0c5c565faf3c20994d7e4381.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Image_115_jpg.rf.16c0fb5f71cfe6f827197afb310a7915.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +38-Male-Hong-Konger-William-Chan_jpg.rf.191a6b43b58aa970514e5c1774cf8022.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering__-15-_JPG_jpg.rf.17366da0888e857fe18892abb32bf8db.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-466-_jpg.rf.10a63afe2bfa0c941ab93b5a68c93e3b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily46_jpg.rf.1ac9ec95864a14bf725f480187443199.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_70_jpg.rf.1b065ec4a7b6a188d471a5d468dfae9f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-824-_jpeg_jpg.rf.1b03986ac7625d3933aeb44b796fc6d7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-671-_jpeg_jpg.rf.1b0705d696e3f2ed71e318877f289f60.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_153_jpg.rf.1b761c2dd5415f692d94a968412b6045.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-256-_jpg.rf.1d89dbabb01178305d14d3cb3f1d054d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-4-_png_jpg.rf.1eb5a526bf59192bcd43523ed120ac3b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_105_png_jpg.rf.1fa39c1a852c360ef86340a71af68513.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_523_jpg.rf.1dd73ba2d3035a78b378c529108caf44.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_342_jpg.rf.1fbf5ee385ac625b896ad825f44a02dd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_250_jpg.rf.20053d8fac8cf558f73d7d6bad7415fa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_208_jpg.rf.23a6d5986978969d81605e172642a908.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-745-_jpg.rf.23c56213785f0b54399e3b00f1e95ea9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-75-_jpg.rf.241a2a12ced1694f1c9ea76a27d19fd9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-370-_jpg.rf.241f271220412b87863b63e176b91e4f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-27-_jpeg_jpg.rf.254bc657c3afa623d95d63ddc0073e30.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-269-_jpeg_jpg.rf.242d4ca5c1d229bfa8c3be6e7eaf13c4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +21-Female-Thai-Benyapa-Jeenprasom_jpg.rf.252885b592a70f02f4f64bf20d4108af.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle2_178_jpg.rf.24fbdf268695a5b1796e86bd1e4e09bf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +251_jpg.rf.258684c2aa56e50b95c2f3f975680b72.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Image_145_jpg.rf.25880da069c5924acd378ff9bb422c0e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_20_jpg.rf.25a2fe2773c45920045e660df42b445a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-76-_jpg.rf.265d7a9ea9a96e2c4395cf06eee40f42.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_187_jpg.rf.26aa8355c3ab45f0be87b45b6718703c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_60-1-_jpg.rf.269b714c2db897439462186a58de9ee0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_11_jpg.rf.26e65974788769670a517dfe1ef1af72.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-594-_jpeg_jpg.rf.279593b358c987bc673dea5dd13ad803.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-505-_jpg.rf.27618e3536dd082713448fef61563205.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-6-_jpg.rf.29cf17889af41942f1c39cfa76d034f8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +27-Male-Chinese-Xing-Xu-Chen_jpg.rf.2a14c1d3b9414a2260dd39e7411bc5b5.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +50_jpg.rf.2ad452353935aaa15e955d5ce14a787e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-904-_jpeg_jpg.rf.2b1b8a35001cdb2ac7fdd99b918fd050.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +37_jpg.rf.2c2520b9f5e5f544b4085fe554e206cb.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +3_jpg.rf.2c6fc6ff1885a78fc342e278ddd2e62b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-525-_jpg.rf.2ebf7b7cb6b46fdcb1ef43cb1975e62e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-762-_jpg.rf.2feccb4382ffdcd63f818f072a55434c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-561-_jpeg_jpg.rf.30e9a75711bb489fbd0305aa4f59b112.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-660-_jpeg_jpg.rf.2ed18f8b07085ddeee26f21e947d3317.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_495_jpg.rf.30e21ef593f795470d9eb56a9733a020.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-75-_jpg.rf.311d1c77492dcd2e99c42d3ce33c9e38.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-35-_jpg.rf.3105c415308a77acddc4ca3644ffcaf1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-625-_jpg.rf.32d10e198c33f02494af87bb45bdea78.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-604-_jpg.rf.329a89746cec212a32edd79d2bd00e7d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-570-_jpeg_jpg.rf.33592765a2723fc28d9bb9c4b3d9b2cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +23-Female-Thai-Pattranite-Limpatiyakorn_jpg.rf.338cf6d6a60ead7572eee407a91f8070.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-671-_jpg.rf.34607ccd4e7eae5a47fde810c696274e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-skin_19_jpeg_jpg.rf.34ac188f3d7689c1c825b2593273314d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +199_jpg.rf.34b45f0cebd837e8bf873ebe538d8b5d.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_58_jpeg_jpg.rf.3556467331a64bd273d6959a9d874e3b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_440_jpg.rf.35ee4b126adb3a39c7ffcdd67ca4037b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +658_jpg.rf.3591b417989f08f62a5df7a8b6929861.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +53-Female-South-Korean-Hye-Soo-Kim_jpg.rf.390bfc6b319806279bc7f859d8b0760e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +dry1_jpg.rf.37b2858e286b39aafb37bcfc7ff96bb9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_618_jpg.rf.3797eede0153a9c01fbfee69bdbf2e64.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +684_jpg.rf.3a078b25defb83ffbf5456d5c491cc78.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +199_jpg.rf.3a5687263ab0ff5b726eee5a3bbcc5c3.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering_-18-_jpg.rf.3a5a8517f080aec89a175d5a094ba736.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_174_jpg.rf.3a4c77b8fd7aa97e1b6704591e7e1edd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_247_jpg.rf.3a61e53c620f5b5f98169dc83a79646b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_236_jpg.rf.3b8ccb34f58645db6e4c72cc52b26342.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +99_jpg.rf.3a909093a0efef6b7dbdd475f729a1a5.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +378_jpg.rf.3dc4079ce36e04bafcd6bcb9428bba6e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-775-_jpg.rf.3de3193880ae521e664c73460225d125.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2677149532_1_jpg.rf.3dae23b5f7d10d85667b7160f031fc0b.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-114-_jpeg_jpg.rf.3c9fa29d8f6865fa8558e7a2f890c2d0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-120-_jpeg_jpg.rf.3de63b0f6d692e5df916c783859b4a2f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-567-_jpg.rf.3c8ec0da75e108f6dcbcaea0a47b60d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +40_jpg.rf.3ef104492bdf093f95f02e5ab5325a7c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +240b91cb-9d15-4918-a3d0-7570365e4de4_jpg.rf.3e54129606a04062b7490f62985db43f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_344_jpg.rf.3fc9dc03ddf58d93a28c26c64ba9e1b1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_92_jpeg_jpg.rf.3f9ca1890fb85cfb6df9fe49debf5605.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +63_jpg.rf.42c751be1a0d37c6036c161f08d1aaf9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +105_jpg.rf.3f168e77911bb0d2d1a9eec9c411362b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_142_jpg.rf.426b7b9b2d5118bed6c7bf2b59cad902.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-228-_jpg.rf.42c94513502fbf615dcb72832092e756.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_219_jpg.rf.433718179de0c1e49d4938722faa0126.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering_-102-_jpg.rf.44f9901a2eb51ab798d3b23e6e648d3e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_55_jpg.rf.43bd7e9248f2d526dff4ce1894e25b53.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-22-_jpeg_jpg.rf.4551575fb6f9a14e2badf713b17542a1.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +229_jpg.rf.456ed4c7879d82cee8e2344971f8bf76.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-375-_jpeg_jpg.rf.48d80b6b126f0fe55a97f75230c90c38.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +256_jpg.rf.487698806288f229b0b2112ee8c69f12.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_469_jpg.rf.4630f720d3f578f6b49f680eafddba0b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-103-_jpg.rf.48e4046f1ad0b5e78b01071e65d9461b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-695-_jpeg_jpg.rf.49666a5a40d562c4442691eded953925.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-162-_jpg.rf.490f9de58de8bc2f070acc5ba77578f8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-433-_jpeg_jpg.rf.497e19101df7cd1d54c72cbbaf2f1f63.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +253_jpg.rf.499fc1d1c5c0b17f8f2fa7b9db86f353.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +levle1_583_jpg.rf.4ae7179d9408bf74559dcba2800cb57a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-453-_jpeg_jpg.rf.4a8aaad4b7247cd67d78e0181b84c077.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_167_jpg.rf.4a3554681e07451321300c9174274803.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_261_jpg.rf.4cf2753bc484f7951ce3d560a465f188.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-170-_jpg.rf.4b3a1fbb27152e584cf7108e7da81c91.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering__-33-_jpg.rf.4d65f0bf9277f5f9943c762571b6f344.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-298-_jpg.rf.4d2d4ca04a925e8e6959cdbe1895fac0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_606_jpg.rf.4dc5d3d1f90628654df9aa93f3bc573f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-230-_jpeg_jpg.rf.4ec76310e06e5421c81b25a58f47fab8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_641_jpg.rf.4e2c09b74f5f8d93d9addb4c3f129fac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_179_jpg.rf.4da0f68d3f7ee6f033c8417d5ff78b66.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-861-_jpeg_jpg.rf.51f113bc94eec3436155507705330de2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-514-_jpg.rf.4f69144843a65023ba6a9bdc762d3e6c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-461-_jpg.rf.4ee3347765b474f944abe2312cfc8262.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_52_jpg.rf.50a3da48e4c31749cbec5980a2ec0ff8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_46_jpg.rf.520e7655b348f6f6082e7e6eba25fef5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_26_jpg.rf.5220089f515e0ea17b07b23b65a0f73b.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Berminyak-32-_jpg.rf.524158e629d1af756c721cfb6c3cab92.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-696-_jpg.rf.5478c7c57323999a6c2906534552dfd0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-726-_jpeg_jpg.rf.54b02127358683562a5b3c8e0ec60949.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +32-Male-Thai-Thassapak-Hsu_jpg.rf.58188e47e17cd565c931ab1e8cea590e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_200_jpg.rf.562575c62b22ffecf5a4901e3cd676d9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_401_jpg.rf.5932f348dc82adc8e84203daeb746d1b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-744-_jpg.rf.5ac5da94fcbc6f3a6d856f72ef41142b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-40-_jpg.rf.5b6affcf1678a342f5db245f39432444.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-215-_jpg.rf.5bf8fccfbc41363e46a25223c4c1e782.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_23_jpg.rf.5adf3a49691e2e97d06974061454859b.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-787-_jpeg_jpg.rf.5def0845c9067d4a995216a37b23e65e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_267_jpg.rf.5cf90d1d0c0103816dc59f06b48c4f42.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_12_jpg.rf.5da5ead245ffcfab4877ab4f1704eabf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2708460596_1_jpg.rf.5c8143a5593fce85e9a1218e8d8c5051.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +68c42e9c-5bc4-4469-a60c-000353f8c054_jpg.rf.5fc35b315cd35b5c1da6f8e77f98434c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +74_jpg.rf.5e161087b93d1b5960cfae75bf4632a0.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +96_jpg.rf.5f97c5ce620412e9847fa25dd04823ef.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Female-South-Korean-Ye-Rin-Bang_jpg.rf.6036c483fddefc023d133fdc528d6b7b.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-602-_jpg.rf.60dfd67f8aae015cb4c0cc8a3f28e8d2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-382-_jpeg_jpg.rf.610f7ee6e3680e8923d60f8555c32e85.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-338-_jpeg_jpg.rf.628d087c29dda47c79143a201b1a1ac3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_391_jpg.rf.61957231f08c27a16caf0230b0f2d3f0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_49_jpg.rf.6421d8184cff14b376922c516dada25b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_197_jpeg_jpg.rf.64e28c6285bca53e8f7e0aac669af5bc.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_372_jpg.rf.64faddcc32aea2044e0b9acc51c4766d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_204_jpg.rf.66f5b642dd7cd7fb1d4162844da7358b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +418_jpg.rf.65267fa676fe9f83425f5f9da5657c40.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-720-_jpeg_jpg.rf.653e8a403c5cbb8d7ef7a042b8eeb8f9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +351_jpg.rf.68d392c0cc198ce5bb2bffea5cab536b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +674_jpg.rf.694612843afe85320bf64dfd88b95a59.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_226_jpg.rf.697c47065a0b2af7197c0d3718e8e96d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_482_jpg.rf.6b68a25f00983dacb51e3e29378d69d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +42_jpg.rf.6a9212f297f1351ce97270d119629cd7.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_527-copy-_jpg.rf.6bc987b08f56736fa12cb247ec51ff8b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Female-South-Korean-Youn-Jung-Go_jpg.rf.6bdc0f42fdd0387b38a5d37d6217142b.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Screenshot-2023-03-29-144534_png_jpg.rf.6d8b0f887cf0e37ff02fa6fe9d3f81e0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +60_jpg.rf.6e4a7cf4836e7976dd25fb84dc8b8346.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_456_jpg.rf.7019f5c5cb3b1fa23b046f3580a993de.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-776-_jpeg_jpg.rf.70274aea182951aabf39906da9a37126.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_371_jpg.rf.70ab356c839d6b427346325ada3d8bd7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_177_jpg.rf.7177e7167a383d84ea06bf3d76cfe2ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-539-_jpeg_jpg.rf.71b7f9365ad417fb1cca432dd4ca196e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-51-_jpg.rf.71c19766bffc18447ccc51d04a69990f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-111-_jpeg_jpg.rf.73ea9c2b3198fd2957d510a9e9e18a75.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-194-_jpg.rf.73f247a9dd86941463b5c773b8f54aa6.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_515_jpg.rf.748602561b23bf9627830a554e2a5035.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-89-_jpg.rf.74abbf818b9d0ad44884cc1d97d77e9f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-757-_jpeg_jpg.rf.74f0a0dc0dd13c125e0edaafd36a723d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-93-_jpg.rf.74c204f88f67ca8d47f890c3d1d41611.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-57-_jpg.rf.7546b2294fdd5def30f796a68ab4428e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_140_jpg.rf.76cc6dcc468a68e8e152bd1b6089bb7d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-29-_jpeg_jpg.rf.75c0be415de3eda1842523441a24be2d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +34-Female-Thai-Pechaya-Wattanamontree_jpg.rf.77def6780a4ac96355d7c3f67ce2fa36.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +18_jpg.rf.781fd7c052f7db8ec6fd4d950a112791.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_630_jpg.rf.77f7053f99467b5416a9bbde99f5cfb3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_506_jpg.rf.783f5ad575cb3e9787d3038093ff7ceb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-144-_jpg.rf.780e62b9d6ae579e6e35854f13c7f5e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_49_jpg.rf.76e96a1b0580b5d42f7bf886d3a5790d.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-587-_jpeg_jpg.rf.78f641efea8e499ae459b8fc083363f6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-778-_jpg.rf.792553c3fef5a772a52679d4e771aac7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-326-_jpg.rf.7b570779c169af09cc2da8559eb71393.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +107473045_1_jpg.rf.7a7790f662fe7c95d876c1ea08b1da2d.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +oily-skin_91_jpeg_jpg.rf.7d3633387dc3b93ca61a7a6f0a3ed53b.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-909-_jpeg_jpg.rf.7c45872dfc621112365c2323a1d3c924.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-144-_jpg.rf.7e08deaa6e1c5714e08f9c6470ca4686.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_421_jpg.rf.7e71dc77713848dce6327f6be233e650.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-339-_jpeg_jpg.rf.7e36181aab8313e873471d728b936cad.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-50-_jpg.rf.800e101653e9b7062112f66f4e3ba8f5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_78_jpg.rf.80d6fb3e8b4493f68a278b9caebabd2b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +41_jpg.rf.80daa083d970254599f538023af57d29.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +64_jpg.rf.815f5da2ec823bd93e678e6ad8f22acb.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +231_jpg.rf.8264b26ca2faf18646f46236122d2d73.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_79_jpg.rf.82f29a68fd5dd7938d38e06fde3865ba.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-122-_jpg.rf.83c262a7acce26fa2ea30b1cddf13560.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-566-_jpg.rf.84ccc2dae2afac7941631374d4faf990.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +42_jpg.rf.85523f6f8c804d3724dadcaa78e0ec5e.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +161_jpg.rf.84eb77ac84ae3a064c07c2b6fc9beadf.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_102_jpg.rf.86878278a22f11f792279d65ff1de739.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily-102-_jpg.rf.8894b743429b31be8e185be80a470862.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle2_138_png_jpg.rf.87e8ea5a5b811252647a25ab845b0d67.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-810-_jpg.rf.889dfde890004baaf35150459da7acb9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_50_jpg.rf.89060f6b3a03d7d88ca560a271c266f4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_186_jpg.rf.88e16325f690236c7b103323328b9c50.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-38-_jpg.rf.8bbf94160e78536fe398d1b03c4b390f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +34-Female-South-Korean-Jin-Joo-Park_jpg.rf.8a5701358cec11f58b855d71895a3800.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_13_jpg.rf.8bc405e2b57714b7852ac149f08e39d8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-55-_jpg.rf.8a7b775587dd907e8a813408e3bfbbe8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +89_jpg.rf.8c7d2a622a1079b89cc680aebaffa428.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-693-_jpg.rf.8dba45156d1604376270df7b55173f77.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-35-_jpg.rf.8d204ed4838a3deb2090d6728508ea8e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +112_jpg.rf.8e1fb351411467fe54b859c88018fcde.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Berminyak-29-_JPG_jpg.rf.8e7f01c613d8d35250625a5f81613e87.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +392_jpg.rf.8e4bae452be598bcd144c6be28723744.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_375_jpg.rf.90467af7e7678253bbbc01c2d109b61b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25-Male-Thai-Sahaphap-Wongratch_jpg.rf.8e88a5b72043c791b2df8d3997606d44.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-71-_jpg.rf.9095d6dc2c1c8a52325475e10c6b0f9d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_500_jpg.rf.8e9e165ecda54c72873ecfc744966998.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-47-_jpeg_jpg.rf.91683242d2d7fdd3d398de0f9812f6b7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_531_jpg.rf.933a1f0c26a7c23c854c693243b07776.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_191_jpg.rf.922ab03fc8b2a87239644332acadd88f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-686-_jpeg_jpg.rf.944e9679de478514e2e968c5ada4aeba.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +71_jpg.rf.9471d19b6941948e1025e8d71eb45a16.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-809-_jpeg_jpg.rf.93c9da975a5c5bcb38d86480dd725d28.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-199-_jpg.rf.94ed512d10fd58d42cc6e3b3eaf33b50.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-517-_jpg.rf.951fd9cdb8d36bd2896d948113bd5d23.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_314_jpg.rf.948e220cf40902e859df3afdd1e9de87.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-38-_jpg.rf.94b18df86b9542f740ba7f245a5c0e34.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_526_jpg.rf.96fc19064c9b7658177c6a3c0013ca2d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_277_jpg.rf.954339ad013d88db0bd672b8d5e5e5af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily47_jpg.rf.975b089fa67a53101ec6531f43e28ed8.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_131_png_jpg.rf.9720e762133cef9564529dafa0434e50.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-9-_JPG_jpg.rf.9748fa458ee25d91b7ce2b1c0af987f3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-816-_jpeg_jpg.rf.983264e2727f246ba4937bc92a86b4b8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +69_jpg.rf.975fd1939cebc9ddb6138534e414bb02.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_125_jpg.rf.986f6055d988498b70e107668dfd0b33.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_330_jpg.rf.9856119c7b84e00fa3acc34264a9959c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_14-1-_jpg.rf.98ad5d1597f88144bad5b7d21b401539.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_478_jpg.rf.9ada04d8b542e98232dd17b0ce9a1b2e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-496-_jpeg_jpg.rf.9c915353c693bd99219ec340fa05de73.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_225_jpg.rf.9ce2ed0203344ea2632eac21a9de8d62.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-155-_jpeg_jpg.rf.9b971523c3f8da4f46083d6f4a624551.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +363_jpg.rf.9b1cbf90df2f11d7b9c5b3dbf3a76fce.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-481-_jpeg_jpg.rf.9d62fd4334ccac362bb0ea7e822d392f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-789-_jpg.rf.9dae4282fb12fe6d36c9e107dd5e1bfd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_198_jpeg_jpg.rf.9e0ad5bc79943f581a52dd8e8e3ad56d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_564_jpg.rf.9e23c5f4ff375c5c6a0a4cd002942329.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-118-_jpg.rf.9eb89a072c0525352eecf21eb1c703ed.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-231-_jpeg_jpg.rf.9e7d3a7b06d36b894c15b6f4b95e2653.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_476_jpg.rf.9fc0369db4041d540ced841de5f69b53.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak121_jpg.rf.9fcbe0e65ad84673f5eceb8346a27293.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_184_jpg.rf.a0a57e18ddd4254fa6fa67758cd0d41a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-135-_jpeg_jpg.rf.a0b60aa4b8e453a32c5e24e8cdadec44.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +01F3MMVXXRHA3S1EBPKVXEFEZZ_jpeg_jpg.rf.a1f1dd3078af3cfb5052470686fb6722.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25-Female-Chinese-Ye-Zhou_jpg.rf.a21cff8b336b8ad104f6d04b31331a05.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-813-_jpeg_jpg.rf.a2f918c7d44c9e240c5b8c4987d03272.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-373-_jpg.rf.a398991df7640572c846313b7cc4bb25.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_105_jpg.rf.a76a9942ffe92bfff83f9114b418cb84.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +168_jpg.rf.a4789990539a2cf556ca6eafcdc70227.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +Image_123_jpg.rf.a5fbfced168ddb5a2e52ef1268330dab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_206_jpg.rf.a87d423b22f228ea84af6d62faa88881.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-119-_jpg.rf.a96e51ee656231bc4f6c0f4d866fa079.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +21_jpg.rf.a9b8ad0e2bc9088cbb4708fb57fc5a63.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +483_jpg.rf.aa4649236928658c6650724c9ee6b98c.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +336_jpg.rf.aa6d95791751b35a7a9a729887da5a6d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-270-_jpg.rf.ab809e0f8bda839a2aca245e40da9d42.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +90_jpg.rf.ad7e8e08e2be76a422a3f82940684e40.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-698-_jpeg_jpg.rf.ae93ee6c5302306bd8f78df65032bb0e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_137_jpeg_jpg.rf.ab15a58a261c9f9839d645d41e41de3d.jpg, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily30_jpg.rf.ad6d9cc8fb4ac1fc4c1eae9ac92f1831.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_365_jpg.rf.ae43c210085fe06f53fc1d199172897b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_589_jpg.rf.ae51d96cdbab5b29b2dfd7bdf6e29bcf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_319_jpg.rf.af0516ce61166bd6e19604079f0c6bc0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_466_jpg.rf.b002130f6e2d63c128c682be2d1c87ad.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-264-_jpg.rf.af5783b40c771f8d76721997ee75718a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_535_jpg.rf.b06cede30b50b9c7635b81bc4aa405fa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-432-_jpg.rf.b0763f27fd5e9e3ef05e4ba5570089fc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Screenshot-2023-03-29-145645_png_jpg.rf.b0c7f1e0de623e7f87775ecfdbd54902.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +622cbcc3-3347-44d5-b885-8d7b498c922b_jpg.rf.b283d46461402cb6bed2dba366696eeb.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-170-_jpg.rf.b21f15106863a0c9de93b84da983832f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily16_jpg.rf.b1933f9c8c4587faf5b780061135cd42.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +249_jpg.rf.b45d184f14b333e355bd38762c2101c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Berminyak-23-_JPG_jpg.rf.b242c873fc5a68245b73dcb04a75e718.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +01F3MMYVZ5AMFQMQVMQYTSAEGA_jpeg_jpg.rf.b43d0c161480ebd74f43e5aacd6aba4d.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_355_jpg.rf.b4882037ea33d19058a14ede6dfdfc8a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +44_jpg.rf.b2bd071844fc8682ed2192d7bc1433be.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-434-_jpeg_jpg.rf.b7b8cd9cb276805c7914a49c02527bcc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +249_jpg.rf.b88c75038eb12c488a7833c0a175116a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +26-Female-Chinese-Qian-Sun_jpg.rf.b4fa4bd1b55ae61810072c7fa432d142.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +42-Female-Hong-Konger-Ka-Lai-Chung_jpg.rf.b64f5cf4a935ce62ecd5fd0011fe1057.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-228-_jpg.rf.b8c05f9fa5d0f168effa5795959c0294.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-43-_jpg.rf.b7f76e2e77bd122674cb7884a3722613.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-prone-skin_189_jpeg_jpg.rf.bb344bfd029a5b6aefc7feca446a05f5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +23-Female-Japanese-Minami-Hamabe_jpg.rf.bcd6a927ee46c68bb7c8e708962d92d4.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle2_46_png_jpg.rf.be59f7336a783c91f4d955a188d32972.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +42-Male-South-Korean-In-Sung-Jo_jpg.rf.bceff0b24ec10b832df1f543bf0b2d41.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Berminyak-7-_jpg.rf.be8a46962609c3480896ebacc8716068.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +37_jpeg_jpg.rf.beb98bb14aa606ebc9a72d6fe7f695a6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-100-_jpeg_jpg.rf.bfc65c9e6267366a3d8623eb8ef7ac15.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_511_png_jpg.rf.becd0cbf8871161fbc099d66ddbd2415.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-259-_jpeg_jpg.rf.c26638dc6c3fd292ea7adcb978d2e0be.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-683-_jpg.rf.bf42454e6ddf7e6292c126df654d298a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-427-_jpeg_jpg.rf.c125e37b5f764ee567f23f72aa55aef4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-902-_jpeg_jpg.rf.c1aee3829fe930d5ba1ce886d5dcadf3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_92_jpg.rf.c3ba458a69a4fbe56648f9f679d773f3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-108-_jpeg_jpg.rf.c3de15a8587ae7d003a878dc87fd17a3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_16_jpeg_jpg.rf.c3f0066f9dce3a042440dd4dadd39973.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-806-_jpg.rf.bfd1c17ef9e4b0646186d449272fed32.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-331-_jpeg_jpg.rf.c362204b22753d0f3dc96d9bde19007a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_96_jpg.rf.c412752f551f177764b4cc8e43fc0f0c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_251_jpg.rf.c605fea9867ce8929315bdb6d925b2e4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +30-Male-Thai-Natthanan-Phunsawat_jpg.rf.c56beaaaeae0efaad7bd13434c2e0f69.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +551_jpg.rf.c6681e74acf63cea849d190cb3c9c2c9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_106_jpg.rf.c7c530763563a92ecc56e70f05850e94.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +652_jpg.rf.c7835787eca7e00485a3af59a18e4592.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +Image_140_jpg.rf.c7fa86e5e788912d43e430b4871d6d12.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +281_jpg.rf.c829bddcc3498d8b83f0c029d0a0dba9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +508_jpg.rf.c8bdb242c7888fe8efb6491407be3fe4.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +158_jpg.rf.caad7b24d35b3e46cc19e0cf6fbceebf.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-134-_jpeg_jpg.rf.ca55486bf36d0eeecdd57ec085b66e53.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_53_jpg.rf.ca95f992623e40da16b1d0bcb30a1013.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-172-_jpeg_jpg.rf.cb60b12981083d4e58ad9b7b20b21abf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_136_jpg.rf.cc8f6495a4f2da5b8468ea12e7e0c787.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_580_jpg.rf.cb937e7af9a2deff1a8b95c8a077689a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +4aa87886-36bf-445b-a3ab-322154f758a3_jpg.rf.caf992586f844a37c5f20f2ce9bda7c7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +181_jpg.rf.cdc803e82e60a999db735f45041af158.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +63_jpg.rf.cda5da95b5b819d246bd853b4cd67f65.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +68_jpg.rf.cccfdd230f02b8498be88a85e76d4ebc.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-9-_jpg.rf.ce1bd9b34eaf5042dab62e0ec996f694.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +297_jpg.rf.ceabeaf18c556a6e27c241de1a781785.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +oily-277-_jpg.rf.ccc3f515b7a5a3bd6cbf8d2741587134.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-159-_jpg.rf.ce792c79692f99908ee3e6342458a3c7.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-783-_jpg.rf.cde069d0d5fb5708139b841137985e44.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +30-Female-Chinese-Yu-Xi-Zhang_jpg.rf.cfe1e8b01d7d352c694da099bf845468.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +159_jpg.rf.cee4621acb794f35cc724024dc487b1c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-288-_jpeg_jpg.rf.d1428d0ffe442a0f1c66d2728dc97824.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +sc-broken_png_jpg.rf.ced1af1c3113e6ac05578b06eb46416a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +Image_141_jpg.rf.d1895380636a959f9c87e0890f97fa19.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-107-_jpeg_jpg.rf.d42ba9908a65584acfa2f2e08b369b10.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-692-_jpeg_jpg.rf.d4dbadddc43625ff8fe67e558b559b40.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-391-_jpg.rf.d493de1640710e9bd1ccb32992497edf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_113_jpg.rf.d4f0949544acdfaaa00caefab8ac2286.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_278_jpg.rf.d562fd3010b65e0557f1c80f9aa0a204.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_483_jpg.rf.d5c3ab59cc5abfd7112dab9e350b9d5c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_136_jpg.rf.d79e6da026441f7006fceb1d4e98bac2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_134_jpg.rf.d7c5cda9b869afa7132165bd1e1e9f92.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_40_jpg.rf.d899e7f9af44a7afd223c785dde6c2b4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_129_jpg.rf.d63a2082751713d60a9ccc5f660686b0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +150_jpg.rf.d9c8b4833925e2c339404ae0ac3dbf2b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +25-Male-Thai-Pakin-Kunaanuwit_jpg.rf.da111a9b8f90b9b2a706022036a92f3e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +7_jpeg_jpg.rf.da1721faedd99ae80dc5169332d6a5e1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-612-_jpeg_jpg.rf.daf94b0a38a4995bddc1cbfa70306565.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +119_jpg.rf.da2b7996dbf2eb8eb424ed0c04936e3f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_632_jpg.rf.db17edd91a4a62059d2f1a7f267cf20f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-30-_jpg.rf.db5789ac278b2605f8c9777a502b4871.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +213_jpg.rf.dc00780da77cec83be72baf4bd7a65ed.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +675_jpg.rf.de19d7ec973e81f81007535cb3f99ea8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-233-_jpg.rf.db8809eb8aa2622493f99a27c259dede.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +529_jpg.rf.dc6fc86cac80145dbcb53c6487d04564.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +58_jpg.rf.dc97a0b452ad81b0b38f7f9f6cdcc32b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_34_jpg.rf.ddef33f107121669d245e67a8321eb88.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry31_jpg.rf.df8b4ee3c23a982b5c996abd77f5de69.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_373_jpg.rf.de3509e6126132bfa02ce76d8cc30d51.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_1_jpg.rf.e1a28cddfb9cc08b2d20bbc13b13d84a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +2973A28100000578-0-image-m-194_1433782669849_jpg.rf.dff9f0186b461794c9fac77adebd0364.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-323-_jpg.rf.e0acbe510334f68482ccf7e8a6dc981b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-36-_jpg.rf.e12e81a4415fa9de70edb05c661d0480.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-658-_jpeg_jpg.rf.e0ef891be37f3516c7fab9d9408bc707.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_360_jpg.rf.e338dd13bed701637b4406137a1d2062.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +272_jpg.rf.e3450a351e515c1aa8d0ac9137c7527c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-611-_jpg.rf.e39b3f8d2b84f102941e9c99cbbd381f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Male-South-Korean-Seong-Wu-Ong_jpg.rf.e3cfa9e10c9762111575c8d55b70f1ef.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +24-Male-South-Korean-Ji-Hoon-Park_jpg.rf.e3bb5dcf51cc539c015f1505bcbcba26.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +118_jpg.rf.e425192afbffe92e16c9d0e873e7a388.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Kering-32-_jpeg_jpg.rf.e3d8586f9eda111c726625ee969fa51d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-39-_jpg.rf.e3da70d75fdc01c96776dc3748fab5d1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +643_jpg.rf.e443a1cefd2fc6215f24a77ed27df964.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-254-_jpeg_jpg.rf.e5aec858ae750b93ff38014546d576f3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +240_F_477645690_E0R8uapTGEyyv0cwUM9P06Uk78kiLPXW_jpg.rf.e4f468d5d280393fe5d65f559bcf96ca.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_51_png_jpg.rf.e919bae318ef4a86de40a55c0ff6f306.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering-11-_jpg.rf.e5be4a91508cee0689b621235043e92f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_416_jpg.rf.e5e31a6fd0a4179f83bad8e004229f56.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +196_jpg.rf.e5f217f2e38ed1ed83c153600c8006c1.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_82_jpg.rf.e9789244053cc85ef698fc6beda95d70.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-207-_jpg.rf.e8471789c893226be8e2b641370588f7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-659-_jpeg_jpg.rf.ea474a03047db50b90360e797bb46592.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +138_jpg.rf.e9f9f2d38602f619d53450d12a794a9b.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_504_jpg.rf.eb6003798b80689bf6786622e1fcd916.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-62-_jpg.rf.e9c41f03f246ac4029597148a1abc44b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_360_jpg.rf.eb714e6fe7ac40a2bde5cd79a4d3fb3f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +137_jpg.rf.ec59ed5beba24f6766c510262e1d5d99.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +677_jpg.rf.ecb983243720bda3faecd9ca59e6a845.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +oily-35-_jpg.rf.ed8dedcf8ecccadd40ecee2801b16641.jpg, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0 +levle2_143_jpg.rf.eca5ee407e59e16f915a8869b3ac8f3f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Male-American-Luke-Plowden_jpg.rf.ecd54b619f3380f24be2a0a8a8b9545e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_78_png_jpg.rf.eedce0c554324a21e689b916add3b000.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-4-_jpg.rf.ef4073ebda8ceed8591b70c8031c7643.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-415-_jpeg_jpg.rf.ee6aa3a3de4af25e3a71bce0f5d5a733.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-5-_jpeg_jpg.rf.edbc3b0e68d40af5ff4f38cc2c0fa241.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +15_jpeg_jpg.rf.eebac1659b125f0abc87274f21d51105.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_46_jpg.rf.ef6a5beaf4478817d85ef6d8817fb994.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_476_jpg.rf.f23c4bea53bba388a58518b2ffdcedb6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +64_jpg.rf.f2ba34bd7e21c0e5521ee94a79818c8b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +139_jpg.rf.f392b65c32e2ca705e46b4a35edf3879.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Kering-15-_jpeg_jpg.rf.f3a8c4320f285e11ca818b9f4b120460.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_267_jpg.rf.f3aa534ddea043a6bed5603305dc1c16.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_100_jpg.rf.f4c45ed22fd2dd9d8ebbdd361c130f8c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-176-_jpg.rf.f61ec2e3499c2c398c75fcb43dedbbe8.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-666-_jpg.rf.f55cca265756e4b31351cf0983e0f496.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-106-_jpg.rf.f64356a6476346cf82a3c6b6645d4751.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-684-_jpg.rf.f67be5b1dd7957b209fddd8108f9ffed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-644-_jpeg_jpg.rf.f658c87952114fbfc5c3d5c631fbe691.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_21_jpg.rf.f6910a56c12f0e6ffae4b07b4da088d3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-588-_jpeg_jpg.rf.f7de8314dec9e7724179a99a54dadcd1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-575-_jpg.rf.f8c75bb7a69b20a3ee37a6677c3d7408.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +23_jpg.rf.f6cc6757e1efedb38092e4daf8832e84.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +34_jpeg_jpg.rf.faa60d566aff41f622d1750e4fd79460.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_102_jpg.rf.faacb48eba121a11113b917f5083e278.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-820-_jpeg_jpg.rf.faad9890e44bf18eb85ff43d8af9dc95.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-702-_jpg.rf.facdb8c863e0c3e46e935eb74ea51133.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-325-_jpg.rf.fab302ebdb873dfe50a57eebd2e8b1bb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-732-_jpg.rf.facc2f2d4b2d31816582b064fa9248ae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-476-_jpg.rf.faec990ab85eede37b0e0f4a1b8c8801.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_51_jpg.rf.fb697c1844cc0d21a44b80405d8197bc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35-Male-Chinese-Ruo-Yun-Zhang_jpg.rf.fb8bc95f685c321654d648fd960a2816.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_139_jpg.rf.fc3e4e83d7c004e07b76d6d5dcbd3715.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering__-28-_jpg.rf.fbeef2a7d2ae7cb8a688fb5b63412cb9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-857-_jpeg_jpg.rf.fc211e40792a5e81a81f87746a6ae345.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-387-_jpg.rf.fc469510d2bcc8bc2094b217debae77f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +239_jpg.rf.fcdf721ed7a99b87f6b836e882093fbb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_38_jpg.rf.fd2c8bfccc8663ab65115017b5aa7a4d.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +29-Male-South-Korean-Kang-Song_jpg.rf.fde9e1486ef1d8a180a7cb05d9397f22.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_24_jpeg_jpg.rf.fe5db1632880233e21a05e3f9c4f8f13.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-796-_jpeg_jpg.rf.fd873faa9c9a74c645dd2655d7f9e59b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +252_jpeg_jpg.rf.fdb8e3a6b21d7ff3e7b5190c7d588778.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-746-_jpg.rf.fe6e0a5c9343e4b03f8d76172f8cde71.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-559-_jpeg_jpg.rf.ff5d22e66d39a226c7255b806b137713.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +101_jpg.rf.ffb91e580c891eb04b715545274b2469.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_596_jpg.rf.ff702e81c045c815b8dd2e49c8a87bac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 diff --git a/tests/datasets/skinproblem-multilabel-classification/train/10_jpg.rf.340db6cb27bc81747ebd9574193f88dd.jpg b/tests/datasets/skinproblem-multilabel-classification/train/10_jpg.rf.340db6cb27bc81747ebd9574193f88dd.jpg new file mode 100644 index 00000000..429edcbb Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/train/10_jpg.rf.340db6cb27bc81747ebd9574193f88dd.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/train/25_jpg.rf.eeb0573aea368aa6699a9f9d4748a402.jpg b/tests/datasets/skinproblem-multilabel-classification/train/25_jpg.rf.eeb0573aea368aa6699a9f9d4748a402.jpg new file mode 100644 index 00000000..23e4f84a Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/train/25_jpg.rf.eeb0573aea368aa6699a9f9d4748a402.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/train/26_jpg.rf.eb38766f1d6ba6c488104e5a91964e04.jpg b/tests/datasets/skinproblem-multilabel-classification/train/26_jpg.rf.eb38766f1d6ba6c488104e5a91964e04.jpg new file mode 100644 index 00000000..a1b984a0 Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/train/26_jpg.rf.eb38766f1d6ba6c488104e5a91964e04.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/train/9bc0d52c-4c9e-4d61-8cd9-d020e86a22c4_jpg.rf.bfabd51afd937f3cc456f35789d1793b.jpg b/tests/datasets/skinproblem-multilabel-classification/train/9bc0d52c-4c9e-4d61-8cd9-d020e86a22c4_jpg.rf.bfabd51afd937f3cc456f35789d1793b.jpg new file mode 100644 index 00000000..af541307 Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/train/9bc0d52c-4c9e-4d61-8cd9-d020e86a22c4_jpg.rf.bfabd51afd937f3cc456f35789d1793b.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/train/_classes.csv b/tests/datasets/skinproblem-multilabel-classification/train/_classes.csv new file mode 100644 index 00000000..229a2125 --- /dev/null +++ b/tests/datasets/skinproblem-multilabel-classification/train/_classes.csv @@ -0,0 +1,3379 @@ +filename, Acne, Blackheads, Dark Spots, Dry Skin, Eye bags, Normal Skin, Oily Skin, Pores, Skin Redness, Wrinkles +661_jpg.rf.0017f9b86015098adfd95ae4d958e94d.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-34-_jpeg_jpg.rf.00329aefb769be3ffa6ce9520c1ed128.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +417_jpg.rf.002c69214f8a38c1a0e916436d06b699.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_361_jpg.rf.0032fef0afa1cbbe5986baf371582be1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_166_png_jpg.rf.0068ff707130674e341bc992e8a85079.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +23-Female-Thai-Tontawan-Tantivejakul_jpg.rf.007e2e2290a93fed79edca6d044d3e43.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_304_jpg.rf.0079d33f1166ba8f0a17e2cf5d4ed8bf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily23_jpg.rf.00aa83aeabe3d29e496e307baa8ea6b2.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_527_jpg.rf.00f70245d46ee3578b1e90641b16dbd9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +01F3MMY8VFRQGHYA3KE2P27HJT_jpeg_jpg.rf.0145d2d607d28147e95b3c8c6acaf2b1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_425_jpg.rf.01002421b0d6dd79e24844d4244d414d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-742-_jpg.rf.010376788e2844b587dcd448e0edefa2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily42_jpg.rf.015fd79ae89fb4045cb1b788aab60db5.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_117_jpg.rf.009289e6d73b74b97bcbcd0531d84664.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +23-Male-Chinese-Cheng-Cheng-Fan_jpg.rf.01646f6088ba6b8af8598f032f358140.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +35_jpg.rf.017f518f2bca1413f420f9c2225748bd.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-798-_jpg.rf.017c8c2d871e369aacd54cd65dad9a25.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +berminyak__-27-_jpg.rf.01ce31186173d7d0ffd20a502b92a640.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-29-_jpg.rf.019073a79d654f13ec648dacf5055741.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-304-_jpg.rf.01ca4f785e252d8472510621b259eba0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-750-_jpeg_jpg.rf.01c632408eb367c6dcf423381541342e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +240_F_505842148_TGTabIfdLCUOOQZD2kRsQnYJD7OTf3BT_jpg.rf.01d8ceda25068a1af00ec2f8a5131894.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle2_84_jpg.rf.01f6f46663d667c4fb2efc3bc15fa954.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-693-_jpeg_jpg.rf.02069a862000bcce557294c04fc188d4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +257_jpg.rf.020dec4b2ceb1615b4525b6038ddb262.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +5ce71059-e21e-4ce5-96d8-4f6848b4b72d_jpg.rf.0278e0bd84a7269fe6ab722030d54aad.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_107_jpg.rf.0213534397c71ff810a1a4806060d32f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_19_jpg.rf.0283b9826fbf9a41011188116c4f581e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_127_jpg.rf.0236b538db917271f1ef0628ec700dd4.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-162-_jpg.rf.0291403f5f432aeabd9f7bd733519e9f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-2-_jpg.rf.02977c2254f316cdb2646bc1018f3d6d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-651-_jpg.rf.0291e4aaee6da3296bedb9104401e0f7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_90_jpg.rf.02ae90abe9866e6e9399b7851382a11a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +74_jpg.rf.02eb50ce6daf863f1fc138cce44ab9f6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +berminyak__-19-_jpg.rf.02dc2ed0a14cf37dc5a0844f578d65e0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-97-_jpg.rf.03089110fe6d116b7b4ad6b1ebe23bfc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_416_jpg.rf.030f90759f33a0074b417a5978aeacdf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +203_jpg.rf.03b93263abf31cdf8423403159008a96.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +25fe82b3-32bf-4e56-9754-cf578c57aab1_jpg.rf.03817304f6f41f453706c49bd01729a8.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +333_jpg.rf.03c32c8f047d1e05c13dff24c84fc0d4.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_176_jpg.rf.038fcb9d3e74a2ad6e2026466943b118.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-175-_jpeg_jpg.rf.0428db3ef5fe491035c7ca8cf3846060.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-122-_jpeg_jpg.rf.0418e0a176195963b16916d73c70f769.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +60_jpg.rf.040a256a6f6d6e06eb1bfd896da536cb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +294_jpg.rf.0449190d663736ec5c55e224a0369cc7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_341_jpg.rf.0479e2947e03b709fcc4d362970753ee.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-629-_jpg.rf.04cd61311e8ed0d06672c246c550d8e2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +48_jpg.rf.047fd47fa099fd1d42aba59a00c4360a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Berminyak205_jpg.rf.03d8fca7b4c52e02e255d567b180d08f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +612_jpg.rf.043e3dd1a9432098d28c063b9d8ad517.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +levle1_151_jpg.rf.04ad85d6645bb05fb1f4aea70e0219f7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-88-_jpg.rf.04ae5cd9dc9288d659d55533f82b6120.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-811-_jpeg_jpg.rf.04e84c171f0ddee78f45a42eacc1c63d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-501-_jpeg_jpg.rf.05515249dafa9410f10ab4e016c72a9a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_145_jpg.rf.05306369e7862e9e97675c0ffa81a940.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-410-_jpg.rf.055a94c35e13c0c2d59df24dc0c25121.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-142-_jpg.rf.05b137562374a00bcdccf3ae519a4426.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_316_jpg.rf.055671fd729340ff28e188e6fff9dcb7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +332_jpg.rf.05a20984ed0eb9a238e756fb06842e38.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +263_jpg.rf.05a0eecc4370561f030a8ba0aa9e69d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +02435230-cde0-4bb8-9f58-141b03658324_jpg.rf.05ab8c538cd148cf69345489ae2b17ba.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +01F3MMYNE0NXTGFZ8T0PGRQPGY_jpeg_jpg.rf.05e228732564c1e36f99431944e56aea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-16-_jpeg_jpg.rf.0609656f05b62df545ce6e352f5f10f0.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_276_jpg.rf.05ec78673949ca70e6fac7517af3b5ac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +8e418a01-44ff-455a-b44f-b6eac4042e9a_jpg.rf.05bcc5e274c63ec326c7bf6a8b7ffe57.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-239-_jpg.rf.0616ebae97ca06c29de0d77adc6c2943.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-129-_jpg.rf.065073234e14ac861ee081fa8b7e81c3.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering-24-_jpg.rf.0624214c2014842a320be4e393c11e40.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +dry-skin_21_jpeg_jpg.rf.06630ffc11d9f7c72843a2cea8ba61a4.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +da-mat-bi-rat-khong-ro-nguyen-nhan-1_jpg.rf.06100e64138e3b34c166ece051642162.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-525-_jpeg_jpg.rf.0679731d9698a16d0dc5181197837b05.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +3_jpg.rf.066f7d62fb0c311a02a93d61ef241cfa.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering__-51-_jpg.rf.06782e389405a89aca022a4badc0686c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +335_jpg.rf.0693d818a8baa3294de108e8a106886c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +23-Male-Thai-Pawat-Chittsawangdee_jpg.rf.06c0601be55376174823e5bf3f2ca1a7.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +194_jpg.rf.0685d16b378efc93390d32de3ddad64b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +146_jpg.rf.06bd8639f70377733513ba238fd83aa7.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_79_jpg.rf.06f45c85155acf8dc9e49d61f65c7ecc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +42_jpeg_jpg.rf.071e90ee9d3f9e16050cc0193a945152.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_448_jpg.rf.071d02de3435b32d83f522d9baeec57d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_121_jpg.rf.072046c469c3ec4ea4882aa7cdde0baf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +download-1-_jpg.rf.074c9507d72096c290f6fefe5b5c33b2.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_175_jpg.rf.075b11841dfcf1e547b082d49ee39320.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +586_jpg.rf.076665cbd46e92245e5cd84789f466dc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-372-_jpg.rf.076a4dd87d1a32a79354f259a0f0740e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-496-_jpg.rf.077dc9c093ededf9a0c0440b82e285f3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_58_jpg.rf.07816bca6355a7ac8d475fe1c84027a2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-150-_jpeg_jpg.rf.0770fbd8f6b20b3467ba435248610b97.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +240_F_110479910_n9eh4gcWPH8kOGIWN8ZD1PvfQ9XhloHO_jpg.rf.0792fafb0f0522d452b751e8e6a0f351.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-820-_jpg.rf.07aeddb23304cefa979d337c5a0bf87d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +23-Male-South-Korean-San-Ha-Yoon_jpg.rf.07c025b2fd27d9fcea36dcbba3edca2f.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +27-Female-Thai-Worranit-Thawornwong_jpg.rf.07d04e2ea77211745c3ef7e2de17a954.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-115-_jpg.rf.07b4993c0a176a5795d55f74edbee008.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-382-_jpg.rf.07d408c67e597e50275cc1519b63273e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_304_jpg.rf.07d3605a0b0f7988d56a2e16e9c85792.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_148_jpg.rf.07e7a794f0ffc85200cacdb75d5f56fa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_353_png_jpg.rf.07d6e4a8e06030384abaf1f76253967f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-618-_jpg.rf.07fc5155024b939b2bea4f9c3c16a4fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-263-_jpg.rf.0840545650f5e0109cac874c10ed66fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-300-_jpeg_jpg.rf.080ebc71292f463b426ebcd819b1c3a9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-462-_jpeg_jpg.rf.08049efa8f9aa709f54b64224eb6c9c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-296-_jpeg_jpg.rf.08b15847671964f52a446fc1a53e8b1b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_2_jpg.rf.08616431003a94cf8e7fde161aad6771.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_191_png_jpg.rf.0885e8620f5ee989b9de157c31dff96e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_410_jpg.rf.08e515fa8176acbc8ac97b321e7e212b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25-Male-Chinese-Hong-Yi-Li_jpg.rf.095bcea62cf527f19953e6a8732b0712.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +125_jpg.rf.093145484ce22a6553e3968306140d81.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-233-_jpeg_jpg.rf.094da7818905befe1a4e227da6add49e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_188_jpg.rf.096822cda83115eff90955b5f8700a2d.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle3_97_jpg.rf.0973fd9ad345d418f4a17838901e19c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_129_jpg.rf.0969ac5dbafc6f78864264810d952c42.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +kering-3-_jpg.rf.09870dd5698813087aae15654656eb60.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +310_jpg.rf.0985b14f62b0fd6e08a39b46fd0d549a.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-864-_jpeg_jpg.rf.09925247d2ada01a6f7220f3f4f36d9e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-23-_jpeg_jpg.rf.09a831098690c2f7cf274e3a62b73d70.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +14_jpg.rf.0995ca8643a50d8e0a1587c268fa528f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +174_jpg.rf.09cd50e5343b8f5aa916c9617553e2a0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-579-_jpg.rf.09cfa144af743fc45dc6d1e2c42a5f85.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_280_jpg.rf.09af1747beb29683b3fceca030ccb39d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-728-_jpg.rf.09dddfa1aa602a06de73d2e9c87610a0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-395-_jpeg_jpg.rf.09d841fa34312fb36c7df59dda49be47.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-4-_jpeg_jpg.rf.09ea5e033a15f88f97b5685d8074fb03.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_51_jpg.rf.09f5467976915c4789b0c8b6a14a43e1.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +oily-61-_jpg.rf.654cead0f55964a195046b4feb74ec3d.jpg, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0 +dry22_jpg.rf.65f8cb5e95a6ade4b105f1dfa1b35651.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +28-Male-Taiwanese-Guan-Hong-Chen_jpg.rf.647d761bfd5dc3dbce3a5b6f94c62cc4.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +120_jpg.rf.64818f1eccba39f49f64d5e54cfdd9b4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-30-_png_jpg.rf.645ad0d069f6f1eea1f5a7dcab314e91.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_51_jpg.rf.64ef1f1d679149c0d235d24e454ae11b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_480_jpg.rf.644874909ca5422b221ba3fdca2c8248.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-93-_jpg.rf.657ece951449fb06dfa5cf436eba81f6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-26-_jpg.rf.65ebded1ad62676b219ff1b502a84a66.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +284_jpg.rf.644029c3f3ea47c7cdb7d01b75085112.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1 +acne-134-_JPG_jpg.rf.65d66111e14f55ee7fa5be3977c97dcf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +403_jpg.rf.665e09e8c444cd89cc52bedc0fddd1ae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +16_jpg.rf.64ca528f895e751404b68786e8120b90.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +oily-144-_jpg.rf.661b85ccbe768b7b455b9faed4845281.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +24-Female-Thai-Apichaya-Saejung_jpg.rf.651f544ed4aa783d3218361de3f3b971.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +313_jpg.rf.64808c1d663fee13697f4cc244636b0b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +185_jpg.rf.658a1498ced9e39cb3a8f1a99041af4a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +75_jpg.rf.65169fbb171b6a7517aa74c2fff92d0e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_193_jpg.rf.648b5bbdd9a004f3bbb765e2778a4705.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_428_png_jpg.rf.66c1d3725afb6b894b95940b0d4bb479.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak271_jpg.rf.650cc6f2f22befe12fea3ae31793d10a.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_512_jpg.rf.66a13d6c25c6e66a5720f3b2163a6336.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_490_jpg.rf.64ce9d1104aa8b266c9c5c5e358ecc35.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Screenshot_3_png_jpg.rf.66b157e78a68b1c7331b851e2ca746fe.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-395-_jpg.rf.66c7d0861c0ffd79685d05eeb999d946.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_8_jpg.rf.656a44da3c513d902a04d9e9b0737fad.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +43_jpg.rf.661f0478128b0395957e9830c670c697.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_624_jpg.rf.65737a6b227f81f5a6586f6b746d4344.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-871-_jpeg_jpg.rf.6584035677af27a0c78a85e55b798075.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-758-_jpeg_jpg.rf.66e3f42d7a41cb7adc25edf434609116.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +30-Male-Thai-Thanapat-Kawila_jpg.rf.66e61dd9c59f11458e6bc1a23bba8fa2.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +412_jpg.rf.66fbd16a514f982f405cc9e081b1fd37.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +119_jpg.rf.66ceaeecdc55db3238732d396b57ba8d.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-358-_jpg.rf.670f29c8c9c8806dfe6734e828329b15.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_397_jpg.rf.671a153eb8346c33a0c8a65b0e20a25f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_92_jpg.rf.67344ea97aa4b1dcccf4fc5385f7d7fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_374_jpg.rf.67154e5540161c7b18e1d1153ac61058.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_101_jpg.rf.67449dea8cf9e1bfff3c7e37679c2932.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +513_jpg.rf.6761a2acb4761c92558e5bce4d7e3c4f.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +105_jpg.rf.67d19ca86d30b72d0b898bb606f648ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_100_jpg.rf.675bdf20a3aa8b9eda6ec4f5285d1337.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-519-_jpeg_jpg.rf.67acc140df4254e2cd1d5009f574a355.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +58_jpg.rf.677cf7ddeb1aa7209095d37ae819ac5d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +oily-98-_jpg.rf.67521a1813d34ed5a0ffb07fd5d49916.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_29_jpg.rf.678254c0479517a545c69bb5abdcbfe2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +497_jpg.rf.6817787a5d6657721b9db5c0b89445e2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-734-_jpg.rf.6856a84123830a18fcdfd64278f1e52e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +512_jpg.rf.680d8a734ec1d9765df8361d70a2e0f7.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Image_62_jpg.rf.68609c49653f4b62ea36c67904d0b263.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_135_jpg.rf.688dcf48f0990cbab0b469d384b491aa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-417-_jpg.rf.6892751f48fc0dba1686cef1e60256b2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-337-_jpg.rf.68bc5ec12286f836bd56794cc42e0226.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Male-Chinese-Yi-Qin-Zhao_jpg.rf.689b89b99fecb023c517ecda4301cbf7.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +a27e16c6-4acc-40a9-adaf-1e152ac7a68b_jpg.rf.6915269e2d5cddac9378137c7a440724.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily-skin_187_jpeg_jpg.rf.6919cd3b94d2cf5fcbf20aff262d386f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-342-_jpg.rf.68f2f275c2da9dba5e4089eb93a8cc17.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +38-Female-Thai-Maneerat-Kam-Uan_jpg.rf.68fd25fc8b3eaecad007c81f7f20cc21.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-549-_jpeg_jpg.rf.6929eaa4fde84929bffa2f3078535a0c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-679-_jpeg_jpg.rf.69566daf47b79243c7acc969b864c3b1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +normal-159-_jpg.rf.69702e52c4fde92194880729e9c2a19d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-405-_jpeg_jpg.rf.697b688b961beb385ada38bf18c087a3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily60_jpg.rf.698a9bc4c0fb24236f651e4926da7c39.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-246-_jpg.rf.699ecf5027cbad3d9a07aae177449728.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +73d83820-4a7c-44c8-9f9e-7e291b3e879a_jpg.rf.699256dda237fe07c42b633e32e6735b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-327-_jpg.rf.69a06e8c465d4f8ee6f520f5bbc63fe9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +151_jpg.rf.69c999d3698aece271afc7a62376c5fb.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +244_jpg.rf.69e4012597246c2d8b4d325835c63df0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +19_jpg.rf.69d3c39b7abf04a9585bf092e07a3eee.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +berminyak__-44-_jpg.rf.69c9cd476747497a3c9b69cf3afb406c.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-603-_jpeg_jpg.rf.6a13800e58ff8bd4b1cdeb7adc46c9b5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_285_jpg.rf.69e40bd12e4911cc255ed6bded06afa2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_201_jpg.rf.69f43d485df3f88965a460b6835676bd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +221_jpg.rf.6a2b58ca4fc2359aa701bd88707b0117.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_347_jpg.rf.6a4d2b69d6a2fdcbbac14bdd207fac0e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_633_jpg.rf.6a866ea90e63cb9bd6c2f21566dfd6b0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_92_jpg.rf.6a62847ee5a6bf80809afca73e2ce4b2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-171-_jpg.rf.6a6b7dbc8fef38744a9059c3a32ffe97.jpg, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0 +acne-501-_jpg.rf.6ab363150acb51cc75566c7e05246480.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +46-Male-Thai-Jesdaporn-Pholdee_jpg.rf.6ab806952a4cc4e0aba252fea802d520.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering__-48-_jpg.rf.6af6bc68227d5893111104edc4f2c532.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +223_jpg.rf.6b1934f9dd9aaca5effaedbde84fe82e.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle3_90_jpg.rf.6b204fb283e47533b38be48df2112f3c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-544-_jpg.rf.6b034e503837d44585c772b0cfaa6f7d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2385871165_1_jpg.rf.6b331c5402624986c6440c6df36ccc85.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +20_jpg.rf.6b43801dd41bfeb1fe15d6ce9cf20b32.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-495-_jpeg_jpg.rf.6b44b4f1613b38eff4320ed7ee735205.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_399_jpg.rf.6b798e1b4b85aded90d99b011395855d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-14-_jpeg_jpg.rf.6b53926121ec91a85953b27ebda78b6a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_192_jpg.rf.6b9b642ddab43977b87de60d565013a3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +62_jpg.rf.6b96649925ad5f3ce413271c4364dd29.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-79-_jpg.rf.6b9f5169f3fe93ef43ec581b9f161d41.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +24-Male-Thai-Supamongkon-Wongwisut_jpg.rf.6bb924e514b649697364fbc8b1c2423e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_72_jpg.rf.6bbd2b81dc165ee3d6a620dcb6787612.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_497_jpg.rf.6baa35566817ec9573b6251f710b20af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_230_jpg.rf.6b82f031eb5d29d1f0df85c760df4686.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-932-_jpeg_jpg.rf.6bf8f86b2bfa9cc571713a28f31a7c64.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_508_jpg.rf.6bfc43db44ce8c7fb516cc4d2b7d7c4d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_261_jpg.rf.6bf8cd2b08be461cd1a703f02d419d8e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +37_jpg.rf.6bfa0170406cc6db08bf8c3d0314d6c9.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +169_jpg.rf.6c0db77f285187fc4c434637373bcebb.jpg, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0 +levle1_442_jpg.rf.6c1c6b45e489b3d32dcd968fcde306b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-53-_jpg.rf.6c200428c6009461674557f5e210ab73.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_455_jpg.rf.6cfd399d208a6167459138dfa1e8430d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +307_jpg.rf.6c35701fdb1c3688d45d46481a61557f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +1c6c36fb-8904-46fa-bbdf-c0338df75f60_jpg.rf.6ccf74b80aef4cf2f3523cbcaa910f95.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily-227-_jpg.rf.6cdf3ccba7e3167c87219deb279b2682.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +9_jpeg_jpg.rf.6cc3c342e1dcd6020522931dfbd8937e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_515_jpg.rf.6d0ac8d9f81bd898f20e4f6f2b19dae6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_207_jpg.rf.6d4506983096a1a32ccba2bf120a625f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_100_jpg.rf.6d02b4210fa5608be6bac3406a4838ae.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +52_jpg.rf.6d4360bfb0f47a251efaa9b344781e16.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-400-_jpg.rf.6d8a7d2e3f2bddf02ecc38a1d13df9d0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_625_jpg.rf.6d4991e5d947dbe272156e3e666d1210.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +130_jpg.rf.6dbf3a3aa4815252f524de18a985b7b7.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +84_jpg.rf.6d925af1df24118c42961290568be67d.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +299_jpg.rf.6e22ef3e7fe9b21ecc52300ced50a43c.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +2352254513_1_jpg.rf.6dc543ac8b41b805b9a41242fc78a678.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +209_jpg.rf.6e507fc0487458b20b10d08f674576d6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +498e89f1-425d-4e0c-9d45-3b220d62861f_jpg.rf.6e54c0938e86a92536f5b4408f9a10c5.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +47-Female-Thai-Kullanat-Preeyawat_jpg.rf.6e59da8e12745e3bf889adb98fbe5254.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +622_jpg.rf.6e5f999931ac9aa733cc1ad27b924957.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_225_jpg.rf.6e5bf301ee2d857d2fb51cafc9a70561.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +22-Male-Thai-Archen-Aydin_jpg.rf.6e74f1110e7a6444908fbc083c065155.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +543_jpg.rf.6e7ebff65123f8fcce6431b5e8b5ddde.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2_jpg.rf.6e80d1275048eac7b3dfa45fc864e1de.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-253-_jpg.rf.6e98252e5733f7b4eb5b8a0e27dbba79.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_18_jpg.rf.6ec2401b737357e31dd17b9f426dfe86.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_526_jpg.rf.6ebf7b74c8871449ea8ee24675ef1975.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35cd8ef3-9d57-40bb-97fa-c45cd29de402_jpg.rf.6ec78a871df000c8cda8e6155d7c983e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +46-Male-Thai-Theeradeth-Wongpuapan_jpg.rf.6ee71df55e28db7af15799c2f19d6a0c.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +603_jpg.rf.6ec638cea3b54a80928721cf66db00e8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +85_jpg.rf.6eabee592d9391f2a80717230ec98334.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +35-Female-Thai-Sheranut-Yusananda_jpg.rf.6eebfa4ad947c333c9a754cee258ef44.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +dry-122-_jpg.rf.6eeaab9dde139302215889e07c04586d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_554_jpg.rf.6efdfe54ac57f8797df5c4f27d0e340e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +540_jpg.rf.6f388570b9196b3f8c3350ee6b838909.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +4_jpg.rf.6f390e46d60557b18fc0a52e29d64c20.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_45_jpg.rf.6f5742103e3945edcb162a6598e68c27.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_488_jpg.rf.6f54ceaf9d88f5b7f7ad672dfcd9d86c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_617_jpg.rf.6f546a34ef3d8152890ac8c289d068d4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_132_jpg.rf.6f80b8f37eb945f2b5421e301087c9f7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_94_jpg.rf.6f661e3838af87b3d590105cb220eb13.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Female-Chinese-Xi-Wei-Tian_jpg.rf.6f6336645445fc19132955a31acfc0b1.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-414-_jpeg_jpg.rf.6f88858b96d1408e0e34b98f242b075f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +360_jpg.rf.6f854d56d7b23fb3c6919ab6b0ccb8a4.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +197_jpg.rf.6fd467fd167a4c3a72932a948782bc7e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-576-_jpeg_jpg.rf.6f91909b0b1b6510263a815ddc59304f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +24_jpg.rf.6fad8893efbad66c98315b98321376f0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +normal-20-_jpg.rf.6fff76534879c23a93f1e571d2973446.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +416_jpg.rf.700178484495c61b3c9eff7c5e2e1dbf.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-833-_jpeg_jpg.rf.70039b5dbace9d00f2968f6fc5660f20.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26_jpg.rf.701dc5bd9ec04f8b08bb77639eaa5bd2.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering-49-_jpg.rf.703709246e5729e4a95177f6c60a84ce.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +25-Male-South-Korean-Yoon-Hwan-Go_jpg.rf.7082df6e27ced0e36b3aa0d48dc054de.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +24-Female-South-Korean-Yi-Hyun-Cho_jpg.rf.7031edbae498a38605c59fbc0e77da61.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_64_jpg.rf.7083752f8d9256316c587d2cd664d8d4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_423_jpg.rf.709350bf08f5c31bc755a35fdc93005c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-142-_jpg.rf.70b090c0e7cb586333a608e2e436caff.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +573_jpg.rf.709e5f033cb3a069880aa5997f299f9a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-prone-skin_180_jpeg_jpg.rf.70def662a036f5fa06a2dea937721b31.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_621_jpg.rf.70f404387c9e5fb177038ca73b0ca754.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +473_jpg.rf.70fb7b670ea04be357bc39e0f33a4c8f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-434-_jpg.rf.713b6313cfb00af950d67ae185299802.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-366-_jpg.rf.71136a3cd9e7f554c479f82db02e3ec4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_164_jpg.rf.718b40e6970924624a2d61c1b08a925b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-126-_jpg.rf.710f2e0763e4939a5f0b0b996d69db2d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_259_jpg.rf.71819ea255899db391ddbe8841d0e170.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-860-_jpeg_jpg.rf.7193c7f23b0a72fffbd71969241024b0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +40248490-a411-40ad-8e66-fcf69487b14d_jpg.rf.71a082f7d527fa6cecef0e1b9c6b3f74.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Berminyak-20-_jpg.rf.71c20dbf0f1eca6b659a97852b8d5b55.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +berminyak__-14-_jpg.rf.71cb6781e4851cc29e0e01ef8705863a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-45-_png_jpg.rf.71d1603ec5726d96e4ef64303eaabf1e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +62_jpg.rf.71eaad9315adca8f1b1cca2a3464e238.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering-42-_jpg.rf.71e6a44503de03510d095a60b4827279.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +680_jpg.rf.71dfa4e26c535bce73d9161cb628243e.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +acne-316-_jpg.rf.72180046d159bbc601f769350edf2137.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +44_jpg.rf.723669762fa2f1fa0c46724bbc804676.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-19-_jpg.rf.721eb790d56fad6f6e88d44ad50e052c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_628_jpg.rf.721dc9ac7a72471b2640467f8d65d32e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-17-_JPG_jpg.rf.723a9285e93b1ddf20a7cb7be20e5e59.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_56_png_jpg.rf.72a4ed3524d6a1743cf84da53bcc9ab3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_121_jpg.rf.72d2c0193be0b5707454679f85d32bcb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-561-_jpg.rf.72bd209df1ed429b97548fea05552857.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-140-_jpg.rf.72d708d9023477886bbfce8cadf34614.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_18_jpg.rf.72d34445e791acdb1c54e23136ca4c79.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_109_jpg.rf.730130eeaab13cd1b0c1f0754e1c622b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-272-_jpg.rf.72f85e1855301f38d8ad02f30fac9093.jpg, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-834-_jpeg_jpg.rf.73a2e7138cdbbd4e28c39e7533d37912.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_135_jpg.rf.7394969e28d4904bfb9f9cbccd5a16db.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +30-Male-Thai-Jirayut-Tangsrisuk_jpg.rf.7377039c10642857586c578153ff49c6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_505_jpg.rf.73793e3bb9305eaefb46bf295cf6514f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-90-_jpg.rf.73d0d8ae6e9e1f609186bda2934bd7b8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-667-_jpeg_jpg.rf.73ea4496f17873b8d0779e4fea5fb9b8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Male-South-Korean-Dong-Yeon-Kwak_jpg.rf.73ed5b8e6ea29be9a9f9caed3b0d06e2.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_338_png_jpg.rf.73a5612afb0c084aef3d2756306c5c2e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-215-_jpg.rf.73f495bbf1d8215c337a3798f40e26fb.jpg, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0 +Kering-28-_jpeg_jpg.rf.74070f9624701bab0547c1f08202f270.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_467_jpg.rf.745b589a15004a57f19a0f8d929b94a3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_86_png_jpg.rf.74516e2a7bdbde259d45dd943d1df52d.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-617-_jpeg_jpg.rf.74652367fe595d2a2f5a30cf2e8f96ed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_158_jpg.rf.74d3a46fea6c4f60d4c729234f26c2d3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_59_jpg.rf.74c4f91d1ebba93ec11f58211752e130.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-534-_jpeg_jpg.rf.74bf5d42ad8d9646a3287ca94e64bcd5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +e92f5c8d-5224-43a4-9481-5ff37c6ff1a9_jpg.rf.74fcdbff4128aaee8c831c2e3672c5a5.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_189_jpg.rf.74ed7a9f52b5f0407b25579d3d565a9f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-492-_jpeg_jpg.rf.74d90369fc65a0ef0bc989e41a138d3a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-293-_jpeg_jpg.rf.75403e1ae06e9b45cc876f54503f112e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-664-_jpg.rf.754f017806bcf8e35ad06bdace5008e0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_438_jpg.rf.7577ce71476c306c94573bb16f4e7cf4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-709-_jpg.rf.7557415c0c88d1875f90a6713064bca6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-469-_jpeg_jpg.rf.7583373d72b37d2906d7378b909479d4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_530_jpg.rf.759a9a32118c36dac60d059bfa6d9543.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-930-_jpeg_jpg.rf.75a5067fe187beb575e80e356d21155c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-266-_jpeg_jpg.rf.75e919d9812aeb1afdad219e4b7cd417.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Male-Chinese-Yu-Xi-Ding_jpg.rf.7598b8304d155939889de3fb03b7dbce.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Berminyak-37-_jpg.rf.75ea1be3faf52d77ef7a2a6bd3e3d0a2.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +518_jpg.rf.76068c53e697117543ccbdfa21598e6b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_238_jpg.rf.75f7b2cf606a3f60f76ed612dc86c3e6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-78-_jpeg_jpg.rf.762e15dadd1081482506945b28b748a9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-177-_jpg.rf.76386b389fa97dca678bc7228102cdaa.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +27-Female-Chinese-Min-Tang_jpg.rf.767186e6e63aaa4cf77d76c1b1ba868a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-118-_jpg.rf.761a6e6e87b10491eb36508cca7b28b0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-22-_jpg.rf.762fb1dc9c4a03e4e0595b011b76c6e7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +ad327f2e-e622-491a-beca-ac8082908306_jpg.rf.7671a5dbfba287ae5ee17428860bcc63.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-747-_jpeg_jpg.rf.7671483adfe7039cf907f50b167632bc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-49-_jpg.rf.767629528002bc9948c05d7d0d0a8a3a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_560_jpg.rf.767683092d26c9d2676145ecea95fe84.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_489_jpg.rf.76eb73a0b982515be469be69e9561368.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_503_jpg.rf.76dfcda174326e128194782244534ed1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-330-_jpeg_jpg.rf.76fb4334b1e9405bd625b066ac05bf18.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +433_jpg.rf.76dbe03182f807a42a891bf5ebcd0af6.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +25-Male-Chinese-Ling-He-Zhang_jpg.rf.7727764e5cfbf10d8f597a00e7472206.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +oily-262-_jpg.rf.77344357dec1a3349f62936f491fbf68.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +oily-skin_88_jpeg_jpg.rf.774b885b27f2a123edc1594e665c5ee8.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +dry-233-_jpg.rf.774cb7151e4cd5c884702ad4df6f5eab.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Image_10_jpg.rf.7765d3f9a7ab4073c1ec58b6e45d7fcd.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +443_jpg.rf.77591a776d0810693bbc6949f8f8592a.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-52-_jpeg_jpg.rf.7773eeca864a495cecb50dc5f7e6e348.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +206_jpg.rf.776a437f19a9cd741942772fc4c01b23.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +242_jpg.rf.77798e52bb388057663e11fbdaa2a50d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-631-_jpeg_jpg.rf.780091efaade8d3941187374d5837521.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_145_jpg.rf.7802ef302ca167219b47e4d0b60b88fb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +19_png_jpg.rf.782abbd0112ec5347629838c1dcb6210.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-368-_jpg.rf.77be50f99952dc92f1413f0d8eece63f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-492-_jpg.rf.7819fe6c7ccffef8a63c09f6cb1be467.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_42_jpg.rf.784875da293cc8c08a84b81a7fa1a9b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-445-_jpg.rf.782dfbc708b33718c32d8f0a1203b0d6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-33-_jpg.rf.787a97039a173037f408645c5d164fc3.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Kering-20-_jpeg_jpg.rf.788d3e196817c674873191338642856f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Berminyak-4-_jpeg_jpg.rf.78a176f79ce16335c8b51f5de86f16e7.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-128-_jpeg_jpg.rf.78a2825ecf442a9fc5536af3d36ec5f1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_72_jpg.rf.78c27d5f36115419a8e0b9f630ad3c87.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-826-_jpg.rf.78b6bf395f9a669948b70c017be41b5d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-450-_jpg.rf.788c8949670019febb940c7fa18debe8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-266-_jpg.rf.78d3dab7c637bc4da03f59ad0e7f3d9e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +32_jpg.rf.78e8b901022d3831d884e84c35d62262.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +dry-128-_jpg.rf.78ca298722620c212ce94d40a6235d65.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-876-_jpeg_jpg.rf.790bdf51afb3cf2b0415bf3667a9fbf7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_79_jpg.rf.7909434d5c02d7002dad2ef0fd8f6161.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-248-_jpeg_jpg.rf.7954370e382682ede977e20eecd5aab6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_80_jpg.rf.795784d35cf5fec2434261f7eb46617d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-380-_jpg.rf.79555a888128680a523c5e26cd2dba03.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_332_jpg.rf.7932931d9700c33229e8438adde0f2fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +45_jpg.rf.79ad45763b1f1181b5dc08935734c8ae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_279_png_jpg.rf.7988c1e3f5c156c766a3644a16108375.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +309_jpg.rf.79bc00fd212af6b947acb01acdbdb044.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +118_jpg.rf.79b69b018597e3efe5e86d1686a7c0fa.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-27-_png_jpg.rf.79d5bca2e056a289eb7435e70e2ecb9b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-791-_jpeg_jpg.rf.7a3c8de857e8a496d1f19dd55c8161a3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-389-_jpeg_jpg.rf.7a166e0ae79108f194d4706e680b9815.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-21-_jpeg_jpg.rf.7a4420133c39f6251876959606489c3b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_402_jpg.rf.7a3d5b7a23a9e78a7b693c0f7b01e57f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +b43985cb-8464-49ba-ac88-72c1875b821d_jpg.rf.7a491cd707a6201b40238790ecb28757.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +dry-skin_115_jpeg_jpg.rf.7a7306eb8c56a6b45eaa052550d016d7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +25-Male-Thai-Kanaphan-Puitrakul_jpg.rf.7a8a3a98060a6e565e508707071c8a75.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +oily8_jpg.rf.7a8dc55c2c05ae265f52c4ed265433ca.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_548_jpg.rf.7a9c14d577eb567366cdafdc7b0cb484.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-235-_jpeg_jpg.rf.7a9eb0a0842f025b03238c0795da41ba.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +52331bc5-e7ac-4ea6-a2fe-31ecd8310e91_jpg.rf.7ac5f2e0389040ceb8d6f4cda66a4764.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-852-_jpeg_jpg.rf.7aa60ba264e79e605038da1a00e1e501.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily49_jpg.rf.7af67ea3434c98a35ed0780dd48ca818.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +193_jpg.rf.7b087c75504fd337ab491c5dfb3dc713.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +461_jpg.rf.7ae1476329fb9e8bcd38e7d9a2eef61b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle2_147_jpg.rf.7b5ac4f323eadfaa5336f076c8d34bb3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-39-_jpg.rf.7b0fbe459a7dc94e3ec7ffd7e03a933b.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_316_jpg.rf.7b86b845bb194eedb349cce09b3522d3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +358_jpg.rf.7b90350307ca5962c9996223f39e87c7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-762-_jpeg_jpg.rf.7b9dda48e9e0865181a2bb31b7881e6d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_10_jpg.rf.7ba5abfb0cee5f084ace7487063e36d7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +ff18447a-5aed-46c6-bb31-d2ec58b52e03_jpg.rf.7ba5d47699535336c3efb92fa886801a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily-130-_jpg.rf.7bbe375c303a0c577c3b7ac12ce571cf.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +_1495410572_jpg.rf.7bc985d3cc36e98e63e89fdb9bf269f8.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +oily-172-_jpg.rf.7bd5327d0e7cf27d3c5078372443fd3d.jpg, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0 +acne-131-_jpeg_jpg.rf.7bdf2a30006f1635a68e038aef047df1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +502_jpg.rf.7bca503d8f5db85155a1b37414e7ebf9.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +01F3MMZ6PBPZ02R00D2GS0XSDQ_jpeg_jpg.rf.7bd8b4d32a0fc019ec5616cff24e370e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +102_jpg.rf.7c0336bd5c594af0614dd29d9022978e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_207_jpg.rf.7c1ffaeadcf3ead612b1d63268218de1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-519-_jpg.rf.7c0e9408bbb9b7c834e8b2a86934f6c7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +218_jpg.rf.7c2fb0eae9056763018ba98d247fa4fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-533-_jpeg_jpg.rf.7c796165f01b42a0ab221c9f5974ddcb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-19-_jpg.rf.7c39ce44cf4ceafbb5eb87acd98da970.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_529_jpg.rf.7ca6eea466c389552642cc7acb85d892.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +553_jpg.rf.7cb8528dc3ecb7fda1028a6098f85461.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_593_png_jpg.rf.7cd9d6c31bd8fb2ba145255d00bbe0d7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +32-Male-Chinese-Yang-Yang_jpg.rf.7d039711502ccce2ecb12e70b1e3ed55.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-235-_jpg.rf.7d195bafc5aab147a493550a6a7c08ee.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-58-_jpg.rf.7cbbf960b489601b8633541a7aaae4e1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_441_jpg.rf.7d324564fb34e8e9317c59c2f9a40207.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +549_jpg.rf.7d3c1c0e469710b6f85c943eea055920.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_499_jpg.rf.7d14671f21b82875016fb79a1f530a92.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_463_jpg.rf.7d618be828eeefca4806aab2b5557bbf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-358-_jpeg_jpg.rf.7d7cffb1bab3d0073b11b83f7ff7c9f3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-9-_jpeg_jpg.rf.7d83419fa07db24b1912b9ddbc982ae5.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +315_jpg.rf.7d9fdbcd65bf87618671e6b3b098fb81.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-538-_jpeg_jpg.rf.7daa9d343a262bd9ffd08452e261fd94.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak191_jpg.rf.7db644e9543fb003075457e974badcd5.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-928-_jpeg_jpg.rf.7daed2bf1109564e7c77d1a10dabe410.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +64_jpg.rf.7de029952cc4e90150858c0728dada2f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering-4-_jpg.rf.7e19d09ecdd635b8cb3d0561316c92a2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_539_jpg.rf.7de17beab226333931c923052832e519.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +568_jpg.rf.7deafbef2adee88ebee3d37a5a990487.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-43-_jpeg_jpg.rf.7e1e933e3f285633954f1397caadc2ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_50_jpg.rf.7e6f2531466e05b7764882e80b0b2fdf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_483_jpg.rf.7e8cffb879257af150241a744ec2ea2b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_133_jpg.rf.7e3080712aa6ac28342c99ed09e70296.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_160_jpg.rf.7ece8c532120b2b563912d5a24f223fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-893-_jpeg_jpg.rf.7eaac042d2b19c78b94198be80a0f4ba.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-345-_jpg.rf.7f25213e7b8bf2473684c30f397a643b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-12-_jpg.rf.7f30d1a1852767402a439e7d8ac76242.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +359_jpg.rf.7f3f6681388055be03ad082b145c7ead.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +berminyak__-40-_jpg.rf.7f406d6127da1a008b5d71576046c43f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-192-_jpeg_jpg.rf.7f6660cc887c33c24065fd7dc3e12a24.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_6_jpeg_jpg.rf.7f8d3213689e380362e849b9da229b82.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-665-_jpeg_jpg.rf.7f500e50d4d6e7539344f988278ce103.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-3-_JPG_jpg.rf.7f6df09e60367a3fe0959480755a09ab.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +388_jpg.rf.7f7f2657b782d17470a005787b14e630.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +44_jpeg_jpg.rf.7f926d31bb3edf087191713c77d5e515.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +447_jpg.rf.7f96fca12f4fedc65df4a27c6686677f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1199d6a1-fa9a-468c-9506-f0be9fa117e6_jpg.rf.7f93011a4abaf770b7c1c2d6558a7c3e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +648_jpg.rf.800be0a81787deb88bbfe58e3e2b812d.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +acne-602-_jpeg_jpg.rf.7f955bead3ac4a6f4939f62e9df3a4f4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-83-_jpg.rf.7ff36c1563f1f6da1532bc7e2f45fb37.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +262545392_1_jpg.rf.7fe429b1b8298548daaaa74b02a4d7f7.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_78_jpg.rf.7fe82f6efa830b9755d6796f6f0c90f8.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-24-_jpeg_jpg.rf.803dc20f4137f3e6ae0ee821785d1369.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +36-Female-South-Korean-Hyo-Joo-Han_jpg.rf.8051378b03843fa349d363d6fb35601a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle2_43_jpg.rf.806afedad8c9d3f0155da5e5d3275fa8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_239_jpg.rf.804e72294c030dfd885d09cc20c5be8e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_80_jpg.rf.8034644c0ce36219b674b4bac2a72e39.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +364_jpg.rf.8083d7fc860342ec5d8876f7fce6019a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +2692234417_1_jpg.rf.806bf090a9878c60f02809e06434cb71.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +kering-48-_jpg.rf.807b7e22574e49f9632c365355342b02.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-607-_jpg.rf.80885ca922ee90881c53577e37260156.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_143_png_jpg.rf.80fca8eab7398169df090ba76ac82706.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-529-_jpg.rf.808a7d2c8a44c0d2f13d170296ceaead.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_20_jpg.rf.80e6de20e2fc084ecd04840f94b3efc6.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_347_jpg.rf.81052fb4e71bde690241a1949c6cf00e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_369_jpg.rf.810cad3ad935606d16b8d39947d48cb8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-663-_jpg.rf.814e62c98c293101989f2631e17f4cf3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1b0a0ae9-6b1c-4552-8948-9e5ef5e74a09_jpg.rf.818c333595b1e3635c0d443a1eba0973.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle2_110_jpg.rf.81721d20c18fde930182284411a2395b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_8_jpg.rf.81913712f9cfd37cd86f1596c1ab3898.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-699-_jpeg_jpg.rf.81a5b10a4b7d350656afbcea4cbaa246.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-52-_jpg.rf.81da199736bbcf36f02826a9d5eff734.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +453_jpg.rf.81b89dd52003a1f41bf7a77c6fb53eb0.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-638-_jpeg_jpg.rf.821edb58b0d9c27689f6af9f19e69e96.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_380_jpg.rf.821495ffefde91b4def5f4913a7415d8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-7-_jpg.rf.822a1a1c8e86354e108d128f2afd90ff.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +609d08fd2e319c0afbe44f3657988a64_jpg.rf.81e4f6d028c3e34d7388d7ef60d89985.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +istockphoto-638452020-612x612_jpg.rf.8243e345aca693b3c666a33e501a915c.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +kering_-79-_jpg.rf.824f7a14083057407b8f79d397b95542.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-722-_jpg.rf.828e472d4d7fc61a36e0050ef68b8476.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +9aca6223-fe38-4958-81d7-71e16a0018c7_jpg.rf.8273ed2c59356f2e0a1431a1abeadad9.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +5_jpeg_jpg.rf.8296f1145bc00adb687ccbbc6e214ca9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +36-Male-South-Korean-In-Guk-Seo_jpg.rf.82bcf85bce8afd4a2841da80233cc95f.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering_-64-_jpg.rf.82f48d2431b0de6020a343a478be2934.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_536_jpg.rf.82c87edee0257f6f459e58fc02eb47d0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-117-_jpeg_jpg.rf.830a315497ce5595b1565e293b402ad8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +208_jpg.rf.832d1eb05ad8c13904f11a019a061527.jpg, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0 +acne-596-_jpg.rf.8310d5571f060ff3fc18f3818627444b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-436-_jpeg_jpg.rf.833465108f260a4a9382e8e4ada83461.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-468-_jpeg_jpg.rf.833efd22cb3bea6241229fc3d6f5b696.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_432_jpg.rf.836790eab3048c30a0732fee38e9eb91.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +212_jpg.rf.837b235e4e794906e61657d41c1e288a.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +acne-196-_jpeg_jpg.rf.838a3592ddfe867f7d871b824a0f0c3a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +36-Male-South-Korean-Seung-Gi-Lee_jpg.rf.83775ba561fdc9b5282d7a1f342aaa0f.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Berminyak-16-_jpg.rf.838bc6a906bddac19a710c5b0eef5e74.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-343-_jpeg_jpg.rf.8398415e2c82880a7593e7dc1a9630fb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_629_jpg.rf.83b3191a539a56d3096ed28f0234f1b8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_129_jpg.rf.83db164d743a25d71d40923a57c25716.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-687-_jpeg_jpg.rf.83c87d2e7bba9fc0eefc717471ba90e9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-771-_jpeg_jpg.rf.83e8849b85f5c9894257e439bc163f82.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +491_jpg.rf.83ba3e921e1e8d98dae2ac93e3304491.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-569-_jpeg_jpg.rf.848345deb0d04e866c2305a902dbf9ac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-82-_jpeg_jpg.rf.84015cb22120cfb00d7b56ffd8ea5c42.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry7_jpg.rf.844dd895a04aa32ba1c617e8f329c5ab.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-549-_jpg.rf.84c25f0840525f7d88448a74896e097f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_49_jpg.rf.852bcab8a7697909c9207d54d29273ae.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_29_jpg.rf.8504ef89b64406900ebac5e21eabdf2b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-541-_jpg.rf.854325ac21867782906dfa5fe7847be4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-377-_jpeg_jpg.rf.857a8a914b7d29d1cf6407aa566f6c95.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +ec54b3c3-73ef-43de-83f5-5e4cd69e79e8_jpg.rf.858c2ff9497d82efbf4569b971b53079.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +170_jpg.rf.857d6f03e93449914486946e03ede0b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-599-_jpg.rf.8564269a95597acfc668880bff95a43e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_598_jpg.rf.8565f3176bd56de3234bff3e3338d9a7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +641_jpg.rf.858ce4be93097dbe9348cbba4e7165a7.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +levle1_51_jpg.rf.85b6d663df6e57f6aba55c6d5b956f0f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_366_jpg.rf.85bd18a75499880f7a1b2dfb62001365.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-skin_13_jpeg_jpg.rf.85e3237f09ddd819bb09fd161bb53cbd.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +75_jpg.rf.85f06b2276590881597ff267965b1e9c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-681-_jpg.rf.85ef8fe40cda7dfe717d106bc87015cd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +29_jpg.rf.86178569fb4dde504366149f7aea2685.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +93_jpg.rf.862fb32e716b8469f7ae7fbdea46ad99.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_322_jpg.rf.863b2329624c175c638037d39ffb41b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_84_jpg.rf.8635d0f3780645c2a644cc460d9bd481.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_195_jpg.rf.863e7a5080720244b8ace80412f2a168.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +01F3MMVH87B4G7M1NH7963VX3M_jpeg_jpg.rf.863bb29ae8592c7f5b20c32fecf745aa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_94_jpg.rf.865ecb5306798da7ca62eeb5baec9791.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_583_jpg.rf.866d1e7ad741208e271fa278f220434b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +94_jpg.rf.864176a5f87c83e43150421982581bc6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +471_jpg.rf.86586c47f1f5716c9cca5431a4ab97c9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-44-_jpg.rf.867310c2135b4a9fea644710a5953d60.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_386_jpg.rf.869525798685c991f0f07c23d4a20879.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +0c4c7bad-0df3-4d0f-ad45-7d67de49c77b_jpg.rf.86a2dd803bff49736dbfbfd91d1c56eb.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_100_jpg.rf.867f224a3c18b813d3903f4326d7b802.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_311_jpg.rf.86ef34a082c3103d2bdaccb0add0d9dc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +493_jpg.rf.86d385c5f38c6c3e2a8754b5bb3831a0.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +acne-38-_jpg.rf.86cb47c36c49d2dc514aa5d16baf0635.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +252_jpg.rf.86b6aa0094b1198aeb077cd0d33e9aa2.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_197_jpg.rf.86fd605fa8893555df22fb919919b1e2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +21_jpeg_jpg.rf.86ffff0cf62b2c840e67d5a6e41cc2d5.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_355_jpg.rf.870c994a5096be32ce257254a00b673c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_442_jpg.rf.8723ed601db2a195944a15864a8a06a1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_252_png_jpg.rf.873e554af4ba895b3f8795841f0c29ce.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +367_jpg.rf.873b235f7b2e20552393d1b6a30b8bde.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +9d786d72-fcfd-426d-b40b-5af6decf4362_jpg.rf.874daabe785f834f6410a4f4905ee158.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +664_jpg.rf.8750673e1103eb71916558d789bb7007.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +653_jpg.rf.8762c1b8092096dbcb07cf2a25a16458.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +6f50c70b-39d7-4c28-a4fc-6f982ad4b5d7_jpg.rf.875ce4891e1710d72aebcac173b4bb80.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +175_jpg.rf.8768a4cdd082de39c40395a8e7940bf9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-891-_jpeg_jpg.rf.87530fdaa40e89548858cdf4ce16a66d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak249_jpg.rf.87723095633f9553fefbab707ea35854.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering-37-_jpg.rf.87822f606a70ddaf326843358a1de38c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +367_jpg.rf.878522aa1fd23a3bae7dfa7c2a416d74.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +01F3MMX3DB7W0CEV30K2NZZR8Q_jpeg_jpg.rf.87a4cb6871e33761ee2c629348b59d3a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_312_jpg.rf.87df4c78b601ef1537b2f37fdaf8b9ae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +422_jpg.rf.8801d47b080f9ac97fc4ef9cd4328004.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-925-_jpeg_jpg.rf.881f56b318d82beb0fa418b7c20bc3ae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-161-_jpeg_jpg.rf.87bd0569a5173e154878f57f2e8e5c28.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_137_jpg.rf.883f84257d6457de1694f1bd581458e8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35-Male-Thai-Thorn-Jindachote_jpg.rf.888055737f2351ec9cb48551534ed105.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +33-Female-South-Korean-Shin-Hye-Park_jpg.rf.8871f6364c5be0b021a9198ff7e7253b.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +240_F_504672454_jndgj2KO7RJwbd1f5qgcFFAYtOcNpOgT_jpg.rf.886eb6ebb07caebbda38ae975407a653.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_522_jpg.rf.8887857d67f9d88c1c378678546db343.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +33-Female-Thai-Tongborisuth-Arisara_jpg.rf.88917e2a8d96cdce44415816d862971c.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_600_jpg.rf.8883fddfc92fbfda03dac0c0cbd50056.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-444-_jpeg_jpg.rf.88c439b011be376569b7f202f6d9f6dc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-44-_jpeg_jpg.rf.88ca3cc1f668409f46e62ecff67f69d7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-344-_jpeg_jpg.rf.88e4ff666cacc84906a0ca30ab1402c6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +197_jpg.rf.88f382ca6860beac8ae70d674db756d1.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +32-Female-South-Korean-In-Sun-Jung_jpg.rf.8917d4e5ee60bdd9bffabac7b3559a14.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_259_jpg.rf.893f22a43dff359e573c78909629b9c2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +162_jpg.rf.8938900abc331803dc10035eb5c7d3bd.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering__-100-_jpg.rf.8942e0fd71a2f1f95fa2c8e50865a62c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-118-_jpeg_jpg.rf.8943c2ead313292069ce1495ba62171b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily37_jpg.rf.89c8eb401b38ff65acaa1e9e2c89178f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +46-Male-South-Korean-Ji-Sub-So_jpg.rf.897536b64890d414967e386bbc86a919.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +161_jpg.rf.89b3ee7a3d837a725694a4a4a736dd08.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-263-_jpg.rf.89e164883449b4f5ee27d98d14cb25b0.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-890-_jpeg_jpg.rf.89f28b0b80a3f3b4ad00effa10107bcd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +109_jpg.rf.89f157644d83a62ea65324c801611ea9.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +44-Male-Thai-Pakorn-Lum_jpg.rf.89e5fb69847c926c1119f97cbecc8f41.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle2_166_png_jpg.rf.89f8da4cffc9f6062cb490879abaf8c6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +554b57c1-0b15-430c-acb3-072fc06d8d9e_jpg.rf.8a8e4b1d79a9d06b3d30d08c3dac0b17.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_189_jpg.rf.8a6570e109f128af43cab2301822de86.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-198-_jpeg_jpg.rf.8a45571e717b37c18bbf25c9cad81616.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2719467991_1_jpg.rf.8a16d2fb02628b537ec32ef9e0d1ed42.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-838-_jpg.rf.8ac4ac435512ca714ab6569542cbcfa5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_147_jpg.rf.8ae639c04b4389dcaa716b63fb7a66ed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_260_jpg.rf.8ada0ebc7b8d241986bbe9e90ffbfa7c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_273_jpg.rf.8a9cd9a51531953a8ca2d8175f98d990.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily2_jpg.rf.8a9df4f8906d582805c284160e2561e2.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-268-_jpeg_jpg.rf.8b4ee1bb01935d184ba7280e623fd687.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +33_jpg.rf.8b015fc5b4efbe5706dbd8174bb4d14f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-336-_jpg.rf.8b57a7e9a13b03923732c97a362937b7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-494-_jpeg_jpg.rf.8b6fbe98a218ba7267a8daabc8215f4c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-133-_jpeg_jpg.rf.8b873d478290bf3b91f1b7a5bf9dd239.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-547-_jpeg_jpg.rf.8b5cc09a11d85c1b7c7e3780ec50303e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_106_jpg.rf.8c0544d4dbde83a43d61fce909c6bde2.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_54_jpg.rf.8bc10c7621163f423a7eeab6b06d08d7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +7bd9180f-305e-432e-af3f-af9fc6a74b78_jpg.rf.8c073e2408d62a8e1bd65aca26de83bd.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-560-_jpg.rf.8c0584c04ebcad7a43e2d2ce28dfd097.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-702-_jpeg_jpg.rf.8bdc48bd5a6d95a401be780261ca676f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_418_jpg.rf.8c48143ff1bce9e5b0204b051e4773a2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +104_jpg.rf.8c38955b24712efb30145579016e611e.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-74-_jpeg_jpg.rf.8c29aefdee63ef16b1fccad885503e3f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-422-_jpg.rf.8c394ee673fd9f0b949bdafd68b62a74.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-21-_JPG_jpg.rf.8c695223d36ece17d379299c4f204a23.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_35_jpg.rf.8c6a400a5ab81eccbead8950576da405.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-283-_jpeg_jpg.rf.8cb754d2e2644bb0dbbefce215c3c9fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_95_jpg.rf.8c72277f46582d54811ae6d6028602b2.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-651-_jpeg_jpg.rf.8c98fdd5d7666a4468f9b36a0263ba5b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_566_jpg.rf.8cd61f5b9d419f54014f0a2fd6443814.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_1_jpg.rf.8c9f31853e76ed3375b541ee5cb0191f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_114_jpg.rf.8cccbdc4a656b5dd89c9aa7171b3d14a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +198_jpg.rf.8cfc4f029612bfc8874dd7153c7a79d5.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_489_jpg.rf.8ce264821519da20a3116cd3532d423a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +ac1_jpg.rf.8cdf2f5d54368160eb624b99fc0767a4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +601_jpg.rf.8cd96a2e55dce88ae628e1d110e2fb35.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_334_png_jpg.rf.8d27be79ccc26c5027f92af586df825e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +171_jpg.rf.8d3176d6e648611e8cb30cdbda98f8e0.jpg, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0 +levle1_392_jpg.rf.8d9c5f9cff94809f4278b846ab14b60a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-73-_jpg.rf.8dc8736e21727d32b290ba3ed1e7f3a8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-150-_jpg.rf.8d86704f0df24fa572fcb8635e021f78.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +edee38c6-6337-4d84-92d7-56c25cbc1ad3_jpg.rf.8dcb66b2a45186ed4ad43e5e0d58bfb3.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-562-_jpeg_jpg.rf.8de881c13ce21115d7a232663745d85c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-470-_jpg.rf.8d614860f181db0f5d4ee44ad9832bfe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_25_jpg.rf.8e2a579a6bdd8b791768458f8565420b.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-146-_jpeg_jpg.rf.8e31e7a9459c2b775e41f4dec7b3de26.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-685-_jpg.rf.8e1723c010a683f87c2e665404920a16.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-21-_jpg.rf.8e23f9840c111676291715b3c9723259.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-514-_jpeg_jpg.rf.8e402712868c1cb97767c41ec9a6f84f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +335_jpg.rf.8e9d3b6ec38cf20b03049a7d33ea1800.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_250_jpg.rf.8e60a6430ed246f757797e66cc2d55f5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +639_jpg.rf.8e6c9971250c79263ed40048377f5e3e.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +kering-89-_jpg.rf.8ecb3c88f770a8ac02e07eda6df57b00.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +249852286_2_jpg.rf.8ed0081bda56a2597c987a52f74451f6.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +39_jpg.rf.8ea13cca7dc5aff1a9a7b325bc07fe69.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +147_jpg.rf.8eed3e78a0a027cded996321774288b2.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +berminyak__-37-_jpg.rf.8f02ce81e64edecc3fe524a1e79dd8e5.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-786-_jpeg_jpg.rf.8f09bd9e5817d02d934e3623b9137747.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-136-_jpg.rf.8ec2be907f767687038e77af67a0aad6.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-36-_jpg.rf.8f14989e12e7fb9564ea5ad98c25fe5a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-6-_jpg.rf.8f6c34a7bdaa3f02978e00a69685d447.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_415_jpg.rf.8f6ef3c2a46ec5eadf1617fc7384fef5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_46_jpg.rf.8f1ea76104d1801cc7d2f7f9bbc6cca0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_14_png.rf.8f1c1dafcefdca417aa18b62097f723b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_176_jpg.rf.8f7408ffa7934d43e6b81526708f35cb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-586-_jpeg_jpg.rf.8fa4ebe3d00bfa0db0c9871da5926a4f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-9-_jpg.rf.8f8306dcd88b5c956f480592f34d59d8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-407-_jpeg_jpg.rf.8fa053e32a3687269837676fc951bda6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +70_jpg.rf.8fc75795582fdb99581b1ca5c233c664.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_53_png_jpg.rf.8fa7eb3724b6457c0de442b782c06378.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_603_jpg.rf.8fd877c3aca677c2fd0e33c4bdca7813.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-781-_jpg.rf.8fdf070163eee30fd4578985a1766650.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_303_jpg.rf.8ffb1a4ff7958d3a356f5619fb747522.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry12_jpg.rf.900550d330bf15b583b3681cea204480.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-797-_jpg.rf.902b82fb30252e6d481e29a28ebfab9f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-565-_jpg.rf.8fecfa92406f676c1910c9b258dfdff7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-645-_jpg.rf.904c0b593332905583bf6064664721e1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_225_jpg.rf.90571373f38a8de2f0c34dd07b8d31c9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +570_jpg.rf.904da2409fd0054b562e7bd97f876b62.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +35_jpg.rf.908d90f02b1eb9140db1ec8397884a13.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Kering-5-_JPG_jpg.rf.9090a03c40b1d0eb03de5879dda7b398.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_112_jpg.rf.906499c26b73def70a66bdf75c3be126.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +640_jpg.rf.909961765a7b7c59bbb5af405476cf7b.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +555_jpg.rf.90c5f526ad19d266696355a9b070a57e.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Image_107_jpg.rf.909c5326f77f55084a51af6c856cd084.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +496_jpg.rf.90c8f6ec8b20563b79b1324be191dbfa.jpg, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-439-_jpeg_jpg.rf.90d4d135c8025279444ab6fb11498659.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +309_jpg.rf.90eea01002675e2d0cc8467045bd7c83.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-17-_jpeg_jpg.rf.91497c44b17971c34330278005d5613c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-9-_jpg.rf.9117fc4817b49e91ff9b0b082ca185bb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-112-_jpeg_jpg.rf.914f9d4322dc16bb751b7fe7a9264abe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +bda58b7d-44d5-46ec-b682-534084cfdb4f_jpg.rf.9150efdadd4ad7ef303b219cb52d0fe5.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-209-_jpeg_jpg.rf.917f8cc6554ba980f5c47281099f874b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_32_jpg.rf.917222844a5698d6767d1ebf7a33aa5c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-16-_jpg.rf.903edb718088a35a1385baf7cf8ff539.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +326_jpg.rf.90feccfb78b97e51f314fed1f0a30cb7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +30-Female-South-Korean-Gyu-Young-Park_jpg.rf.919ca3d6c663af631e64957715306bb5.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +427_jpg.rf.9198d641c37ecf6b4f17596d64d3c9ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +8_jpg.rf.91aaed5a20001b44302d1979986012a4.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_205_jpg.rf.918bdc5a45f8e5bebf515aa32a2a933b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +147_jpg.rf.91b199241818fce9de3fc844e0c87f27.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +acne-326-_jpeg_jpg.rf.91b51d823f71b1597ea93debc7c913bd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_270_jpg.rf.91ed2fb88daed8ad291024d248b59e77.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-406-_jpeg_jpg.rf.91d5b0d05e1714e182fea4f666eed4fa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-34-_jpg.rf.92104607c21ea19d0bfdce4df97fa5d3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-717-_jpg.rf.92029f0e3fae8314123bbfa52ec43927.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-46-_png_jpg.rf.9214f5e1bd865aae697f58c97bac4604.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2882152573_1_jpg.rf.9228175d5ed27bb61b7e6c0c8ce5c9cb.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_595_jpg.rf.927c755b47097f8f85471666b6a17796.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-463-_jpeg_jpg.rf.92281e54347d913e3167dec4eb23566a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-754-_jpg.rf.92338474cac25660e5408e1a4dee4971.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +3b9d484c12c66b804c84a030efea4cda_jpg.rf.928e843972c511f16b5cd85dd71073c4.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily13_jpg.rf.92a6e29a14a7a9c7786558918ae70042.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +247_jpg.rf.927e1b769021711046b27850701a8e63.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_565_jpg.rf.927edadd54bc7924eea351824638d9a8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +43_jpg.rf.92b83222948cf2855404ce13bbc1fab7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_49-1-_jpg.rf.92b8fb8aa83da546982435d2432787ce.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_284_jpg.rf.92f6c9a31ed089aa68c65217da02452b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_271_jpg.rf.92df0e7fca23e0c4eb76f4c280d2da59.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +24-Male-Chinese-Yi-Lin_jpg.rf.92f85a18d12f66e711f8ddec6a8a510e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +oily-48-_jpg.rf.92fcabce0ccb5a0a3a1bbed0f9728034.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Kering-5-_jpeg_jpg.rf.930401d740d3fc93bc0f96027bcb21c9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-72-_jpg.rf.9308644f5e0f1e90e587e9979b2102e7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_9_jpg.rf.93170c702ed8540d67e8c0dc125ec0b5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +50_jpg.rf.92fa0c93d00d859cafe2218310565f26.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +29-Male-Chinese-Yi-Tian-Hu_jpg.rf.9337384700aab3cf907ad147a9201498.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-104-_jpg.rf.93677af240fca0f3cf0ac3345a735dca.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +128_jpg.rf.9370e0bc57069d36142781889ce87cbc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_172_jpg.rf.9397d0e2eac6163f94ce6d3abc7fef00.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_312_jpg.rf.9381fc5dc5d37db2cd15c37e39184b22.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +92d51c3d-40f5-42c4-9721-2af45d3691e0_jpg.rf.35a47486b2f739b3a4ff740e79c0312b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle2_25_jpg.rf.3680960274b07c39df8ba8fdcf684f5b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_381_jpg.rf.36b84b7b2545639e41ccc91217a044dc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_101_jpg.rf.3664e004ca1670fbdd1afa2888c68bf4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-698-_jpg.rf.364dd0c6f485600bdf00a7d202e03e46.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-394-_jpg.rf.34cd51404679ece11256f051c03ea13f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_478_jpg.rf.364d0fbb024268773e9b4b89be5132b9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-797-_jpeg_jpg.rf.3632918c5de60b0d3df2901efe6aa3f8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +241_jpg.rf.35dabd93b432314386dcb467f4fe1524.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +363_jpg.rf.3519b7565f58cdef0ae75c61ab271c52.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +acne-743-_jpeg_jpg.rf.35cc84f63c4ef69fae57651e26a314d0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-133-_jpg.rf.3601eca94c1ef03e809bd64195cb75d6.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_67_jpg.rf.34e9b7cc7056c20a5f891a12a478ed8c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_164_jpg.rf.36274aa2f121b852f013abef94c18052.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-592-_jpeg_jpg.rf.34f3d101d12ba0e18736dad70f9020e5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-803-_jpg.rf.362cd380f8bbef7c4d9b09f2438fe896.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +176a2fc2-e888-458d-945d-89c7ce94abad_jpg.rf.36285f7a7ab7aefa01b975a55eed488a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +13_jpeg_jpg.rf.361eaeccc726d8d1bbcc2f71308464dc.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +19a85cc5-9226-4f8a-841c-1e05bd8340db_jpg.rf.35e8a3db4f6554c4a8de5e2c4a3b4fc0.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering_-58-_jpg.rf.3502c5f9cfd3a6134804c2a1c23f30b9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +220_jpg.rf.361f7c4e133afe56d5799638b9688013.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +4_jpg.rf.36c318ed2fe54aee54cfa64322238ed7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +295_jpg.rf.3548eb1a216c71d1782da01aa9f8b3a2.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-675-_jpg.rf.35e6989907a77d7d833efc4f38d18f09.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2394089614_1_jpg.rf.36bea1465675739501def97b6e36ee54.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +28-Female-South-Korean-Ji-Soo-Kim_jpg.rf.36e846656b5d089d2a4de4de31b6f0e7.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_461_jpg.rf.36dbd53e10ab0d31966b6ca32082399c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-458-_jpg.rf.35b11f07afadfcc635013b49bbd36f49.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +123_jpg.rf.36a5bf6c91ebbc6d9d95863bbc741a02.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-94-_jpg.rf.36f3129f024aa6d52caa6e32d9051980.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-523-_jpg.rf.373ba14ca185ce49f73733b89b6e7a06.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry25_jpg.rf.373bdd49b9f41050511f5342b85c44a2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +2_jpg.rf.371598f104bdb6f45f158df5e771395a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering_-35-_jpg.rf.373d01b88e7a1315fc6d80b21af87a53.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +c5b6cdc7-5654-47d8-87c1-79ac0ba4daba_jpg.rf.374eb368d1d400e70fc1bb02d86043a2.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +01F3MMYV5NBAMXWHPA6EKMF3SP_jpeg_jpg.rf.37457997dfb9abe441ca62b74727cff8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2707642369_1_jpg.rf.375062e35a7e295c0066f9efaea16d74.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_620_jpg.rf.376430152f84770e0e89a80aedd73fa0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_495_jpg.rf.375da1b565cd3068262e3403448ae5e2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-2-_jpeg_jpg.rf.37535e0cdb7d2ebcebacd5c3a9cee5e1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +0cc20ae0-c7a8-478b-a974-a5aebabffc15_jpg.rf.3788f883c2d62f96282d70e6933b76d3.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +64fe2db7-da0d-4afb-b2bf-e28a658c8849_jpg.rf.3791ab8fb21a41224f46e1922b731360.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +dry24_jpg.rf.3788f32c7488281024c217134137bd1b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-790-_jpg.rf.3768330007c9796d64dd8a471037fddc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_116_jpg.rf.379d18f978f0c3b764786975923c9a9f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak136_jpg.rf.37bdc5ac9d9761ae8ddfb65a3485e939.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +9a457d99-b7cb-4168-b5a5-08324b8f9852_jpg.rf.37cf6014d6929f44f1d37b1f80184709.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-724-_jpg.rf.37cf22bba132e89fad4cf86070cdedb5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_22_jpg.rf.3860465ca2a12f17cf03570300678b71.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +225_jpg.rf.382264387e1603f3f4371282af175936.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +176_jpg.rf.3848a381b0af10ee869869b5a3f9449e.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +acne-485-_jpg.rf.38460b1d079a4df98a71dbc0837c0bc3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_58_jpg.rf.388f8ce9117f56cefe119fd93989adb9.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +oily41_jpg.rf.389c7bbeafa75d19acee061e6e9bc3fe.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_64_jpg.rf.38a39e011db1ce0cc66c8422f9573284.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-67-_jpg.rf.389c818b9b6832d281bf226616beca3d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +650_jpg.rf.38b002beebeca8f1acb342df347bc1df.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_446_jpg.rf.38c88e02b6efc06968267dd12061251c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-798-_jpeg_jpg.rf.38be245971555051b64874d2bc4549f6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_156_jpg.rf.38c6dd880687efa787c2cfd076f43a2b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Male-South-Korean-Jun-Young-Lee_jpg.rf.38d28d80ee097b054b09b904b22be15a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_41_jpg.rf.3909292efbc9c04278f29354d2f7ed1a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +5848657d-7411-4b5a-919d-925d5f03a33d_jpg.rf.394cc179b55a7fb4109010727bafc6da.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +401_jpg.rf.39399282ac5c53f5b3dd6798750e4077.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_234_jpg.rf.395a528182011f2f8a6c95ef6973e458.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_100_jpg.rf.399a5850bb8e35f0926b73c67e6cebd5.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-502-_jpg.rf.39bfa47478530148a3806539e3b61469.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-250-_jpg.rf.39b5a6e4b1548a2fb31dab509333d583.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +119_jpg.rf.39c5212b89c6c168d54b9423973844d5.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_420_jpg.rf.39c7fc5f52d7f4e5bd9565757d105e1a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_27_jpg.rf.39f0d962b737ff15323af71116977804.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily-64-_jpg.rf.3a59c20d624f374c522e880cb7aca785.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +101_jpg.rf.3a5bdcf903a0ce363bddaa7203e0634f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +excelvplus-Vascular-Lesions-Telangiectasias-Ross-P1-before_jpg.rf.3a8937b7affa524749b788f3e3075833.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +Kering-2-_JPG_jpg.rf.3aa245bc04cf98f106ad1c86abe46fcb.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_486_jpg.rf.3a622ee2f556f342584f901019c5439d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +140_jpg.rf.3a8c0693d51fb2a5cb6ea9d01d6cffab.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +levle0_353_jpg.rf.3ab7559cd9ddf4392270bf72111c3682.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-311-_jpeg_jpg.rf.3abf06ce84607a42e4d0d0fdcec24164.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-46-_jpeg_jpg.rf.3aa6a788835a03bc03aae838629e24f9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +69_jpg.rf.3af5ad162dc16de08894fc0922122924.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-47-_jpg.rf.3b31a099e6a1b6c19125324c62de3cd9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +310_jpg.rf.3b657dbd1e4b8a844ca88afb40e45602.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-390-_jpg.rf.3b737ef056f12842dd722e78dd8e90d4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-39-_jpg.rf.3b64ebb7ac0b7af1d66c78887d242388.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +614_jpg.rf.3bb78e91574fd2b61bc244ee1fa9b832.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-666-_jpeg_jpg.rf.3b9b394c292830eb44766accdbd46dec.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_411_jpg.rf.3b99c8357edf9e19c7922d3fdcfec4d2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35_jpeg_jpg.rf.3ba1d61141355b441f42b46310328f7d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-354-_jpg.rf.3bbf2645ac264511d2740d55b0800dff.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-73-_jpeg_jpg.rf.3beebca2b7bcc3882a40ac5ff0a983c2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_351_jpg.rf.3bdc03b7095a615fd350bd04918bcd96.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_212_png_jpg.rf.3c0c5210521f30d963dc5337245bfd5e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +419_jpg.rf.3bc2e7e03b6ea6d159c781d27e846d81.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-740-_jpeg_jpg.rf.3c0d1ee8a86faecc184253135d04b01c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-456-_jpg.rf.3c1c7e0f541e6bf927fd4fa8f57b13e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +156_jpg.rf.3bfd2fc54c82ee61511c5f323c2f8ada.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +184_jpg.rf.3c527c3cde4e70a496cbc21906cc96ad.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-417-_jpeg_jpg.rf.3c40d5b1c51dffd58518e49b26455743.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +93_jpg.rf.3c2188069c86a40fbd8cda4f5aff7137.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +oily45_jpg.rf.3c275ed46eab3198c0f9bf38c8c82eda.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_141_jpg.rf.3c6a7828efdca32f77cc27c85c6ecc47.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_194_jpg.rf.3c6c510459567c770204fe390a914850.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_122_jpg.rf.3c9c1620e1a1a7a44d4a1890357a20e5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_359_jpg.rf.3c879fccc876441b6a5360457db3bd0b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +21_jpg.rf.3cb4f81aac4a1d65321d17b50b157e1e.jpg, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0 +23-Male-Chinese-Hao-Ran-Zhou_jpg.rf.3d0415aa92a2f012b7faa462d07d9a53.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +206273130_1_jpg.rf.3cf1168cca2b1f9a8a6aad3a6858170f.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-577-_jpeg_jpg.rf.3ceb4a5b1b8de75a84d8fbea35cc883f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +179_jpg.rf.3d1b924bcd65e49f415eea1e48fd8e58.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_181_jpg.rf.3d0b355af5c7ecfa904dc4b7ebe53c0a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_53_jpg.rf.3d0b36e3e55f8537a36c801fe3769c88.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_135_jpg.rf.3d4bdb1351de9da65de197c942af2cfd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_46_png_jpg.rf.3d87b848def902c9e84e6ea6e16f040f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +629_jpg.rf.3da4bb8de1229d972976cd21af325113.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_297_jpg.rf.3d990f959a9f2948c5d64ffa0a36ef30.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +131_jpg.rf.3db0056c980f903db6b9f458bdf32af1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +63_jpg.rf.3e174332d63abe357ab13198817bfd22.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-760-_jpg.rf.3e0e99581f971a9790d6eb87bd64f5cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-117-_jpg.rf.3dfba55f7bb5196b632c976969978b68.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-26-_jpeg_jpg.rf.3e1abacbba769bd3e80c87b9dd07d029.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +a7_jpg.rf.3e699523434ce54a3fe0e5132eeb0248.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-239-_JPG_jpg.rf.3e1b87ea20a45cf11595a86f7c432145.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-240-_jpg.rf.3e3ace74b62f628a42ab398d974e5014.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28d57376-7104-46f9-9808-a677ad93d838_jpg.rf.3e7c2fa645b2b30644225909ea4f820b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_228_jpg.rf.3e81e8f967ef5cffb53cf98f0e411de7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily50_jpg.rf.3eb70a3dfccfc4294a023f3788cfafaa.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_525_jpg.rf.3eec91f5925b2c56298650229ec94f4b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_317_jpg.rf.3eed87f4d666b677a25e190082fb421f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-75-_jpg.rf.3f0e3bc2415e92feccd259078245524d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_62_png_jpg.rf.3f549b98ad4ab96658540e35191a38fb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +375_jpg.rf.3ef489e79febeadfbfeb1779dcaea97a.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +oily12_jpg.rf.3f4a77a436bde2582fe6ecbe3c9657ab.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-398-_jpeg_jpg.rf.3f5fed58ab869c30b940f9c81c37bdd9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-16-_jpg.rf.3f86f4a418f63c9eecb46dd93cfc7833.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-225-_jpeg_jpg.rf.3fb07136e2ad14632d6ffc1343b921b6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +125_jpg.rf.3f67fd4388cad764385b201f377a1924.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +62_jpg.rf.3ffc64ca343bd49d9a24a47eef425906.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-665-_jpg.rf.401ab1835645b31c79088628b2ee4b4f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-154-_jpg.rf.3fb9e712ae6e42556ae89a5829ec65d8.jpg, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0 +237_jpg.rf.3fcc1d74d4d71c408e1c0b4343731d3a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_39_jpg.rf.401ef6de3a9d2265af7abf8daf2dda02.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_452_jpg.rf.4047518714fc6f762447681c273da7d4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-914-_jpeg_jpg.rf.406642d2e11fd15e265d82b8976ce487.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +298_jpg.rf.403080cbe85cb2678f3945e024cebfb3.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +_2250439467_jpg.rf.4071f39cfea59455a7d5ed60abe73a69.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-63-_jpg.rf.407760b7be5ebcf125e7e143901390a6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily58_jpg.rf.4088473f8c202983b93d9dd050e361ad.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +54_jpg.rf.407505192b80366a4a7676f3b3423e11.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +592_jpg.rf.40988c439a4e42410d9c718ac9b29d22.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +180_jpg.rf.40bc1beae412081c063f4543189e7c6b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Kering-6-_JPG_jpg.rf.408cdf57c649ae38dcde8e8a6f031e1e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily-65-_jpg.rf.40da72e6bd5f1405b639aec443617389.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-533-_jpg.rf.40f21096063b7462c1cadf2380b286a8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +112_jpg.rf.40f25a4fecc500c4a9e1092732d740af.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-697-_jpeg_jpg.rf.410a9e8a190b09c9f58d3d956d4ad316.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +214_jpg.rf.4140685d332d632cbc507f9692913ac5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_460_jpg.rf.41224057a73f9202342c14827fd3e8fc.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +347_jpg.rf.4151b0efc8db13f7cd0f740b88832635.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +2426631349_1_jpg.rf.412e58f5b21818da2519ea24dfd4cdec.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +347_jpg.rf.414bafdf69e08151fa57ea0d8d6800ea.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +36_jpeg_jpg.rf.418ccd667b1377d35f4bcac469f3509c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_37_jpg.rf.41684dd7794a7934e6f3c9200ac4b5a1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +135_jpg.rf.416fb3adda1b4bfb27977a5b7fe7a0af.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-303-_jpeg_jpg.rf.41c57ff87afdd079a828fc37f5c18e7f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +109_jpg.rf.42542b7a346efe5249d382247864b887.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-156-_jpg.rf.4260591c5198d5dc9fb9c3b738d43ccd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-347-_jpeg_jpg.rf.41c68f62c7ff5cb96c101fcf6a8de48b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +20-Male-Thai-Pongsapak-Oudompoch_jpg.rf.4221eff7d9335b08aba55fe140a6f4fc.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +266_jpeg_jpg.rf.4267c5bf2fdd351ba1d0fa636b2206e6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-614-_jpg.rf.426e12f20905bd7aacf7f235ec607efa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +302_jpg.rf.4275c5f4bb4d587efc145b775d84ad4e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +206_jpg.rf.427b59432828be001d54d975598ef792.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-179-_jpg.rf.42b4befb8b4cd4502bd63bef38b9f8f2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35-Male-Thai-Yuke-Songpaisan_jpg.rf.429c800eac10e90e115755affd4dc9a3.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_16_jpeg_jpg.rf.42c56a32b996f02bb32692cbd786e94b.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_303_jpg.rf.42cc5a403517db69889f92a7a97d0016.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-404-_jpeg_jpg.rf.42cd20f0d26021fd812f04786a441ff9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_588_jpg.rf.42d9e4391d569599663683f94f5f0d03.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +7d10921e4115ecccf3476d7d7ba530a0_jpg.rf.42cdc6aeec809ac4a365a312402f6bd5.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_270_jpg.rf.42de0fe99ced5a4f59c367c7bd476a86.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_189_png_jpg.rf.43133ed5b07057b03ed6dbe4f7756e8a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +554_jpg.rf.435f741e319d633bc140e918912a8cbb.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-688-_jpeg_jpg.rf.42ef7f647dd1ebd68974c75d95eaf95a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-692-_jpg.rf.4369b863d6da3fb238c3a1774732d632.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +b0dd8006-1128-4a8a-b09f-b625e5f970d6_jpg.rf.42fb41b6d3ba4dffd2f66717e3cd23f7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-259-_jpg.rf.43691ace25200ea9db9166c400a627d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_269_jpg.rf.436e420b1b9406077b6982e2376011b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-109-_jpg.rf.439a49259094a1e341d156f9bf8eec9b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-3-_jpg.rf.4416067932066179b7ae48db0f8b295b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +73_jpg.rf.43f69dcc5a783b83d4265d6b9812c279.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +32-Male-Chinese-Zhan-Xiao_jpg.rf.44051ec090b673f360ce15881927e1db.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +362_jpg.rf.43a1a94ad9e27445bb4d484cb4fbfee9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +oily-167-_jpg.rf.443971a6a8406a038ff5884641638cc7.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering_-71-_jpg.rf.444a57e89fda093c2ce822ec89fbf267.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily-168-_jpg.rf.441ffdaec26800b943fdb61268a9a920.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +14_jpg.rf.4485460bfec2fb387474f093c6ea9906.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +219_jpg.rf.446eaef37442afda7a0904c2475bd318.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +393_jpg.rf.4468be1e54d39fa01d149eb563245835.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-4-_jpg.rf.4473795f88381356bb5c634a837df39a.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-44-_png_jpg.rf.444525fcc91890b4ad450db17dcfc871.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +317_jpg.rf.449ad29c71c0a4efbd11e491a3aed640.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +2706940948_1_jpg.rf.44b6989ddadf27e5f6e7b8a44916a341.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-765-_jpeg_jpg.rf.448af607eefc92fb2a3ca3f1f3acfc85.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-1-_jpg.rf.44c96265dfdda64faa9ed862ff4b0988.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_532_png_jpg.rf.4525acfb242a5fdc9304e618d91ad209.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +75_jpg.rf.45324beae6dc954ded7a80c27c75709e.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +94_jpg.rf.453553d755b57528d5f51fb1552270fc.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +230_jpg.rf.4549006da5e946198f0a3f360aac914c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +248_jpg.rf.45500494bc385b5763d448d6f607a86b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_471_jpg.rf.4571377fd19fa5d58359dd774e8506d8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-86-_jpg.rf.4560cbd01c69d8646db08a17bb2fd9ae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +214_jpg.rf.4553e7aff451ff5c7055d6d96cd75d79.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +dry32_jpg.rf.4582324735c4ac31ffd53e65ebfa7021.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +64_jpg.rf.458553a07b819d0e2216769d282b98d1.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +245_jpg.rf.45976ad7224a7ece7761bdb5336bdbb4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-246-_jpeg_jpg.rf.45bb7f473b0b45b96fb241cda52ed35e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +104_jpg.rf.45c2926f86111a586a007d2cddaebf58.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-126-_jpeg_jpg.rf.45d8e12503f35cd1e10bf27611d1dd3e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-831-_jpg.rf.45f3d5ba473e7807a9fd1630be3cbce5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-32-_jpg.rf.45ee70660cd5053fb9ad9218028a1a82.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_87_JPG_jpg.rf.45f4103c04a84d16671cc121c8b154c3.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +119432bf-56fd-45ed-aba9-64ee745e90f3_jpg.rf.45fd95fccb232e020423406d45a80def.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-109-_jpeg_jpg.rf.460edc822a36e46716e16e90d853fd5d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_16_jpg.rf.45f9e866fdae6c173200a181efb0f227.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-836-_jpeg_jpg.rf.466d76829349061e1265362ee314ac59.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_626_jpg.rf.4651965d5e12881d7e2a96b8229288aa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry29_jpg.rf.4680e55f92c1e00237e6692a84039aba.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_582_jpg.rf.467351c35f064cd813e0d2b891fbd123.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-277-_jpg.rf.46932e31dab36034b410cc8260d2fff2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_593_jpg.rf.464756f01fe9fdc3477201595b358406.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-45-_jpg.rf.46f1dda8e9ae3c05c6e5571274e33213.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Kering-33-_jpeg_jpg.rf.46de99139a07354502f42d53271ccec9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-47-_jpg.rf.472bad7cbfaf73ece62d9a5681321c48.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +42-Female-Taiwanese-Chia-Hua-Chen_jpg.rf.4732bf77a202851e8bc17c6146f0461d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_40_jpg.rf.472fc86fb3b5b7f62be4134a4af4944d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Male-Chinese-Yi-Bo-Wang_jpg.rf.473ea4aa3c16c4200362701c3736bc45.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +616_jpg.rf.4742caca515b7bb954de57690b1d0505.jpg, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 +acne-340-_jpeg_jpg.rf.473685f96017e70284f47e6fd22d099f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-239-_jpeg_jpg.rf.4707ef5b818c835722744a9e891c1744.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_21_jpg.rf.4748c41f30f61e97bc9a686e52aa32f4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_312_jpg.rf.47719377bd113b0e840177c48603c0e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-246-_jpg.rf.474a67e75f180b51a849239c0db339b3.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +88_jpg.rf.4760b84aec95a837e6ea06c33000732a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering__-109-_jpg.rf.4772c627c325125bf51fb83c880fd7e6.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +33_jpg.rf.478dbc745956fb09ba4ad08b75eceb39.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-202-_jpeg_jpg.rf.4776dd49c9fc544a52c3dcdeb466e7b7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +195_jpg.rf.4777dd8698d2c81fe212ccff41c9a0f2.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +28-Male-Chinese-Kai-Xu_jpg.rf.47749fdfc2f532b2c0313c7551c5ed70.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_419_jpg.rf.479a7bc3e57ebc920b12032756374e68.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-39-_jpg.rf.47a3b27a227d68900f48c7c82e1f912b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_168_jpg.rf.47d6ed4a37f25db4e49c4a5fa9664b8b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +356_jpg.rf.47e7b1afbea6ebe7768f954107759e97.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-885-_jpeg_jpg.rf.4827968ce47b3dd5d15633d8455ddf06.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-89-_jpeg_jpg.rf.4879316bd79ec17dc2cd1eeec910f85e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-364-_jpeg_jpg.rf.483b86681f23506bda747289404264ff.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +124_jpg.rf.481f8f33a1d53cce437c88e8bb3ec216.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-662-_jpeg_jpg.rf.48873778c9a334bb86396e7f5f0c000f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_363_jpg.rf.48c19aac85df333695b77a71e35ee221.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-61-_jpeg_jpg.rf.4913d51ddf912852c10975f21579a84a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-499-_jpg.rf.4921d22266811c6ad2b4c7001953e2c2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +17_jpg.rf.493e6924244c8038f7c95c63dbe9f201.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +oily-251-_jpg.rf.4944cadb9ad458748e16f350fbea1161.jpg, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0 +160_jpg.rf.4926b9539ea6e632b168fe7b471c5d49.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-208-_jpg.rf.4936056d0cfbe78e259b938b11a8a879.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Female-Chinese-Ruo-Nan-Zhang_jpg.rf.49849a2b3f9e0f37f42126e0ccc101d6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-581-_jpeg_jpg.rf.4a02a4d394e652b1b643738d5e871c4d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_3_jpg.rf.496a6d16e56ff27c24d3e88c7170b2f1.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-738-_jpeg_jpg.rf.495862c0ddffe89297889e4f27213f62.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-132-_jpeg_jpg.rf.4a046dcdad07607d11fd04c5c45e8c74.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-512-_jpg.rf.4a176c0bbf03d9df2735a23b4afdd488.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_349_jpg.rf.4a2b408b770526cc9f6e49e6af064e87.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2458204984_1_jpg.rf.4a2a14ba5ce23f51a55dcc1a2265ea11.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +kering_-16-_jpg.rf.4a6e66d96d6e049d606bcfdfc57108c3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-184-_jpeg_jpg.rf.4a798a9476aab8b91700cffdc19af7dc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-24-_jpeg_jpg.rf.4ab2d7aec1804a27e8b0df0f8639937c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +29-Male-South-Korean-Joo-Hyuk-Nam_jpg.rf.4a82cad4f3bb2ba8b555e4de0d942820.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +676_jpg.rf.4a98161b4d0046327ed1fa1e4893c14a.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +29-Male-Thai-Nutthanun-Leeratanakachorn_jpg.rf.4a7fa25cdcf76ee193b5dce5b16f6c47.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-493-_jpeg_jpg.rf.4aec04d96eb98216e866a02a232ad993.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Male-Canadian-Hyo-Seop-Ahn_jpg.rf.4b0e04bbd5aec103077a067b92423071.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_433_jpg.rf.4b08631c44b426fda3ed44146c5292a0.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-680-_jpg.rf.4b09f6a3b25892c8361dcbae7254fbee.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +77_jpg.rf.4b3ab3e431d8e5ddb755f0ac84875303.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +27_jpg.rf.4b93073ddaa9d333b1949115eaee77d6.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_379_jpg.rf.4ba8442745d789cfab135a8fd91fd55e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +273_jpg.rf.4b5444cbb6a3721a3a02ffed8ee64557.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_378_jpg.rf.4bc0f141e10a7e5dee5aa2bd3b74e4cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_451_jpg.rf.4c0f4d14fced7bfe112f60b1f365bae2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-131-_jpg.rf.4bde06a385e9af9e73ab869bbaec8df9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-18-_JPG_jpg.rf.4bbf833628e306cf43909be75519e709.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-385-_jpg.rf.4c826e7a0c324e74b04d7853ffc79ee1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +41-Female-Japanese-Ai-Kato_jpg.rf.4c1cbbef6b8e826c0230642605c17713.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +339_jpg.rf.4c3871a0e1a2707f2ddf313e10954ee7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-59-_jpg.rf.4c43effeb2dee7edf03c955f964a6b16.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_40_jpg.rf.4c86f24e7bb8d59fbb6e4336f0cd5449.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +dry17_jpg.rf.4c8890c4cedd36fa118ae7c661e23416.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-446-_jpg.rf.4ce8cd0adc357b1f38941da84a847a2e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-432-_jpeg_jpg.rf.4c950675c59f71ae1a60a7fa21b28312.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +266_jpg.rf.4c9938e828c3606805751d47b016955f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +579_jpg.rf.4cef6a9af452d9b9ca177d155533492c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-444-_jpg.rf.4cf2694d6d653d86d13be99926b0c91f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_45_jpg.rf.4cf7eb7e3649d49c7c7829945f55f29f.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_197_jpg.rf.4d161de9b4525fdff1157fe5535382fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-447-_jpeg_jpg.rf.4d106158e54730e8b36de3cc7b16b8ba.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_4_jpg.rf.4d204abac9cc1194c3f231eb36f95c7b.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +556_jpg.rf.4d0bd242a6a29978710a38d73e03b7b8.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-21-_jpeg_jpg.rf.4d221789415925d498183b285753e905.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_494_jpg.rf.4d2279eafd5a62aee591f95fb32dceef.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_132_jpg.rf.4d219f1c80049252a83ee31a37931225.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-362-_jpeg_jpg.rf.4d42f7dc9ba0e607c2b09a51e319b43c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +76_jpg.rf.4d5fa60d93231e0c446d64e9c2515c3a.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +_1444434770_jpg.rf.4d965146432832b7e6d97759d547db3e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +100_jpg.rf.4d820670f841e1ef9a8c37105a9bbb1b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-142-_jpeg_jpg.rf.4dbba74e5bb07235219f4c54d6d39621.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-764-_jpeg_jpg.rf.4dcc481854f9439e0a18ef4f14729be8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_545_jpg.rf.4d8c8236e7b5a2a989b010af1b0f9536.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-55-_jpg.rf.4dd2f45a1454f3af10614be5be990980.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-217-_jpg.rf.4dd55bd326969e684e8ca613d1e6de43.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +rosacea-telangiectasia_jpg.rf.4de3f435244ca501bfe483526cbd6075.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +61_jpg.rf.4ddd741a254c8926b72bf557fffc3250.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering_-50-_jpg.rf.4df4636eb234498e0c30e6d99b5bb2d5.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-790-_jpeg_jpg.rf.4deb7e0d7d6315a7ce693f2c78075046.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_360_jpg.rf.4df7eaea7af603e4bd71ea6375c782b9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_595_jpg.rf.4e1546ce60de09d2fbbe24abdee12247.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-81-_jpg.rf.4e2ec6b6d94be9ba4e37f6f7c8ddf99c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_10_jpeg_jpg.rf.4e1906a787739235c3d7d9a2a03881eb.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_311_jpg.rf.4e44c3018502afbc70447b3516a150c6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-78-_jpg.rf.4e547bd41d8b23db448cae403ebc8ea8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-321-_jpg.rf.4e4ebab02d086acb9fd5812322c86f30.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +29-Male-Chinese-Wan-Yi-Zhang_jpg.rf.4e59129bac0563175ce384c977ff394a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +24-Male-Chinese-Wei-Long-Song_jpg.rf.4e5b860217e413b17d195d0723811013.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_334_jpg.rf.4e7624a8568cfc5976411551adc6b9b4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_347_jpg.rf.4e65a744e520610783a7a3bbf42a5f2c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_335_jpg.rf.4e8661946ca2fefcdb2e02baddece8a1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +231_jpg.rf.4e8dc25976e6761aa1fd3cdf9a156bde.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +levle2_109_jpg.rf.4ea78f5d5b8522f6280bc1cb30563df3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-182-_jpeg_jpg.rf.4e8aa2bfda5913d1a0af2e125231ab92.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Normal108_jpg.rf.4eb2387be6ace1bd1bb3377148eb8c0c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +57_jpg.rf.4ec266ebefc93c5296107c4ce7d1041c.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-99-_jpg.rf.4ecd68b5aecd7707ddf643a91498dff0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-99-_jpg.rf.4ee1f0199c0e600c2926f8e6ceddb6c7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-408-_jpg.rf.4f2b0c531caec72d00e0be3ac7fda157.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-27-_jpg.rf.4eee3aa87868b8187d55a2b7972da5a3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +24-Female-Thai-Yongwaree-Ngamkasem_jpg.rf.4f25f806c9f534273880ef0256aa023e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +2707644515_1_jpg.rf.4f05df408ffe6f0760c964b69fcf78b4.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_58_jpg.rf.4f38dace4272d3a39bcc066ce6e0ec8a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +c23d7a77-682c-4c44-b224-d6486aeb35ba_jpg.rf.4fa124e0bf8836bebb13a84a6275f732.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering-15-_jpg.rf.4f92fd9e663e879694aee8fb01cd4a76.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +144_jpg.rf.4fa639afbf3aa615573e8e8333eab69c.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +acne-156-_jpeg_jpg.rf.5001943ec2d0327a8e3d25fc7eebd2d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-308-_jpeg_jpg.rf.4fc0b9675a3578cb02f0b76579e12ae0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_147_png_jpg.rf.4fdca7af5c47371e7f68c2d0141dba21.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +noi-mao-mach_jpg.rf.500ad1a25ba908cbf38df9841f9927c5.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +kering_-122-_jpg.rf.501bbed0cd8733dd2fbeab4c47b8fe8b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily-94-_jpg.rf.5024f7da9a8a9c15b0b3c2590a561d22.jpg, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0 +Kering-7-_jpeg_jpg.rf.502f10ab793f5b4f6cad560f5bd8e0ef.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_122_jpg.rf.5078478ea7bd82a5953704e2542dd19d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-645-_jpeg_jpg.rf.50d04b483bead41e506843fa8dd1a040.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_203_jpg.rf.507d187f2c781072898e9cad987987ab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +30_jpg.rf.50b34909634c40e7901afdaa94b7783f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +1030333538_1_jpg.rf.50d0d04c902bdc8b8b12ecf5c1f60e56.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_179_jpg.rf.50f4fd01a20be06c5047c8b8a8686ddf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +41-Male-Thai-Louis-Scott_jpg.rf.50da6f4ace6f710e8f60a42610bb0048.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +121_jpg.rf.50fe6f5993f93e0a62491d8fa070841f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +oily-skin_22_jpeg_jpg.rf.50f77962103d35a780cf538c3cb6296e.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_99_jpg.rf.5115c90538573e0c25012f4bbe03ca53.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-20-_jpeg_jpg.rf.5110fa92b2876b06a71bfd847251981e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_86_jpg.rf.50fa9f8f949deb3f556efb569d097ca8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_111_jpg.rf.51269d8a643e1c97225404b37a1b4b7d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-29-_jpeg_jpg.rf.51516052a8c21f7988614b151f739b1f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +30-Male-Chinese-Jing-Ting-Bai_jpg.rf.5133548096423abc2425b2440519eb02.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_139_jpg.rf.5152f73a8f204485d1b3916b87cdb2b9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +193_jpg.rf.519913204862aa5795e8eecfc02ad580.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +322_jpg.rf.516f1211877156abecc8573753bc9731.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_174_jpg.rf.517512242f0f8961ab8ab69eca78541f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +642_jpg.rf.51312975ca27143be2fcc7bbd9dab3ad.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +acne-330-_jpg.rf.51f0500aa14ea081f05ab2ef2a3a9b96.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_409_jpg.rf.523656ce960eb434723580be7cde1248.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +100_jpg.rf.522013aeb31d7918f4fedac6486e3a75.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Female-Thai-Ramida-Theerapat_jpg.rf.51a429494824825281a9831b9e469724.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-132-_jpg.rf.5265fabee10c197ec31daed426deb112.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +69_jpg.rf.52751dce7d8ca05c21380639a97c5829.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +3a061b4a-6135-42fa-bfb9-24cc1f239b04_jpg.rf.526c852d3667d7730196f52bacc423e8.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-106-_jpg.rf.525d1cbac61f96d846e68988b835a016.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +557_jpg.rf.52b48e3baaf45fc94dfe27e379cb87f5.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Skin-Redness_jpg.rf.52d2e36230702cd653b025aad593d5c6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +19_jpg.rf.52c180c6816ba20ec55bc899b6ed3458.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Image_27_jpg.rf.52be4e37fc0d3684a45494fadf12f98f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-49-_png_jpg.rf.52f3b4007b7b1fe32c7cb5f45d7708c5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-792-_jpg.rf.530246af7525bf040a4f53b8571b9020.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +2688545621_1_jpg.rf.5327037f3db0b5690819d207d17f0c1e.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_530_jpg.rf.52fd3bb8eea65399e2bc2ffd6b4bc035.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-108-_jpg.rf.53271579d75b913b64c5d5663866d7b8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +129_jpg.rf.5389918c9dfa6145bed70f0ddad80a75.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +205_jpg.rf.5390305c84477489c92326ec631949c9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +303_jpg.rf.5327b6d722a67c32485c172e7d9db8f2.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_414_jpg.rf.5394ebe0b99c8ebf195594a124e566da.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Male-Chinese-Zan-Jin-Zhu_jpg.rf.53a44a9d895a112bf3681e3d9ac455ab.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +236817064_1_jpg.rf.53c951f9e4d3b6a73183471339b6f35d.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-398-_jpg.rf.53d5ef3b6005ca8c00e3aa0a700b006a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +326_jpg.rf.53e76cb37cf663723b5c15a80189fde9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +c047b4bc-c11c-41bf-aa01-a13604fa4b31_jpg.rf.53df4ecf6d7c4d23c55cc79f20e491f3.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering-23-_jpg.rf.53e40b7f3767f61b19e17027454fb9db.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +343_jpg.rf.5402e4474580ea2b1315921e1a5450b4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-179-_jpeg_jpg.rf.54031a9f0a8a75298145b3db67c5e928.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +111_jpg.rf.5423c6bb3faa81289645b6533f0d91d4.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-795-_jpg.rf.5406439af574bd7aea289c28d56728b2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_586_jpg.rf.5430680f57231c5b9aa4b68012b218c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-563-_jpeg_jpg.rf.543df5f5c42ec6cfecf160f8e7ecb98d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +410_jpg.rf.54a61954cac428f134eb69bb4c58b2a2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +82_jpg.rf.54ed5bd4b6aad2e23243229b3e3c60c8.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-104-_jpg.rf.5451f4ca845be45e44e5f1cd957a9d5b.jpg, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_78_jpg.rf.5495105797c8928c7e2e45bdec60dd63.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-508-_jpeg_jpg.rf.54fb2b2dce8539b289d4207c83acafac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-14-_jpg.rf.54c57ebd9de3a8f151fc9bdb43a941b6.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +57_jpg.rf.5508e8a6104884ddd8832c508173e685.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +73_jpg.rf.553f8b286b80ac5988a65ab72c9da40f.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +41-Male-South-Korean-Joon-Gi-Lee_jpg.rf.55042255e7dc883f4e933fc5833a70da.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-361-_jpeg_jpg.rf.55aef32196e1021b453babdeb5c43c82.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-41-_jpg.rf.55097b662d3c3208c8b7a83da1f48f58.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_102_jpg.rf.55a61b21474a0f3247315767c3372f4c.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_354_jpg.rf.5546219b1f9d67033d131f030d3a093d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_107_jpg.rf.557e91208d4db3980eb1f4ea8f5f62a4.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_227_jpg.rf.55d05ddc4aed8bc14a2629f454b90c65.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2937757570_1_jpg.rf.55ea2967d434b4d395516f9ec979ec2b.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-349-_jpg.rf.55e2799b398f4deb9ea0625a0a933bb1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-203-_jpeg_jpg.rf.55bccf260337bb1b2029ef934178159b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_246_jpg.rf.55f672f84378e4b20519b5636bf1f402.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-655-_jpeg_jpg.rf.55fa096c15f36ec400c23fb94fab1f47.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_332_jpg.rf.55edab9bc8bf4b2b410ba7042000b53f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +285_jpg.rf.55fed18a8f347f8c4f2989fbef04e487.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +34_jpg.rf.56375f8c496ff43deadb88e97e189ce8.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +89_jpg.rf.565daec67643eb9420931ec96997fb8c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +9de7356fc7fe5df062b9719352b38f9c_jpg.rf.5665fe923bda388b9988f3d844c7394c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +354_jpg.rf.5682dfa78a64f385da34ed8442a18ec6.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +2837076619_1_jpg.rf.56ab75446ac9587197accd9974345ae9.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-865-_jpeg_jpg.rf.568b02807d4da23eda825fe75083becb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_74_png_jpg.rf.56968b4c31184e091d63ed65465799ec.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_46_jpg.rf.5704246e8da18fa4f3e94895f29bbed4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-32-_jpg.rf.570a000ab21a6d63d563f1f197e7d1e1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-421-_jpeg_jpg.rf.573e0ada673af7a8f931983b8577f772.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_106_jpg.rf.570aac152eb70ad13916135e4aaeb138.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-38-_jpg.rf.5716eb7a8f77e35cd06a657260fee476.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle2_94_jpg.rf.5758507376cf6413df6d99dc13fa18ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-185-_jpg.rf.5742e70ae3d734e4e513c90c5cf88c0c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_265_jpg.rf.5764bc2408428219075ae67052e5d57a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Male-Chinese-Wang-Rong_jpg.rf.575ce253d4f423ce5275294a81c56e9d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +oily-103-_jpg.rf.576ab20e40ea7e805f0f1497f6ea888c.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +138_jpg.rf.57769fba491003b60a39577a95781f20.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-42-_jpeg_jpg.rf.576fe5bc985b428d3938a0f406755826.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_18_jpg.rf.578645ae17a4aaa45852365ca17723a0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-20-_JPG_jpg.rf.57864d2185dbb376cb834aa5efae9cac.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +9_jpg.rf.57add3c1c611160e83a93ab84aabd305.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_125_jpg.rf.57e5cd12a97c8980c34108dc72ee8ba4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +dry-231-_jpg.rf.57db827c1637deca209c5eff35cba541.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering_-84-_jpg.rf.5843e8cb5dfbad4f5c79a0d7f023c5a4.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Imag_12_jpg.rf.57f1ad370e664f95d1a1044b86cd14c1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +oily-136-_jpg.rf.584978ea6772622d0df209b987868bbd.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +51_jpg.rf.5809eb9da3a280d232683759e07e1054.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-756-_jpg.rf.5821941d3ac6b815ba4923d1ca29f7d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_317_jpg.rf.58273554252450f1c49b73219ffd743e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +288_jpg.rf.58646f1ea38880cf5770e869611d5f92.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_4_jpg.rf.5857f396567b1fd68d057a9ad9137bb7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-18-_png_jpg.rf.58ecdb2b07fed0ddf055d8f3358ee474.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-122-_jpg.rf.58b0d0b941321ef80575cd381cd7718a.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +32_jpeg_jpg.rf.5896dc1f2fee08910ff19f6f50a3169b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +86_jpg.rf.58cdf62fd89415a533ab4fa31b0fd9d0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_467_jpg.rf.58fa4b4e9ce504638ed9ab8ff16a3391.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +30-Male-South-Korean-Seung-Hwan-Lee_jpg.rf.58f90d75f9ff7f0e63b84da78b66b7b4.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-285-_jpg.rf.5904f19d1fab99c7701cc7d23da714d7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-42-_jpg.rf.58fc6fa2954cae94ed6581afedaca2f4.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-743-_jpg.rf.592372d420491f6d4462fbb18f7f4b5d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-50-_png_jpg.rf.590a1c2c86bf0b0ab97b09ae0bb07302.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +386_jpg.rf.59178412edd43437ace467bbada05533.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +78_jpg.rf.594952d93b49d0ff576d3595015a2326.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Image_42_jpeg_jpg.rf.595479acb60ee7306619ded6c2917c75.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +296_jpg.rf.597b0b2d1b380981c226047c49d77402.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +29-Male-Thai-Pirapat-Watthanasetsiri_jpg.rf.5917d0877052c7517e9bf8054e39e9a6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +87_jpg.rf.59680dcb3c78f7c7c05916601a645dcb.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +44-Female-Chinese-Lan-Qin_jpg.rf.59cef61a92ffefb9a207b833235051b2.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-751-_jpeg_jpg.rf.5986a6ddbfd42c210f9b278ad058bb7d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-180-_jpeg_jpg.rf.59e6bc92865ddeca24f4145501607d2e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +523_jpg.rf.59e79dc49bb3512c122d9c164c24a84f.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-373-_jpeg_jpg.rf.59ef3dfaf3df42d21e2e5dd86bb6ab8e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-95-_jpeg_jpg.rf.59ee3c55a0b17630a608809a20a888a2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-502-_jpeg_jpg.rf.5a301bfa9a95992d81ab54c73791ee9c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +428_jpg.rf.5a52c26daf2f0e92251059bdc0edc8e3.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_190_jpg.rf.5aa76df97cad55564b5d720851dc9e1e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_494_jpg.rf.5a70c253ba53944c7539b8541bcb209e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +488_jpg.rf.5aa01858a445a0156ba105b73e8378f5.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Normal280_jpg.rf.5afc655a0d4874c6c00eb2663976aef3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-744-_jpeg_jpg.rf.5af2ec3640336d13cddf0026108d3df5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +77_jpg.rf.5b20e93952f6f4ae5e90ebe2d5f6ba5f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +8bc7ed41-6811-462e-ab70-da82d3a5fbda_jpg.rf.5b254b1dee6a0940e8f079853c5bacd7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_351_jpg.rf.5b9811de5c88455cb9498900eb3f8f74.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-96-_jpeg_jpg.rf.5bb13938da254c12ff420298636231e2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +149_jpg.rf.5bb71b89108dbab3fd33983a691b6414.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_34_jpg.rf.5bb2a267ec6139da433a4f36d645213f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +425_jpg.rf.5bbeee034f88e125c135c9b0f8b444f5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_196_jpg.rf.5bdc7a2a4ac67776429091f2b9a90550.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_80_jpg.rf.5bf7ae4f70e3cbd4ca2efb277acba5cb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-479-_jpg.rf.5c213d62212596a53129df0850de9ea9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +_1877565250_jpg.rf.5c2a1384effce07dee007442e4ffc1c8.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +levle0_406_jpg.rf.5c3dc4c544468bb999b2bce5fae867df.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_362_jpg.rf.5c665789aa8d9ecc54ed161a2321cad5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_174_jpg.rf.5c6b3bf0afb7e1325cf2e9d45a707384.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_13_jpg.rf.5c2ec8a9d7d7fe055352b82959a0260e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-678-_jpeg_jpg.rf.5ca0e00b943ef4d58fd4dc4915f3202c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +29-Male-Thai-Nattapol-Diloknawarit_jpg.rf.5c91fc729dece374bcee6c85e511c60e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-498-_jpeg_jpg.rf.5cb1c9d60d92eb18e1e17f9b4416aaf4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-41-_jpeg_jpg.rf.5ce245e62b3eba18b6f0adb860200eb1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-279-_jpeg_jpg.rf.5ce57e76ec81fad7450847ccfbc327f8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +48_jpg.rf.5d11808861e0d29fe452e2870987a9be.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +22-Male-Thai-Kittiphop-Sereevichayasawat_jpg.rf.5d3c36e9d7fb996ea710aee7f38db3d6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-767-_jpg.rf.5d064c9e7fae8f9907562be07f4cab6e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2d9a563b-082d-4d24-bb5b-3517cf0d91f9_jpg.rf.5d4503543bc6ddc060fee6adfa37d263.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily40_jpg.rf.5d6142d8b75f8ae4dc2041e8d103a5c0.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_103_jpg.rf.5d75c65950f72f6c2567e76b3da24695.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_325_png_jpg.rf.5d64da0ca58e0c4669f401cfe8e6a23b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +74_jpg.rf.5d8152d7d8bbfdfba5f9c302984bbade.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +390_jpg.rf.5d86ff5f78d34d54718d6cb7274e66fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-431-_jpg.rf.5d9890792598ccb35ad47396e59f025b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_470_jpg.rf.5daa23ec57b1d98da49de0cf518c5de1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-754-_jpeg_jpg.rf.5d7736dbe1e93575d0354c70d2703480.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_86_jpg.rf.5dd3a9159b70a6c2e1429a344773f62e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_415_jpg.rf.5daaab65a7fa82563fbb3105ed4fd4eb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_171_jpg.rf.5dae57578fbdab5104e14f8954707c53.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +132_jpg.rf.5dd5b34f8f9cc3a6a2fee3eb6e82da69.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-287-_jpg.rf.5dd85cc21229b3142d3dcc2fd4e7a173.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +aed80655-8950-44c7-9f98-8f603eb5366a_jpg.rf.5dd88c7647c432a99eb09b1db0c7c43a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-742-_jpeg_jpg.rf.5dfab57ad3d0c292b625db5088320b65.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-26-_JPG_jpg.rf.5eaf167ffcd8b5c7190cef0d01ca2c62.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-218-_jpg.rf.5eb3093810c98379cdcabdb03d572186.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-571-_jpg.rf.5e4ab822c9c53ab7d7476bf908ceec8c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_109_jpg.rf.5e2aa8fac76ae36322b194e4b564dec2.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +558_jpg.rf.5ed2133d8bffb4b55968a95e1b154f01.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +123_jpg.rf.5ed3bdd6266eb3b94844d5877df4e9a3.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +shutterstock_1545102416_jpg.rf.5ee4c267deff230d0b08d9a707f45ae8.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +48_jpg.rf.5f0bc7e1eba7ebf2d29db3a76f12cddf.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Image_52_jpeg_jpg.rf.5ef58e1e5fb2e63eb4abd9cbea16c5c7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +301_jpg.rf.5f1980c0a8df02e08d78d2eea3ddad50.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering__-14-_JPG_jpg.rf.5f0aac6e02f03ea486026ae61c49f404.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_428_jpg.rf.5eef5742f7e08feb603860389a433017.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_143_jpg.rf.5f5819aaa8fa81fdc94624615e4d1f3e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +34-Male-Thai-Pirat-Nitipaisalkul_jpg.rf.5f55c87c0637d44b352739ec3dac77db.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering-6-_jpg.rf.5f5c938c9d6ccf4e5c9fe23b5032c871.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_64_png_jpg.rf.5f61df117a63f4b9b7ae717f9080eef6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-315-_jpg.rf.5f9e5cc9e1fb93d7aa65cfdc672cc3b6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-557-_jpeg_jpg.rf.5f736b46e213e47a879609e4583ed651.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_366_jpg.rf.5f67bf5c9aebf65023371c98f56c926e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-170-_jpeg_jpg.rf.5fa0f117c1f6b65fe61e4a815d9f37c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-146-_jpg.rf.5fd4bdca1fc8c02fa6c355e7cfe3e111.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +64_jpg.rf.5fe0b54a3f0282c2d027194ddc91317d.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-340-_jpg.rf.5fbdc9e77983fa008413c8b835df3e2a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-439-_jpg.rf.5fa6a31c2b3de7b10d8f18117244b30a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_507_jpg.rf.603bbfdef0daf4a5df3618cc1ab4c895.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +166_jpg.rf.6017d0447ed0e0448aa4fbe1f4c84f43.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +oily-238-_JPG_jpg.rf.5fe50772a00415a8c143e4e3f36d4266.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering_-67-_jpg.rf.6013c002e0c0bdcfe6e125d43141882f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-26-_jpg.rf.604161310c6e727cc832001ab397679c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_146_png_jpg.rf.60511ac207de541ddb97c5bdc2d8e26f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_182_jpg.rf.60860ad92802c683aafc2cbf0cadb031.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +210_jpg.rf.6054b7fd5011bbb379e84acd57c00666.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +660_jpg.rf.603e0464690d8054535ede923f9bb4df.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-599-_jpeg_jpg.rf.607ecf72a80af6cffc40ccc9fbe8305c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +159_jpg.rf.60cb434d71794c447929c1150dedd762.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +30-Male-Thai-James-Ma_jpg.rf.60aefc6cb7100859d9bc7c8104a0820a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +24_jpg.rf.60fee4d5e5939c3f7f60d78a360b1bac.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_342_jpg.rf.6128eb22881047f79c67690def8c5197.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-467-_jpeg_jpg.rf.616399b612c9a743f6565fa45ae3046b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-89-_jpg.rf.61405a46dea3f441ddaf442895e58b25.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +39-Female-Thai-Chotika-Wongwilas_jpg.rf.6216274683e12dc9971d719812b44ab3.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_389_jpg.rf.61dc22be8d21726da546a268b9a4f169.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_151_jpg.rf.62260ee2e0d7b60d6e9dee47dab1dc4a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +42-Male-South-Korean-Dong-Wook-Lee_jpg.rf.616c4b62d9c9817046b54c602bf78045.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle2_131_jpg.rf.6230292f42d76ee829f1f2afaf6f24f9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +110_jpg.rf.623d8de4c0ee4c20535f00f919e15697.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +474_jpg.rf.626628d582e5c1364356591c4896fa57.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +132_jpg.rf.62467f20c5fe91dfbdb219624a2b0ffd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +31-Male-South-Korean-Ki-Yong-Jang_jpg.rf.628713131349c610b8ab45ef8318d4a3.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-913-_jpeg_jpg.rf.62a8813db5f80d8a0953aa89af196258.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-367-_jpg.rf.62aab20fa7250d6508b3c93a0482225b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +24_jpeg_jpg.rf.62a095bdbf297df1583f2c0b78b84d9e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +565_jpg.rf.62b8380727437f7352818a47767570b4.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1 +Image_37-1-_jpg.rf.6318d7f03897d426785a40fa7e70b18f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +66_jpg.rf.62f83ccf24b3c3ff09de3db627bc9578.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle2_163_jpg.rf.63473095e0ae34dab407916ece9c85d2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_148_jpg.rf.62d2b7785131b4b69f7495f190ba06f2.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_297_jpg.rf.63466da7fc6d898e7f27d55113038796.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +62_jpg.rf.635d29e633224c9ad537089454d143b7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_427_jpg.rf.635462948f41551a5f4b59acf6b2e57e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-30-_jpg.rf.637b917163be7e504d92e936e3e0259c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Berminyak-26-_jpeg_jpg.rf.6385ec45f5295789d8459ae376a76e16.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-393-_jpeg_jpg.rf.63908298abc906f2b44afbcfde51ca54.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily36_jpg.rf.63cb0ce21910b8c37686cd2bc6f5824a.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-816-_jpg.rf.63c9504c7221d5df691d7793c46858b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +201_jpg.rf.63d407328ccb08e5af6d64f77b4ca903.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +19-Male-Thai-Nattawat-Jirochtikul_jpg.rf.63da39b5c3ab987bcae80da07953337e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +387_jpg.rf.63f9ebde17b4067bfbfea0dae218a883.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-22-_png_jpg.rf.63ecde9dada4868d36d88677693ec112.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-102-_jpg.rf.6418c4d262d8502e70dee51a61fbe307.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-29-_jpg.rf.63f7dd94d8db4d7c79ff84f6a78b1e05.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +24-Female-Chinese-Yu-Xiao-Lu_jpg.rf.640807680093f370fd0360118ed8cf2e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-819-_jpg.rf.0b549e30070b73fa11022c608cbbbb08.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +100_jpg.rf.0a2babc3f313879b0bccc016122ac35f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_160_jpg.rf.0c112326989e296d84386e4b4b1a1043.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-10-_JPG_jpg.rf.0b928a5a92b1530cb721b5451a77c1ae.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_563_png_jpg.rf.0a4b891eaa1bdb6d11b7602ae7887b84.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +86_jpg.rf.0bcd7ddb0ec2db1c090ec92d225b8690.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +oily-190-_jpg.rf.0aae50f466497b942f54b23fc27fef13.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-369-_jpeg_jpg.rf.0b25c33c2a5b271a2943ad246be81e56.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +31-Female-Chinese-Bing-Yan-Yuan_jpg.rf.0b847627d7e4804631dac56a4f276fe9.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_78_jpg.rf.0b2d35c9373334b5eb48443f60a9da14.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_484_jpg.rf.0b213bbc3377ac00c28f5aad583b27f1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-51-_JPG_jpg.rf.09f7fd762b948fd2dd59f531b0ecee89.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle2_34_jpg.rf.0c02f11987c741dc718206d305eced01.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +87_jpg.rf.0c34600c707bfc59db687bbb826ffefa.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle3_24_jpg.rf.0b17967eb468250f5929159390b80bc7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_30_jpg.rf.0b669be17e8a3cd2fc59ce43793347cd.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +dry2_jpg.rf.0b4bb492c098575bc793c3efeeaf376a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_173_jpg.rf.0aa64a3ea58ac2c1efd24f4139390730.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +563_jpg.rf.0a699ff95b424355026e204a73baa062.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_619_jpg.rf.0bebd5fc5cf04c8b636c37f62d44dc2d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-32-_jpg.rf.0ac24c6cedb35856bccbf353f47eb7c4.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_634_jpg.rf.0bb170291ca96faf7f3f551e11272106.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_233_jpg.rf.0c4220963f4a819999f10eb074756efa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-15-_JPG_jpg.rf.0b004e7eff2e6319ce8e7aff8d0a100e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Kering-49-_jpg.rf.0c4e6d2aef7f5d703b7aef66a67cae40.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-30-_jpg.rf.0b7e99be33148a6ef6c1b6024596ff60.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +image_jpg.rf.0ced21567a9e225f6a209acf1e6e0c08.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +levle2_117_jpg.rf.0c736c0d6de2a4e5e86c879a1fa7d3d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily39_jpg.rf.0cd9000dac0a4507c3be639c21378faa.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +33-Female-Thai-Nittha-Jirayungyurn_jpg.rf.0c72385b368e9d58484e27d4c28cdd5a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_179_jpg.rf.0ccd837deb87565632e319b0d265dbb8.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_142_jpg.rf.0c8b2beb55eb376d1a08bc3f16705c62.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_29_jpg.rf.0c4a3a78da4d2e3fbd461253132a4d8d.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +84_jpg.rf.0d1685e52cd7900afac22f7ce6cf5321.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_34_jpg.rf.0d0f3a6ed1e4fb587bea4fa6d91cbb4e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_170_jpg.rf.0d17260fd99d3bd26327721245bd9f8c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +104_jpg.rf.0cf9e8ff8fdb3a08b50459d0d31420d9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-635-_jpg.rf.0d29a2cc65de6710dafe0321e27ede0d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily29_jpg.rf.0d26be8d38a1719a89bf062cb319ba01.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_131_jpeg_jpg.rf.0d41bd0a2c57d2a8564cbba840f71302.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-683-_jpeg_jpg.rf.0d38e05f667de0cf6f7f6e5c59518e20.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +673_jpg.rf.0d42d7fc3ebda591fb56084bfc440746.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +213_jpg.rf.0d5f03485acce109bcd3d0137e24041a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-879-_jpeg_jpg.rf.0d625cdc0b98dc1ae6969e542b22c329.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +669_jpg.rf.0d54e520c58d4c5bd8756c24a05b04e7.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +28-Female-Thai-Pavida-Moriggi_jpg.rf.0d7c8eb3800c08e196e23e6172970882.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-546-_jpg.rf.0d74ef6e39a191631b91a806dc84211c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +647_jpg.rf.0db35facc3575fd5e365eb88b7f06a7b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +196_jpg.rf.0d965c8645d816d2cb7a8c6e5766558d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_82_jpg.rf.0de46a5d99614ff5ac869852e27dcf16.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-527-_jpg.rf.0dd99e5f941d06b92813ae1483e97da6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-537-_jpeg_jpg.rf.0e91916a9393356856715b849e21fa13.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-389-_jpg.rf.0e6253f3502294e6089edc18a10780df.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-117-_jpg.rf.0e706bea25a3636491fcdb5aac94fcf2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +72_jpg.rf.0eae2b9f1cd326f51f3eafbf1bf008e9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-34-_jpg.rf.0ec41899845adb6af5c677564e0d8f9c.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-6-_png_jpg.rf.0ed3f615934faa2bf30c7e890fec4426.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_142_jpg.rf.0ed8f1d4e5c9ac32c5baaeaf25981655.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-669-_jpg.rf.0edbe4ab8eb19206b0ddc3ccd59fc85c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-660-_jpg.rf.0eebe62e06148482c5f7901c27f85ec5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_50_jpg.rf.0e3e7a6dfc0c5f9772ddb6a91b51b276.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_71_jpg.rf.0edc13437e7d11c6922da256f9848605.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-128-_jpg.rf.0f096344615375b14254183afacec636.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_77_jpg.rf.0f2e8ef87a70f3703e7351d1da466f3b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-19-_jpeg_jpg.rf.0f33a8c7cde3b03666329b5ae8af4ec8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-17-_jpg.rf.0f35100078056bf5fd984c598cb2a565.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_515_jpg.rf.0f43eacec40b519024612cc45ce9d52b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_12_jpg.rf.0f50d1a6e1abf8250ae2f38659cb230e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-83-_jpeg_jpg.rf.0f3a1d99dc2921889b2e5e3198590f87.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35-Male-South-Korean-Hae-In-Jung_jpg.rf.0f5dff50fcf376f2bc56c9b31a7e8ddb.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +20_jpg.rf.0f69f582a7693d465db4ab36fdcdee1b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +berminyak__-3-_jpg.rf.0f8392085901071ef154486212bc7d95.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-659-_jpg.rf.0fd286d3cc8f66609d9d1379ee20ef05.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_229_png_jpg.rf.0f90d8ce762dcf107512c463fc0e7233.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_9_jpg.rf.0fe1904abe42f980bda8c3ae698d7961.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-567-_jpeg_jpg.rf.0ff152f51e29690cfcf75720b663bab7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-114-_jpg.rf.1010f03ff1dfd52ae5e9e40cbd9bfdb1.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +143_jpg.rf.10357dde047ea9ef0c8791ceb33c4903.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +acne-34-_png_jpg.rf.1029423481a3866aa8bed0bff380c74f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-14-_jpg.rf.103d92b680cb20ab834d6b2c8caae48c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_177_png_jpg.rf.101e7d48fe55cc7f33ffbe2b58188f61.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_314_jpg.rf.104e0386ba9569475eb3a5991634b28d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-312-_jpg.rf.1054baab4279e2041745b461dc6f9b79.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_82_jpg.rf.106b5683179f843437fdb445612d71bb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_130_jpg.rf.1079942217a2e07c710603481d198974.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-799-_jpg.rf.1077c860268e5c6128524cc08ff01901.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-276-_jpg.rf.109ae26512bbbe7c2860a85c552e55e0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_44_jpg.rf.1095374bb2247daf11f0220537a17132.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2828057266_1_jpg.rf.10a25aa11727810a46e950e4785602f1.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-183-_jpeg_jpg.rf.10c1a88bba6a47acca7621d5b459fadf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_524_jpg.rf.10be55924946aca16972bed994159821.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +38_jpg.rf.10a41f55c89824b1d84595c3d399a536.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_282_jpg.rf.10bc32cce572b2573f298967a6b6bcd0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-59-_JPG_jpg.rf.10fe51442c4cd2fc27b8c9cce3b42fde.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +22-Male-Thai-Chayakorn-Jutamas_jpg.rf.10d7055b4fa72db1498694ac4585592c.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_148_jpg.rf.10d9d614b8afe43a1bdb246d0a9bd041.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +23-Male-Thai-Korapat-Kirdpan_jpg.rf.112c7ea83770d7ea9e1aac13b655609a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-524-_jpg.rf.11675e7e103857ffa1ef3480b1dcb600.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-550-_jpg.rf.11589b3649bdfecc8cce69b25d0829e1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +618_jpg.rf.115acd27c6630965ac1c83ba672b8263.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +kering_-22-_jpg.rf.1146a38c23a96ea00a7c216f643b18f7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +617_jpg.rf.11785f936cccbfba14f8c260fa38ee9b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +kering__-3-_JPG_jpg.rf.117beb753f351a7a85b0eba9189ab5f1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +33-Male-Canadian-Woo-Shik-Choi_jpg.rf.11a458b0c17e39e368bd3634e39cbd7b.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle3_136_jpg.rf.11794e21812440ca69bf770ecf9b5a8b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +b95a81a8-5497-4996-a7f0-4283fba79042_jpg.rf.11f13010a78fd8de3ec169f370773fba.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-605-_jpeg_jpg.rf.11f3ab4565f6020da864e335ce3df882.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28_jpg.rf.11b6759094ef185dd3f295582d69045e.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +27-Female-Chinese-Xie-Ning-Liu_jpg.rf.1255854b395f63dcaba10510ef4fa26d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-prone-skin_116_jpeg_jpg.rf.11f54cd54a40a950ca02e7315e1ea9ca.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-574-_jpg.rf.127c81e6f22dc4abbc25c012bc8e7f8f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +423_jpg.rf.11b4254ab26c8cd629affe88426d7413.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +27-Male-Chinese-Bo-Qian-Ma_jpg.rf.1263427de926eafab999013b4551d2d6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +36-Male-South-Korean-Chang-Wook-Ji_jpg.rf.12bf339820995cf8398ffd164d427bad.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +38afb976-5704-475b-93f3-ed7c2e58c184_jpg.rf.12b38de58f85b0c07b021e59ea8f53ef.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-287-_jpeg_jpg.rf.12b2f806e6380a7ee24be04394eeb1ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-396-_jpg.rf.12cf9349a0c1274e76edd249f3d4120c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_83_jpg.rf.131ce606ebb891c7473bfe64ae8cb62a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-227-_jpg.rf.12d8ab8c6e8045cd7fddaf2cdfc9cf72.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak260_jpg.rf.132a855182cb30b3e02e8ab126195b65.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_192_jpg.rf.133b46aad4f7decfa6055043d0d53603.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +219_jpg.rf.13a4e0e14c64cbe4a0a42fbe9d1312b9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +21_jpg.rf.138047274a69510dd3f9ee864963916f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +3deebf0a-72f9-4fa9-8764-3ac44ada6fd7_jpg.rf.13a75e01c18b52af0c9bba70fed57958.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Ance-Scars_jpg.rf.135577fb8e1361918a49ee7739327ba7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_175_jpeg_jpg.rf.13e9780d00a875fab2ec4103e6775230.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_176_jpg.rf.1413dff2ef90b9229231f138184274b6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28_jpeg_jpg.rf.13cad03af749793c0935be7070e8d56b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_102_png_jpg.rf.13d0c645121d6aa59c243f3d040d3f3c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_153_jpg.rf.142d99c110353fb1b11cd6e947e7facb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_49_jpg.rf.145843f7e5038819a529719264ac4a73.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +636_jpg.rf.146720ac7354110d90bc3a7f4a748898.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1 +dry-239-_jpg.rf.1495d3afe51ceabd2e56705fe6fffff3.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +379_jpg.rf.1493941b07f35aea064fe74a1a807c8b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle2_134_jpg.rf.14454e2946913556d5d3023e221ae544.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_517_jpg.rf.149d5390121b96707832f6698b4e9439.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-68-_jpeg_jpg.rf.149f8e67e6c1cf4c786d7667152e1fd3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_290_jpg.rf.14d1cc3d62685310759e5509ca1b00fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_170_jpg.rf.14b61c1532bda737d021859022b69b86.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +52_jpg.rf.14e4a40d79c68c30c1fed599d52e8f29.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Image_95_jpg.rf.1534f7f1e131b23e26e9e0b617deeb87.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +_2265062407_jpg.rf.156756d41a261b1f527bacc6b660181f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +levle0_353_jpg.rf.1559058be904beb99bb8d1251ace22c4.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle2_186_jpg.rf.159387b0795ec3ea80d11e346267bd57.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +505_jpg.rf.15a1f2589728dc28482098899ce548eb.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Kering-21-_jpg.rf.15abde38f84cdba825c16467a62d1dc2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_394_jpg.rf.15b31630ac7b99c8ff1e2c3949c5eaf0.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-527-_jpeg_jpg.rf.15cafdffcdca2aabb0b356bbd81fccfc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-60-_jpg.rf.15f510f6516cfb9a39c04597ee999e52.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-24-_jpg.rf.163c4a2298bd1c0e9594c181171bb8f8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-103-_jpg.rf.166a4341c1539ae9c546cb015d1e07cd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-127-_jpg.rf.165703ff28da9be7f417abcde6134884.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-279-_jpg.rf.167b46d6766790653fd7ac5cf2b14286.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_91_jpg.rf.169ec6a3bc11d0ec9725ec5dd3f3616c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-379-_jpg.rf.169c7b427a41422482557df2ce9581b6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-294-_jpeg_jpg.rf.1698a7596ca54427f3412398cae14366.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-477-_jpg.rf.167e313260c0f6e7cbf8b33869a302fb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-464-_jpeg_jpg.rf.16b5ba1d26bcbb12c942a3e6625ee4a7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_92_jpg.rf.16bdaef8f503e97d40c32d4b767f86b0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Normal81_jpg.rf.16ce9f5b46840e784faddadc36f01881.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +berminyak__-24-_jpg.rf.16e0d616f4fe2d497b52ecf6c8a0c703.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_577_jpg.rf.1707d47f774d994e7a69d56d053271ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-834-_jpg.rf.16fe737d9cd4f00c4b149c9249c6dc37.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-31-_jpg.rf.16f51fc15eb4bdddf9f36899932bd9ab.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-130-_jpg.rf.1707620b22a6fada1ddf938a632d4c80.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +39-Female-Taiwanese-Wei-Ning-Hsu_jpg.rf.172f35220c79e6f86e2fe87bc8149fec.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Kering-6-_jpeg_jpg.rf.1716dcc9850e465ab5a5b3541f519181.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-211-_jpeg_jpg.rf.1751baca451b5a88a671bf543fd4f0da.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-413-_jpeg_jpg.rf.172887489b249276486c66b0e20f7f6f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_426_png_jpg.rf.17a330c48a809b8a510ba57539a46855.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_25_jpg.rf.1795d4c382796133f0d5f3c5d6bdb3f1.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering-36-_jpg.rf.177f75f607bf89b2419a9d8a1ab8e893.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_474_jpg.rf.17a6d98ca6be793917370cd65a932da4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-160-_jpg.rf.17d1341a61d7fff9743c6a27945e630f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_428_jpg.rf.17e1f0ec2cc5e979a564f34e5ede4784.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_105_jpg.rf.17bd30a75f29da9212966142eaba3e4a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +35-Female-Chinese-Ni-Ni_jpg.rf.180c126df192cd5fcb06855bd633020f.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_530_jpg.rf.180d22bebd943cdacef3e5b7fcbc2938.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_93_jpg.rf.1801a08c695bef6d63e00e556760f2f1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +37_jpg.rf.18292d1cbefcd935e4791b352d4ed419.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-292-_jpg.rf.181314ce701fa2f1530eabce2ac452f4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-50-_jpg.rf.184dcb388e390946a807aac9ecec15ca.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-586-_jpg.rf.183a341db9204f6854e8887b0762a690.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_172_jpg.rf.185353cc409934463e38623c7aeb8de9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +254_jpeg_jpg.rf.18b106698fb3e4bdf86142f91ded219a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_343_jpg.rf.18ad2e91173a671e8e30a8fe3d40e460.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-18-_jpg.rf.18d74ba39d587d1f818a2c3466fb35fc.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_172_jpg.rf.188e9db8bee98072190b883bd0c32c9e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-336-_jpeg_jpg.rf.18b5c0f3d2fd43f80ec737902b1f7704.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-12-_jpg.rf.18f14cbd7153f47c37d4c47799ca394a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +34_jpg.rf.18d95b4fc66fdeab00451ad8d9b860f9.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +33-Male-Chinese-Shi-Qi-Fu_jpg.rf.1906dad5b98f379b9b01d069d100e7fc.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +berminyak__-29-_jpg.rf.19204aaa4496f39e320119a99d6ac28d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_576_jpg.rf.1926e96622c241572447304fc2e95a5a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +373_jpg.rf.18f6d910a2de565922c00c2e66a63caa.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_190_png_jpg.rf.1939cc6bd979c53a638a7d3df721c2d2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily54_jpg.rf.19408ff63412378d84e7241ca3e7a64b.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_450_jpg.rf.193c1ca99a15a8ff92ca2649069550a5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_102_jpg.rf.196baf127e16bb70d960bc482a4dcff3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-540-_jpeg_jpg.rf.1938d3b4eda87d6990ef5c6fe08ff663.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-41-_jpg.rf.19472bfdfbc7dccbd6344bf323e6d174.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-15-_jpg.rf.1967f62c35e0b40b35f2ce3001e8eef1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_115_jpg.rf.1966acb79d65d05e9736786c3e597a32.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-530-_jpeg_jpg.rf.176e51cfc4cbab7c42e059249a89db5c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-921-_jpeg_jpg.rf.19a66494bc1304d8fb9e3895a0b34680.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_114_jpg.rf.17ba7f798640775887f71100c025701f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-80-_jpg.rf.1977330cf46d6bb562f57dccd05d0cf1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_36_jpg.rf.1a027f61c5a2e2b4c1d982c0e07bb3b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-109-_jpg.rf.1a23ea5b4791e160242e0fd975a2d1de.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_155_jpg.rf.1a85a2f3a37a23513427c03e4aaff6e6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_27_jpg.rf.199157e3055bf36c8037dca593bf8c94.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-80-_jpg.rf.1a4e7a03d5aa9c22c9716768d284da0f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily-60-_jpg.rf.1a50d8935251d3376c410f5eb2cd85bb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-222-_jpg.rf.1ab37a804e3eb7d4b9ed05f0a733b746.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily7_jpg.rf.1ac59dde4acc80443b2e6312c0aa7791.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +574_jpg.rf.1aa952e7449908b475e78a6f78034bb7.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +531_jpg.rf.1ac89d1fd64c2d33cb3120a883f64fa7.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +220_jpg.rf.1afd04fa1351f4aceb37b89dc995e507.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +117_jpg.rf.1ae9141241ec59d49110dc790460866a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-506-_jpg.rf.1ae2bfe89352d18d501473f2e2248930.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_374_png_jpg.rf.1ace8eee0e578edae46f866a30236784.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_144_jpg.rf.1b36d02ea9c405c3c394e8d5fb41a1ce.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-412-_jpg.rf.1b170a02118738b18db1ff50adb80bfa.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +380_jpg.rf.1b3bfecb7aeda45b9b3b2f40e04b53f0.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +berminyak__-28-_jpg.rf.1b4f9bfdb0f7ad97040a1e2bbc5945bf.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_62_jpg.rf.1b244d50d2349262ecebef80a7721fdb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +74_jpg.rf.1b717a0cf0d6a6d6110a9f136e91220a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Berminyak140_jpg.rf.1b6d02ce84d620ad4c7cb001e61a89fd.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-656-_jpeg_jpg.rf.1b83f9fed9c73a864ec2cae2a6a567c6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_118_jpg.rf.1bb367d5e4d55a1f117a6f71f61bc24a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-371-_jpeg_jpg.rf.1ba987f1b6e3ee3f4f903d44c7ede5ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_30_jpg.rf.1bbe427dcad65dac4b9f8f2f6276209c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-104-_jpg.rf.1ba4a5f507d1fb42aef36865b7747829.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-310-_jpg.rf.1bd35049c319e898f3314d2cc0b93bdb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1e812d5b-ee7a-4ec5-bd3e-04ca72cd805a_jpg.rf.1bc9969b0e8081fc36583344c5188dd1.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-29-_png_jpg.rf.1bcb36c36b379913edffa98fe0f9079a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_562_jpg.rf.1bd835f4221d93d5231714ab07083cd8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_105_jpg.rf.1beac45bf99d1703d6eb13fb75ec2aa6.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-380-_jpeg_jpg.rf.1c2320b5659260bbf55ef4ab66f8fcfd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-397-_jpeg_jpg.rf.1bed5ecc5ed1f29aecc0e14c799b5a3d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_506_jpg.rf.1bdda0680b681439c5aee3b2d6ab34b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-626-_jpg.rf.1c6137ada1eaff57971e68fe765bde26.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +22_jpg.rf.1c84d3ddb5f378f2f1407ff1ec4fa08c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +250_jpg.rf.1c77e10d35711aa5c0ff3d27c2d72c8a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-219-_jpeg_jpg.rf.1caba059dd93c2b0b0ac317542e0a597.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_65_png_jpg.rf.1c5ec56d27d3a4880878c2f5eb34ca55.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_310_jpg.rf.1c9909dfba0b04b25e9e2ebba748e909.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +261_jpg.rf.1cfc269bb37bb0f12c673efbd3408b06.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-35-_png_jpg.rf.1cfd3e2a56067828f1f6fa32ffb11556.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-226-_jpeg_jpg.rf.1d0fa2bd7937a790a8783390b34d05af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +21-Male-South-Korean-Hyun-Wook-Choi_jpg.rf.1d1cb4b2f1340ecab9be1a9a236f3d35.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_615_jpg.rf.1d2113722401137986d18665683f069b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-257-_jpeg_jpg.rf.1d278ff2914db4cf9f95409acb761ae6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +638_jpg.rf.1d2b75d0e629956d7125b7bb2b3a47f0.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_37_jpg.rf.1d2a5faac1e426aefac064a009337781.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-443-_jpg.rf.1d4c280f6ae4727e6858ae9d984aef39.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-120-_jpg.rf.1da4a6ad37fdf3c2147f9474b9798b3f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-319-_jpg.rf.1d5436526a004bf6c3d357fafdb5abb8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +66_jpg.rf.1d9ea9b1a97fdad14b489dabca5dc16a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-757-_jpg.rf.1da758f795a55f96bea2539ea0e830ba.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-15-_jpg.rf.1dac0d16d8fa431e372e4d168d4bfbca.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-189-_jpg.rf.1dad8e5ae3f99ca4741278177d64152d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_271_jpg.rf.1dcba256b9dad58b6eb099df547be354.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_73_jpg.rf.1e02a4a46dd648794f847467c08f233e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-554-_jpeg_jpg.rf.1e000ce892dde271ab273ce8492e7922.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +210_jpg.rf.1e048bf6492d95d47e9e44cceca4ea40.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-62-_jpg.rf.1e4d3dc5b553f315124374342b8ea508.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_150_jpg.rf.1e817095267d0ea51cad464bbe198310.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_128_jpg.rf.1e0f4967ef089dbe4b7ff6b6d792c3fb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +20_jpg.rf.1e73156f1c8b7e19fec2c2863417c409.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-654-_jpeg_jpg.rf.1e9057060550791bdc5060a21e792995.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +267_jpeg_jpg.rf.1e9207bc987749d32159f62741143096.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-275-_jpg.rf.1ea0bf3f8b422170b4d2ede13e48079f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +464_jpg.rf.1ed079f9c164f5196d067832e174b164.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_532_jpg.rf.1eb89c4772c52dc740853c98c66baec3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +_1892688412_jpg.rf.1ee138dcc585b11be7f68546908e41f0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +207_jpg.rf.1ed21bf3fb1ef599832b2ecbd8b0874e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +71_jpg.rf.1ee6f067b657354c5c8259f53a8e2886.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +2665784919_1_jpg.rf.1eea20e5f36d32251028ede0052a30ee.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +158_jpg.rf.1f08c674ba33a9cfa8964e221b0eae35.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +berminyak__-46-_jpg.rf.1eeaa4a0eb442c84b1cc480fcf9f4699.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-251-_jpg.rf.1f3425b8b51b0881fdfe8f873c7e1a33.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +77251e7e-eaf9-493b-b4cd-a261ca6b1a14_jpg.rf.1f374400c8b7f226028906ebce808b59.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_433_jpg.rf.1f3802293c4e2d92c3d7749c933c8b99.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-4-_jpg.rf.1f567ca1156ea7c85bf9b703b2d90af8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-113-_jpg.rf.1f22cdb8744d4450030633c40d24c938.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-18-_jpg.rf.1f454855b25c89042df2b72965476053.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-245-_jpg.rf.1f4614f2a0a974a4b0dd7f156282c9c0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_18_jpg.rf.1f10e781fb46d23b320e8e9f0fe680fd.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +28_jpg.rf.1f568b739d951f2bce3ff6c4ebc1d774.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_371_jpg.rf.1f91bbb68ecee0d2be512cfd0121f898.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_168_jpg.rf.1f80cc483c82e3442b2b84e393ed723a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_120_jpeg_jpg.rf.1f9ac2cab2ef70fa39bc36a06f300cab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-331-_jpg.rf.1fa78319112738e27dc5abdb0d6b95ef.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-39-_jpg.rf.1fa7fe1cfed96a745da87a44baac3b15.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_444_jpg.rf.1f6370f54157bd5f85aa501fe3b02372.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_1_jpg.rf.1fa745c50d78ff36913ef5e5b75ffa3d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +604_jpg.rf.1fd20a7f95b56a7be54cb14107c41da1.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-720-_jpg.rf.2000523be0aedde997ac6fdada415d02.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +b61395854e4e782abf671266ca89d1b0_jpg.rf.1fe28a494e4bf8c93ad4f3bc7b3b4ae8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +524_jpg.rf.1fe700a3cafe7cc38e2328c0bb267de6.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +oily-skin_148_png_jpg.rf.200fa8ab8536491e1db21f160fc98244.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +581_jpg.rf.2007dd5a03550c3812293d68c7362a81.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +16_jpg.rf.2012746595222934184fea09a6eb1336.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_257_jpg.rf.2007906c66149cdb4fad7342f6f8c986.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_364_jpg.rf.201870360afe2c326e9e828babd72029.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +279_jpg.rf.2029ea78d40c438980072d5f3c494666.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +Berminyak-27-_JPG_jpg.rf.202d23067d42612e1035f6f997fd94eb.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +2699422785_1_jpg.rf.203dbc9a60477a2885cacfc84a6fd1c0.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_65_jpg.rf.2065bf9c4173f2db330aa08af7360ac5.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +587_jpg.rf.209a3091224d18283274969b521ee077.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_417_jpg.rf.20ab25f4c7b10bb9391101c01b4e69b0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +37-Female-South-Korean-Min-Young-Park_jpg.rf.20ab8b62667d452643a6791de5b5a679.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +33-Male-Thai-Prin-Suparat_jpg.rf.20e560777fa9370c9a13a99e51fd0988.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +c3bf013ac75c78c5d78fc8f7277af52f_jpg.rf.20b42019fd38b41118064d54af60f02b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +dry23_jpg.rf.213329e6c018fb6e99ef5b68f97f2aa3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_389_jpg.rf.20ef97c6275abd3f360d352f900da913.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +582_jpg.rf.210e8509f074fb938925ed02b09ef435.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +202_jpg.rf.2136ddd0f42070118f09d559ec3e2af4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +613_jpg.rf.213cc06caa301d02cce49a196174a835.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-343-_jpg.rf.217131025e47bef39b7010b0e107c306.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +36-Female-South-Korean-Woo-Hee-Chun_jpg.rf.2171bb3ae156e9878370eed828fd7130.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-463-_jpg.rf.216c23545679cefe41c65b35950091c8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_424_jpg.rf.2179c07c621eaa0f5b322a4def4626af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_28_jpg.rf.21a57c6daf957c838a21709a7778a488.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-196-_jpg.rf.21ab940cc15c60a45cb67bb32dcb502e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-85-_jpg.rf.21cbe686e4835f621474aaacd0361925.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +40-Male-South-Korean-Dong-Wook-Kim_jpg.rf.221080d54adcd53d3d46f38ce0cd682e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_485_jpg.rf.223323d4e7951e747d9387d2e4bd0784.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_436_jpg.rf.22183a64c96724c3aefb2c81954c6ecf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_385_jpg.rf.22435a790770498294f30784a9b65d42.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-735-_jpeg_jpg.rf.2255cf2492fca8a7f0bd72bebc8c40a7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-805-_jpeg_jpg.rf.225fc4f79a969a324ff21337ad33cc9a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-25-_JPG_jpg.rf.225ce28f86e13c751ddd56fbf3aed4c0.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +38-Female-South-Korean-Hyun-Jin-Seo_jpg.rf.22541cf0625c585f31ad5fe93b296008.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_173_jpg.rf.227ee424e3300ed7946641bc4a0c43f1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-611-_jpeg_jpg.rf.22a9180228fc3d612349372106acf210.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_175_jpg.rf.228b67a3cfd3a898bf0ff54afad19bb6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +29-Female-Chinese-Fei-Xing_jpg.rf.22679e85af3afddf34c21ef89ff3bdd5.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-469-_jpg.rf.22de1a36ff3f9b498f17807e81a13f53.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-41-_jpg.rf.22d9041287d27c6ca1faf511159022a9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_616_png_jpg.rf.22d4f9dbe478f35a85d52f6c4e03fe1c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_130_jpg.rf.22e645672bd401e5baecdd5f4b921e26.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +37-Female-Thai-Watchara-Sukchum_jpg.rf.22cf655c52af3bfcfd6d44f5ba95e926.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-456-_jpeg_jpg.rf.23048513ba0fc6939811d88e04d102b1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_125_jpg.rf.22f2538276cb7be881ef3bd965c5676b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_36_jpg.rf.232030ba0cabf6d954232ab4eaafe6d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_567_jpg.rf.2329ff1a3873d57ed03accbfb1f363e1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Male-Thai-Way-ar-Sangngern_jpg.rf.234ed8d75b79913c7550d82ae7fdf3d3.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-8-_png_jpg.rf.2349479632583acd6a87c211a2fdb40a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_426_jpg.rf.23360413b65f91aea5a1c0a177c39d33.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_99_jpg.rf.2353065ba34f83a49b7bc5783aebc554.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +c5f852a3-2482-436b-882a-dc449c836613_jpg.rf.2406994ea333d2a6ee567d8cf2aea153.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-647-_jpeg_jpg.rf.23c17b1b870657b17b7a92ee73a8b9cd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_44_jpg.rf.23f27e509f139bf5da163f2c9e93c840.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +466_jpg.rf.240c412aea6bdb19a347a94e690d1289.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +levle1_482_jpg.rf.242932928d88b8c3efface191e982df8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_391_jpg.rf.2428e99697e27201860ca3a3187b0aaf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_349_jpg.rf.244a9b3a41b33c3d65ca55897d7c45a4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-8-_jpg.rf.2457bd39806f6ff81a6524e6122b3c9a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +302_jpg.rf.243c9f00b1b3047a8d382b6af43e3fdd.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_358_jpg.rf.2465e0fd8bf5d57f0e19e296da47b97d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-365-_jpg.rf.2455fef738cd83590e4c8c09215870df.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +38-Female-Filipino-Love-Marie-Payawal-Ongpauco-Escudero_jpg.rf.24b7e5d240cd94764290faf0c764fdea.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-751-_jpg.rf.24d78be52f7331beb504cc201bb3b3aa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-62-_jpg.rf.24ba56ed64a17a8f2a3d9fd94424b273.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-774-_jpeg_jpg.rf.24c4e6bb27d5c5b33e94c5665df445f4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-87-_jpeg_jpg.rf.24db8ca16fbc3ef16e480549b1cb77ac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_64_jpg.rf.24ff754a6bccb4d636628d9f950cf036.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +6b0bbb7874796fd11f8edb526cba498e_jpg.rf.25063f6d7a1997f7cd02134d298bc67c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +340_jpg.rf.255d21992857d462707eb0d622e6a2b4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +389_jpg.rf.25668c2d07aca8ddf12276e589b9f111.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-29-_jpg.rf.2589f41e21e736230f17c93ead1d2f9c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-227-_jpeg_jpg.rf.25bfbca701c54f4535300c79f0a2dd12.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_393_jpg.rf.25baa4b3c36054bf3b19829b0c777505.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +445_jpg.rf.25c0ad9510ab60215266ea07fe062790.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Berminyak88_jpg.rf.25d05d95eb89dc1017cb9cd42908e9a2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_23_jpg.rf.25c73f73d8ce74493e03018fc928f0ad.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily-skin_110_jpeg_jpg.rf.25e17cba18cf51e9e1b04f28b5e28037.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +334_jpg.rf.26363af01aa1c0430945a60a1c982117.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +224_jpg.rf.268e6c693fcbd264eb161f8f5b54052d.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +29-Female-Thai-Chayanit-Chansangavej_jpg.rf.265f25fff25113027c8aa7c410f43365.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +oily-193-_jpg.rf.26924d6f2a37fcc8aacb0e752dcdf5b0.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering-35-_jpg.rf.269beb6d2d1c725653bac78d655caa01.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +76_jpg.rf.269a880351cb284a73733d527ccaaeb8.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak28_jpg.rf.26a2f9cdc7a5129ec9053cd164c032aa.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-694-_jpg.rf.26b72ad67c738b379bd21ba1b1e0a9e2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-689-_jpeg_jpg.rf.27057605a63889dbc3fc86164fb5604c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +611_jpg.rf.27137ba094f269d8c39033cbd9bd69fb.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +2719473671_1_jpg.rf.26b88918547341550b7711d80db806e2.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_121_jpg.rf.2729b41183c198094181a2f328e51b60.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +14_jpg.rf.270278e9a5e67c1e9865f82059089be4.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-173-_jpeg_jpg.rf.27198cdcced5d97eb5e9694b5d3e7ec8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_109_jpeg_jpg.rf.2733936b833a0901ce82134c4166cc3d.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +berminyak__-1-_jpg.rf.27549d94accd74581479d4527cfcee7b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_145_jpg.rf.2738535df7047492ef3b5b0e2caf513f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-553-_jpg.rf.276d50411c9e4304f35a588fc84a4b0d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_102_jpg.rf.2763dc7dec05b1a295a84f29bf314893.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_38_jpg.rf.2780d87248e5f3a164840bd6b01341ee.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-256-_jpeg_jpg.rf.27878236bae80a528b27008fd29b94ab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-523-_jpeg_jpg.rf.2795ce7c371d3bb14a27e705e296e6f9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_1_jpg.rf.27a246255a08924ac6bce2729cc43419.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +226_jpg.rf.27b76f970b285204b308917126e2ca96.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +116_jpg.rf.27abbf328fa52a9acee74c2379207529.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-30-_jpeg_jpg.rf.27fa24cc5213d67e95795c1ad60741b2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_492_jpg.rf.27d387cdf393636adf5b321214e03c6c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-138-_jpg.rf.27dd9dd4159687cff44c615e59150c61.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-318-_jpeg_jpg.rf.280855762385adb65d4ba7d00856ac0a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +539_jpg.rf.280c2f941ef5a716d8242170a6d92536.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_450_jpg.rf.2830423a3bcc83bfcee6bc3e7247349b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +312_jpg.rf.2821d7101e50399f2180f574c4da4ea2.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +65_jpg.rf.28322aea533446ccbd61b0d79ab9f518.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-600-_jpg.rf.283a294124d5180db1af7f51a5db48d3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-624-_jpg.rf.28407368f48799882655b109e2f99e7f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry3_jpg.rf.284183cdb117cb14571c133557bb527b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +86_jpg.rf.2841a92f73868408dc8e414977fcb7da.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_58_jpg.rf.287ea0201d92ce8d034a74d497f88c76.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +167_jpg.rf.28a5bbca05383089758dad276e94f1ce.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +acne-241-_jpeg_jpg.rf.28b0c40dc8d2537cac6306b76c54f077.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +83_jpg.rf.28c3ff547c174ee83e4c7f78d558ea82.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-277-_jpeg_jpg.rf.28d14aad92b86d3cf871da9f747dca50.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-58-_jpg.rf.28d36c589d1ee8897a5e400807a11a17.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Male-Thai-Jiratchapong-Srisang_jpg.rf.28f58d669edcfc8b930ab27c8ff2cb1f.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_275_jpg.rf.28e2d22ced88e6102a85ad3e8ead9f60.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_528_jpg.rf.2906c651229e6ce07419bb551bf8e36d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_26_png_jpg.rf.293b43ca53074cb126541c163d94a9c5.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-622-_jpg.rf.28f9bf500ebb43fada52e5161a503d78.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-374-_jpg.rf.2946b08453bf8f464d54716ed42888c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +215_jpg.rf.295aeb7c63de3c36f9481eb1236f2699.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +305_jpg.rf.29478f6f12aad8e45512461f854d660d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-33-_jpeg_jpg.rf.2999c0e36d55a818fb5ecb9bbcc96ad5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-712-_jpg.rf.295b4134c7aecfa3e4e303bbf29fbd54.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_64_jpg.rf.29beb7a77f46b431a32fb0fba94ba6d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2397008230_1_jpg.rf.2996e5c6ca0e83e2454e9d392aa4a1fb.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-24-_png_jpg.rf.29c88c33afa985aea208024b251e7976.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-923-_jpeg_jpg.rf.2a1014f772fb10e3b0e9663d900fb8af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +318_jpg.rf.29dbefc3605fbc867380f6fd86e614d5.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +178_jpg.rf.2a19204f655631302a8db9eb956d414a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Berminyak131_jpg.rf.2a1c109f557e440cdcb0a7e3adbb5080.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +65_jpg.rf.2a356081b688a5c4ec5e8e498d541e89.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-289-_jpeg_jpg.rf.2a3270b78dc9017f05c722d998a195b6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-13-_jpeg_jpg.rf.2a288c7d6b0a29689a171ebcad9f2629.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +31_jpg.rf.2a475252f9de8d92cf536d11081275fb.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +oily-123-_jpg.rf.2a48b1332e8e72643eced8d0e0e87b19.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +45_jpg.rf.2a4d9872e895ac92d8d2be81146a5ee5.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_91_jpg.rf.2a4fb649f4c18025e9869f7043733f6b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-275-_jpeg_jpg.rf.2a562a449d8f3389953d9795876dcf20.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +103_jpg.rf.2a67c6b07877b39f8d6648289ceee840.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_299_jpg.rf.2a71f502b39a1a265dea3a60f50f2b94.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-131-_jpg.rf.2a765d0295a1f36b0d7feb9066600e47.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-245-_jpeg_jpg.rf.2ac5284d3d1ff666948a2bf8f3155f6e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_257_jpg.rf.2adac865b953a26b80ad5c46aeafdbac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-680-_jpeg_jpg.rf.2ad8e3103ce34f6921b611dd69505c25.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-346-_jpeg_jpg.rf.2aa877ccdc9fb24e12eb31942231854d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +519_jpg.rf.2acd4aa7170f8a6cf0ff089936777ef6.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering__-71-_jpg.rf.2b0b626f1517db9eac25b5d04d9a6b29.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +318_jpg.rf.2b15a40a1d6a349654b87a3cec5a273c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-268-_jpg.rf.2b19d45c49eef4bfac6dfdb97fb5eca2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-775-_jpeg_jpg.rf.2af5efbf17cd6521478417bb486f61ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_141_jpg.rf.2b3b197127a2d6df4a7150db877028d3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +4fe4d594-0473-4419-8edf-9d85802134b1_jpg.rf.2b4334859487c48057537f7c32632c86.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +2720295826_1_jpg.rf.2b43d820c3fd191f977b3fe132f0b543.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +306_jpg.rf.2b38fad52d0f2c7e4f4e16a46a2a76db.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-348-_jpg.rf.2b526442665fb4ed367f2f9e4e6d819f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_559_jpg.rf.2b80a51bccbeec490ac54fa8711571b7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-130-_jpeg_jpg.rf.2b8f69ea96389b9be10093f05887eb3f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_83_jpeg_jpg.rf.2b741c9abb1b3f59fab7926ea605b574.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-365-_jpeg_jpg.rf.2b4590b4abcea978f6c62545d5528c9e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-655-_jpg.rf.2b901a86bdae40eece22fa9e19e6f6f9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +211_jpg.rf.2b92bf66378e65739a5ada449cd881a9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_607_jpg.rf.2ba7674cbb991f81b46ee314b9865716.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-624-_jpeg_jpg.rf.2bc8a6b0dfa27a555d111c8a11c3adb8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_213_jpg.rf.2bb8d8a3171e5efd8b85f0a831be6920.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_115_jpg.rf.2bb09329b298370cd59d2eff9d809371.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +38-Male-South-Korean-Joong-Ki-Song_jpg.rf.2bd5945098411b37b195593d169177f0.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +oily-55-_jpg.rf.2be8393f0024704b68ee305014243d07.jpg, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0 +oily-181-_jpg.rf.2c169d472a6775929cdf5a1c4c721579.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +70_jpg.rf.2ca6e2659b25f084368657be72690d75.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +168_jpg.rf.2c2c4f1478eb032fcde1fc62a5806d50.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +oily-132-_jpg.rf.2ce4a3b16716876b8f7057ba5a04a3f0.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +dry15_jpg.rf.2c4d13123d53a811a0c0edb4367c09d1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-736-_jpeg_jpg.rf.2cd5438d8d690b56f804c0264122e5b1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_265_jpg.rf.2c556f9eb05d10fe1b266fd6b7376582.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +411_jpg.rf.2cf76730be2632b7bc95e60e34fef3c9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_103_jpg.rf.2cf7b9d7bcce21a6d530431d71e36f48.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-22-_jpg.rf.2cefb43d53b02362dbfd824833801fb5.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_61_jpg.rf.2cfe8c9447e927905eb81e936766b3f5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +521_jpg.rf.2d0ce5ce4930f19b9dcc7e91e4d480c0.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +Image_148_png.rf.2d1069fe281bd770558d0ef035f0228d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-43-_jpg.rf.2d0ebf5eef0dda0c06605e881fa3c422.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-414-_jpg.rf.2d02e88897258e17b2ca54a6622f667f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_75_jpg.rf.2d3b0ef1a1e46af4980a18c586f2d3ca.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +c459d2af-6e50-4d0c-8f13-4a98cb6685e6_jpg.rf.2d2b8d2b9689f10904cc9487f69ce088.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-450-_jpeg_jpg.rf.2d32f582e8528680dd19836c05b1a476.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2196749951_1_jpg.rf.2d58c3d8b39d5b406ee542fdda5682bb.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +74294949-66bf-4c61-a02e-e0ddb14383c8_jpg.rf.2d667867e9cf4e64f8e680f49a39d43d.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-424-_jpeg_jpg.rf.2d662cfd544f61c7716a00e73f2aea75.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-335-_jpeg_jpg.rf.2d6806c9d91ae82aa325db089f06b33b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_10_jpg.rf.2d904f9ae222fbecf372c20493256161.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_179_jpg.rf.2d64530cc51c581a2e2cd9de7aba3d25.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry13_jpg.rf.2d7ff414953538ad73ea4b4c34a755bd.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-848-_jpeg_jpg.rf.2d51fc6e26c3cec18390cd8df7b324a9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak36_jpg.rf.2da9df5cab39215d1317f1a2c0d7dade.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +470_jpg.rf.2df76c4db8f3ec69df213f4ecaf0fd9e.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-316-_jpeg_jpg.rf.2e19315c89c8ef53946420059baf3d8d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_256_jpg.rf.2e1cc97f5024edfbe5b4f4b22650e05a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +203_jpg.rf.2de73420627f82d2e17cfc188e82d437.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +282_jpg.rf.2e2750625f279dd5adf29c5ae6755428.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +43-Female-South-Korean-Tae-Hee-Kim_jpg.rf.2e5718042e08154008fc34b1839e63f6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-471-_jpeg_jpg.rf.2e2d51603db67a956e0d8acb6c8d0df0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +77_jpg.rf.2e5b66f4c5d9fe0d45a367ef1b7d43eb.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +kering-26-_jpg.rf.2e67fd8e160d2d4dfff2cea8cf49626b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-121-_jpeg_jpg.rf.2e82a2f62524ce313940211eeab7515b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-333-_jpeg_jpg.rf.2e8d4d741274466b5d84836505c92ac3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-899-_jpeg_jpg.rf.2e9bd01aed73d13b3f8ff06845b111bb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_125_jpg.rf.2ed58a5d63c417e218b14f5b990e8228.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-344-_jpg.rf.2ee06344dfed25123e296b51a973ac15.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_52_jpg.rf.2ec7b4b960c329ea6f34514a68db6a71.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_100_jpg.rf.2ed92f685d0133c1c4945634cab5fe05.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-21-_jpg.rf.2eef4a465abeb77bef84d1904e12efd8.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +berminyak__-16-_jpg.rf.2ef3a9fead8589f69bcd73e5bce15adb.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-614-_jpeg_jpg.rf.2ef8f2e03b393b57017a13890bdbe143.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_129_jpg.rf.2f05b34ae5aa8a2b154a6f05007cf216.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-50-_jpeg_jpg.rf.2f0c5159ca1d22640da33b1a50b0d4aa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_53_png_jpg.rf.2f1e2f5a59cd71e0c0605aedab64ab55.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25-Male-Chinese-Jun-Jie-Huang_jpg.rf.2f4b8e1e1ad37476a7d48c7ad506ae1e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-466-_jpeg_jpg.rf.2f46b09b40e401e614c344eb98a99d9c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-401-_jpeg_jpg.rf.2f56c98b0dbf07f2bd11910f73d91d6b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-50-_jpg.rf.2f993f2cb1833547750560e8d6f7790a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_11_jpg.rf.2fa81ade01f1d5c0204c9cd49368c6c2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-86-_jpg.rf.2f9e345cfbbe37c93d80f7812dff94b0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_70_jpg.rf.2fc6e33e5382beae659d6c1a89d29da8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-100-_jpg.rf.2fe5e425634a783b1cc3f3cf47e29a03.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_53_jpg.rf.2fda7133af29276e2614669eecffd8aa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-677-_jpeg_jpg.rf.2ff79ac80cba13065c646863f954d3a7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-7-_png_jpg.rf.3016b5922535ca6939c1154b91e052d9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-15-_png_jpg.rf.2ff79cd54235e2083a165e4fa1972088.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-696-_jpeg_jpg.rf.301fdb506ea8ca192d8fb89dd557ccb8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +414_jpg.rf.3028dfbbf7c928316fbe0dd6e058689d.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-785-_jpg.rf.303d437be05ccd3fc7de59f830c98312.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_146_jpg.rf.30331083f77f7f230da601f775e36dbb.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-354-_jpeg_jpg.rf.3035ab84f43d0cee069af9e336a781e5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +01F3MMWRMC4TXQKFQWF8PSB3WS_jpeg_jpg.rf.30516e14eca053ad9f6e9d55afd52256.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +34-Male-South-Korean-Jong-Suk-Lee_jpg.rf.30a9bd25936dc942e643d213a83eb269.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_509_jpg.rf.30d5a73795ba1dbc42b3eaf7667bfbbe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-109-_jpg.rf.30ef70477e017718989dae6b104545c2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +4_jpg.rf.3091ce4ddd743bb48df68a67676f80d6.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +117_jpg.rf.31102bc7af01c3798b4017c81ab10a1f.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +240_F_469528209_UGQNLPTvuhBFyDk3S00oB1B80nTtd3h1_jpg.rf.311db487310910bbd8c7bca1e2e3390c.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_41_jpg.rf.3100465e97a84a9237e527ad1b601e34.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_392_png_jpg.rf.3128249c87966836d460a46d52f859df.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-842-_jpg.rf.31386f8df9b638f553208d5a57cad845.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-98-_jpg.rf.315a27ebdf41ab89a280f121f8019668.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +256_jpg.rf.316681ec18d7eeed102466183356eae1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_111_jpg.rf.313dab1f234bef4e68e6cb058a5644b3.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +1fbdb999-2e96-44d0-807e-7e3644ed7044_jpg.rf.317dc48ea93737d2914ad6227bd65460.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_486_jpg.rf.31741fb137223fc75a658eeaf64d019f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25-Male-Thai-Warut-Chawalitrujiwong_jpg.rf.3182cedf60975bdd279d08ae5ee81182.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-402-_jpg.rf.3179ed33b7a79b39bf82c4bbb7c55588.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_100_jpg.rf.31a898db2ad5170aaf19ce2aa383d7e5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_5_jpg.rf.31984c763ac0f16ae93b9f828ff437f2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-536-_jpeg_jpg.rf.31b09498543ea6a04427e48f63546a76.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-4-_jpg.rf.318e4f7151da5b906992d68b5e5d6903.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-741-_jpeg_jpg.rf.31ee6dd5fac51a6c464b26a08a2e9609.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-252-_jpg.rf.31b490c93279f9e4f7e145bd9b054da2.jpg, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0 +225_jpg.rf.31cc64d8cbd4952481a4a17f1cd0804a.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +12_jpeg_jpg.rf.31f54656a1751a0be25dd8fe3ab7c65f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +01F3MMZ4J6M8C0N5VAMN449RRZ_jpeg_jpg.rf.31fd17f68120a02c922fc1293228dc45.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-176-_jpg.rf.321da98acc5c76d7b9c75cb84aa9d2b0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-114-_jpg.rf.321defb577e748296edaea5736c96c4b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +187_jpg.rf.3204dc0f9cfd2dc207d311fb9be2ab77.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +oily56_jpg.rf.322137e4a01849c5ff773da1b1c725ab.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_171_jpg.rf.322851e165f0ef01d2bd98837080325d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_98_png_jpg.rf.322d59ca7cc210194299e4c4683dec5c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +berminyak__-5-_jpg.rf.322be3de6ff3e6865b2abf4f1322adc1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_383_jpg.rf.329cb6de3eceef6d5754eb8e0c661924.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +8386c412-a76b-477a-a5d5-776396d5c145_jpg.rf.32ae30c3bae74cadfbadf4a11b33b439.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_382_jpg.rf.3267d07cb2bc887d70034fb651839897.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35_jpg.rf.324a674c7df5fd70684c08cc5eb8f59a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-33-_png_jpg.rf.32b342c8b382f7948002576812fe4096.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +di-ung-da-mat_jpg.rf.32c5d1e30a0f01eedf00d82816fc7ef9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-220-_jpeg_jpg.rf.32efc52837984ad48574435572733c4c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-795-_jpeg_jpg.rf.32c24b5a9dbf8b89b58a08189ebdc6e5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-18-_JPG_jpg.rf.32f1836853711035bc50a79792773677.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_85_jpg.rf.335847b6e6aaa6efc113c9c1ce76ce0f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-482-_jpg.rf.32fcc385859d9575dd89899212e7c72f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Female-Thai-Nara-Thepnupha_jpg.rf.3313427500c1d302bb8d2f17129839fb.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +111_jpg.rf.3391819cc894e0f8aa37ef54ee3ec9ce.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_10_jpg.rf.33dd44c20ffe1d988acd66b987c81805.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +61_jpg.rf.33c37aad076b5d893fdcdfaac98097a3.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_177_jpg.rf.33e6331014ec7069e6e1979d08fa044f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +97_jpg.rf.337fbead7f2f83efda66b7d88555299a.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +79_jpg.rf.33dfedcd1a3d6e6191475de54574c4a7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +238_jpg.rf.340953eeab9e7a4627867c918a3bcac8.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_458_jpg.rf.33ec9756e637499758544f3dbc8be4a4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +10_jpg.rf.340db6cb27bc81747ebd9574193f88dd.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-726-_jpg.rf.343dfe7e65e121ae2101242a369403fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_102_jpg.rf.3422689bb17e5025f1f4217b99916536.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-229-_jpg.rf.3418ad36141380488582e511ff7af667.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +503_jpg.rf.3450121163671beba3d63e9065c1035c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-skin_31_jpeg_jpg.rf.345e8f4a8503103b73e7a11f5fb43759.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_111_jpg.rf.3457ea00f4b4133a9977048df1bd46c6.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle2_148_jpg.rf.3492a3c31ae817510ad4c9e50132013f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +162_jpg.rf.3494695b5ba3e0fb1dfa70de08da36f9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_111_jpg.rf.945667e9653523ec69adf255b734b11d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +240_F_303787590_E77HGFUoZJhi2l5GEpegMU9jmUrfrHYv_jpg.rf.93b855275851ffcbf73ff2e93af9e868.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-714-_jpeg_jpg.rf.946fb43d872eccd4a178dc9078d125e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-17-_JPG_jpg.rf.942ebe962d6a69c416248ccb5f997888.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_85_jpg.rf.9551eb32a512ffb2150cceec82facc26.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_441_jpg.rf.94476fb37af414a9940d461f76ca621f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_447_jpg.rf.93b19859c2fb7ffec4d0472b0b0fe9cd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-60-_jpeg_jpg.rf.9539c4b3b3b497483d49711bebbb9d61.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_613_jpg.rf.942707dd7f3c6f029977c05fe67dd75f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_594_jpg.rf.93efca81e94952b467a05d436eeb2701.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-5-_jpg.rf.93df4191838b352d892fdca05a140499.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Berminyak-8-_jpg.rf.95437345eae28b21582142404cbdc6aa.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_323_jpg.rf.9552086497cfb01a7832f3436ff62711.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-357-_jpeg_jpg.rf.94e9df3173b1be839a78dd87717868cc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_166_jpg.rf.94c102de6e2c984c906ff0b56cd03b42.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-21-_jpg.rf.953babd18c1376ddc6f2b3e61d5414a6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-426-_jpg.rf.94d4550c6cf7be0acdf5a5a5dea3bd4d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-352-_jpeg_jpg.rf.94a1ba62accdc4906d5612a3a03db93f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +ff7e81f3-a740-4d2e-9f37-908cecc840b6_jpg.rf.956531f51111c55c9bdbebd614f5fc0f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +85_jpg.rf.95680751b36c10ab581a38edc900f534.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_519_jpg.rf.956acbcb87e22dc7b4830d63af3ccfdf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +228_jpg.rf.9565b7d98951b87ae78ce6f36a1fe5f3.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +31_jpg.rf.93b52d0e61e5b5fb38309d6aa4a86148.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +149_jpg.rf.9497c507241cd52c6098ad56131455ab.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +istockphoto-1214062242-170667a_jpg.rf.939cde18e4f77eabdcd7c0cb23212995.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +kering__-107-_jpg.rf.951998ba174b4909cbc9958a9db57f44.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-782-_jpg.rf.9548d3d2d12b560e11380f483b4ea626.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-580-_jpg.rf.9548464d85d2286d4329b2960c1c49e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_84_jpg.rf.9518581cc96cc0e4037383bec4b7e3c0.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-880-_jpeg_jpg.rf.9583777b93a3d5865fb73c301584c106.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_31_jpg.rf.95999d5fbeb09962f574f08ae5269478.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_5_jpg.rf.957d6a80ad69c44728c51887175663f0.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +106_jpg.rf.958d89cde2a642ddfded72b6a090d625.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +2806176632_1_jpg.rf.95b355c4fe00f3f689d6556152452b63.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_406_jpg.rf.95fd4f0ff8265359d7ef23524ff4b3eb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +65ebca0f-b426-486b-b5ff-68f23466b98a_jpg.rf.9619541cbccca2e664d4392596c4f04d.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_4_jpg.rf.95cd0eac5eaa2f197c33e27de98aea13.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-612-_jpg.rf.963476844490fa438485602a2ea9e579.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-11-_jpeg_jpg.rf.9640695dddab64b5a977917c36494356.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-905-_jpeg_jpg.rf.9623cff1877a1f72b6fb56831e8093f3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +79_jpg.rf.9647bbdc94f7539ea78f9f6040816dc3.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +236_jpg.rf.9691e01006e0d83a073dce757074b5e6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_21_jpg.rf.964e1a5041ada26bf14868652ebaa188.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +6d19f100-58ec-49cc-aa84-200efb30dbd0_jpg.rf.968a2edb6149f91b23ae125a3210b719.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +226_jpg.rf.9650b7b21958ff82e5def51acd449633.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_513_jpg.rf.96ad8eb6163ca192c2555ce75d556124.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +32-Male-South-Korean-Jin-Young-Jung_jpg.rf.9693124941c4a02d6788799161dccb4e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_108_jpg.rf.96d278a4828837aafa7aebb46d13b314.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_37_jpg.rf.96c85a289ad00f729d1776887710e5fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_352_jpg.rf.96f78e20cd55f82b3b821a6bf3d61b50.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-226-_jpg.rf.96e7239f98d4ea8788a635f7e51e6ad4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +366_jpg.rf.96dfe635b2f5febf2fe71c81492ae24f.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_401_jpg.rf.97209bc5283c5f1fa34bfd0abcda2fa8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_334_jpg.rf.9727752c4d1ee115760c45dd3f80879b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-445-_jpeg_jpg.rf.97228a4e51bc665e3c57451b2be1f1ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-64-_jpg.rf.9750d7dcdc5d35cd12dab892c95ab9ad.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +663_jpg.rf.97804c0c88e5aa13fbcef47e4733ff3e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +106_jpg.rf.979b503074fb6f22ac93966c0cdd39db.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +131_jpg.rf.979b3e1bf39319a70781787012850881.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_587_jpg.rf.97ce72a9f9c7ababf9fefbba054175ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-846-_jpeg_jpg.rf.97a56cde43c392b421f707d8657c924f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-636-_jpeg_jpg.rf.97eb56a8a0a68cbacb929c9713d866b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +18_jpg.rf.97ff0cc6e8783f0119718e3621c08d59.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +77_jpg.rf.978fe345d2a37fc14f6823ab360f9949.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_5_png_jpg.rf.9838d128a33b286ef615442c75fbb671.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_232_jpg.rf.9839002766b2a0d206336505a1de9209.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_89_jpg.rf.98557fd4d08348953ac12595bd8a3ff9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +372_jpg.rf.9846558c921785481a68ed63597fa1c4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_145_png_jpg.rf.985bd649fb9143a5398ffb0f2e9bb3d4.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-793-_jpg.rf.98849a7359365ee82f61c42d6c9d3759.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily19_jpg.rf.98649edb2832047a7a3370d3eadf690e.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +101_jpg.rf.98608cca4681703fab7496a7b704210c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +c955da5e-9306-4ba5-b6ae-bc8332466a63_jpg.rf.988bdbfb8c91d1a6b2451e4075101da5.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +25_jpeg_jpg.rf.98a6874314f86769a44d0a7660697458.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-703-_jpeg_jpg.rf.98b0ff963630fc836c39d0d2527f5a63.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-437-_jpeg_jpg.rf.98aaf1afda36fc16ada676de09d0cbaf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-670-_jpg.rf.98a85b52302c3fc7fe624332f8a26abe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25-Male-South-Korean-Jae-Wook-Lee_jpg.rf.98d8f73ea2288774242410cc987d5c22.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_227_jpg.rf.98c6d9426b7e7284b06ee03c98202545.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-564-_jpeg_jpg.rf.98caff85d5cc99e86901800fc113bc57.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +40-Male-Thai-Kosawis-Piyasakulkaew_jpg.rf.98e67d4307319c016d07901216a8a032.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_613_jpg.rf.98f8b4a717a46afaed772c4180f3580c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-104-_jpg.rf.98e73b0f1f3190713b12c49836085289.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-635-_jpeg_jpg.rf.9937d08f6cf1892eab695e965030ac7e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +451_jpg.rf.99020dd341945163d6d5bf28966e6bf9.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_115_jpg.rf.994adffaf69dd0852a4edd646c528261.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_124_jpg.rf.9941140965f967db6f9d3c03679b1438.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +541_jpg.rf.996ccf20bec321575a7da90fa634adb4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +7a5ef40e-dd58-44f5-ab19-380d8fe2ff80_jpg.rf.9950a2f3df5c982303661d11c22e868f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_263_jpg.rf.996c81cb1e7f0880165cacaa510d6815.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +400_jpg.rf.99b51b6cee7e2bbfa32cc002a13a316e.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_127_jpg.rf.99992058ebfa74e37da87b2b9d43daef.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_35_jpg.rf.99be11247fa231b944537d0d81fc7727.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_81_jpg.rf.99eacfcb16ae476d625d7323addc8ba5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-777-_jpeg_jpg.rf.99d95c0d9a0c390215d7369fa340f2e6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-348-_jpeg_jpg.rf.99c35ce7cba2a14cbb3df26f026b6eef.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_83_jpg.rf.99ee63c67abd33fda54a5da18f75c4d3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-630-_jpg.rf.99fbae8d479c6ca64b716e9ac30defa2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +31_jpg.rf.9a8c55bfcca93977ade95070a097ac28.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +levle0_89_jpg.rf.9a516549ee58cac97cf13fb0b9a5020e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-25-_jpg.rf.9a549dc69c927744367ddc9a7421afa2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-74-_jpg.rf.9aab53857c5148b1a6b9a82454bbb9c2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_638_jpg.rf.9a92955826122f4a90b5fe6c1342af80.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-531-_jpg.rf.9ac4b35e5695efbe26957cd2cbfd0f34.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Screenshot_2_png_jpg.rf.9a66659e02ef096f08803c57832da240.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +242_jpg.rf.9aee4480ef741247348a2325a19a8354.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +oily-skin_125_jpeg_jpg.rf.9b26acac6ac3a93201e56a8482d981fd.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-228-_jpeg_jpg.rf.9acdce8a74fa1937876e20c357058349.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-517-_jpeg_jpg.rf.9b1e1e431a054bab9d4195885731d6bd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-585-_jpeg_jpg.rf.9b2acb532e662702c1de2e0fc1ec7db3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +118_jpg.rf.9b355cbc9121eea3e0366b02108cdbb0.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Berminyak46_jpg.rf.9b34761aa3212dc591be2216ec7cbda9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-491-_jpg.rf.9b39b26b86651226c72b8a1a20091f3c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +221_jpg.rf.9b7034c8cb4f47a7e2acf710230e7aad.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_72_png_jpg.rf.9b82fe6f78d00bdc9f186ab0659e4e9a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +b836149e-b247-44b4-85f0-0e986dc4d9e9_jpg.rf.9b8e62ef6afd92a989ef2447b4559d94.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-676-_jpg.rf.9b8ace5379168d3ca119cc1e53e7cb9f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-28-_jpg.rf.9b92ed50471b3305f394634a74ed0311.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-168-_jpeg_jpg.rf.9b9dc5a728006d00322a3e8e1ead5de0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-765-_jpg.rf.9b58cb82c8d0b16b42104155762c24e9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-723-_jpg.rf.9ba8eb852f4d7bfa4e45e4e4b1fe7102.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-13-_JPG_jpg.rf.9bb2006913d03becc1ac19b63bf9eded.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-15-_jpg.rf.9c2f21b90c2193db17b57fa4422930b6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-295-_jpg.rf.9bed9568c200f2281558902994a18b9a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-8-_jpg.rf.9c3e92df4eee5aa30426269f957ec45f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_326_jpg.rf.9c5089872c0b2858f142d7bc9e69a215.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-727-_jpg.rf.9c4f7a794fbc520c7b4abb34fb119060.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-465-_jpg.rf.9c6628b8e93dd245e0d52c7f6b30e3b1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +965748b1b0e0f4cc84e6fb5daa335cb8_jpg.rf.9c863a1cef600f59cbe5cb79978c4928.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_61_jpg.rf.9c8af75b1fa813cf2472d541bd4de43c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-49-_jpg.rf.9cc3c66d8c026d14593d7b5a68a77bb5.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +33-Female-South-Korean-Bo-Young-Park_jpg.rf.9c9e99347e182f5c83771db33e780b21.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +259_jpg.rf.9cbe9f9dca4c38fcbe1f2ebbce5f1fdf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Male-South-Korean-Min-Gue-Kim_jpg.rf.9d5442dba018af831bc516e773c42266.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering_-123-_jpg.rf.9d14bcf574f9deae54816e3ad5ee1cc9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +38-Male-Thai-Sukollawat-Kanarot_jpg.rf.9d5fec2a7f587dbaf2620b0df5e2992b.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +476_jpg.rf.9cf8eb7283c74cd9893d45b708bc269e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_169_jpg.rf.9d655c41f95448f68c44f2fd719c1423.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-106-_jpg.rf.9d6de45d7325d776852f2debeff31655.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-307-_jpeg_jpg.rf.9db810a0c774fd89457edea54d5bb754.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_160_jpg.rf.9dbedabaa710019971318bfb9228253b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_327_jpg.rf.9e0456892f9c6d276f0453821d557e8a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +71_jpg.rf.9dc04988d3fd5fb3d4a622d52c0361d4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_74_jpg.rf.9dedbc0126854f88195a66ed2f3126de.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-301-_jpeg_jpg.rf.9e0f73a0e6b35ddeb9734f7e55c83d1a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +124_jpg.rf.9e2fb2f008ecd5bf107074b68f69b8c0.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_211_jpg.rf.9e559fcad554145b7af6358a89124cea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_264_jpg.rf.9e504143e6a41cdde9dbe56216c70df9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-65-_jpg.rf.9e5bd56424c9a2c2883569dd3c823398.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_93_jpg.rf.9e6f13678ce22944ef5912cea5420992.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +dry10_jpg.rf.9eb9247536a7522d8d392c2eda0a4d35.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-691-_jpg.rf.9ece6998e185cb57661fdbe1ccfaab6e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-829-_jpeg_jpg.rf.9e7346d60faccdb035b9e9f160e1b669.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-697-_jpg.rf.9edc0f0bfdf71fe16ea284a686806b69.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-733-_jpeg_jpg.rf.9ed9684693d87da8d79aa6b5be6a6d44.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-583-_jpeg_jpg.rf.9ee62bcd2a9de0299d4257ac15e3544b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-28-_jpg.rf.9f03ea5a0ae7921e5a84daaa037506db.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_309_jpg.rf.9ed7b07ac1eb6c9667f2c592e145acea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-264-_jpg.rf.9f31fe058685c3cfa7107c1475cf2480.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_310_jpg.rf.9f217b0298b9ee0392391539a8623235.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_108_jpg.rf.9f35f3e3d03e3156cf8a5e2cccc78c09.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-581-_jpg.rf.9f402c5be8db86981d5c5dda8cf66624.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +33952331-5d44-459f-ab1a-296158f90411_jpg.rf.9f7f3565bc797bb7bca6d1a91635a90a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +227_jpg.rf.9f9657bfcedb12999f40be4a48aeed8b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +154_jpg.rf.9f2bf702449dad77ebcb7b0e49f6a324.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-678-_jpg.rf.9f9ba12916261b48db9328c7916b62f9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +59_jpg.rf.9fa7d3d3241dd61761d6f017f3eb7581.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-250-_jpeg_jpg.rf.9fbc6a5717dfcdf00e7ceb53b750a322.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-593-_jpg.rf.9fd798de93a62098418b83493092080e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +11_jpg.rf.9fd8372c66870cdcf98d04874bebe542.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2_jpg.rf.9fe3c6ac82cc448b8672ee299b18d29d.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_76_jpg.rf.9ff47ceeb82254829a88bafd7f4d7f13.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-924-_jpeg_jpg.rf.a0057065ed8e029ecf625ca1e219a9c8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_35_jpg.rf.a01de5816a0394bcda43d4cd9df66652.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +567_jpg.rf.a0525ce793466363af3e9377292b84f6.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1 +01F3MMXQ085C2RDJEHNDX2FMKE_jpeg_jpg.rf.a036531ba8eb067d022d06a2881744ce.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_47_jpg.rf.a0287795471c5146c6e14c99910a0f23.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_325_jpg.rf.a02c3548670e9fef5bb18545cdf99025.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-874-_jpeg_jpg.rf.a07d7bada6e80c11aacede8012206854.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +bb3ccdc4-afc1-4253-bc82-d598110f720e_jpg.rf.a075953df0cb6687e75068bc19558146.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +6_jpg.rf.a066112043c155c214336be1f8648c03.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +44-Male-South-Korean-Ji-Cheol-Gong_jpg.rf.a0891dbfd0fe1336f321b5e2150ccf92.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +dry-107-_jpg.rf.a0db042c4fadf5291661ebb900b4d6ab.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_167_jpg.rf.a09bc78a611053c525a237917e25ae8a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +normal-158-_jpg.rf.a0d0dc9f1253de61e5b3f060b1db5a60.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_48_jpg.rf.a0ded870963e52b805e8860b896dba15.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-631-_jpg.rf.a10d13471dcf29f69437a4bc8de71d17.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_202_jpg.rf.a10f34d1ad106ade4e79cd8f240e3c4b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Male-South-Korean-Dong-Min-Lee_jpg.rf.a13427d8bbc82b2bf015d259c2a3c01b.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +40_jpg.rf.a12ddc48075d9a88801b1ef070e9e20b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-732-_jpeg_jpg.rf.a137b4d61c50b81a0f5c4f5f00f46462.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-178-_jpeg_jpg.rf.a138b92bb73bb12e2f1dc0d6c68fd013.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-483-_jpeg_jpg.rf.a139484cb0222cd22f36c9267a082694.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +656_jpg.rf.a1495f4e5e184d911f6a1913c465269a.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle2_184_jpg.rf.a15cd59e7b142e574cca4c352b6727c5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-31-_jpeg_jpg.rf.a1587fff33f86188665b2eece1ee80ac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-915-_jpeg_jpg.rf.a16369cff02306e4951ecb6c56e39a31.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-882-_jpeg_jpg.rf.a1a3fd96be313915b9e98ff09c72af90.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-113-_jpeg_jpg.rf.a172415db060d77551d028f0b5f45f37.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-504-_jpg.rf.a16cc5f96491c5e0a0d59df542deffec.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +01F3MMWXCRJZGN9E33KZPXT82D_jpeg_jpg.rf.a1a7dbe7576ff5d40e68ddd22670ca37.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_484_jpg.rf.a1b47cf981e64c8b459336e1737655f7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-748-_jpeg_jpg.rf.a1bf473b7e8b116c6b7e26e6e5a4c9c5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28_jpg.rf.a1bf820b03bacac8c7a6dd0d36433ed4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_132_jpg.rf.a1cd363199a0b713601515be5438d97f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-236-_jpg.rf.a1d6d4ed65b46117432e252869257230.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-1-_jpg.rf.a1d9044c0e8d52153a0432a90dfeea07.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_67_jpg.rf.a1e37abf85307067e8489c9d42a42078.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +139_jpg.rf.a1e4c51c1b561991792daed83bd8cb05.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +13_jpg.rf.a1f71447d0c2d0d95b56356f1f24332c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-252-_jpg.rf.a20fb9517413aa22256ae030a0b57fd5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-7-_jpeg_jpg.rf.a1f576246ea66c63253439514c5fb3ed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-68-_jpg.rf.a2498dafe5a3466d3150c3f5f80ece47.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-12-_jpeg_jpg.rf.a268b231f671e5367b89e4b9a2ae04d8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-776-_jpg.rf.a25c4dcc8bbba6e3b22f3fdec47c8fab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +38-Female-Thai-Tachakorn-Boonlupyanun_jpg.rf.a2805609f04ac94ed37b9c248b2b5c03.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +dry20_JPG_jpg.rf.a294b66ba5dbe4ead943fb28b32b4f3c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_41_jpg.rf.a2ad060adec90013d181be6459393564.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +327_jpg.rf.a2b541f5c9c30acd4551e6a414a0ee68.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +293_jpg.rf.a2ae861137b3625ee7d68c9c2e4ad663.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-131-_jpg.rf.a2c2b11ba46c9da34ff2edba82d2433f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +1_jpg.rf.a2d2d22abf648b6ace58d9ceae799721.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_422_png_jpg.rf.a302df74961aa0ff6b85476dbfa91073.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-14-_jpg.rf.a2d456186329af9ff90799723972f6c9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +41-Male-South-Korean-Tae-Pyung-Kim_jpg.rf.a2f24cc536661cac86a79c4c69794dda.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-383-_jpg.rf.a322eaf72bf7ad0056710a9cdc865fe4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +164_jpg.rf.a33322c1a9a3358a667f1998c53f23c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-262-_jpg.rf.a32367ec89502f680c64dd24e6f13ed8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +33-Male-South-Korean-Jun-Ho-Lee_jpg.rf.a324216380fec1397d605acf2c16dae9.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +188_jpg.rf.a337b1f3272e1f1642452e735864923c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-918-_jpeg_jpg.rf.a34fd61bb59a4c28c459400c25a87698.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_1_jpg.rf.a3800f6d880fe12c82990115efe07682.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_513_png_jpg.rf.a357da84eaac9f858a7112ea991083ed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-1-_jpeg_jpg.rf.a39b29ef0db53fb735ed973ae84ea5f7.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle2_111_jpg.rf.a374e571c1db7395f6cdbb73415440cc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_278_jpg.rf.a3b06a7a13bc2cc858ddc4058b1bf29b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-23-_jpeg_jpg.rf.a3d89ccda48e3419e7a3d1fbc02d2ddd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-563-_jpg.rf.a35c891f09ae57afd6806b1fb3d96331.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-59-_jpeg_jpg.rf.a4355525644a8b612dee7ad68a05dfb6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +532_jpg.rf.a44f1ed6035339a569a4c2b93b49b280.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_337_jpg.rf.a43cfe13432f41619b4851f758319c35.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-9-_jpeg_jpg.rf.a454126b1604343362ebb86a1d9ff369.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +316_jpg.rf.a4a2947d2ae090767530a8d3d942a44b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_131_jpg.rf.a4988d3d3c7de767b02d8b71c65e1d6d.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-85-_jpg.rf.a490cd360f03593273b23b504ca65ab0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +530_jpg.rf.a4b291b7782b52d9b0c29831df6cc13e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_440_jpg.rf.a4b7bf8632e7b45e2e101bab344f160a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily31_jpg.rf.a4c0c977372087e8789de0a12f0bea0a.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-628-_jpg.rf.a4dec94854cb02ae807e5a09c0010f0f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_122_jpg.rf.a4f19a21c4c713159cdef01decc5c0d6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +39-Male-Thai-Pakorn-Chatborirak_jpg.rf.a54a53900990aa7a5af993a2de623f96.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-474-_jpeg_jpg.rf.a507351663a0bed9ceb033ebcc29d4e7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +38_jpg.rf.a530b9ace19a471681b2ed8c0d7489c5.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +605_jpg.rf.a5698345d0ad4b2486de006430f943c7.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +34-Male-South-Korean-Taec-Yeon-Ok_jpg.rf.a54ff53253033e7000a8baa9f34a1803.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_542_png_jpg.rf.a54f538b2faf4fe594a724ee38701ca1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +3_jpg.rf.a56158743cbae7bc35f7fda3af2aa69c.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +kering-11-_jpg.rf.a586adf4baa0c9e2fbad60bd6ef6bec0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +467_jpg.rf.a56fd5cdbddf525a92391415eeaffd66.jpg, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0 +levle0_163_jpg.rf.a58799823feb209748f28b6bea30af62.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +394_jpg.rf.a57d89f21f255a4c3f03d579cbab61c2.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-487-_jpg.rf.a594df2e8f6dfc2e31f8980651a8e9f2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-570-_jpg.rf.a59cc8a51eabef8c784766059b3c3375.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +berminyak-24_jpg.rf.a59d31f19f04297f2416331ed9706be6.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_385_jpg.rf.a590e2f079ee323f41ab8dd9027957d0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-350-_jpg.rf.a5bb551342531282a4de453f23bd6b86.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-92-_jpg.rf.a5b605f23551aef2ad51998bd10a0ba3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-766-_jpg.rf.a5df8af65e7e6668d8f2b59a2fd124ec.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-206-_jpg.rf.a5e2449b2d66bf928d7217e80270e5ba.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-250-_jpg.rf.a66b7c48a8bbe0334839935d1a763c3c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily57_jpg.rf.a684c58e110bf5325c4aefb06abb2e4f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_52_jpg.rf.a6a73f85984148d599dd7426366f4bd6.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Berminyak-21-_jpeg_jpg.rf.a60d136a968e906106bf3c38cc10ca84.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +53_jpg.rf.a6423b34f5c022feaa0d5bb2f08f4053.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2692799934_1_jpg.rf.a6c24e34102f0da458247a5fff63b617.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-30-_jpeg_jpg.rf.a6c3c144bc262a2292da546f8870cb6e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_89_jpg.rf.a6de9e91cca160cdc725a62477bac1b6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-577-_jpg.rf.a6d195c1d67f54883b889631368ac0b8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-46-_jpg.rf.a6e49872b56db5d45d2422ccc7da0397.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +600_jpg.rf.a6a86b14badc09e616c5334684e4e81f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-21-_jpg.rf.a6ea74435d6d54b0f1a6df54e9f7bbb6.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +34-Male-Chinese-Guo-Chao-Ren_jpg.rf.a72f089cb3ac85c1b5b4bbf56209b4d0.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_247_jpg.rf.a6ee14bed7716dda2cde6589da9c011d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-47-_png_jpg.rf.a74626c334e28bbdba14e0b2c8701c37.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25-Female-Chinese-Lu-Si-Zhao_jpg.rf.a74590a3e7c981f5cd2d043ff5f7ee78.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering_-19-_jpg.rf.a7b135f12ee67380fa4e4334b5377cda.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily-34-_jpg.rf.a7c4656df697e391ee7d963bf87d3238.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_295_jpg.rf.a75b0a27994b188044cee707bcb63359.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_412_jpg.rf.a7d54980320fdcdc11466a02bcd0c34e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_200_png_jpg.rf.a7ca81e23ec54d6de3c968f4685ffcd3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-441-_jpg.rf.a7c49d56bc6f87fadb3a6a8ab9bf1019.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-237-_jpg.rf.a7ae37c2532cf10d31a6cf35ac6e3e62.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +da-mat-mong-noi-mach-mau-lieu-co-nguy-hiem_jpg.rf.a7ec62e14f46c5f283034f832f9f9b35.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-110-_jpeg_jpg.rf.a7f1871edf40ba8ea3f89101c23a9e79.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-83-_jpg.rf.a7c62a779814a757652de585b2305089.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +37-Male-South-Korean-Hong-Shik-Uhm_jpg.rf.a80ce31aaa95bc2a12f82c0f52003ab1.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Normal227_jpg.rf.a8804436f8b4eda001d8cf84a95ef689.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +9_jpg.rf.a81fff90ae90d86e7a3adac4b7381c47.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-38-_jpeg_jpg.rf.a7fe8d8982084a8cd514de5ae07f7a8b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-724-_jpeg_jpg.rf.a8208c7aaf05ce0eb6547e3ad05a7fa3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_157_jpg.rf.a8a4d84d4c0535e0f3afbc6227b18c26.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +670_jpg.rf.a8dba2028f961eb04bf08c5b97a85873.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_144_jpg.rf.a8c989dffdebc98ba456c5f352b31448.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-526-_jpeg_jpg.rf.a8f5bc82efc035c89e68363b50487c7a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-199-_jpg.rf.a8dd8e1462b77db3fbd30c81f9e0ce09.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_304_jpg.rf.a8ffebb5dfa44715dfb3d50cb5c3fcb8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +142_jpg.rf.a93021b103d786ec14649cede003d9bd.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-454-_jpg.rf.a93883d9fc3cc6aa604774a60836bbd4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-601-_jpg.rf.a92e7bcd4f73f2e22f51994403ce8ab5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +22-Male-Thai-Trai-Nimtawat_jpg.rf.a9410aa6ad3c9b43280d9c1ee500dfc9.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_107_jpg.rf.a93ea2ac6e9f4b8878498f467b9b0e16.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-152-_jpeg_jpg.rf.a958c7f93c5568157b8fc867c2e75681.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_130_jpg.rf.a963da6043fb484434ec41ae8d3bf8fc.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_377_jpg.rf.a9409b02e9c79b23ca859db1545cff00.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-298-_jpeg_jpg.rf.a96b38846b01358dda809ab82fe1f7a6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_44_jpg.rf.a9a2a4c2d5f4bf5cd1fa410e65aa5ec2.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +77150e42-e906-44e4-95a7-6fa0bbd87a65_jpg.rf.a9be6cecc2fd966f823868d02f257c9b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily15_jpg.rf.a9ec7b6acd69c78b81d2553852cb0323.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Kering-42-_jpg.rf.a979d147e9743425dcddb5f5f30e3a38.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +143_jpg.rf.aa032aa828e955dabc3b631946a5a1c4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-42-_jpg.rf.aa111416c0a79b9be5bb121d13879c2e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-101-_jpg.rf.aa0d6948fbec80d4243572c4c46c0c57.jpg, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_519_jpg.rf.a9c5c68c4fa8bee203bed7d2ff6e59be.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-186-_jpeg_jpg.rf.aa202b26c01972d6db742d2a00252d01.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_169_jpg.rf.aa0d4d806baed15f1c7f3c45bc57b0b1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-217-_jpeg_jpg.rf.aa355ba784d8a03892e2c2c7894328f7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-201-_jpeg_jpg.rf.aa475187b210f4a2a3f3a95a5e181315.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-102-_jpeg_jpg.rf.aa72b9a4b1099578365678f4a7048940.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1534767c-7b6c-4d0b-8946-6410736c00b7_jpg.rf.aa68476fb69a41f6369e08847b61bbfb.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +dry5_jpg.rf.aa73943c9645c11c3f1dd47692d5e8a7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +3_jpg.rf.aa74e2eda69883c8bcaef35b18f38ac1.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-69-_jpg.rf.aa8539f6b81712bb0746f90f5a35bfc2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-248-_jpg.rf.aa77c585af38076f1d2815319cc7edf3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +105_jpg.rf.aab5b9c76016ca57207404e1ee438f83.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +70a7a98e-5a6d-4ca3-acd4-12a2e14bbab2_jpg.rf.aa9c0c5aa559df857e0bc8754dd76138.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +27-Female-Thai-Narumon-Weerawatnodom_jpg.rf.aac81e3ea754bf20ba7132b335faf127.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_33_jpg.rf.aae7a3aef84192f00db5c1b7d7d7cb32.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35-Male-Chinese-Yi-Luo_jpg.rf.aadff9373b8c67fa62ec71a3a133a61d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +dry-147-_jpg.rf.aaf0dce48c9bd6c2d91f865b0840df4f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_80_jpg.rf.aab48b9566de7f68532f1c94cca8e742.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +24604-eczema-on-face_jpg.rf.ab055673f635ca3ef2c24240d9adab08.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-647-_jpg.rf.ab15f04b7f66ff3dc092cc6fcf912811.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_231_jpg.rf.ab1d87d2347cc9f11ab0e49845351ca3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-574-_jpeg_jpg.rf.ab6cad935a5ffdf8463a859e9fd5865b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +57_jpg.rf.ab98bf594f1c1604f9a1ca44a76d972d.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-154-_jpg.rf.ab6d6e7b3602fb667b5ca9fa88ac31fc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +9bfe69da-441e-45f2-be9d-1c75badaa96b_jpg.rf.abb78e2ec66a05a1c80a7244ee28b5bc.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +602_jpg.rf.ab2667af43f635a8cb13863cdf2c25e8.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-289-_jpg.rf.ab96e980c4bd75265309ada390fb44a4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +5f292e0e-5fcb-4ddb-a380-c4834263bc4f_jpg.rf.abd36483f47ad9796ae310a869c6e38e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +66_jpg.rf.abd786f397da973615df3b462ecceb36.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +dry-skin_62_jpeg_jpg.rf.abcff42a88b7a6cca238469cfadc3e53.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_524_jpg.rf.abd9239fbb098026b98670ad34f64e9a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +c433012d-eaba-4a83-a741-f7b34a94e775_jpg.rf.ac185dc5be84bd64e71c153465a9858b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering-45-_JPG_jpg.rf.ac160a44d1c4f254bd6ff169732714cb.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-384-_jpeg_jpg.rf.ac09b14b9f91a16fbca27d2cf7be76ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-759-_jpg.rf.abe3d479a9695e0f6a11de84d144a721.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-477-_jpeg_jpg.rf.ac53f9be6dd766c9e40e0eeb4f0685ab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-27-_jpg.rf.ac22d415dadd6dc85a59a220c3de8733.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +73_jpg.rf.ac471accde35b17b1d675742459e7eb2.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-392-_jpeg_jpg.rf.ac1cecf6a53f5bbdcd89cbf287d8e489.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-255-_jpeg_jpg.rf.ac972a310bf4e5117b742ad811af8b1b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_510_jpg.rf.ac727f9aaf62dfe804630dd17378929a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +24_jpg.rf.aceaa786d66ea6bda36842a43e452fce.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_90_jpg.rf.ad00c5cc459706a47e22bea21ce701eb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_447_png_jpg.rf.acf69e9a2f911fa916ecb38b35863e1b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +12_jpg.rf.ac9bf1db1ce3438f4388398a2cb5d293.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +437_jpg.rf.ad00803907bb7a186b5184e5e0693aee.jpg, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0 +292_jpg.rf.acee440535c1ecb3c01b23528616fc76.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +levle1_631_jpg.rf.ad5fc2656b99451093d60121f4222ab7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-472-_jpg.rf.ad392400841c17bece3230dfadc01690.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_164_png_jpg.rf.ad65f6490e47d607d67e0594417913b9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-701-_jpeg_jpg.rf.ad69e60ab7c12513cfd6727d6081660f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-543-_jpeg_jpg.rf.adc935cbbbd04b4f9233a49dfa47bff1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-613-_jpg.rf.ad722b5ede095b205eb357a2d564b3e1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-30-_JPG_jpg.rf.add710ffbbf45ec46c558df4d9283e1e.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +2b5b6032-b30d-4e88-a888-81aa8b7cd480_jpg.rf.ad9128a86f5a9519a76081186954e5ca.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +40_jpg.rf.adea6b018497aa0bac03b06725dfbb39.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-663-_jpeg_jpg.rf.adf2580c0180e31c8c49008a46ea8c06.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-731-_jpeg_jpg.rf.ae01d4eaa08166264577683de2e5f2ec.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +534_jpg.rf.addcb9be420e77eaaf2f3a023c7926fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-99-_jpeg_jpg.rf.ae43541e8ced526a40e383193060b540.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_149_jpg.rf.ae293afd0d631557b47fcb4efa40a19c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_546_jpg.rf.ae861da8ff4fcc79f2d45d2458bfe42a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-71-_jpeg_jpg.rf.ae97b180de1e253f73375b236cc8f4af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-163-_jpeg_jpg.rf.aee79d15c6e6af751a69c61a2e895101.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_520_jpg.rf.aecfe2257db17484b26241ef112a64cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-368-_jpeg_jpg.rf.aeee515c9936baa4bfee7c3b763ccc80.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_164_jpg.rf.aebe92f318314c95423ac5f99646dffa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-195-_jpeg_jpg.rf.aef3c3fc5ade923e3d2c15d30d5d71d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +54_jpg.rf.af07143c2031661a72a3fa17835d2017.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +16_jpg.rf.af032c0871b323325041adcbf1488ffb.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-474-_jpg.rf.af142d9921e60ca27336f74e0c9eaa4a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_125_jpg.rf.af67a23ed0661759cddf5c1f79ae4795.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-748-_jpg.rf.af68f35382795e2c228dac1668398ff9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +274_jpg.rf.af6b47a56c98b57d615ec7a953180797.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_49_jpg.rf.af6915f938540dece425bc4c8d8ee2e8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-88-_jpeg_jpg.rf.af97c42b7c2e0f846e905525edbad94d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +469_jpg.rf.af8a6ede2a2a4d0f74346bfdec1dc5ed.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +acne-911-_jpeg_jpg.rf.afb483791b8c9b9496270dda864e839e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +210_jpg.rf.afc8fc954f129f2ac998734d40dd6bee.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +11_jpg.rf.afcefe68de76687a3db37ab5f3bf16e7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-760-_jpeg_jpg.rf.afdfacb22e9cb4bc5ea95c83ab1e0a56.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25-Male-Thai-Thanawat-Ratanakitpaisan_jpg.rf.afe9bbcc86968da0fc03af9bd88e9455.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-794-_jpg.rf.aff1892a956f6cc89825dd9aa2fcc87f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +60_jpg.rf.afef6831a225cb0a2b5491997165ee1d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_234_jpg.rf.b0197aa9fbe59d13d4b374f4b97754db.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_65_png_jpg.rf.afa678ff9ff3a775020bf47ce3932ad8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-44-_jpg.rf.b03cd0f5df1f853a8dd298f87ed25df2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +28-Male-South-Korean-Min-Hyun-Hwang_jpg.rf.b057e6a9c5fc1bd0afa78eb3ebaf60bc.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering_-92-_jpg.rf.b058322633c872485613c33909417b61.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +31-Male-South-Korean-Dong-Yoon-Jang_jpg.rf.b0796b22e274c00632217b00223d90c9.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Kering-10-_jpeg_jpg.rf.b097865d1b7effe6ceadf4bce5e65e9b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-803-_jpeg_jpg.rf.b0dd48aa9dacebc6bf2bf1a7b5eba5fa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +489_jpg.rf.b0baee7022c5e921fbf6a747894c77df.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +149_jpg.rf.b10c25d264737543de1efe1b3f9bf59b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +35-Male-South-Korean-Soo-Hyun-Kim_jpg.rf.b12c14f9c594d1de6b86691396cc3bde.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-164-_jpg.rf.b13360dc0ba4c76a810fe1eccf19bd30.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-258-_jpg.rf.b1404471b0871404260db25eb066ec24.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_221_jpg.rf.b159252aa0d79adecfd5ce1c0097f2c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-112-_jpg.rf.b1a7d4730cb1595ad90f9ea6157b2651.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-377-_jpg.rf.b16d2c924587e759efc12dd677175546.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak286_jpg.rf.b1d56d14a841495d470dcabb4bf50208.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-53-_jpg.rf.b299b49b8e56090ceb14ec5d2ecef39d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-210-_jpg.rf.b22cb100bbc47aa2c317f3e83b276d2f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_553_jpg.rf.b21f7629eda2c68c6ee2f6e5e6f569b9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-34-_jpg.rf.b2adc26fec22529024cb62d06bc85065.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +240_jpg.rf.b2c92c02aff38d037727a3e8fb99fdf6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_74_jpg.rf.b2effdcf90eca1986c17e70c459cb26e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +berminyak__-43-_jpg.rf.b2f92c2d3fd56fc5e57bdad2adec5d68.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering-9-_jpg.rf.b3274e162c23b461032de5aee00fb425.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +01F3MMVNR3XB2A86H1Y87XW2SE_jpeg_jpg.rf.b33d735f04d235b729ce8e65038b9b3e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +366_jpg.rf.b349dcb1e6a81ba49d0605641ffa82ae.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_112_JPG_jpg.rf.b343b784ff01e40400e3c36ab2249f4b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_177_jpg.rf.b38ac65eaa6455202b73a878a5a0aa1e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-709-_jpeg_jpg.rf.b37bf985a74ace9ad1f78009ebe5f1cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-81-_jpg.rf.b36db96d20944719d62d30619a88f30d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-386-_jpeg_jpg.rf.b3a7329faa2e027f5c5a2dc0d3817d2a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-221-_jpg.rf.b3bf627c1c6d44c352ab09e334369054.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-694-_jpeg_jpg.rf.b39469b1d17f0f1025f464810892f378.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_22_jpg.rf.b3a6a6ecb6307f0a707135fe04209aa2.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Berminyak130_jpg.rf.b36ebd7346ca644ec89ff3cea25d0828.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_53_jpg.rf.b3f7f3a851938f487c5e7fb54ebd2c3b.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_70_png_jpg.rf.b426504586056ff0163c2db1eb622c61.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +32-Male-South-Korean-Min-Ho-Choi_jpg.rf.b41ef9b978ebd2a8969e19c6a38dd8f3.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +408_jpg.rf.b3e8a4e21e632d37a0d8f148dfb02232.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +22_jpg.rf.b47926608f1c957e1e20b9dc52ec67f3.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +9b4d1032-c4d7-4b30-89ba-523cf69d36ff_jpg.rf.b481410c0d55e6f9226c82320c37f7fe.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +597_jpg.rf.b46438e9f680937f8af66b87446468c0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_170_jpeg_jpg.rf.b43ae79da51efcda54a0d89e95dc4cc9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +istockphoto-527033884-612x612_jpg.rf.b4a421004994b1761c85de783f3673e6.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_324_jpg.rf.b4c918a94143a06c208de1628c6e5a43.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_313_jpg.rf.b4c3d412bc8e210b556be45d85eb8e24.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +180_jpg.rf.b4d6336be7aaa5059bec05a528132389.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Berminyak277_jpg.rf.b4db8791315c5ba464baac89c9e468ac.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_58_jpg.rf.b4f2ca0cdd20af9b356588a4a8bdcd76.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Berminyak6_jpg.rf.b4df229c3afb17cdf10a2e069ebe5755.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_9_jpg.rf.b50a3faf2f66de90b4ccd55418513118.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +342_jpg.rf.b4d906d9301f281ec9b6fd8e21e5f77b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +511_jpg.rf.b4f351115fd70a7b93bb28484e3f31c3.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +319_jpg.rf.b5274284eea06411919d7c6c97d66344.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +460_jpg.rf.b59bdf25f4762f75315dd820d4b3eefd.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_307_jpg.rf.b552563a6f3f37166a4d2e6098082d28.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-643-_jpeg_jpg.rf.b55679fe50fbee26bb6e883159f6e47b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +55_jpg.rf.b56d8a369f9f0b53f746ff80b304b6a6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-22-_jpeg_jpg.rf.b51a4deceee0d360779d5c959f3782dc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_146_jpg.rf.b5b3cd32c23e33adbc5dd4de78622fcb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_155_jpg.rf.b5b588699b1898d92351b77dfe280f14.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +01F3MMX6SD07BKJYXNPC9DHQTD_jpeg_jpg.rf.b5b6b3302b98005f33c6f21b3095b1ad.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +668_jpg.rf.b5e054ff71ec15009cd0a055a8ee13d8.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering_-42-_jpg.rf.b5db020918891e37fb7206882885e8bb.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-20-_jpg.rf.b603b797b1d815681415d0d2d81f56bb.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-815-_jpg.rf.b5ef1c562e92bcec617e9a9e4a51dd8e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +200_jpg.rf.b61804b4a8ae8b1b2e926f46bbd1023f.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-779-_jpg.rf.b649b602d350480d8815304876401897.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +516_jpg.rf.b633d55136da0fffbcf358c954c2d090.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +acne-828-_jpg.rf.b64611a8b52d80811032a61fbece4f6b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1eac0354-9727-4098-9fe2-b8fe17318953_jpg.rf.b658a727c42e194eb16f63be3be8e2d1.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_337_jpg.rf.b65e8ebf797db542f5a689a736157b1f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-151-_jpg.rf.b70a58fd0799fdb906e9fd8cc887cc1f.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +7_jpg.rf.b6e0d569070a50ab65d174ef6246a9e6.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-592-_jpg.rf.b7080795c2984b013c7e132382cf76be.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-301-_jpg.rf.b72af8f68a65b3bb31424d179a9c94c7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-719-_jpeg_jpg.rf.b73098d2ddc2e91bce27fc1d8d018359.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-299-_jpeg_jpg.rf.b717216428f22511ced2495bbe6a3f08.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +655_jpg.rf.b73dfe89497d11d5088cfe73d656b090.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +32-Male-South-Korean-Ha-Joon-Wi_jpg.rf.b73507287a68596aaced149bebf8e1c4.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-176-_jpeg_jpg.rf.b78ce94b1246b0ce3281b6d57dc26657.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +545_jpg.rf.b7d1c91fbf1c4262ba6a9189c9e0162a.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_163_jpg.rf.b7cd785871cc6496a61b0b5778fe149c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +329_jpg.rf.b84d75df205ef4daa693eb0714eb0781.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +608_jpg.rf.b851067a175d9cd177ba63d8cad0cc91.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_523_jpg.rf.b85ebdbee5011a7d92185a9ad455e7e7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_23_jpg.rf.b872531216a9510703ad4b3a61aaf999.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +40_jpg.rf.b873c4658fb28ecde9bd437c22d5152b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +Image_70_jpg.rf.b88324a0a2b16d5bfb61e0d7b1738d9a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-572-_jpg.rf.b8b0c6cb20d761200d07904928c15149.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_224_jpg.rf.b8ad10be4897f7097089141dc786acd2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-232-_jpeg_jpg.rf.b8b270b3f2808f2e40565689e88dfaeb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +88_jpg.rf.b8b74d10d97a5ceb286d323fcf0d3792.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_112_jpg.rf.b8ce0c51f91cba528bd444372e3b31b2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_70_jpg.rf.b8b7543869e5dc977157fd0e581f338c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_222_jpg.rf.b93b4c0e4df468b46a3e55042a3be0e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_161_jpg.rf.b8eff14f65522a8818b15bd32d1cef10.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-670-_jpeg_jpg.rf.b9281f8be5d294bc1bdfa2d01fb76215.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_419_jpg.rf.b93c174830cb4ffd3219b64cf4988eca.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-333-_jpg.rf.b93dc96d81e84eb3270227cc3df2b595.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-784-_jpeg_jpg.rf.b950ec88dfd0a184af41f9ac790247ee.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry11_jpg.rf.b94bc0f4d7daa902fe79c4a84bc9e1cf.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +246_jpg.rf.b9604a451729c7b09ff4d8c8b7480e39.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-749-_jpeg_jpg.rf.b97734fbbf1c8acb784ae9090711221f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-572-_jpeg_jpg.rf.b97e157bb4adf71ed5ab98e4f2f84e9a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-37-_jpg.rf.b98e5f094bfe3edca1cb87fa08179448.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +144_jpg.rf.b97ca48ac58be710eace9b625e42d596.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Image_60_jpg.rf.b98fc4a4869b6fa54044fd2f3a16ae8e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +18_jpeg_jpg.rf.b9a05f8e6631b768b38047052f31e70b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +147_jpg.rf.b99fcc307e00adccf7a00a45fe5d1211.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-900-_jpeg_jpg.rf.b99ee9ac35c760b56a7533dc005d79d0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-25-_jpeg_jpg.rf.b9d82a155a1b1a3ec315123b304e66a6.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_627_jpg.rf.b9da120cd8e22e590709702143642d74.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +682_jpg.rf.b9b3214029d9e672ce04a216be924c85.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-32-_png_jpg.rf.b9bfcc28ef116b4f97017fd7676fb825.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +314_jpg.rf.ba11301b59205e94b881aec1004c1f98.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +226_jpg.rf.b9f3d26f20d225030694cba84ea3521f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +42_jpg.rf.b9e3a7553f9d25ee2bcdbd12c1141e05.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-17-_jpg.rf.b9f3ffa4c2aa6f2f089fed4f57162e57.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_84_jpg.rf.ba1179a2eadff390483420b487b83f2c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-821-_jpg.rf.ba2426f5298d43984686671fbab626d9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry8_jpg.rf.ba34b024ea7e7bcb2da373e8f2db62ba.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-449-_jpeg_jpg.rf.ba9eab6c291abac431383e7e99f7c65d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak267_jpg.rf.ba3af826593bd95852539b50a9c83f4d.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +oily-66-_jpg.rf.ba5eee7d92298cfe87a8fa753a77cd53.jpg, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0 +Image_81_jpg.rf.ba32b4b84acd944418cce88005628355.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +30-Male-South-Korean-Seung-Ho-Yoo_jpg.rf.babd7f38da64e1a6ab8a453cb91626b5.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering_-49-_jpg.rf.bafb932b7e3811f7f8adb5b49147ba1d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +9956c273-f3a6-46d3-8c76-b48e6bdc6056_jpg.rf.bac9907f326e4018c528532302fd9040.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-306-_jpg.rf.bac8870eb99a7f182cc155a271d3bbb9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +308_jpg.rf.baff57d23318fe632e05db794d0231da.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +Image_94_jpg.rf.bb0cd25b076271ed1a359e8513ce5919.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-271-_jpg.rf.bb178e700f451e096915424d728e8bfd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-832-_jpg.rf.bb241b58003512e6ab1a457ae62b68d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-314-_jpeg_jpg.rf.bb2bdae2a4f024fd3c4d1cecfe671d6e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-251-_jpeg_jpg.rf.bb3df2d012d3a366d36843c850957f33.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-1-_jpg.rf.bb484cc23dc6e34c13d14d942ebff2e0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_54_jpg.rf.bb87ba2339c3ce1ca0544d74a8d4060b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +56_jpg.rf.bb555406b75aa8766e5bd5ff8b524575.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-728-_jpeg_jpg.rf.bbd6f3286441d06ab64d2240744624de.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +31-Female-Chinese-Gu-lnezer-Bextiyar_jpg.rf.bb9cb405e27fc6de2d785a2e79f8aa86.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_209_jpg.rf.bbfbdfb62aaaece6bf0c6005724cf1c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_362_jpg.rf.bbe2bdd0147099fd8f85239d67793497.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-247-_jpeg_jpg.rf.bc0206353dcc1900af795acb4827a00d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +7_jpg.rf.bc0b8f7a6a3c438dbb424b96d4ce538f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-877-_jpeg_jpg.rf.bc1f17d9a8224f6985adace8fb2e08f5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Male-Chinese-Xin-Cheng-Zhang_jpg.rf.bc2fdd35778b91899329701cb0cd56e4.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +bcec08c3-86f4-4677-a03b-9a95efcaa236_jpg.rf.bc3a7ae405e08768e406e47ca02fbc52.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering_-132-_jpg.rf.bc3d1b9216f8d8fa82ecf36db0efc032.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_573_jpg.rf.bc41caac6623427ecc7577f11da176cb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-8-_jpg.rf.bc44dea9cba835d28d501842ff0a574b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_113_jpg.rf.bc4df065e8d029f2bcd5704a660e270a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-173-_jpg.rf.bc4c31730fd91a183c9a8fcf2ef471d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_472_jpg.rf.bc5f9946c29c47153e930defb33b862d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +236_jpg.rf.bc86660af1ecb18b09a64071ce12a2d9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +413_jpg.rf.bc98b316cf0ccc672ef77455b0872b08.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle2_33_jpg.rf.bd03772152f2dedb37f194a86b0d2003.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_4_png_jpg.rf.bcca77e1c1bfa08340d48f3c9a85d0fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_52_jpg.rf.bc9c9b73a656843b51775ab671db3988.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2569659822_1_jpg.rf.bd0ad25fc1bb93a4a3145790541ed45d.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle2_175_jpg.rf.bd07da3d4d1e18f5b282ad512daada72.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-933-_jpeg_jpg.rf.bd09830a138e048a345afc4f440c116e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-684-_jpeg_jpg.rf.bd1344d4f12becb1f1706a43b4542cf6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-404-_jpg.rf.bd5dc52bc394f4d2d2e53f6766c7c759.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-332-_jpeg_jpg.rf.bd8c1bab5a9f06afe38c1eafd9cf9e34.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +239_jpg.rf.bd8401332f4ff768f04a4b9ebedd0ace.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +dry14_jpg.rf.bda0008d51778066d9e0796c2b2a1cd4.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_84_jpg.rf.bdcbc5d858d80548f428df3e5dd270fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +632_jpg.rf.bdb2c57101a80b0a4fb7e12bc503aec9.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_171_jpg.rf.bdc92f8fb8e4ba51555e0c9a9e2bb4f3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +240_F_175181783_9h2VmEFY0xA9yc8O2fqKvhzsEOAfDFtD_jpg.rf.bda9a92cec278bbbaa4d268c60c5b5c4.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +256_jpg.rf.bdefc8dcd8018ca566ba9c23db13a88f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_137_jpg.rf.bdd846af7c3ee237a3cfc99b6297dcb0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-934-_jpeg_jpg.rf.be3df18d18f4d78527708e7704633db0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_164_jpg.rf.be19dd66453f34bd0871cb96e79a8d59.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-189-_jpeg_jpg.rf.be6fb2ad99cd4f3b18f489ac80708e93.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-13-_JPG_jpg.rf.be7bb42b92b33f357016a3a8b18edddc.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_516_png_jpg.rf.be882c1d7c27c5e9e1ff230b66931621.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +353_jpg.rf.be7f56e700ed60766516d9b97c30b78b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering_-53-_jpg.rf.bee667e4b308a025ed9d7151c27c8146.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +47_jpg.rf.bed8fcfac88184504dc259e08f25df39.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-782-_jpeg_jpg.rf.bef6be975ced3ca2dc8d35c5371b983d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-767-_jpeg_jpg.rf.bf026657f4c28cb35d3d3ea90756c2ac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_67_jpg.rf.bf098bbdb09d02dbb06ef2eda2b12823.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_242_jpg.rf.bf148f5a4920b8fc49244cb4175f0cdb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_5_jpg.rf.bf14afae9f90dac645ee831f4c34538e.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-387-_jpeg_jpg.rf.bf1c63bab8163f757b2dee249c0fe922.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_72_jpg.rf.bf2a155ae1ec6f27a913aede13319432.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-156-_jpg.rf.bf203fdd10308e532e4832bc64d6cddf.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_59_jpg.rf.bf355175ec7b4c02264d299e453d7785.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-130-_jpg.rf.bf38561227531b192caf8ff0ce7d92aa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_98_jpg.rf.bf41533ed5264b36935b67bbc93e62b7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-401-_jpg.rf.bfa4e2cab2d00f573bc4db02d66f22b6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_15_jpg.rf.bf63d99a37ba46f9aab48db0eb30bce0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +9bc0d52c-4c9e-4d61-8cd9-d020e86a22c4_jpg.rf.bfabd51afd937f3cc456f35789d1793b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-177-_jpeg_jpg.rf.bfa2918d302945227ec46e7947cf1482.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-887-_jpeg_jpg.rf.bfbbe62ef5d7d337dc59abf01cc75d93.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_188_jpg.rf.f05c84f05b56bd1b864d4b6588587b10.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_121_png_jpg.rf.ef75eb799c7647b04fd8f8b36c3502b1.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +a127972f-4a39-4ac5-86cf-4f8cf78c584c_jpg.rf.efb488b49d0fd77ceba607ba25f69750.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +102_jpg.rf.f04b7291e0330822283651e05a6b8837.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +17_jpg.rf.ef9a9a804d3e2d874b073f02fc04e507.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-555-_jpeg_jpg.rf.ef7dc8f0072a55c54d42b213316ad67c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_479_jpg.rf.f04c5b9af152046327b1d96373a646e6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-822-_jpg.rf.f13dddc438ccde2c08d44a10d031ab24.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-843-_jpeg_jpg.rf.f0314d109275f24fa0187ab035fba039.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-152-_jpg.rf.f13b9eb356c864a71ac623b87f27e5c9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_60_jpg.rf.f12bb40ce54f0bf611ceddc2954409c5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_241_jpg.rf.f01aa4f4dac04bd08cb2db903e2edc02.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-335-_jpg.rf.f0e96bad59740cf231e562153f2c1954.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26_jpeg_jpg.rf.ef851a00030d82674063c36144f4cc26.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +672_jpg.rf.f10c36c231abfc2ed496198ac4f5beab.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +23-Male-Chinese-Fei-Yu-Chen_jpg.rf.f0a6d47af1b7eb28493318c18412ee8d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-58-_jpeg_jpg.rf.ef7689d022e0d3c025f5c49ebf77e607.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-28-_jpg.rf.f148099d74fa111cad9477ff04467767.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +normal-187-_jpg.rf.f0d1983aabe40bae7d7914e5e5c8d0e8.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-883-_jpeg_jpg.rf.ef74475fa0e85592fb5cb3bc2036c74a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-39-_jpg.rf.f1367fd9bc9fdc6ffab6425548e83f97.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_185_png_jpg.rf.ef88bb6b5260891b59e7fa8b7639ed0f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_110_jpg.rf.f148765698db35267329e392fa5981bf.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +30-Male-Thai-Chinnarat-Siriphongchawalit_jpg.rf.f14a970387f81c89e05b37aea0563b44.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +145_jpg.rf.f1501cb9e8e91da76e80f985a48ba23a.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +acne-548-_jpg.rf.f151048ed90b7e06254fb0af92234871.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +635_jpg.rf.efb2f9290bf0267518f4d71168e289ef.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +berminyak__-25-_jpg.rf.efd1bad83d3d118d2830ee5963877797.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_196_jpg.rf.f05e9e326a5f8ef0e918afd7b939d78a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-516-_jpeg_jpg.rf.f122d042dc872c5abd97c1e99a15d508.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-82-_jpg.rf.f16b4ee3c73923487b90b82207f1573e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +391_jpg.rf.f181bb4c4a96f4fad262a7608a325598.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-878-_jpeg_jpg.rf.f15f265afaa89524e1fac5d099d7fbc8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-79-_jpeg_jpg.rf.f1804d6a97430a6d67313ef42db4d361.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_61_jpg.rf.f1824de1b48dd8e2d96d3960e2564576.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_513_jpg.rf.f1a0a22612c867f71dd7aa7199443151.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-267-_jpeg_jpg.rf.f18973ceb6277bfbb00ea9d149927990.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +01F3MMVRGMNJVWB5CBCY48RNFN_jpeg_jpg.rf.f191fbbb6d3c8ba287f778fb5085c34d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-715-_jpg.rf.f1b1bbe1b2efec3e483cc31cbc48096c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-274-_jpg.rf.f1b2151cbde638f826bd8bc8b6aeb75d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_114_jpg.rf.f1b859779872a280f2a27d0af1e61e46.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-7-_jpeg_jpg.rf.f1e6af42f8fe13420250ad12b1c572e6.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-741-_jpg.rf.f1e8c9ef1c3511b33f66cac8183a392f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-40-_jpg.rf.f24364a6dcfca5f450d6b44d1cc0619e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +20200418_102430_703398_thuoc-tri-cham-sua-max-1800x1800_jpg.rf.f1e9392bd85dcba36ea915400e244b70.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +levle1_271_jpg.rf.f1f047c2cc5f191db867e89d729f89ec.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-11-_PNG_jpg.rf.f2478d43db1796db310bf6b541cd0507.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_135_jpg.rf.f24f9969b21339596bec960ecf6c70ec.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +15_jpg.rf.f28724f95043314d4771130a08a27b36.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +370_jpg.rf.f259c7def0e984855ed0a36ac9a66db9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_362_jpg.rf.f25485377a47c4642aaf2e5b7b3be6cb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +normal-162-_jpg.rf.f29f7a21275816b07dbc36cbb9362346.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-594-_jpg.rf.f2959d4e0e0cc82157104a20be499357.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-2-_jpg.rf.f29f84f98a947134268e4ada1e01182f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-424-_jpg.rf.f2cb1a0c4a36e9d82fecb19cbeb37e94.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_249_jpg.rf.f2ce8246481fbd20aa5a84af6a67db47.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_296_jpg.rf.f2fa881f2f2540bc002467de85111a9c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-813-_jpg.rf.f2e64e8e92461e373feb56c048fd5102.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +c0500787-800px-wm_jpg.rf.f2de33653efa23047d2b72e763c46b1c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +28-Male-Chinese-Rui-Peng-Ao_jpg.rf.f333112e0ffbd6b5452f6259ef8ae440.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-220-_jpg.rf.f3113acc5572c1de4226bd99097cf99e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-827-_jpg.rf.f2fb3cd5e0a2d4baab08a8229ff88124.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-823-_jpeg_jpg.rf.f31975456bb2006cdd2f98b02de851b2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-154-_jpeg_jpg.rf.f37b273afe9574e765f4a278a6639d4d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +ffdee95b-0c76-493f-8077-a59987a7282d_jpg.rf.f3905a109bca9cc4301f99ac7535c64f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-895-_jpeg_jpg.rf.f3960539245325cb260363c120e9a12e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2091682510_1_jpg.rf.f35a7c6d3ef558804c53ab57b086ea6e.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +oily-87-_jpg.rf.f3c39a772d574caef10586964a8a3ec9.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering-33-_jpg.rf.f3c5dbe77d67d44324845f768919ef37.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_186_jpg.rf.f3d4883fca6e5d22f9f6f38e9f186483.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +58_jpg.rf.f3ad0747378185610708581dd01b41d7.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +134_jpg.rf.f3f9efd4aff73175c81f4ab8a3dec6e5.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-222-_jpeg_jpg.rf.f434edef36bd31a83c5562ddc845d1c7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-281-_jpeg_jpg.rf.f3ecebe7269b026bf715e257fb14a775.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +78_jpg.rf.f44b105c76c2b6cc6a38ab24eb01f91c.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +105_jpg.rf.f47b70bd4b1b7ad3344d40e0d919c3c1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +27-Male-Thai-Chanon-Santinatornkul_jpg.rf.f478950b8b34511879aeeef8aad5837a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_144_jpg.rf.f473c35eb8747e4145f7c32386817682.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +normal-160-_jpg.rf.f4767f8e26e1368c3aa4f3abd3d589aa.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +oily-127-_jpg.rf.f4d9dc412f3e324a5771b8b98b8750fd.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +594_jpg.rf.f4ce6f3ba7f74a539bc4d238256afab8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_550_jpg.rf.f4ae4c557b7d24e326370a09efa77dae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +39_jpg.rf.f498b041904a6321e2ffd405874b0638.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_516_jpg.rf.f4ec5a65dade339d236b34413ce67196.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +275_jpg.rf.f54e6652ea7c555eafe4c33a070c1a1f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +472_jpg.rf.f5320434b24e94bcbcab9a7ffb22b058.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_109_jpg.rf.f4ec5ba68f5822f6618188acf687583b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-412-_jpeg_jpg.rf.f551bcf054d60e2f035f6311ac22fa1a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_135_jpg.rf.f5865e25044c7462e8569aa02b6f7a3d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_28_jpg.rf.f57df2fb36ae10ba3c683470fbb0595a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +right_before_jpg.rf.f592a2ae8a693b28e568668ec1fd1d10.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +levle1_207_jpg.rf.f553c021236e449665e8a655f8444c55.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-2-_png_jpg.rf.f5c63a0258ad994e4a04643d7e4fb170.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_199_jpg.rf.f5a511218e9e812878b6b4640cdca45c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +177_jpg.rf.f5ecba8eff5a6773eb36d69ff6beb201.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-123-_jpeg_jpg.rf.f67f5c62a125dacbf4adb231e4a75012.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_98_jpg.rf.f5ed028c176aca95cee4e1ded2df5278.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Normal300_jpg.rf.f645c29b39942857e1bcd514780c0a2a.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +23-Male-Thai-Koraphat-Lamnoi_jpg.rf.f689429b7a07a1e91fb63c26bbb754c9.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-637-_jpeg_jpg.rf.f69007094ce52a46784b708b31331a31.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-506-_jpeg_jpg.rf.f693d920901fbd56ec76b7318114f749.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-165-_jpeg_jpg.rf.f682c12cca4992bff2804cf662707203.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-14-_jpeg_jpg.rf.f6c6244a41382864660116f707918f4d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +30_jpeg_jpg.rf.f6c89b01905d1992e392be84bba46728.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-342-_jpeg_jpg.rf.f6d20215adc917ef2a31d72cbfdc832b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +40-Male-Chinese-Wei-Guang-Gao_jpg.rf.f6bd40ff6b048fee461effb52198d9b1.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_154_jpg.rf.f6ebd21f6726283f4cfd6b2f39b53db3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +269_jpg.rf.f6d429894ba8c497256f34c5f58257f5.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-839-_jpeg_jpg.rf.f74549be978bc1b872eb185953986725.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1979d8a0484daed8c4e98ee0581f447f_jpg.rf.f5cf4ead1adbb2318463271ebf8e5ed8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-167-_jpg.rf.f75b5185571e63828cb89f8c629a1953.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-126-_jpg.rf.f788b2f717675550e3e9f6343b272229.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_98_jpeg_jpg.rf.f792c6aa84331f6af2e6d6ace34af400.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +oily-33-_jpg.rf.f75eb77eed4f77db9f7edd12b17ad14c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_65_jpg.rf.f75cf6e1441021be458a87fef6c5fb70.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_150_jpg.rf.f7af5c13e6e316c65dc63047303ee249.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-249-_jpeg_jpg.rf.f7ac52dd693dbfea8234ea9396ff8189.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_319_jpg.rf.f7cf5ed2d20c004130908c1abab6ca3c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-5-_jpg.rf.f7bde189ce9e3df9a187f8f5d11a799d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-409-_jpg.rf.f7dc1d785138cf60ad1733ba21b0ba25.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +402_jpg.rf.f8107c66a2d57151b5b8dd9f60ac6366.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_405_jpg.rf.f7f56a959dd22b13c42fe917349f0696.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_317_jpg.rf.f842f9d5cba23e0038b937d5cbb611bb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-66-_jpg.rf.f8527921af02641996edd4c014179172.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-313-_jpg.rf.f856c6e48b5b3fc860ad982e9bf1174b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +bfbf39c0-0a38-4ed9-8520-8026dc5ad63f_jpg.rf.f85ad41803a0525c5b3d8ab1a4363017.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily-278-_jpg.rf.f86f7961140eb532dc5a59b636939498.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +7cdc340e-1748-45ef-8cbe-38390f2380ef_jpg.rf.f88754b6c7da4fd4e4b9b2ae17a4b065.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +191_jpg.rf.f8777682f9a9d47d2598539ff986b1f1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +125_jpg.rf.f88cd89109247108760b4d3b29aa6ddc.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-650-_jpg.rf.f89e1547a58ee787d03fa6b1054bfd0a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-13-_jpg.rf.f8bcc1db80ef631608ec8d1cfab0db0b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily21_jpg.rf.f8fa45416e273f8456ed215456a0d62a.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +5_jpg.rf.f8d566e378301854bfc318ecdf70559c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +590_jpg.rf.f8caed8d28d96e717f5decdbfaf3a345.jpg, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1 +levle0_403_jpg.rf.f91135fc6be71786866933ee49b0a102.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-87-_jpg.rf.f933ba15bfde399eda8645a227522765.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-48-_jpg.rf.f934699184c22d45f7540b31b31173e0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering__-105-_jpg.rf.f9436b937c17a4d51e60ecf5b0298e88.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +254_jpg.rf.f9837ca4c5b22356c39a9e52b93be7de.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1 +levle1_528_jpg.rf.f9631ff205add0a63b8322a2e1b2cbf8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-140-_jpeg_jpg.rf.f97af77258f55a8d5a19e2f39601e3cc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-386-_jpg.rf.f99b495bcc4f4656d038caf5e716f778.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +468_jpg.rf.f9a0b86d37296859cdb9c035d04e1fdc.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +Image_59_jpg.rf.f9ae90ef2cbfb5075a428740749c325d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_140_jpeg_jpg.rf.f9c877ca043ed78eef1dec5cd94f6e26.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +102_jpg.rf.f9b6cd82fda9f1b1fa1acf72665da806.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-429-_jpeg_jpg.rf.f9ba400e4930caca33a2c7c1cda5399d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-116-_jpg.rf.f9de1b03f32d474709979bb18ed0ad5d.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +30-Female-Thai-Urassaya-Sperbund_jpg.rf.f9e89c6a34b2a0e7dc3627aa4d095765.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-613-_jpeg_jpg.rf.f9f07508b3285538008c50088d4561fb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_35_jpg.rf.fa17131c4b55e41f07f9bdc74cfe096c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +406_jpg.rf.f9f487126a5f834a92549c8ee8e7bc70.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +136_jpg.rf.fa250f47026172483ffe68a060704cfe.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-378-_jpeg_jpg.rf.fa35ca25212fc97970de455397a20fe2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_105_jpg.rf.fa8067020f2b3429b725634ae190236d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +11_jpeg_jpg.rf.fa6cbe1fd4ef0bb047976607ec180fcd.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_88_jpg.rf.faa9e1637267559ffaaee6ee3b3d55c3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1a331685-ed23-4320-a382-5b08f46fdc56_jpg.rf.fa9a0a7cc27a84c462572506c63f16a7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-705-_jpeg_jpg.rf.fabd236e440014facba8e0d646b14136.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_421_jpg.rf.facf5f2414cffe681f5e20d1f3920ea6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_175_jpg.rf.fac039417072891f87c406d92509b5c8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-49-_jpg.rf.fad3cb63408653e1d39f4cb96bd7b15b.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +2720297592_1_jpg.rf.faf83374bd764377e352963fd2657751.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +kering_-150-_jpg.rf.fb3c160300f8b370b34157f6a2263eef.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +32-Male-South-Korean-Hyung-Sik-Park_jpg.rf.fb4a6fa8bf054e945d5eb2bf9cc4c3a0.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-897-_jpeg_jpg.rf.fb1a9aca4c650440718efaa43ac589ec.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_69_jpg.rf.fb42263fa86d4d2257f1a65b1fc0e70c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +217_jpg.rf.fb4dc0b7b6dfaaabee3dd78e7c73c750.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-779-_jpeg_jpg.rf.fb4f14712efad6e504cc877389b8f64f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_125_png_jpg.rf.fb62ade23648e243bce6e0f90beb9aa2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak189_jpg.rf.fb5e25efcc48adfcb82efcc503751f27.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-139-_jpeg_jpg.rf.fb9156938ad003fdd34c69379a766ffa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +130_jpg.rf.fc0d7c485f2684b516112b3bb3304c3e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +186_jpg.rf.fc064e5a3b3a2dfd9e794b902631d5e1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +115_jpg.rf.fc135c6d4601ac4b322a6c065e7905a1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-459-_jpeg_jpg.rf.fc2907d9ddabeff8d413566fcd74df57.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-39-_jpeg_jpg.rf.fc37b92c8ecd7783ce41b2a671a5d0e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Rosacea-eritematosa-iniziale_jpg.rf.fc39f71fdf16420b8f42cca4e32f4a4f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +45_jpg.rf.fc526d0a888ebdbbb91264f51605aca7.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_597_jpg.rf.fca336e7cbce5abf27749e4f7527b0fb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-548-_jpeg_jpg.rf.fcaffdb72648955b56e02492e0a463b2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +76_jpg.rf.fc59e0ade674b354ca5095d96489d43e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle2_147_jpg.rf.fcb72df6005e9a17bb0665193a91deed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +140_jpg.rf.fcf1d7ddf2c4440dc9cd2a550892fd81.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +52_jpg.rf.fcbc3c51185307739caf5d739f7537c5.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +98_jpg.rf.fce3fc0bdbd9494652536690c4014e3a.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +344ed90a-2401-45b0-b48a-d023138260dd_jpg.rf.fcc233b16a6a48ce619ed49aaa61959d.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering_-74-_jpg.rf.fcfa9c56fadb5ff1f04da61a60251017.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-108-_jpg.rf.fd0bf10342f26ceffaf3f5e2b1e4e324.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_424_jpg.rf.fd0d101ef3f2d4017217a1dd92d89765.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak-4_jpg.rf.fd3269e199f7b495b749404299215f55.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-451-_jpeg_jpg.rf.fd2d00581af2fcec32767f2b116977f1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_449_jpg.rf.fd8e7f293e0d73670cef651ca281309b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +222_jpg.rf.fd801433e161bb1a6a24b824ee8623ed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-707-_jpg.rf.fd4efdc51d7cdf1bdedeb5f69925942d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_166_jpg.rf.fd916609d5a91c1beed18e3d3eb270e5.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-1-_jpeg_jpg.rf.fdab1650b4418f5769789db8647731a1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +136_jpg.rf.fdb578f89d538967c37ffe9beb424e7c.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Image_47_jpg.rf.fdf584abf579ce9f6effa9d58227d7be.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_55_jpg.rf.fe1620d95cb02e3d64bed604c7174009.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_488_jpg.rf.fdbdb4aa8dfe2c7166db28fd70e00ae8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-36-_png_jpg.rf.fdd61be8a769495f5e058fccbfae99a1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +446_jpg.rf.fe1028f777656eb550afde7a51e0ceca.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +85_jpg.rf.fe305acaa8e19a53f01a1e4be0b729ae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +305_jpg.rf.fe711a7de72f62244349d7f1f4705beb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2200730806_1_jpg.rf.fe721802f9bd6425cb1218f569a62648.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_429_jpg.rf.fe6cc36b139b0db09a106e45ca41b437.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_399_jpg.rf.fe78812d612e553c852141f8188958e7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-668-_jpeg_jpg.rf.fe8b40da07b70709f5d10a88a288e6e4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_308_jpg.rf.fefbeb7c3ec6dbf9b7fa192c033c64b4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-601-_jpeg_jpg.rf.fe9d6105279d6cf7c5be1190ede0bb97.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-689-_jpg.rf.ff0c4b27dd44586b31e1afa8988b75ba.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Img_82_jpg.rf.ff0bc2b0ce7b8890a0bbdf17293afacc.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +522_jpg.rf.ff10aed95a4dfafab81474cdc1f3a46f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-462-_jpg.rf.fefe670cbe5d89d06718bf55af33986f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +500_jpg.rf.ff2d52b79591c632f2128f79746f3bf8.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Image_269_jpg.rf.ff3b533e6e835991b2ca0cf41b96f19e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering_-54-_jpg.rf.ff384f732d3ce7a260fdda166735d8c6.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +71_jpg.rf.ff35d43d10d0186f2f72fc5cf47b77ff.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +628_jpg.rf.ff62db6867722e687637c576ade9f478.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +levle1_249_jpg.rf.ff3cc9df72b0c38f721e8d5291274fed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +148_jpg.rf.ff4f10a00a97568aed253b767272df7f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-457-_jpeg_jpg.rf.ff61461caf0ac906f91bc72ead94f000.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-137-_jpeg_jpg.rf.ffa263779773890dd57382dd0f45e189.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-38-_png_jpg.rf.ff7bc89d24d5b0ea31f6eccd63b05a4e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_504_jpg.rf.ff6861d4206a82f98d98dd72632922bc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +352_jpg.rf.ffa569c3a6103480b9ed38236590d3af.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +d01572b7-9a7c-41f9-866e-b6e134320f34_jpg.rf.ffc241bd93dbe2e2a5c9a92b5d45aae7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_147_jpg.rf.ffc844ff4fcd7cdf0cde220575cd3df7.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Image_57_jpg.rf.ffdbdd128175b5ff57275753f81101d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +593_jpg.rf.ffdd8d0dd1708ff152581d7e3013eb4c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +how-to-get-rid-of-redness-on-face-feat_jpg.rf.ffe06b4db065e58a077e268175b27191.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +24-Female-South-Korean-Yoo-Jung-Kim_jpg.rf.fff493623ebbe7da317f331c61d3540f.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +238_jpg.rf.ffe1cb49e67a13af11c0cfc382195015.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +66_jpg.rf.c0977a85add97eac09a93a5e64b9be59.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_270_jpg.rf.c069e41f97afac1ca29d94316671b710.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_503_jpg.rf.c08b1a99c21923bed327d350598500d2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +8758c7fb-97b7-4a27-81a2-4283d5a7badf_jpg.rf.c0a78736a8f44b761573c25ac4f58b82.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-318-_jpg.rf.c0ece34b2055120824865c83bb5b5900.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_290_jpg.rf.c09d0d5f857b7136f557856ccc806cf5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +90000433-07eb-4170-b8bc-07e267030ced_jpg.rf.bfc48e2d6c9e7312986fb4caa3b03269.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_21_jpg.rf.c05148958e4e5efa889683175822841d.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_216_jpg.rf.c0e6a5dfb2f844b42ae5876fcfdaa1da.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily25_jpg.rf.c015c5c54847b0f077dd0cda1a0367d0.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +65_jpg.rf.c0a26e949f4618d8d5dd775d12be1324.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-628-_jpeg_jpg.rf.c0b688f03819d93dd1b195718829f022.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-27-_jpg.rf.c0ded4ccf31b5401fbfdccfc74766657.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily52_jpg.rf.c0117739abe011a71bf1b535559ea831.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_485_jpg.rf.c0ca5aafd2a4e719465ab2d51a029555.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Female-Thai-Mookda-Narinrak_jpg.rf.c023dbad626211125f21327d016891ca.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +2906ef14-4042-451e-a639-58110a02c0aa_jpg.rf.c0faec46269d4f65dbe26087e2ea12e8.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-755-_jpeg_jpg.rf.c00f38763b92dc071842724c548c3f38.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_139_jpg.rf.c0037a7cfa73d797aa6198f0ea404283.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +240_F_203088174_Y10HdNxqyBbEFTBctiIDfMBQNO8Ze3p2_jpg.rf.c07e46bea4b7128f665a01fd1df8e539.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Image_7_jpeg_jpg.rf.c0d498176d95ac4f05b03f4022033c98.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_185_jpg.rf.c0fce7923429449fe5b3dd9411d9f69b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-486-_jpeg_jpg.rf.c0dfd4391535222f8e744726e7a2169b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +276_jpg.rf.c12a0070370b56899b203bffe0bb8bcf.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering-31-_jpg.rf.c132cf22f621a238954b81c43e8519da.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-427-_jpg.rf.c1019d039abc0449f59d6de7dc78f71e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +34_jpeg_jpg.rf.c0680a60b7351cce1e9294838b249630.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +52_jpg.rf.c12bf081320c4248d455df6a7f9243e5.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-54-_jpeg_jpg.rf.c00cf0a1a4a23fb6a584e02435849353.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +433679f8-b278-4787-9f44-b5ad8dde11da_jpg.rf.c13bfc0e79ae8d3bdf14fe1c60ee7a03.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-136-_jpg.rf.c192e07421af335324b89110516394f1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-480-_jpg.rf.c184a2282bcdfbdd9f865547dd3567ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-111-_jpg.rf.c163e3e388c15f5450dc2640c1ccd480.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-807-_jpg.rf.c1978be15269be624f24d402bab85567.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +302dfc97-3056-4940-8493-c14c37825f4d_jpg.rf.c19c569e7118a1c6f4bc9716546cd72a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_177_jpg.rf.c19c78b2e2f5423ee148cb6bffa7f74c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-7-_jpg.rf.c1d6943d5820e4b9abb734b240d15fab.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +32-Male-Thai-Chonlathit-Yodpratum_jpg.rf.c1e45abe85d3aabce53402196bd03755.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-484-_jpeg_jpg.rf.c205dd456dccf57733b7cf1e2386b279.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +32d2d89f-a727-4c29-a43b-5560002d2387_jpg.rf.c1e1fdc643ac1e205b24d40e7cec0f73.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +649_jpg.rf.c2602c63e0664c9f5f3c9520618c05cc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_422_jpg.rf.c26e0e30ca79eac1a01e575f936df2da.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-175-_jpg.rf.c26b145eb80dfaa43b220d6cd78e1518.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +158_jpg.rf.c291adf6f1b43037564ed5510f269ce8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +666_jpg.rf.c29857d0c90aa665447100df46fdff6c.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Kering-12-_jpeg_jpg.rf.c2b46b4ca42360d93753f3a2492328a1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Berminyak9_jpg.rf.c2d9eb41efe1ac0c0f69cd638dbc4776.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_417_png_jpg.rf.c2d25b5af641a85a511d49518c67f5fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-65-_jpg.rf.c2b91ae26a9ec2ac46a77f70068a2e42.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-780-_jpg.rf.c2eec6ba8783129be407b2d1ec297624.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +36_jpg.rf.c2f993746e160190a5df4ee9500b8147.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_244_jpg.rf.c2e296379a511b659624a621f6dbc7a5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-147-_jpeg_jpg.rf.c2ff7049eb0a7d76763742c1a745257c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_512_jpg.rf.c300a32dff3f6cfd450943d50c6f0106.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-147-_jpg.rf.c30aa786a42fa8600d7384fb8bc3a922.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-518-_jpeg_jpg.rf.c370aadb959a8a70e31fc359fd3c87d8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +GettyImages-546459592-5b215d8996c74956bc733aa9f6dd21a8_jpg.rf.c3143ba8e23e0fac693f00fadb78d2ed.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-725-_jpg.rf.c3734f7053bd2200bfc14e51bc6c64cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +089c8f6d-60da-46db-931e-9812d35b3599_jpg.rf.c37f32d2a801374c8b53fe1a49444c0f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-92-_jpg.rf.c370ca1203043b6aab7315d276d7fbd2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-388-_jpg.rf.c3831b7c42b2da224e714bbe255a2133.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-284-_jpeg_jpg.rf.c3b7048e853656639d33a9e8a55adfd2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-195-_jpg.rf.c38ef1fc8e5afc188698ad68dcc28a55.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +87_jpg.rf.c3a3f336a1b13f6b25b95edf4006d43c.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +levle1_276_jpg.rf.c3e0b4245928ba1e349340fa4ed2f339.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_76_jpg.rf.c3eb2f15b7f4f68a8e19bbcb79cf5643.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +30_jpg.rf.c400f2a133d5b79a05060f9753942d58.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +42-Female-Thai-Araya-Alberta-Hargate_jpg.rf.c3fb68f0d071e876652bf6a174405205.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +361_jpg.rf.c457273d6f8273be4a57feab190416bd.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +berminyak__-52-_JPG_jpg.rf.c48c6fa118660e9162b58fda74437bb8.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-364-_jpg.rf.c46578b862502b6a137ea98246db5a4a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_65_jpg.rf.c48eb0f898398025c89b3d1351d59737.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-35-_jpg.rf.c4611d56093d2db7274d36ba49d25e33.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-46-_jpg.rf.c4b9db666bf4e7e788b6c73be757e62d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_564_png_jpg.rf.c4b1b1d8551f2a00594dbd3a484a1bd2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26_jpg.rf.c4cf403d327ee79f2194cb17e143ae8c.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-219-_jpg.rf.c51c61ed9c1b48a8935ebd1831f19d6f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +104074861_1_jpg.rf.c4cc8b4846d141420cf6fb1ef05ad6bb.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +138_jpg.rf.c504320e85d0978eac7dc76e2dd3a3c9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +35-Female-Chinese-Tian-Jing_jpg.rf.c52cecb3654bce914efa2212936e9c11.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_404_jpg.rf.c52d29f330e99b91e4ac9b67ce54c10d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-157-_jpeg_jpg.rf.c54306d5a273713e8e93a4226b7ac49a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-115-_jpg.rf.c56c0dc11b7886044e627f9591fe9e73.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +2423457666_1_jpg.rf.c55f3dabb06848801ab0d935ea1d61ca.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +oily34_jpg.rf.c5815faa158dad46fd276129b81ce732.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_266_jpg.rf.c62b8a0e988f82894c66636ea0543d3a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-52-_jpg.rf.c6173912671b7e9029392adff6515299.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-824-_jpg.rf.c5a4cc2331695509df0fc24b1fe2a44c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +7_jpg.rf.c64013b55943a264b33d74993d424a82.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Berminyak279_jpg.rf.c5a476936ec81b7954c9df8d6894203d.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +572e21f5-c1e3-40a5-b90a-ef122a2c898f_jpg.rf.c64b500471ba770ce7f1d574e38e0646.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_302_jpg.rf.c64966ad4bbe0f8d9c47a43025f93071.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-307-_jpg.rf.c6529a7210315d545eb259a97073a31c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2450984791_1_jpg.rf.c68acbe135ff406d80fa2f9a5a2630c9.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +132_jpg.rf.c659bed6906ad98a8fda6490e6ab7c45.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_180_jpg.rf.c69f75483b50f119a42cb22bfd72bf15.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +357_jpg.rf.c6a5d274eef871e0dea006dba5f7e4ec.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +1129801707_1_jpg.rf.c6a9ee7617d476def31a99e6156e18c5.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-560-_jpeg_jpg.rf.c6d2c3083d5430d3dea63e934f2e6048.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +14_jpg.rf.c6a7b580b6a02f2ba8b9f9c5c2201810.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +311_jpg.rf.c70a2c75e1437e3844ac07632457f79d.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_110_jpg.rf.c7107a038b18f885c54def570bac947f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-11-_jpg.rf.c73287d4936796f3c42e6c9ab0f1bb4b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_614_jpg.rf.c78402b5dbac2718be2e654659b5b9ab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_555_jpg.rf.c7678309282ac6b113e16570fb6e4479.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-141-_jpeg_jpg.rf.c79e8f7bfac9360eef9c5a8a3924ab68.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +aea4383c-8a9d-442c-9636-2e5d415458f2_jpg.rf.c78a4550ccb720bf582e1f4efbc99470.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-565-_jpeg_jpg.rf.c7bdbcc32f976a285d734de4e44269fa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2e81a0f1-fe62-4ccc-8ddb-409bd965277c_jpg.rf.c7d15fc16150d97db678db555d6b52c3.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +562_jpg.rf.c7ce9b028fb1db4c965af2769f518d07.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_163_jpg.rf.c7db94d26a703b59c55c3d935f558088.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-830-_jpg.rf.c7daae2f18d0596c81b8d0037b495dac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_104_jpg.rf.c7eac3abec70ba1d4763a66271a0826a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_129_jpg.rf.c80f65796996e09c99aae67e74add132.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-243-_jpg.rf.c820700cb7a1203142087869d10bcb5f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_614_png_jpg.rf.c7fdf30f13a3693566e804ffe27813c0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +398_jpg.rf.c810b7103946e84c0139a33749bd4ab5.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-802-_jpeg_jpg.rf.c83e68a3885e65d14941aabf143667a4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-568-_jpeg_jpg.rf.c83b419746df7d03094c7fa6ceaa9437.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +323_jpg.rf.c8338fac84e940febaea41f02c7567d6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_491_jpg.rf.c83e6c6c3136ebb083a2ab82ea7664fa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-830-_jpeg_jpg.rf.c84e876c3f5f25af36e39464068b5faa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +_1869624823_jpg.rf.c8610b95bf7e52717a4cae51cccecd83.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +levle1_156_jpg.rf.c8870d6da02158ad6dd1f3e4baf9d0d9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-42-_jpg.rf.c88d2c426bafe62c14208a639e15006d.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +31-Female-Thai-Pimchanok-Luevisadpaibul_jpg.rf.c90651f2cbc5cf2bfc26c854a02de724.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_112_jpg.rf.c9118d68f8baf7935cf05cb087df4a08.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_425_jpg.rf.c8cfd06f7834c7df7151f8b132e10a8c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-828-_jpeg_jpg.rf.c8ee6b1d5d159f700bf365e292aa00b9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_308_jpg.rf.c98af7de27c4fa30d6f1590d5c8b0250.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_150_jpg.rf.c973f41fa39adab9f64c08db31494e95.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-817-_jpeg_jpg.rf.c935e63b55a5dc9bcdb5396eae3da384.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-112-_jpg.rf.c93ee981de6b2c9b8c57f0c2c04e1441.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +23-Male-Thai-Wachirawit-Ruangwiwat_jpg.rf.c9ad25ebb63d4ac31b7df2cf975a0c0b.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +399_jpg.rf.c9db35357c3f9da4fbd2bade25d583e4.jpg, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0 +acne-703-_jpg.rf.c9c42b2ff1305e3e873af393c68c0897.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-262-_jpeg_jpg.rf.c98e0b5d0f3899366a61778cac227371.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-273-_jpeg_jpg.rf.c9e255e0944e66ae8475992b4fb2431b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2b7d3151-a306-4590-9687-517b41d8e03a_jpg.rf.ca422951676be06127e220ba7b441e01.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily14_jpg.rf.ca093e584e03d006352f813b64c3916f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-773-_jpeg_jpg.rf.ca5bb8f32bf45de08d2bb52b18d788a5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +591_jpg.rf.c9dd1912ad1312a9ff565aa2e7c216d1.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +dry16_jpg.rf.ca4aaf737face807b9ad5e2414eb8466.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering__-16-_JPG_jpg.rf.ca68bd942382e1e5d499822eba284259.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +dry-skin_61_jpeg_jpg.rf.ca73c4249f1a8111911d1edf8c08a17d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-416-_jpeg_jpg.rf.caa8fd28fc8bc305f7765b556334dd43.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-19-_jpeg_jpg.rf.cac6d0dbf81626ace990f9aff94fcbc1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Screenshot-2023-03-29-145440_png_jpg.rf.caccb37ec7dbc3bdf92e39ddb4a51141.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-805-_jpg.rf.cab82fa26a22bca8a4ca81b2fca1cbb9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +IMG_6807_JPG_jpg.rf.caccbc1b10b14178d5606ac927a2c7e3.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +88_jpg.rf.cacdabe2a376b7b23c384816aabe016c.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_170_jpeg_jpg.rf.cb09249a74e613aff7f2496ef463480c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_122_png_jpg.rf.caf155dcbf6721b73e5623a4a989b29a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-125-_jpg.rf.cacfb6d3d59ed54ca8fbd5a285246718.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_242_jpg.rf.caf7c4433a9806897c31e40018dbfd95.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-713-_jpg.rf.cb364ae915a4d24764d03f4cb5b8cf4f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_257_jpg.rf.cb65c8e993d205284f37eee76fe65114.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_143_jpg.rf.cb93fd001047dccce8899ae0bcc6012d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-707-_jpeg_jpg.rf.cba42a0075bb0cdd574c8903586edb39.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-10-_png_jpg.rf.cb6a9a31417195abe6aec046d576ce3a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_218_jpg.rf.cb9e941378d718fc347cfcf466ce4f00.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-290-_jpg.rf.cba86b474a561ccbd8cbcf4c094d93b0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Female-Thai-Ployshompoo-Supasap_jpg.rf.cbd69b20b5d219eb1f395af46cd59b74.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +23-Male-Chinese-Lei-Wu_jpg.rf.cbaeb00459e33ea950c85454521b41f9.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-205-_jpg.rf.cbd27bb65ae7f8c38a4b2fee8d0124ef.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-25-_jpg.rf.cbdb8099e82b99ff89ac5d65d89f585d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-623-_jpeg_jpg.rf.cc045f6dd17c8120aa5007a9e034c41c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_421_jpg.rf.cbe14f9296968a9024fa6af6999188f4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_498_jpg.rf.cc08960b127a00750926a90c7fada9a2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +316_jpg.rf.cc388b51320a7b3f9487b3090d6d30bd.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1 +24-Male-South-Korean-Solomon-Park_jpg.rf.cc53e34eaf4d8c705ee9b5e7760b26d6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-507-_jpg.rf.cc0f46267ab9bb0ca78d4c05df7ea8f9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-526-_jpg.rf.cc31ea608151e83b1bf1077107e494b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_168_jpg.rf.cc57bc99545ce580c2eb84ad3bb10cbb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_69_jpg.rf.cc542c80fc184caa4a5d1e072938405a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-598-_jpg.rf.cc5d0cdb7344ec4ce305b1305256378e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-749-_jpg.rf.cc7d388a1c8be20233da581b4b5b92fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +331_jpg.rf.cca60dc402f03eb8debe7baf13fdbb25.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +berminyak__-21-_jpg.rf.cc617e9b2629c9dd356491a099c37d5a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_334_jpg.rf.ccb86cb1d3b6dc83366d53cf97d5d45c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +a64232a8-9f63-4c30-9bde-faff7e1d8624_jpg.rf.cc9b315f66c13b112eae7b381f696b61.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_433_png_jpg.rf.cd1120252a6a4844f96635e770511c1b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +74_jpg.rf.cd1280fc8a985c9a953bae65f87ab7fe.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +492_jpg.rf.cd1bd58813b4ef9de1de8285f75c8fc0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-59-_jpg.rf.ccd949543d23b41edf826a42fde3f81a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-699-_jpg.rf.cd73f6a52a16ae478ccfd07352e56d5c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-529-_jpeg_jpg.rf.cdcec35df490fe5594cf167f4f60f8f9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +146_jpg.rf.cd8c7c131135ec8078d8a73d3a7aa839.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +acne-258-_jpeg_jpg.rf.cd918b5468acf6a5279088af84b6310f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-68-_jpg.rf.cd3fa9c84fd412a02ae40efce2f8cec8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +27_jpg.rf.cdb2cb26c330d27f9cb6880a597c260b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +berminyak__-20-_jpg.rf.cdf9d199f8b98be5c04cc89b06552f5c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +323_jpg.rf.cd975ceb6aafe4b7797d6f198ff18d6f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering_-111-_jpg.rf.ce23ef779ae9754c0249aa06615611ae.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily5_jpg.rf.ce1920fd0a07869cfd64058964d1c860.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +67_jpg.rf.ce0c2349c0c7454e2533742b67c634c0.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_436_jpg.rf.ce2ef3431bcf4cd48e7c5143c60ed5ba.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_281_jpg.rf.ce5148bf3377d5643f47d559fc47346d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_465_jpg.rf.ce5b1aee7d89113fc16bc7b37393ea37.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-144-_jpeg_jpg.rf.ce6826e0ce34243241a88738a71fbc38.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +23a01088b97c930c8fa8c6781a4c2f0d_jpg.rf.cec1a1996c5b19524cd8fb6fbdcb22ab.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-888-_jpeg_jpg.rf.ce936d0338e8e4917abc84590211b3a1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-825-_jpeg_jpg.rf.cea78635baf6ac7c4c0c74149e443f23.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_586_jpg.rf.ceca7cc8d1103f9674c45d624a2537b4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_85_jpg.rf.ceca86b8f55ffee4e84a682d87fd864e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_126_jpg.rf.ceeb957cd96dc8976322ddbbe6450949.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_371_jpg.rf.cf1986c30d46323a577a2bd79813bf86.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-242-_jpg.rf.cecffc580135521ec3d5eaa4375befc4.jpg, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0 +240_F_175181761_NaukWVZ1iZTbDytJzuiQqN5Y2Xc0zZ2C_jpg.rf.cf398b651473c1ed4f082e1ef8ac9786.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-845-_jpg.rf.cf5779739cf5e29ac1eed8fb7886f2fc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_181_jpg.rf.ce7edf58feaf41ffd32e184ede7d2f40.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +a4ae6746-e35e-44b3-a000-6d21a0b8c26e_jpg.rf.cf79df8f530b0075fd8280a7b0c9b79f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_420_jpg.rf.cf7270fffbe3bb6ec889566d742c89af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +133_jpg.rf.cf8952c2186bbe221bc58db1d8fc94a6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_505_jpg.rf.cf9f5460731c384bb54725ec07106bf6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-31-_jpg.rf.cf8a5fce484e24dde273a447cd0e9848.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +657_jpg.rf.cf9df4d2846d363a8e362db86f7f0b54.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +af511d5a-491e-4e16-8d16-197455613fdf_jpg.rf.cfaa5c1f29d516c0afb4a01d4a50fe44.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +25-Male-Thai-Kunlatorn-Chivaaree_jpg.rf.cfddfc4ab6f6519bd34a0ddfca034088.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +171_jpg.rf.d00938a80653e2098c56841effb633d1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +7d332dcd-6ae9-4947-8437-f5c512b3bc34_jpg.rf.d03461ddcb046d884c5e5772bd78f0c6.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily-229-_jpg.rf.d041f00d0f4877069e89f39ac285a979.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-868-_jpeg_jpg.rf.d0b7d787e7183e104b6b4ce130e05da6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-792-_jpeg_jpg.rf.d05553aa9ece9ee864458445c9c12845.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +50_jpg.rf.d0576355d9b81f90cd734481c9a6d0c3.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +166_jpg.rf.d0656ce4eaa032ddbb3bab3ed85db99b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +457_jpg.rf.d0a40b1673305bbc7008369bf3d2dbe9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-642-_jpg.rf.d0d72edbff6f9ea9a1cc222d6055928d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-353-_jpg.rf.d0abcf64a975b50dfc1b9be3da4dec02.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-547-_jpg.rf.d127facc7ba3944815e3623932afb871.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-627-_jpg.rf.d0da205399db1de37b407e15c8d8f507.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_348_jpg.rf.d0d95b5aec98e3d04c643a979fcbeb4e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-119-_jpg.rf.d115cb141a68229200223f0264f8e243.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_541_jpg.rf.d161da5a6e31e1274823eda485841a93.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +625_jpg.rf.d15d4db2a49a5c7a306d5cd5897df565.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +acne-856-_jpeg_jpg.rf.d13017fa662bd9085de55753ea82d976.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-52-_jpg.rf.d17c2ec110d3d016400de3d0ede39acd.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-38-_jpg.rf.d1c9b4bfc1e5292815a4c13fbd759f73.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +85_jpg.rf.d209f1f516361bc44b22374ae2011230.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +561_jpg.rf.d181e2483e9a8c8e393014d7151b2281.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +596_jpg.rf.d1c7fad1aff66196b9908e1f28e8e539.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_223_png_jpg.rf.d2425aa07bbb9f3f7040a4f515683842.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +55-Male-Thai-Saharat-Sangkapricha_jpg.rf.d22f42752b6eb3c2a1f15022c9d67279.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-453-_jpg.rf.d236972cf217bd11afd41453c970b8f0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +55_jpg.rf.d2705c2c42daa2bc182bdabc36e820a0.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +349_jpg.rf.d2788ff30f3fbee035a1ff049b811413.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +52_jpg.rf.d248eb731ee4368b6f71efa05f7a3e89.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +13_jpg.rf.d2771b32f025085465fc88c98942a1a1.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak87_jpg.rf.d27aff33b5cd253bd726e5cc11c8c666.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering_-34-_jpg.rf.d27e3e420558894d581fcc3599d6a76e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-77-_jpeg_jpg.rf.d281d759d933a6c07230358ab0b6a955.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +255_jpg.rf.d2b63a2ebdd2e5e53ac2d576955405d2.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +Image_77_jpg.rf.d2c081a89d475f7252f9525a08efc638.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_336_jpg.rf.d289746d51366ae1614e9206afd97d32.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_166_jpeg_jpg.rf.d32b93fc3aa44a0ce9db1ea6afc16766.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-650-_jpeg_jpg.rf.d2cb1d37a3d1bc85379676668c219b9b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-150-_jpg.rf.d33493541ba7a27271aa86ab9fd2c684.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +67_jpg.rf.d345e95601cf0925d533ca29819596ad.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-293-_jpg.rf.d358eb64f715e875af5fe7a3a47db641.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-134-_jpg.rf.d39aea7f977240f41fa664e470af4706.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_207_jpg.rf.d35c5973a233aa3cde2037c5fcd55601.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-2-_jpg.rf.d3acc6cdcb6cc89cb52f49331cd3bc89.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-374-_jpeg_jpg.rf.d3bea94a3b91f0599b5a93bb252885fc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +da-mat-noi-man-do-khong-ngua-do-di-ung_jpg.rf.d3cb6772d931b34bc08937016ce19201.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-77-_jpg.rf.d3b93cb3f7a89f0853fe7ed9ff7da7ff.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_128_jpg.rf.d3ddf8d2bc5eaca52a688073e7aebd32.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_44_jpg.rf.d3d36ccbc7f8681eca9a05b01ff732cc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-903-_jpeg_jpg.rf.d3f407b2af5ce8e88f45ca679dfafa49.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_176_jpg.rf.d459942f0ecc85c98e09956795bc50fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +33-Female-Chinese-Yi-Tong-Li_jpg.rf.d47e1a83f9f348a4cda2f7e98c33c263.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +340_jpg.rf.d4a7fcccf9810ce22a4a81450dfb55a2.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1 +kering-44-_jpg.rf.d4ae3acf661e75aebe5420501a995271.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-25-_png_jpg.rf.d3f5193634caa6b024a8d6452c9ff8a7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_345_jpg.rf.d4c6b33c0c75d7ba54643e827de5391d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +f217bee4-a473-4ae8-9247-b8b1b7204a42_jpg.rf.d52828bc298220bfc881471e73cbe749.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_585_jpg.rf.d4b789c6a017bcfc7c38c974fb085de5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_469_jpg.rf.d535dfdf4944a4a6b15499b57bf3f57d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-93-_jpeg_jpg.rf.d56445a4bc26cbbfed316365ca083e8a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-770-_jpeg_jpg.rf.d5536b4a3b659eb2e38a95e94bf45f41.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-495-_jpg.rf.d50bdcf0f5db615f1ac0abb715f466d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_556_jpg.rf.d594e5d78c47b2332d1d1094c90f66cc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +84e79edd-919d-46a4-9fc7-1c587e418fb9_jpg.rf.d601b45538fc9b3efe8cfbcba9330865.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +253_jpeg_jpg.rf.d5db055f6bc1586fe83d158f1407b5b8.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_42_jpg.rf.d6136961c3a1b8a5a3fce7a66b7f92ac.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +43_jpeg_jpg.rf.d613f883469cd396cb5b14031550235a.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +53_jpg.rf.d5cee5821d71a372534b1ddba573799d.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +25-Male-Chinese-He-Di-Wang_jpg.rf.d61ecca546f2e3f6858c7565e8652ffe.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering__-19-_JPG_jpg.rf.d61c8f89cfa8c531bc4eaa94c815c4c3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-730-_jpg.rf.d632a39cdf130741867b7ab1f17d7f10.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26_jpg.rf.d6439dcce644e3d10cf99c008723c8b4.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +d9caacff-e747-4ba5-9518-0b25ae7be532_jpg.rf.d64eda53b2ca13bc060207bf101e4f7e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-172-_jpg.rf.d63c9cc207b17dd8d8b5d10aececa91e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-528-_jpg.rf.d65c9a8ec78041359e89e94c82cb3b67.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_3_jpg.rf.d6f3250348b248336ab0c2d69ba2f933.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +207_jpg.rf.d6f51604b48db72adc2e730286003291.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering__-43-_jpg.rf.d6c5a5a1b6fbc6fd396d9f2342fee80a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-97-_jpg.rf.d736cc67c5bee9e1b23f70449ca671c3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +30-Female-Chinese-Yan-Ni-Dai_jpg.rf.d73c8411c62cb52c2dea4d12663f3f2d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +22_jpg.rf.d756a001ac4888d7f7f6ef8b43647c65.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +395_jpg.rf.d75ab581bd7af1c21e144fe3c72648c1.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +487_jpg.rf.d73f572858c9e0315d7b8be26640d769.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_381_jpg.rf.d7652ba952e05d6c725c3aae755f93d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1_jpeg_jpg.rf.d780b9aaec65042a5336e9158605be74.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +01F3MMXM63VN6G9WMZPBRX3K7H_jpeg_jpg.rf.d7e19e244dc789cff83d16d4d4768e0b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-520-_jpg.rf.d80b645e47549a18580c61a4c6900250.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-255-_jpg.rf.d82d2bce30b69db10d860ac5b62ceae9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_123_jpg.rf.d8647e16f70525786bda3b57c9f62fc1.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +berminyak__-48-_jpg.rf.d8472183f2a9a1c042a50a85a0c59f6d.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +oily-119-_jpg.rf.d83d9ed092c172c622da505683838309.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-739-_jpg.rf.d88d02ba13d8a9e2d754cacdf229f3a1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +194_jpg.rf.d876024163f4b828f62bd1ec9013408a.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +56_jpg.rf.d8ae6a0dc69c6ef1f2e60e128690f47d.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_182_jpg.rf.d8b58cc94f1414956e71c423b7dc5e79.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily38_jpg.rf.d8e73041a98dd620265bfb0d360575b1.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +365_jpg.rf.d8deed44a21543d43190392a5083b6f0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering_-90-_jpg.rf.d8b6fc803c5a3a1feceafd66a03c0417.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_446_jpg.rf.d8e1dd2ae5d2c1a7e5fdcbfcfe72e55f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +564_jpg.rf.d8fedc6e09409ceeb8ff0188073fbfd0.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering-30-_jpg.rf.d92e53be048b070cef5da05bebde4d65.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +87_jpg.rf.d92a5c2eb57cc4a95f2bf258168da541.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +33-Female-Thai-Fonthip-Watcharatrakul_jpg.rf.d94d312d719b32594b82546f5a2cb504.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-296-_jpg.rf.d955c6287a530b6985ee5b3b78966378.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-57-_jpeg_jpg.rf.d96dfa36e97b62a875ecb901c388ce99.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-687-_jpg.rf.d96a91307bbc4c311f6e2958d268f804.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +345_jpg.rf.d9146419593d23b254016b465934ab8c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-715-_jpeg_jpg.rf.d99e7ad59c68444473d30734ed8dd402.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +204_jpg.rf.d97d57dce1804ee4847b3655817dc5a0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +133_jpg.rf.d9ac8e9c31d70490f9136b642b869b5f.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_48_jpg.rf.d9af50727245504fbc94bc2ca5a47778.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +645_jpg.rf.d9bb478a67ee48f10ec93fb9a6ecc96c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_141_jpg.rf.d9ca3eb0441dc4b2ce6f13449a3b8f65.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-513-_jpg.rf.d9d2365989997688afa44a17d8bafacf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_223_jpg.rf.d9d5327866647b2d2df997ecebb20f9d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-66-_jpg.rf.d9f272b3ebbe6329b463552d2e1e7559.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-113-_jpg.rf.da2883e148f89c05e3066a5c81f05673.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +272_jpg.rf.da18ce7ffce7ac89f7302fe56b981846.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-826-_jpeg_jpg.rf.da37e6c84176c070741f14b1b7d057c2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-410-_jpeg_jpg.rf.da4f5707bd5befd79be1188339d328b3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +220_jpg.rf.da690269bcade5f41c2f8692626ad30d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +39_jpg.rf.da59da4d3df82b57ffb456b72b584593.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_378_jpg.rf.da9dd366079d5b6d507950ff4cb97f19.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-467-_jpg.rf.da6cd8b79b88e4223675538040e85d33.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_557_jpg.rf.da84c088548cf46c308cb61ba563d87b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_95_jpg.rf.daa77f477473dc7815f3773bc0485ccd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_5_jpeg_jpg.rf.da84be4aacc82c18d167d01ec2954752.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle2_132_jpg.rf.dab2433ee34dd941bb283ec62f149a6f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-23-_jpg.rf.dabcff30c570bfb9a2b320060164956e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-271-_jpg.rf.dabf9a924a2226c9a463ccf6092310dc.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-406-_jpg.rf.dad4621355dc88a79f4601b87aa982f7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-849-_jpeg_jpg.rf.dad7dfaface3006b2d66b366506f4846.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_59_jpg.rf.daf5d9dbdec2c70c6377458200a157b9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-204-_jpeg_jpg.rf.dafaa19d9e03ec6a5828556754f39f67.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-119-_jpeg_jpg.rf.daff2cae64c17cc80ce02bcc4c3242a7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-620-_jpg.rf.db0292df1a28ae079570c8e6dfcc4a8f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Male-Thai-Kasidet-Plookphol_jpg.rf.db05cb8e54aece22a03cd7be672f92a9.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +32-Male-Chinese-Zhe-Han-Zhang_jpg.rf.db2cc3f0dd916e8aec253880365ab7a7.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +6bdd3c06-ef12-4245-96fc-6b2258426c4d_jpg.rf.db3a028bf81f003632ca3470269c5b91.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +404_jpg.rf.db47afe7b3838ef61492e11741c4a9c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +346_jpg.rf.db475e726dbdad205d73f8e77df78e0e.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_128_jpg.rf.db6e90b1b45c2345c9c02de5ef48c229.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +241_jpg.rf.db83c0f97663aa15ac6455e5cad87aa0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-skin_38_jpeg_jpg.rf.dbc2c386875572149d12dfec063d2552.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_80_png_jpg.rf.dbec13812e197c69ead634a55ced3a69.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_235_jpg.rf.db8f94a1bc4b2d25ebfd42afe14b1935.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_272_jpg.rf.dbce0feece1967f81d91860fd9b72f31.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-13-_jpeg_jpg.rf.dbf0e5fb201e07b935907b59c9288297.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-201-_jpg.rf.dc15f050712d8814e24e12ea7da006b2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-11-_jpeg_jpg.rf.dc1ba2546b5d1b4bc528993542919633.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +90_jpg.rf.dc2240b90aefdb5e6c231a13b7ba6563.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_39_jpg.rf.dc4e5f1a82888ef2ccd915a1e694d8ef.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-135-_jpg.rf.dc4686bd5a8e300a5a25260b8b982f51.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-151-_jpeg_jpg.rf.dc8a6361ae1c270322ecd0e35d47097b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-26-_jpg.rf.dc321bb8c79f8048fdba9d7ab01d125d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_357_jpg.rf.dd0a491d92362f3d4f009db41fd08219.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-475-_jpeg_jpg.rf.dd170219a47c4603427bd0fe40f6fbbf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-40-_jpeg_jpg.rf.dd3b1dd9a9d2793e4647f554e01b931e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_183_jpeg_jpg.rf.dd4a75c49c3abd99f518dea3f10eea79.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-607-_jpeg_jpg.rf.dd3a1e4d83c0048e5eb0400e7593df66.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_91_jpg.rf.dda50f3957eff8bea618fd6224bbdca3.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_454_jpg.rf.dd5b30c1720575cf5e1bba3b20f24b0c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Female-South-Korean-Min-Si-Go_jpg.rf.dd716a755e7969368fe6b39b81008215.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +19_jpg.rf.ddced4deedaa8216dad7345315e61f94.jpg, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0 +365_jpg.rf.ddbc641af2e27342c4ca43134446eba9.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_268_jpg.rf.dddb8f1fc4c88e19904b482d05b847da.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_51_jpg.rf.de1c58748bfb6b225487916e21455280.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-706-_jpg.rf.de3c61e8f2fa6aea599dc59da8e93ac3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +228_jpg.rf.de2992adf74be075b5e84ef1c8f4af98.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +29-Male-South-Korean-Jong-Chan-Na_jpg.rf.de600308afabacc4d4cbc949bc2478cd.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-777-_jpg.rf.dde8e322776e77d91a65e24f578af076.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-768-_jpg.rf.de7c9e28617414e55054213c75906b41.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-21-_jpg.rf.de71eb84fce771b8ebf651562aca85bc.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +178_jpg.rf.debddac8f092ad990c0c667aafeb038c.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +Image_60_jpg.rf.de8dd715c6536a82f2b1aa87f6636d45.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-530-_jpg.rf.dec0d0fcd404734c2fb65e15c820fcff.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_514_jpg.rf.ded9dce8498d76ab2664bd5d18652e4f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_491_jpg.rf.dee275cfc103692a829c4c6270720d79.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak-25_jpg.rf.dec278ef3cda40da7bc9d81838ed4a78.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +261_jpg.rf.dee9a5b43e4c7cbffebb2878fff0f8a9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +290_jpg.rf.dee8a3a5bf672a0c7691bbb008c06205.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_341_jpg.rf.df0900a36de4c8f88f3089a735394828.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-45-_jpg.rf.df4b2e4e6a90e884d31de62752b0d3e5.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +576_jpg.rf.df05fb044c8dccbbddd3c31f3536d8ad.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +berminyak__-50-_jpg.rf.df149f91fcd4902d7c451995a3b90bfb.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +25_jpg.rf.df27c55e2b01830ebbc08cf2e1ab75b1.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_61_jpg.rf.df6e2cf3b0fa49e86bf0f5bb9b60c7d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_13_jpg.rf.dfa40de36a3d9d9fe745000fc6d4df66.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-138-_jpeg_jpg.rf.df7fea386ef6ca00910c1af747f1d413.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_124_png_jpg.rf.dfb1b1168b11e68ca9a8166aab5447d7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak258_jpg.rf.dfd6a1e9d42ca100e9b8b6e8bf83b03f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +338_jpg.rf.dfdb23568cb22ed1c904169ad9306d74.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering_-63-_jpg.rf.dff65d8488bac9af58ebc21395c4567f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-396-_jpeg_jpg.rf.dfeaeaaf3c032af177db64bd63c53b64.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-244-_jpg.rf.e022fea974d89cde4685465e00860bc7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +images_jpg.rf.e03a33aa2780c64d6e9402f03e1c572b.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-26-_png_jpg.rf.e038448729e97fb472b9457852d02dc6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-505-_jpeg_jpg.rf.e04de27782c6ed487b3a2b497f728f8b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_390_jpg.rf.e02651a8cf227b2c74d7bbb630d13146.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-218-_jpeg_jpg.rf.e0842191fc926760ec5c3eca57a04ccb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_591_jpg.rf.e07af9ba7159f9694fcb0519cfb6d629.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_356_jpg.rf.e06270468f0286e686171d6dbeb0c79a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-88-_jpg.rf.e08517976f00d7eb51fb296c39d4fe62.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-521-_jpg.rf.e0889eec1ceba7ad0eecc12df81eeff4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-56-_jpg.rf.e0bebb5ba90d9e50471d86217c0ed74d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_129_jpg.rf.e0b37e5caffcf56d4d22c5bee9f8de71.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-662-_jpg.rf.e0ae57ac65646d7ff433514cddd3ee68.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_309_png_jpg.rf.e0c582ab08058bfc21ba04bdd61d5fea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_150_jpg.rf.e120867ed4384a39c8a10586cd24b9f7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_221_jpg.rf.e0cb9a1ea959fb2f3488fab36e833433.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-55-_jpeg_jpg.rf.e0f8b993482d2132752f5794d649ec83.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-739-_jpeg_jpg.rf.e0d96067f572cb5a5b7bd82b2d911829.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +47_jpg.rf.e0ed3ef026aa34d943434a54d4cfde50.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_396_jpg.rf.e148102bfe7333e858d487d96b209077.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_393_jpg.rf.e1225fab354d68d04cc3f716c5f77ad0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_83_jpg.rf.e185581541feea5135a2b8a1e9bc3603.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-648-_jpg.rf.e14cb5b20dacba88d5d24f4b1a0f9623.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_164_jpeg_jpg.rf.e15697411129b4ffee0692c73261202f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-166-_jpg.rf.e187153a646f9a6a979a86ab1596e0fc.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +485_jpg.rf.e18aa35ea8eed4729b9f0989ccaac8f3.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +183_jpg.rf.e1a5be150a32d5eeabb77b3592d91b36.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-3-_png_jpg.rf.e1e23fbaf1628713b3bf9e5f3d1a1df1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +205_jpg.rf.e1ccc2bfc03bbce4758a213688122510.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_558_jpg.rf.e1e456e88aa587cbf72fd6b378a7507b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-1-_jpg.rf.e2270d06e0c31bd6d4ce0e29c5f66f65.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-317-_jpg.rf.e1ed66ee27800a85f30cc5e8dcd8e27e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Condition-Rosacea_jpg.rf.e20c8b1e6c8a3e07c886b0a9a2f0059b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-101-_jpeg_jpg.rf.e28522281d53d6f4e529610508e8896b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-376-_jpeg_jpg.rf.e25ca48fba7ad041b8e26dda9aa35aa1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +287_jpg.rf.e23a3564b05e2ea5a79aea5a158ef5a7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_551_jpg.rf.e25d36f2526d569f27bc55cf368804cb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +799b41f6-36cf-401e-8937-bc2e710054b5_jpg.rf.e296d167228e131a9a6e0fe078ebf4bc.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +84_jpg.rf.e2a6670867d605a365a567f2a1505892.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_100_jpg.rf.e2c01e496dc78506fb0f7b0dddd32037.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-522-_jpeg_jpg.rf.e2cfb91d88c07186e1f50df8bc7ebbee.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +22-Male-Thai-Tanapon-Sukhumpatanasan_jpg.rf.e2d95a4fa52f856866cfeb358369c986.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-639-_jpeg_jpg.rf.e32124257423e735f6c965f993eb290f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Male-Chinese-Chao-Yuan-Deng_jpg.rf.e324bed864184ef36b0f449a1e90f174.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +61471768-edc5-47a1-bc1b-34ba53a39c1f_jpg.rf.e38e0b416d589812ccf0db413feca53f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_306_jpg.rf.e3bec79cb647463d06f35bcb7857a845.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-511-_jpg.rf.e3919e701e36793b052e53cc0dfa2aa7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2198286445_1_jpg.rf.e3cb0a3f6c8d10de77d2540950ce667f.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-24-_jpg.rf.e3936a0158a1688cfa56631743aa181b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_4_jpg.rf.e404b76e9373d254cc2efa91e363d66d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_190_jpg.rf.e4329efeeba1412df76d7db6fb6497cd.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +oily-158-_jpg.rf.e441449adb314c482e0b47d493127b41.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +481_jpg.rf.e43c27ddf92d38dbfefd70d6964ef630.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering-55-_jpg.rf.e442103aec98faaced19069563b8d90a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_338_jpg.rf.e4470e0cfe28a7ee711bdb68bf83cfb6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +353_jpg.rf.e45c2c6ec4e5b08c943009351ce1ea5b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_64_jpg.rf.e46366112c4d12ca415fb91aeb40fa9b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-334-_jpeg_jpg.rf.e4771f0a70593635b2c82967dba0dab7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_69_jpg.rf.e46d4ca01f46775bc8450e589e4710bd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_429_jpg.rf.e49cb6c90ceadc6fcf1853998574b8ee.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_331_jpg.rf.e49ede33bcc1c3a0281908667183f44c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_453_jpg.rf.e4f41a9de0b254aecf1c40656bfbc029.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-22-_jpeg_jpg.rf.e4d914608c01df897180d5d422bcb358.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-120-_jpg.rf.e51a7c2b11e7a2fab0d3f4cf43e130da.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-595-_jpeg_jpg.rf.e4e214c4b8e6356ad9f3970be2aa4c61.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_86_jpg.rf.e5636176a58bb95feb46c516a273feed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_383_jpg.rf.e579393a84eb2f2a04fe1a210282cd8f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-691-_jpeg_jpg.rf.e552c2cef1987d1b70953edbd92077a9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-886-_jpeg_jpg.rf.e56eeb7a299a88ae996310ef496b76df.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_476_jpg.rf.e58d9500b3c478d18d7bba16b4563ca6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-113-_jpg.rf.e585b1cb9e23019bd591856211b59369.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_370_jpg.rf.e58a3d40369e58879da344a35af3074e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-273-_jpg.rf.e59bf19c6a8cc92661023b0658e5e885.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_34_jpg.rf.e5f2bd66562ecd60cb91e93050fe6ced.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-546-_jpeg_jpg.rf.e5cdf49ef68a615d6441777e40c36b36.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_493_jpg.rf.e5f62cd3b84778ff36c3e7000bdc6e14.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +82_jpg.rf.e5fa1a406ecaae7c2eb7eb4181386c22.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-229-_jpeg_jpg.rf.e5fd64657cd236756197f3ff4f6f080e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-416-_jpg.rf.e621b84a993ae361965bbacc3f1d6c66.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-500-_jpg.rf.e667ac9ab78bacf8095587c3fba2b2bd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-86-_jpg.rf.e662fc0f00177e5fb7ff57883fc8ebf7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +8156d885-5dd2-4cd4-9a2a-c52d5982fd53_jpg.rf.e6a5eedaa9d14de59fe606a28a4198e2.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-88-_jpg.rf.e67638a90f6ab5f1dd6cfc7e7d314ef4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_167_jpeg_jpg.rf.e6be0fa17f1082bc5e3c7106c786e340.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-18-_jpg.rf.e70edd13345a13a163b08f07a3300417.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +452_jpg.rf.e70488448537c9a89804fd49a9bffa7f.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-169-_jpeg_jpg.rf.e69cd126021c9c262275e62dbdeb9814.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-133-_jpg.rf.e707bee313b5e53074ac1be025a6104a.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_457_jpg.rf.e74bf648ec1ed5128a1225e428700c1d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_256_jpg.rf.e72b78f60bb46da8087e5ae1b32c9ec2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Screenshot_34_png_jpg.rf.e75702bd6e41619482a714c274c56691.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-491-_jpeg_jpg.rf.e737418de339547e8e3c48752b722b3b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-859-_jpeg_jpg.rf.e7df9cbf7b71fd587839e0d373a5b2c2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_56_png.rf.e78758b841ca73ff5aef22dcd01a30bc.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-484-_jpg.rf.e73353a6de2550de16849696c83bd81c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +da-mat-bi-do-rat-va-ngua-600x400_jpg.rf.e7d8c8725859dcf7557d71dc9c04ae07.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +659_jpg.rf.e782bfc66e051aaaa3627fb446e65048.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-784-_jpg.rf.e7e0226541780869673faf1001651e13.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_345_jpg.rf.e7ec201d6fc1f39d0e4e362a78026848.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_82_png_jpg.rf.e7e5f2e6f8a22a85fb2e5fe45ee7244b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_62_jpg.rf.e81bb9d203b3c951b9717e488c67827e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +25-Male-South-Korean-In-Hyuk-Bae_jpg.rf.e86dd3368795e1c59509b676f84831a7.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_152_jpg.rf.e881bbc20c933d7a9ba57522ad0f619a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +31-Female-Chinese-Ni-Ao-Yang_jpg.rf.e89a8e032ab47dfeebc3c0f9d7379c37.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering__-29-_jpg.rf.e8631370f72ff299f445264f00af8824.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +berminyak__-18-_jpg.rf.e8a9a2f4e943152a008dddbf5c0ead04.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-198-_jpg.rf.e8e19e7333ef872559d70f408f4b7258.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_272_jpg.rf.e8ff70993ad9f68ad79cf749cb12021f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_78_jpg.rf.e8ff9a6d47d1ba2080ffacb1b37e52a1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_601_jpg.rf.e90fadf749e7809f5a9b2f09c2bfdca7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-197-_jpeg_jpg.rf.e93bef2cde0b8c09dfbd3a8daa723d2f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_544_png_jpg.rf.e939d058b171055fe1a20b432b136142.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_538_jpg.rf.e9332c930367aba2581f269adb814398.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_136_jpg.rf.e96e351dddebc5602b910c656558917f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-96-_jpg.rf.e93eb363342fc699513debd1d328fbb3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +183_jpg.rf.e94624759d64f2ba1c1de1248b06adeb.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +33_jpg.rf.e9c1a0b8eaaa990390c914a1768952ed.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering-7-_jpg.rf.e9749f47acd3cc452f849e9669d1c0b5.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +dry-skin_102_jpeg_jpg.rf.e9c1cd1a44f5505d3a758a352e195960.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +971c8949-b535-4361-b480-15efb539e1d6_jpg.rf.e9fb18863535cf28359c1eccae0863da.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-272-_jpeg_jpg.rf.ea15aa7b0dc0944f62dbfe458d12df6f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-4-_JPG_jpg.rf.ea1898a7b16fb2c1985a6bca991186ce.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +IMG_6817_JPG_jpg.rf.ea20b6ba8c99c988f54347319e922926.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +110_jpg.rf.ea24dec19108db1650197ca1b31e0ad4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_251_jpg.rf.ea4ca03a9305949da5bb4f38c01edb60.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_114_jpg.rf.ea6fb72c376497394fdb27a7da17b21b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_38_jpg.rf.eab6db7d9085afa61a405aef5b701002.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_313_jpg.rf.ea57d1adc498ed9e14b3d83aea21818f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-37-_jpeg_jpg.rf.ea52d9be39c1dd28cf132de82e4f1750.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-15-_jpg.rf.eacb02e60ffce1d32761d507d0a94b80.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_63_jpg.rf.ead0becb0046563cbd94cf6092a1299f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +01F3MMXJ24BWB58NHE1QVGMZJ3_jpeg_jpg.rf.eb060bd7c643be7f60118475a8f0c252.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1_jpg.rf.eb077e4ca02e80888cf40347a548b44a.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +oily-276-_jpg.rf.eafcfc273e9afac43f0e377d94e33b49.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-329-_jpeg_jpg.rf.eb347dec756bf0beb62211f761d126af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily3_jpg.rf.eb252c8cf9a664d4aa68986ba882e9e0.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering-59-_jpg.rf.eb4e4b3ce04379dec6dadd344e0bd4c5.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +c50f9565-f974-4676-a061-89c528cc4152_jpg.rf.eb6a009b78895dc0657ff01717ae0b41.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-145-_jpg.rf.eb73eb36af230e8252c01a812c48487f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_122_jpg.rf.eb31b9803d27af44f054b1aa11071d39.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_238_jpg.rf.ebb6c0ca8c00ebe6f3d470ba05ee0348.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +142_jpg.rf.ebd30a45a0b2b6c73bfc5c656e9b2edb.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_293_jpg.rf.ebb907646d0438daa3be194381426a2b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-573-_jpg.rf.ebf326c1ce452faa991b31a4f33ae76e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +526_jpg.rf.ec812363e10d1b4785ebb0aafcceeaa5.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +26_jpg.rf.eb38766f1d6ba6c488104e5a91964e04.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_1_jpg.rf.ec75bf46b6ee29a83a7f7c548b8a98cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +104_jpg.rf.ec3481cc603096fbc5328e278279b3b4.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-658-_jpg.rf.ec845de3cc06f266a406a12a49b6e6c6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-22-_jpg.rf.ecb6a03176d10c16bbfe0fbc57f5eeb5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +250_jpg.rf.ecbff59a8a7e4db2b902a893c237491c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-420-_jpg.rf.ecc344e2aa610c123e8d73ae302f49bb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-649-_jpg.rf.ed049b90e900b86a28a772f8ae3e8018.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +68_jpg.rf.ed31c50da6060193c42fc6682051089c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-257-_jpg.rf.ed64eb4b52b95672e74276babe3d6249.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +454_jpg.rf.ed6262e9698dc4dbd6431aaa24a8546e.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +levle1_241_jpg.rf.ed4d86d4192625d161e707a0f0353f54.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-4-_jpeg_jpg.rf.ed7dad43a439d438d88331ce50c61764.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_279_jpg.rf.ed78aa5da4e690428202e0843306d276.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_45_jpg.rf.eda88244908cdd895f7cd957997182a5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-854-_jpeg_jpg.rf.edb7a1a0faefee54ca3f8d666ec26fa8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-721-_jpg.rf.ee1e6b65e72f3ef29330bb5865596c4c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-70-_jpeg_jpg.rf.ee063692c75e127d153c7578ed86cfc2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +chung-do-mat2_jpg.rf.ee691d358a1cb3d706b5b348fcaff362.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-515-_jpeg_jpg.rf.ee3e445810a51658813116fbc2dda9f8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_81_jpg.rf.edccbbb412606d02c6bb66a8abfa2693.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_431_jpg.rf.ee6d962c1357f5f76b3a17d78f9628d3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +324_jpg.rf.ee6d5637af716573f454b67712204698.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-806-_jpeg_jpg.rf.ee367f5f3eba988b3c911cc3fdd66b4c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_362_jpg.rf.ee80ebf0351c050179976930fdea43f1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +29-Male-Thai-Jakapan-Puttha_jpg.rf.ee888fb0d2b340a4315ddf081abff54f.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle2_105_jpg.rf.ee81deba6ee53c35a76de957af169a79.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_47_png_jpg.rf.ee8fd0bbb290c12b05a7668a6dd55c4c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-47-_jpg.rf.eea3f5ed35159a0dabac96b9c2270036.jpg, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0 +acne-265-_jpg.rf.ee96bafc27c555a1090b972a7cd6afb8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_61-1-_jpg.rf.eeb902ce58d297848dfdea0c71cd8693.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-skin_119_jpeg_jpg.rf.eea8b4af95bb4357037f018034d625c0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +504_jpg.rf.eec26f1cd8b18691741eb077bf609b56.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25_jpg.rf.eeb0573aea368aa6699a9f9d4748a402.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +dry9_jpg.rf.eec41684d10ff3b525682079012837d1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_278_jpg.rf.eeb435f4f7f6759bf231dbb2ad14ca73.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +212_jpg.rf.eed1403f66d46b0bbca4d131dab72508.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +345_jpg.rf.eedbc7f9c2223779e00cba4356ce8880.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-800-_jpeg_jpg.rf.ef185f4da47f13b81993bc790d83bd9a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-516-_jpg.rf.eef1d3ab5f3a9977a8f00d7329ab6af6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +146_jpg.rf.eec59118fd4c42c3f66b455e3196be9b.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-761-_jpg.rf.ef2721e7af23b75970f460be5ec287d7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-37-_jpg.rf.ef1f41299555a816bfed3947219db914.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_372_jpg.rf.ef0fd606def5bf1b83c6675a4b799adc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_82_jpg.rf.ef2ab12dc54f0d8d30fe11eec60eb09c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-490-_jpeg_jpg.rf.ef43ef668b3e1b3eba812bb170d3e9e8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +243_jpg.rf.ef616044dcda3143e500f8daa398493f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 diff --git a/tests/datasets/skinproblem-multilabel-classification/valid/10_jpeg_jpg.rf.5f58057c0d03d635858bd19c57a5a220.jpg b/tests/datasets/skinproblem-multilabel-classification/valid/10_jpeg_jpg.rf.5f58057c0d03d635858bd19c57a5a220.jpg new file mode 100644 index 00000000..eb50fe33 Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/valid/10_jpeg_jpg.rf.5f58057c0d03d635858bd19c57a5a220.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/valid/24_jpg.rf.606ed093bb23c47b9d416e0d82cb7257.jpg b/tests/datasets/skinproblem-multilabel-classification/valid/24_jpg.rf.606ed093bb23c47b9d416e0d82cb7257.jpg new file mode 100644 index 00000000..4e0a2898 Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/valid/24_jpg.rf.606ed093bb23c47b9d416e0d82cb7257.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/valid/36-Male-South-Korean-Young-Kwang-Kim_jpg.rf.6000c01fdb4b4010869425b66dbabaf6.jpg b/tests/datasets/skinproblem-multilabel-classification/valid/36-Male-South-Korean-Young-Kwang-Kim_jpg.rf.6000c01fdb4b4010869425b66dbabaf6.jpg new file mode 100644 index 00000000..d93c5bba Binary files /dev/null and b/tests/datasets/skinproblem-multilabel-classification/valid/36-Male-South-Korean-Young-Kwang-Kim_jpg.rf.6000c01fdb4b4010869425b66dbabaf6.jpg differ diff --git a/tests/datasets/skinproblem-multilabel-classification/valid/_classes.csv b/tests/datasets/skinproblem-multilabel-classification/valid/_classes.csv new file mode 100644 index 00000000..f18ac7c3 --- /dev/null +++ b/tests/datasets/skinproblem-multilabel-classification/valid/_classes.csv @@ -0,0 +1,970 @@ +filename, Acne, Blackheads, Dark Spots, Dry Skin, Eye bags, Normal Skin, Oily Skin, Pores, Skin Redness, Wrinkles +levle1_423_jpg.rf.63f6bcaa074781a7686d68a12af2bf40.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +51_jpg.rf.675b2b4a3a4e0069fa33c31adeac163f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +2936372160_1_jpg.rf.664ceb5304f4931d3f0e00efd95fc569.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +458_jpg.rf.6467c74e70cecc357c0a75a0fea38e0f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_247_png_jpg.rf.63c46a03e6711eec089a48e453abb430.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +376_jpg.rf.626e0d1688c4edcab8b6b32f9e1c4e25.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +127_jpg.rf.639ce6b6d37c42a914021091d3b13f65.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +45284b25-20b4-4fc4-a51b-647b3845f21b_jpg.rf.6214554bd37a9a21452811715ab77ce9.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle2_58_jpg.rf.61f993363dbb2d91369a515e020adbb6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +11_jpg.rf.653aeda93cc73e58e679e92167fa5607.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-509-_jpeg_jpg.rf.6277c1d6e8a5593fed48dc8ccd48fd8f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_73_jpg.rf.66a248eba592837eb4465ca32a964e1c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_243_jpg.rf.6276ab1ea5b7d2c2e4a96994658fbce6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +435_jpg.rf.686981b3503416fff3f36709b53c17b7.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +20-Male-Thai-Phuwin-Tangsakyuen_jpg.rf.65d9a7bc1c53702d46150ff0e64db4bf.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering__-97-_jpg.rf.623d193de4152b4609de1172f33176cc.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_110_jpg.rf.621e544abb7954af8d907e39a14612d2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +633_jpg.rf.67bc64cd8c92a31e440699e8c3116382.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +levle2_168_jpg.rf.6a12ee821d0fa2dcd0825a6999de71e7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +96_jpg.rf.65ca2e50ce9f3007054a2e25054a2b78.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +31-Male-Thai-Pruk-Panich_jpg.rf.6859cfd60ed1c0d3334f487be61119d6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-609-_jpg.rf.62d2bd2914ab795eea0225c0ba81f5bc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +47_jpg.rf.696b3d0af7a09d4c0f64444c0684f71c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-148-_jpg.rf.68ad56d6f857f101cf1d3ad7d689e68e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-718-_jpg.rf.6996fdf107752c588d9287bbbc178d96.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +62298495-7a8d-4aa7-924d-4a4224366072_jpg.rf.6522eb4cc9ac249d01a34b78af7c8922.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-107-_jpg.rf.6317de61006d27d22083da89b23851fb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_502_jpg.rf.630b7fe27cee0e0cb44a319cd8aef1d2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-10-_jpg.rf.62afa4ecf90ea0abe56a4a0c68c904ed.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_4_jpg.rf.6a141644a99a0fbe4afc79005bb5c12a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +41_jpg.rf.6a24fd53a6d854aafffa076098267d46.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-460-_jpg.rf.6b1de2e50bac7284c2b8c1f1eff7e55a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-430-_jpg.rf.6b2cc3e21f8a50dc59871d6b991af376.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_437_png_jpg.rf.6b9f72f1b6d738e94395b9f2230011af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_412_jpg.rf.6bb24d8e0ecf96d08b5c563f85a499ac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +50_jpg.rf.6be88747fd17e7cf6c371bbbe1baab29.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering__-76-_jpg.rf.6bb7f552e2b4adc70bc16d57531eb161.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_195_png_jpg.rf.6bfe7f53bf76e2450c5a9cf4a92c2131.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_225_jpg.rf.6c2a254ad99adb0412540c81212a05f3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-391-_jpeg_jpg.rf.6ca1df1daf43b60a3a6cd1be6e90f9ae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +237_jpg.rf.6d2502cd4dc912aaf2a2a02653b162dd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_564_jpg.rf.6d44150048159ab29b0299ffd94e6f07.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +87_jpg.rf.6dd0d10da6fa3ff3274f85eaa38c0fdd.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +oily-186-_jpg.rf.6e13cf708b4b136c852592be427f0b50.jpg, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0 +kering_-101-_jpg.rf.6d64a5eea83fe32119e33a28db383afe.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_346_jpg.rf.6e2059b6903d51c5ddb4c5576e954352.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-718-_jpeg_jpg.rf.6e46c32fecb30469aaa025b7da839b3a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-125-_jpg.rf.6e5616c39fa851dfb4e31cc111f0997a.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering-22-_jpg.rf.6f7364a1748600a945a7f5e70d137c27.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-117-_jpg.rf.6ec74d050d04e66de0480353a7ef2b57.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +98_jpg.rf.6efe84b46aca1225a87be5cfaeb5c4b4.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +12_jpg.rf.6ef5cd33c597d97c38f12e250965857c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +134_jpg.rf.6fabdc3d2e79de64f75347d3eff44752.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +494_jpg.rf.71328173ade14d8afa24867a938ba5d7.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-503-_jpg.rf.70e7fa29dfbe02fb33282f8ea9dff143.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +192_jpg.rf.7147ae77580a9d82ed5511484e91da81.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle1_499_jpg.rf.716a3339efbef142f0cce40ce4b3734e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-141-_jpg.rf.7204b9865e5fe4eddaa004d5dda9c969.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-661-_jpeg_jpg.rf.71949ae6a9e63437670e7bb16d26bd35.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +34-Male-South-Korean-Sang-Bum-Kim_jpg.rf.71d9bd0cba3caf0d56bb40f6f7d5d595.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-367-_jpeg_jpg.rf.71d4c4fc761cb00f222c00003c3e7950.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +69731f1a-ff7b-45ae-880b-a0aaac6efda0_jpg.rf.72cdef51a27e2710e1f7a3f263d4d619.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering_-147-_jpg.rf.73ac77c47711e97d72f1b999c8a3b64d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_522_jpg.rf.73b0167ea0faf4b8e0398cd7ab7d76a3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +ea45b08a-f7e8-4fd0-89ee-88f643a52289_jpg.rf.73a15b85438f225f1ed592df5aaabff9.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_139_jpg.rf.746b323bc85d9ec882a5519e35945cf0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_137_jpg.rf.7470e53f2ef7806575f018a7cb01c00d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +291_jpg.rf.74969fe3bd632518ae968c8db297a6da.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-490-_jpg.rf.746eac6fde3a7a6c2ba7a7d65419fa4e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_410_jpg.rf.74ba29894090cc31953122e8245ec4aa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_489_jpg.rf.74b6d63d5e476f85c4b5da30bfa314d8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_290_jpg.rf.74d2e9e928cf8281f86711256f194d02.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-929-_jpeg_jpg.rf.7547ee70e79811d66187872c5c459608.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-53-_jpeg_jpg.rf.75bdc0fcc1114a5b94aa79881d81d829.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +33_jpeg_jpg.rf.75f01f59d4d02556e63bc4a850315f6c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-588-_jpg.rf.7651a676ca197d1cd82fbd3401d20186.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +6_jpeg_jpg.rf.7684f5a86f93b080dfcc7a17eac83868.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_159_jpg.rf.76efeb61800d318b48fad1774d817ec9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-836-_jpg.rf.76a2f351a9723cffa77ea156e9209698.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-145-_jpeg_jpg.rf.77299eab43c1d4f9e63bdadcddf98e7a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_427_jpg.rf.76d932ffcb2d3c4b85ac8e1849674fbd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +289_jpg.rf.77e8981bebe81369af6b8fc253832701.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_480_jpg.rf.77e21e54c43c8514e1a46253a8a5f94c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_94_png_jpg.rf.7762824a8d135ac82905e1e9de9801f5.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-486-_jpg.rf.77ea1a389e5e9cdac38d46fd18b7a874.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_216_jpg.rf.7872fe4760dd215b63400fd277891ba5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_165_jpg.rf.78a85905a78b39ff220048fc87cc8b21.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-704-_jpeg_jpg.rf.791a76de5dc510520252de752a3992e7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +56_jpg.rf.78c591455473a6d9aa8ba8c549e76f0c.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-676-_jpeg_jpg.rf.794f70d34d20a78e89abc5d4c00a95dd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +96_jpg.rf.796de0c8579c6e672991405841775c77.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-18-_jpeg_jpg.rf.79cfc05570618026ed3f859cf304d31f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +06b372d2-f0b4-4bbe-8736-13af471cba9e_jpg.rf.7a051dc70a31739ddb7a3cf1781a346f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +berminyak__-23-_jpg.rf.7a1091d0c656a6196d8fc131c6edfe17.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +dry-skin_118_jpeg_jpg.rf.7a7f5daa3e13afad869a171f61418b38.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +163_jpg.rf.7acb3a24e4a093cddc10adbac8fcfdde.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-197-_jpg.rf.7b5725fe45fd39a6b17196731ae6f8ab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry28_jpg.rf.7bbaa8a026b99a9e1c49df3447fb867d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-skin_110_jpeg_jpg.rf.7bab4d0ec196c72618d8b9f57c9ac33e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +berminyak-37_jpg.rf.7bd1175d135943d76dd6c5f636d1ed8f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +120801881_1_jpg.rf.7b741e4d439cfa4c991e8db09c4db3ce.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-292-_jpeg_jpg.rf.7ca2054eb43a5a3e68de1d323db7d0e5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +20220303_042827_532264_da-mat-bi-ngua-va-d-max-1800x1800_jpg.rf.7d8f57db4bdc2404689c92c749f580d6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +Image_54_jpg.rf.7c268209845d42355218dc80484f52d7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_120_jpg.rf.7d11ef69c92fe360768b9e462c66adaa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-258-_jpg.rf.7db6524059854f910a46fc9d48f9345b.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +90_jpg.rf.7e2b3800568fa85a2be4936ccf945a9c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +385_jpg.rf.7dfc418a6161b118a0287889652834ac.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering__-93-_jpg.rf.7ddc9eb47ca3a5b92f8f7379441c85f3.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-415-_jpg.rf.7e2f09a2a1fd65292134a7fde8c34a37.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-688-_jpg.rf.7eaa73aac00d674f8b632ba44dc700d4.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Berminyak295_jpg.rf.7eafc25b2a3effaed301213b910872e2.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-524-_jpeg_jpg.rf.7eee5c145d693866c69f8d67f62d735d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-3-_jpeg_jpg.rf.7f8c46406bf38ba7460a36747272377d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-487-_jpeg_jpg.rf.7f2123f1cd500c433b36b4e577564351.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_152_jpg.rf.7f91ab7f8d05be6616cc61cfc793f642.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_408_jpg.rf.8001c1427462ab6666453d5a6807f7d8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-518-_jpg.rf.80bf9308e61b1b17084abbf3fd144220.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_210_jpg.rf.80d6e0c5ebc3be08d5a64c1ac4ec7db5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +20_jpg.rf.80e91cb6ab9152baa48922ab6af5d33c.jpg, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0 +acne-215-_jpeg_jpg.rf.80f9b13a04889473c116fb082411a957.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-82-_jpg.rf.82596f135f8f7931d35b7819da3b5259.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-221-_jpeg_jpg.rf.817f880671e36d901c4bb8d62e146513.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_167_jpg.rf.817f0ff9a204b2c68ffbef046328faf1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_374_jpg.rf.81f8c9aaa79825196ec56e2a26f0d15b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-674-_jpg.rf.82f2cef5ac03589d5154666fda61bca2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +337_jpg.rf.8320c81b83f03d4306e888ea1c94ceec.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-385-_jpeg_jpg.rf.832427b69df427eba5bbfc700eb03580.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +109_jpg.rf.83f9a221f74554472f9b2367d55cbcaa.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle2_8_jpg.rf.8442b263dcec24fb67f7ce22ffc25c85.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +245_jpg.rf.84e18438aad1901381ba5c76fd6eb463.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-143-_jpg.rf.84c2a6734f99102920e8bc8f0677e4c5.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_411_jpg.rf.84a67cc949f491483ab235f00acde29b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_77_jpg.rf.85d9278b27e579c2254acc572366d26f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_212_jpg.rf.85313a421731f873cc9f3208beca9918.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_43_jpg.rf.864403a3372ae51dd3715de3b1d8accc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-338-_jpg.rf.85bc981bf0af6bd3f3ef1b6c625c1311.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-440-_jpg.rf.86b687d1db3e9a75d0e486e76f2c67a6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-7-_JPG_jpg.rf.869672c915b160c680628f68c58adf7e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +34-Female-South-Korean-Hae-Sun-Shin_jpg.rf.86f7f843e1cf8e37e73da007e354e1df.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-13-_png_jpg.rf.86dae196fd76b3a0e85c8ad5a48770d0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_397_jpg.rf.886891ed5f841ca0d18badbf65135eff.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-267-_jpg.rf.88a3cb37ed5c5c2d3295bf5a75158963.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +270_jpg.rf.887485b03d63f9f39b431aec6d6ee12e.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle1_165_jpg.rf.87e6fcc3f9e003d791135d1765de8bdc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-265-_jpeg_jpg.rf.88a614353dafabbf2a8f21704b221f57.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +40-Female-Chinese-Li-Ya-Tong_jpg.rf.890329ceac700b7e398d5558586a7a38.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +29_jpg.rf.88e8cf87949388e84e71000f8e318207.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-237-_jpeg_jpg.rf.891e19aaf6db31eda5ae57984ef5305f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-149-_jpg.rf.8928f63bb927c9d0e9c348fef066dd2f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +36-Female-Chinese-Qian-Song_jpg.rf.89c6d3f38c90216589b5e51133170b7e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_390_jpg.rf.89f93226ab62c2e0631cb1f81ddfe368.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-105-_jpg.rf.895c6fbde1eac84b248dd59d9acf0ea8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +535_jpg.rf.8cde6d3ff92b359e9fc126ee557ba203.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +acne-498-_jpg.rf.8aa76734add9cc124b6e2590b564e73a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-8-_jpeg_jpg.rf.8c324da13212e33f3c86d9644b6ec862.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_501_jpg.rf.8be6091ea7b16275fa188a39b3472cd0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_544_jpg.rf.8d837902ba3c0183544bd8b9adb4b11b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +39-Male-South-Korean-Yeon-Seok-Ahn_jpg.rf.8d9653a8a535aed77f986886255b0c97.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_217_jpg.rf.8d922617d1b0e1f757572ba51974167b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +23_jpg.rf.8d4f5bda5c2f288d55a68e2fadaa526e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +25_jpg.rf.8e02f4e8a322e4da8e533360ca1c8a62.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +berminyak__-33-_jpg.rf.8eacd70191a57e1dd8ea17f388e1e916.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +241_jpg.rf.8fcfa4020ad43cdeb575780fb0a9b4f9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle2_145_png_jpg.rf.8e5b30fb1326b953284e57f7f7607e8f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-455-_jpeg_jpg.rf.8f88b3d132fdf4d57e28c3ae2c62be4c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +566_jpg.rf.903051a57e7bf89637d69bfc8be9c793.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1 +265_jpeg_jpg.rf.8f67185cc72355eab4bfc4ed21b4b313.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +2113106881_1_jpg.rf.8f3ae119bb67c683b875e8e6425213eb.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +486_jpg.rf.90bdf0aa235796c203b2c6f68e94c7de.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +94_jpg.rf.903f2b2ed5eb9bec1c74c4fa2fa283eb.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +2de295c0-801d-429e-8f74-fce181cc87cc_jpg.rf.9163e8630cfbca622594bbf81167408f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +2748918408_1_jpg.rf.9052a4fad4ef583d07e4babe4af42ad8.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +23_png_jpg.rf.91ad808060fd1a945c25870fd5067d1f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-695-_jpg.rf.91a3c46dbb1326513f2355bd08555714.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +479_jpg.rf.925963bd2b941cd78d9003b65a6420bb.jpg, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0 +acne-801-_jpeg_jpg.rf.91c9a7dc0d94ce600d3b2775232c714e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_527_jpg.rf.92a6f9ff0650707b362790186d324591.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_424_jpg.rf.927e03cef5c372c8bc11b7ddf78049fd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +69_jpg.rf.92a2147b737848ad26d047ac3b68210a.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-14-_jpg.rf.92b2064fec6c26bc715a76e8bcd5cb35.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +30-Female-Thai-Jacqueline-Muench_jpg.rf.92f1b9fd12669de0a2dd7f1c1a2f0ba6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +55daa5e2-fa42-4cec-bb7f-da75a5b54809_jpg.rf.9442fe1a69e58bf5ccdb2dad8671680e.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +27_jpg.rf.93a936148791d3459e75dda3c587a91a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +252_jpg.rf.9407736f9e6a5c43482edb07deb7ba73.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +2738625961_1_jpg.rf.94ab8ae1610d209edbcf262c29746bda.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Berminyak-17-_jpeg_jpg.rf.94cbb82793e65ecc8835faae54cd927b.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle2_178_jpg.rf.94f402780950336a565eb9c4ff2aaa96.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_336_jpg.rf.95296194d1690aae94947ff4cfcf7e28.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-10-_jpg.rf.953b98966cfc3663e2af268856878966.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +60293a66-e96e-41fc-b34b-77d2822d59d4_jpg.rf.96ff7593ac5a7f2a8bbe063c6372bcbc.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-153-_jpeg_jpg.rf.96a0f0a076069c85be6972650a1e2986.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-540-_jpg.rf.954b5f07f6411c106d0727eea5f267bc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-11-_jpg.rf.97211d29314f377d3fe4b8fd7776986f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Facial_Redness_Treatmentpicture_1056_1_jpg.rf.9721e0fa017b78f50ba3b3923f641ec6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +377_jpg.rf.979c26363f179150e625cdd46d757987.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +29-Female-Chinese-Meng-Yan-Bai_jpg.rf.980dd0c8b5fc1f806a6d96a9aa3db265.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_274_jpg.rf.98494ef8853db59af404ff9b99e95628.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Female-Vietnamese-Truc-Anh-Le_jpg.rf.98119a1faa8a1c30df80ae8075b1ce53.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-171-_jpeg_jpg.rf.98830637e279cdf2e9b661c06c71b552.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-770-_jpg.rf.989f018ab58a0532d942df9153021ca4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-347-_jpg.rf.98a39e8e6ee77b4e6ca1358bf4ad2339.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-763-_jpeg_jpg.rf.98e043f60b6ef654142f956be522edd5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-77-_jpg.rf.98ee29e112a944cc7429b4f7256baca8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_36_jpg.rf.989f5699cc83d56366c5f580f0ebac32.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +oily-145-_jpg.rf.99d93dee040fbb186be5ed4bf44be8fb.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +314_jpg.rf.997f93698fc0617c4a9759b84303f3e8.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_140_jpg.rf.9a17f3ce2251728dd52fc1cfec3fd916.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-55-_jpg.rf.99f666b00eb64ded1be21853966957f0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_167_jpg.rf.9a348655143f4806f2841a1891f70ce0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-95-_jpg.rf.9a37c3a24c22168a9ca2f3e0391769f2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-591-_jpg.rf.9a469ca1e9fd9c100f5336eccffd65f0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-27-_jpeg_jpg.rf.9d2355b1aee2b27505fcf4cab0c834c4.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-443-_jpeg_jpg.rf.9be8baf391e90c6436715a12680a7d1b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35-Female-Thai-Jessica-Pasaphan_jpg.rf.9cbebbf52c148a2e05acceeb554b5e75.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_427_jpg.rf.9d48cc6ca7f86d88e1234e79de76f09b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_364_jpg.rf.9dba821f13ffa3af308f81ba204ed481.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +16_jpeg_jpg.rf.9c1233e00c4c1e8ec08c0b9836e9a355.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-10-_jpeg_jpg.rf.9c38f8cbc9c08b2e24ec57c3da96868f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_43_jpg.rf.9d3af7da28ceb2cf17aaa7f017fd33ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_100_jpg.rf.9d884e9241b29d2d69d46871195d4489.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +537_jpg.rf.9dde6aa8257929ac9ab8129d20530a3c.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +01F3MMXPCMJG0YNSRQB08N2W6Y_jpeg_jpg.rf.9e6e253f1a4f6fedb6386417836bffb1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_155_jpg.rf.9feabc2ff76dfcaeb499b0c99c0970ff.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_271_jpg.rf.9fa48068764c0abc85897dc744e2796c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +517_jpg.rf.a083cbffa388bc320c6bfa1ba8869c43.jpg, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-596-_jpeg_jpg.rf.a1fa9b9ccee6b33213b37ee0f5efae1c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-47-_jpg.rf.a13ae3c91f4278b62ea26cde238848ff.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_279_jpg.rf.a208fb981f93ce8a8323a2f9a0b6ed34.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-223-_jpeg_jpg.rf.a24812082c8421ad88cbba4324655287.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-573-_jpeg_jpg.rf.a15b99bab83d5f2600e188cc6ac7c88f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_83_jpg.rf.a2826ff4d897610e2ad72ca22e2336a8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-128-_jpg.rf.a2e24046241c7921c0dd34a3d25eb002.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Kering-13-_jpeg_jpg.rf.a3492fa5b40368546bbf2847a8fd16eb.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_233_jpg.rf.a36adb724f04e653caa4925bdeb210af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-384-_jpg.rf.a39b7c476a1f55a1781d698ab2a29d07.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-649-_jpeg_jpg.rf.a3a3ac579aeef3dabe2ef1e323757647.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Screenshot-2023-03-29-145623_png_jpg.rf.a42d295448a2881cf9790fdf5621b11c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +oily48_jpg.rf.a4c8dc79e452280dfe26f9b1cfebaa17.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-5561750_1280_jpg.rf.a58e52fc01f85bb905cee1ea65bf4b55.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +Image_8_jpg.rf.a540acd324d0c4cd9ce56002f3377514.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-392-_jpg.rf.a64041564d8b573ec7e508857933ffd1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dau-hieu-tren-da-canh-bao-ban-phai-di-xet-nghiem-mau-ngay_jpg.rf.a68ed230e427901abb8bb6d7c1de0fa0.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +levle2_137_jpg.rf.a6a2b72ab95a78850e6e18fcb4116efa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-341-_jpg.rf.a5bd2772adde98a05acee39dc5e4c80d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +589_jpg.rf.a6c349a019b618fc5ed7a6bcac7c1997.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1 +Kering-8-_JPG_jpg.rf.a833d6f14d9c2b005f3abc4c7b1384c9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-725-_jpeg_jpg.rf.a83a3206ec1f63034179a87c6e4ce609.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-3-_jpg.rf.a84b968c7833b093fa47ec9bc3590200.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily-39-_jpg.rf.a8fdc63e5772bdb01adf3277adb8d992.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_444_png_jpg.rf.a8a7da297453d1f227c747b9cff53d52.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-43-_jpg.rf.a91d4adf11711395e80a3322c7379a98.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_133_png_jpg.rf.a8c3d384c3a9733ebde0cc02d59aee37.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-3-_jpg.rf.aa2a64aa14ee10d9c196854d76a3c91b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_183_jpg.rf.a9649be0602a8ee3b9a19a069e454b76.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily22_jpg.rf.aa3782994d11f8920c0e6ebb29cb9783.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +685_jpg.rf.a946085b68a2a413340eb62b96ec9c44.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-118-_jpg.rf.aa65e15ba4aebcb403b0f99e2b59c7aa.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_315_jpg.rf.aa7e064ffb22ef7a6ceb89631691aeea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-615-_jpeg_jpg.rf.aa7e0f6e80654c899ea369c41ab48d35.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +343_jpg.rf.abd1f012c17452d09366fa2a9e9a2ae3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-610-_jpeg_jpg.rf.ab9bcef4dd8fd5dcf76ab598e1f4800e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-705-_jpg.rf.aa682b2920d9f12b1cef5579f5de0d1c.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +dry-skin_105_jpeg_jpg.rf.ab5c02ecf87f40384fce19848fb75cd1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +34_jpg.rf.aca1dabf91476c095695bd90cf38b712.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +2_jpg.rf.ac22af962c699890205606e53b9224df.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-441-_jpeg_jpg.rf.ac001458ac3d5afe2d11a4169ee952af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-407-_jpg.rf.acbe982c09d079e4d27ea16b691636fa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-7-_JPG_jpg.rf.acc4de6e93faef1b46bbdd81d65681ef.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +258_jpg.rf.ace6d307cd9f417629b92573440f84e9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_127_jpg.rf.ace4b533af6776ae192bf0bf74026b42.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-16-_JPG_jpg.rf.ad243b0ad420f1817fce93c113897802.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Kering-17-_jpeg_jpg.rf.ad34d476943f9f9e583312e78afd794a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-194-_jpeg_jpg.rf.ade7193c447b100c357463a107e3ac1d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-3-_jpg.rf.add18b5791a8655716e716091b597a77.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-425-_jpeg_jpg.rf.ad7304a0f89638c9ac8b5963f2fc0e3f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-31-_jpg.rf.ae6716e5576387664ffd4bcb6599c511.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +383_jpg.rf.aed6f8a24bd3d7b42e888f8094b27cca.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +oily59_jpg.rf.b00a7cc7310394fd6d2325ff5da87b59.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_509_jpg.rf.af522d3625389801198cb4a939515e3c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-901-_jpeg_jpg.rf.afd371f951afeb7370d0180b4651db66.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-366-_jpeg_jpg.rf.b015e9846a861f49c52badc401f33519.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +normal-140-_jpg.rf.b06451a381afc99714d7b752caa109e7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-285-_jpeg_jpg.rf.b0408f8880ba5f804dc76071c01d61b7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +11_jpg.rf.b1763a9f7042da573254da3ad203c3e6.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering-43-_jpg.rf.b017a5ea0553a00168268431e196e7c0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-23-_jpg.rf.b084f9c841dea9dd04475b5f9322ecd2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +d8373987-1c8d-4e53-be06-be48e794c0b9_jpg.rf.b122ab31a9597aed079c28eba06afc80.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-667-_jpg.rf.b0b52766e333fb2be27c159b0888090c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +44-Female-South-Korean-Doo-Na-Bae_jpg.rf.b178e94fd75da6b36b7b52192f7960dc.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +14_jpeg_jpg.rf.b1c68acf785aa7f435e066832e69fca5.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +33-Male-Chinese-Yu-Ning-Liu_jpg.rf.b19ceebcff92c138ae9eb9836037c65e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +oily-205-_jpg.rf.b1a647219568f808b2ace2709e46f0c8.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +oily-95-_jpg.rf.b1fc40437c89de42a5d5b1e2dfd9b27c.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-290-_jpeg_jpg.rf.b1d9e74aca43f0803e66fd08f5bbcb83.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_193_jpg.rf.b1f5cf0c1f00c91ec6430a0762a5185a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +25-Female-Chinese-Miao-Yi-Zhang_jpg.rf.b2219e38575836324d90eb8bd398228a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +227_jpg.rf.b35ad16374cd60e7dc85b19690cf8a9d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-28-_png_jpg.rf.b2d75a73fc11efde7f7c52ea3bbc2ece.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_187_jpg.rf.b41b9f9ed81852810435486632fe335d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-13-_jpg.rf.b233929fa2c007c936504d9b3aec39d1.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-19-_jpg.rf.b427bc2f0d53df13b60f2cb499122703.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle3_111_jpg.rf.b46e85adb3467df761efefaeec8c25b5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_219_jpg.rf.b45f08cd399b34c33a8ed0bddae7c1d6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Male-Japanese-Jun-Shison_jpg.rf.b50f868b53891951c857dd8230d5e9eb.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Kering-25-_jpg.rf.b4f32d7d2eca7fe0db7b405ef78d5167.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering-46-_jpg.rf.b5944a461c594cb1730ae7c3cf3fc088.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-907-_jpeg_jpg.rf.b5985f4691116796cbc49e7073d9364c.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +dry33_jpg.rf.b51135296cf169582897b46f0fe80485.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_79_jpg.rf.b5b1bf3652befde716a918e3ec41c756.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +236_jpg.rf.b64cdf94c9496ab44d167f5b13551e12.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_517_jpg.rf.b65e5b9802a831d5094f2f435eee5760.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +525_jpg.rf.b6ce3d4ac4e1d1b06947750e66b682ca.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +190_jpg.rf.b74d13c62ea423beffa104f4d6b57df1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Berminyak-18-_jpg.rf.b838a5edc309ef96da089c834d6aa8d3.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-260-_jpeg_jpg.rf.b811bb267fcee68484358a10a0061a2b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-308-_jpg.rf.b8052840716bec5fceb3ecc433400201.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +324_jpg.rf.b96241f41ef673e77bc3db775001e944.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-721-_jpeg_jpg.rf.b8725fe11b3981c43b2ca69db9a21dfa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-158-_jpeg_jpg.rf.b8659fa22cb9419afcb1087006f0bf20.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-139-_jpg.rf.b8ee135f0f18fc89238d88f8b8f18c6f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +139_jpg.rf.b9a22421d4cc3ca6fe411a034c798551.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle3_113_jpg.rf.b9c29406cf1a0ceb722fbf7cf180a38a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +da-bi-kich-ung_9b3f3875_d7b1_4c14_b67c_5e6434550409_jpg.rf.baeaa96f7f4cb3ddcf8c81d95cec905f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +kering-46-_jpg.rf.b9ef28a97b36ef0a21716b44d38ed3cd.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-242-_jpeg_jpg.rf.bb33184a6e1fe574bf7e5407f9a1f37a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_330_jpg.rf.bc5f86bb8b8ed8960b7dda516c4b4a6d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_262_jpg.rf.bbd91083f2b1d29aee3918929dad62bd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-863-_jpeg_jpg.rf.bb67ce724599918c8cb43868725722e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_33_png_jpg.rf.bc91181c9f01c142f28a3e98f08e1a28.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +211_jpg.rf.bc820c98ec7f5d5d613e364851e2d6e7.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +levle1_540_jpg.rf.bd2da4f1b7b90ed016b9fbab541b30f6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-747-_jpg.rf.bcfe86362c5c68dbe6656c70a5b6ca2b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_115_jpg.rf.bd8db5b1e1bb47be1e324fa852d97f43.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-41-_jpg.rf.bd99413f2626ab5280715ecaab67076f.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +46_jpg.rf.be40de61f69c9feed4d5b39cac869ac2.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +oily27_jpg.rf.be53e8af173eef34063e1c95285d7780.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +27-Male-South-Korean-Min-Jae-Kim_jpg.rf.be6b03a86001f317b2ee3d8ac139baa5.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Berminyak-2-_jpeg_jpg.rf.be73daddc6bbf301157d9f72e4f81a70.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-536-_jpg.rf.bde58d7eb3380ba7a8e81a54210d09f0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +209_jpg.rf.bf034baa8c02d0b7bc31cd8bd53941b7.jpg, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0 +157_jpg.rf.bf31b0597dd89da3d0e7e35a9f81fd9a.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-207-_jpeg_jpg.rf.bf19aa372a924ab0613d48cc4e88f610.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_160_jpeg_jpg.rf.bf3a127c75903a511c4fcc004e27b32b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-15-_jpeg_jpg.rf.bf3b3f794c3280ac2c4ce078ddc0b28a.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_531_jpg.rf.bf49c9ce3cdaf4d70ad406d09d2459ac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +542_jpg.rf.bf47dc3cc6e8f2c9a7affc385ff4a791.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_125_png_jpg.rf.bf8f57579493d73ecb5af7580b01f386.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +da-mong-noi-mach-mau-1-2_png_jpg.rf.bf98362149f679c0b0c472869080182e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +levle1_405_jpg.rf.c01da94119a5fa859394a2d173900ade.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +477_jpg.rf.c0933102dd3d842714d350ac85b3d234.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-121-_jpg.rf.c017e6f78c0b767b4b826279592c9ab3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +240_F_210297159_7tMSDq38NJGGGfjubjo57EoqK1L4Qw6O_jpg.rf.c043da99303e2a177ac15c3f307497e6.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-61-_jpg.rf.c0bcb135c8f6e01269b94005d184e92a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +65_jpg.rf.c0c8d90904dad946f9b5daeaf156f885.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +oily-183-_jpg.rf.c106c92f808b679843bf1a6af5e32e4c.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +630_jpg.rf.c19cc023ea95b3f289ce358358cc0cdb.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +81_jpg.rf.c1ad95c06fd9ab36609cb872a1e06951.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_229_jpg.rf.c1b6b3771274e82954078ea37e510071.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_282_jpg.rf.c21629bc5845fed19e044c2121b86d2c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +66_jpg.rf.c22e9069315c1348fabb1fb2084847d0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_19_jpg.rf.c323daf3b755a70f9219e09bb393982c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_388_jpg.rf.c295210503b889044c7802753da0b733.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_57_jpg.rf.c34ad5ba91bc28601740ec28cf7cd87b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_2_jpg.rf.c39e3331b4810a28d9be3082e18e798b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +34_jpg.rf.c3bc9c908f6dd147fce3f604ffd6a2b1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +oily11_jpg.rf.c4f8a66cea2402fb2cd1685671db03b5.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_131_jpg.rf.c41a89ebc6200bd8a46c074f51aa9028.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +233_jpg.rf.c5b6588c6276627475baad1c8ac9e8b9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering_-17-_jpg.rf.c6fd2ffb3f0109e758e1909c6d1400a0.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +berminyak__-17-_jpg.rf.c7370daff4716ae059e64bfac8b362df.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_483_jpg.rf.c7f5c8ea24d9cc69b3508881aa373658.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +569_jpg.rf.c7845e90ba7bac9cf7ed20646dd05392.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +450_jpg.rf.c7df29684af1f982d8c27e1764702a08.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-26-_jpg.rf.c844081d1e8385014671eb30b9993a21.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_68_jpg.rf.c85b5f2952d065d6f33de8a2ee195fcb.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-672-_jpeg_jpg.rf.c7ee3dd1901bc8cdb52849465783fe98.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-869-_jpeg_jpg.rf.c862feb1316e98ccc195def3b6c15070.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-20-_jpeg_jpg.rf.c8cc38e920fa9083354ecac5745d2ad1.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +151_jpg.rf.c8ddb9618a7b3fbcc23d10effd0fd4be.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-551-_jpeg_jpg.rf.c9429c7dbb36678885ae11868d35f2a3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +448_jpg.rf.c9bb85a704e5be14c3e7f92c396dc3e4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +121_jpg.rf.c9b6b5b8c18f7a60750fcb67c0e9aa2b.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +acne-809-_jpg.rf.c98e2a8a3ee86a1db224ee612aecfc8d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +36_jpg.rf.ca9376734560936746e5910f251733d3.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +177_jpg.rf.caa2178f75fb9becaaed5cf85202aa13.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering-34-_jpg.rf.cb9c6e4e5074600cd1937d2c97f3dab7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +175_jpg.rf.cb13b054d62448c43c272295263b0371.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +27-Female-South-Korean-Ka-Young-Moon_jpg.rf.cb69fb1dc86780afad59c93b70312b7c.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +berminyak-47_jpg.rf.cc504228e9f9dd4c21be995954bfc5db.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Kering-27-_jpeg_jpg.rf.cc598a815272a335f5ffe6e2ee459c84.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +165_jpg.rf.cc1a4bf0105d486bc547539d1ca78f2d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-922-_jpeg_jpg.rf.ccfc4b6c67b291ec88fdb769a2f2f43a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_549_jpg.rf.cd2a07f746c28f2e5e32273dadfed58d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-37-_png_jpg.rf.cd41a21cd18d102e3f0df5d33bd72cf3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-16-_jpeg_jpg.rf.ccff3315e0c4336a2a85787cb6978981.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +341_jpg.rf.cd607ae49ff4615d61c67ba9d98d92ce.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +29-Male-Thai-Masu-Junyangdikul_jpg.rf.cd445115c304361c17d6674a91a97a81.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_47_jpg.rf.cd9a296e89656f6bd0301d6aa5847401.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-24-_jpg.rf.cd9948fa1c5ac7c508c1e3d85a06701f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +27-Female-Thai-Maylada-Susri_jpg.rf.ce29dc8aed48b1046a0dee88e5f66332.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +79_jpg.rf.cdee24fc40639aff6d8a90d3eda3f74f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +normal-181-_jpg.rf.ce9bdd5a0e56bf106f556c0d40ba297d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_14_JPG_jpg.rf.ce9e417060c001534d9dd9128a104cc6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_423_jpg.rf.ceb7f387a020b9cee21583352453fd84.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-skin_41_jpeg_jpg.rf.d019347ef42be74cc6d7988993f79cdf.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_254_jpg.rf.cf945d1e4d4d8f2f192a4f0158e1bbe4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_210_jpg.rf.cffef930d7bad0e7cd7b52c9d18f74c9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_20_jpg.rf.d1a002748e2de88ee2791836b10cdc32.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +552_jpg.rf.d05902718358c970cd3c4d6a10c6365d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +162_jpg.rf.d138fe6d7e980d1bd7671d48718fab38.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-638-_jpg.rf.d108031369766febbeaf870dbefcf4e7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +267_jpg.rf.d3a938f53d1d8ddd5517080a97b80eb5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_258_jpg.rf.d1d92819c6726ea63189049bbbff43a1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_71_jpg.rf.d27dbf64241a342bcaf4d1826ac49f92.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-3-_jpg.rf.d396179e5485f42688f738e42eb73205.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +97eeed53-d813-4f64-863e-229b88975281_jpg.rf.d44d712d868876d19d31623e4ae3e131.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-468-_jpg.rf.d481044bf8008ca1eaa54ef2e2ed112e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_158_jpg.rf.d53f0cfe513b5c0496c3df73ca3a719d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +a3_jpg.rf.d50b69aa95218af0f6217040ba70099c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-452-_jpeg_jpg.rf.d490145406143c41d8de0182883e50d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +_1679374360_jpg.rf.d59a91e394e05c165fb28c3089796bad.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +oily-152-_jpg.rf.d55acd3ce787e91845ec448cea72600d.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-639-_jpg.rf.d5a9d47ef3df73f1fe4da36bbdb96e65.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +234_jpg.rf.d5d9773cd4f23a35ece8f54f2f76fe22.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-499-_jpeg_jpg.rf.d6ec1e373d509c4f21f5237c51c91170.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_418_jpg.rf.d64ad4e144c6c6e0ab685fa942396c4f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +35-Male-South-Korean-Woong-Jae-Im_jpg.rf.d60b79669a4923bcf48cc55747cb049a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_102_jpg.rf.d75953e80bd7d39eabf08f492e1ee885.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-39-_jpg.rf.d70e97ee6236c7e670d6376f96eefe55.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_134_jpg.rf.d706e6ed5f788d9facf9c1042b2639ee.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_358_jpg.rf.d7069220aa0d2efa46f2b8445b2168ab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_266_jpg.rf.d7e7507d1b6cfb7718d2e6be9e91927b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-103-_jpeg_jpg.rf.d8be18599e767406f2ad821a6a86d6f8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-210-_jpeg_jpg.rf.d7672126d2e872d1bdc2a1f1ef5ebe23.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-80-_jpg.rf.d9574b18af40fc36a7ca11f3ac8c9369.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_373_jpg.rf.d9b822f3577af727943704f957820eae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-1-_png_jpg.rf.d9644bfac4b851436d5c695120fd8709.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +2719463843_1_jpg.rf.d923e7a893414c815456006de28c06e8.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-819-_jpeg_jpg.rf.d82c84672646e7a1f3d8b5b47040aa2c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-585-_jpg.rf.da736dd8d9b15c1204cb511104c23669.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-309-_jpg.rf.da02815ee3a68611b1332a810c81618b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-6-_jpg.rf.d9d56abfc8fd4ede245ec5f9863771b7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-482-_jpeg_jpg.rf.da046525ae5c10cad827104d50fbc817.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-159-_jpg.rf.da9987db020dfeb0ca492efa3a2e8ea1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_407_jpg.rf.dabdfdbc600e4b5c8001c085bd811526.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +62_jpg.rf.db06eaac0ee395c36aad354cccf7cc65.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +189_jpg.rf.db1b1311e066ad9cb2c54097dda7068d.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +oily-243-_jpg.rf.dcaabfdaf93031c1d88dc3f1d3d87a0a.jpg, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-409-_jpeg_jpg.rf.dd382adfa529faafbe611a8d68d5d072.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_319_jpg.rf.dce63a8b06f06c7cf77c17ebc3556247.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-862-_jpeg_jpg.rf.db53a9a931b9aaebde361460ecd2ab75.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_211_jpg.rf.dd4db61c13641b2954f94305a11ee41e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_151_jpg.rf.dd8ad88852e15298a6397576e27c660c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-272-_jpg.rf.ddd24f3fa2a99d95dda08c1848245cfa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-764-_jpg.rf.de5a1977d5bfce99f32b09e762cf1193.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-11-_jpeg_jpg.rf.dd76a29c4dbff8cc9de81ae64bca9009.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +336_jpg.rf.ded30e332436669a8e08f877fab22a0f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_23-1-_jpg.rf.dea304f053a2f1eab77bc97b262b9794.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_6_jpg.rf.deac883e5146dad2fe47566285f2f9ef.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-591-_jpeg_jpg.rf.e03918ed5e81e8ca54c7c757798b401a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +72_jpg.rf.dee4d26bc9d7287f614cdbbd1b1f56a0.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +224_jpg.rf.dfebae4ef72b2b2b43c48e35d5156482.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-917-_jpeg_jpg.rf.e053ffa8382564bc3a8bd8be7703c94b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_56_jpg.rf.e111f2fdd21a8a883abc9ea4549c105e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +240_F_137816577_BdN2G0s5CvyjWBez0EiievvSDnhnEqNF_jpg.rf.e13d12ae4a81ec4e64e5aa465e6134ab.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_255_png_jpg.rf.e05e762ed963dfac5cd9b59dbca76171.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-528-_jpeg_jpg.rf.e0ce7db098c818a624ed7e8342c711bf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +396_jpg.rf.e18e6ade112063a9790d966dd4d7eea0.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +acne-357-_jpg.rf.e38261a14b4cf42640bcf74c987d0e56.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-609-_jpeg_jpg.rf.e3b2a9ef6df1e9fd270421a66bed8b7a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_46_jpg.rf.e1cfa077879ec8cb04ea15e6ff8990bd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Male-Chinese-Han-Gao_jpg.rf.e3e8ad5887172e3d3c28ddd693fa70e2.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-322-_jpeg_jpg.rf.e4052a27c911edfec9267015141d440d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_149_jpg.rf.e482d983c9c995e8a82a63a2362477e0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_46_jpg.rf.e43ff28808e486a1ad978c6965b8d565.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-673-_jpeg_jpg.rf.e4b66d45d606e43181c9d27f8234f77e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-485-_jpeg_jpg.rf.e54bf3112abe929e85c8fd8f7e3ba488.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_4_jpg.rf.e4be61ce1fe2387a152992e02a2d8c79.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-889-_jpeg_jpg.rf.e57ac3c8964e52efda95f04f80c3083e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +327_jpg.rf.e582c7abd78f38189e81c3999b782632.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering-45-_jpg.rf.e5817716fbffb464c9e38f8045283b96.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-379-_jpeg_jpg.rf.e5a6483dc72a06ef8b7497e0e09509d4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-808-_jpeg_jpg.rf.e594875ad6c5eb74dd1402704f71e119.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +84c1c5de-7940-4b6d-bc5e-cbdfded76eb9_jpg.rf.e5b2169ad311e0b34bb538c42d7bf03a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_10_jpg.rf.e5a7b38abd30f26f7de6c0abbf575a68.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +Berminyak-28-_JPG_jpg.rf.e60fe91d1725de33299532a875958e01.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +342_jpg.rf.e5d7389201cc133e0894582349c28ad9.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +acne-520-_jpeg_jpg.rf.e6ad2ccad0bfc39b3791e37c643ab921.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_195_jpg.rf.e6342bd9e1115bf89fb0434b5bd065c7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-708-_jpg.rf.e676c35c8845b8791e90cc48cc8e23f1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +484_jpg.rf.e629cca248eb3ba2f07a6e4d5963746b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-430-_jpeg_jpg.rf.e6c0337183f08dd9d4d7fbb1cdb4aa8c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +207_jpg.rf.e702b126c62ebde7c3afeb50abdc246c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +325_jpg.rf.e6d5b7ddf7d1d84d510ce61490814af4.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +acne-620-_jpeg_jpg.rf.e70a6ad5319da4b1f44e60f66adc0ddd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-138-_jpg.rf.e8f6229f4943c6d37ef22af39147bcee.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-545-_jpg.rf.e828aad9fa759ec1a07bbc12b0260ba5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +100_jpg.rf.e97011ad0c1299945f07bc01c157c115.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +01F3MMX85MKKVDR2PMR3S2F4B6_jpeg_jpg.rf.e8abcbb39d1ffb3e10e8307080666272.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-280-_jpg.rf.e97ff4e4d3df485ae0eba2b0132b474d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-381-_jpeg_jpg.rf.e99f5de2b0992a10368bfaff9124ccaf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +13_jpg.rf.e9aed7e96a6b709b553d4d2b174540e4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-11-_jpg.rf.e9c3dea0bf1b24f0e8b17cbe769133f9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-769-_jpg.rf.ea5cec8b76a39d1d35bf63921c032dd3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-110-_jpg.rf.ea21800873d5b17b04a7860a5f40cecd.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Berminyak-11-_jpg.rf.ea7afeb4105c5b1ccfead446f41acb15.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +kering-33-_jpg.rf.ea8c1488ad238f30745681efa5a82150.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Berminyak96_jpg.rf.eaa34cee5c5339e8cbe693aefe9f5c52.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-838-_jpeg_jpg.rf.eab203a3525f3882eef4fc543cb51588.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28-Male-Thai-Noppanut-Guntachai_jpg.rf.ea93aacaf7503bc2e239f9cdcf5785e3.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +152_jpg.rf.eb1855c73b4917b13a8f9587b4d7e6c2.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-710-_jpeg_jpg.rf.eb1ed59c9c33be046da128a53e1d80dc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_187_png_jpg.rf.eb52ba234023e8bcf93fd5902760b20c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +normal-164-_jpg.rf.eb5f6d62aa6202625467e3c90743129d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_112_jpg.rf.eb5d4b78b3e4b73339bf5d64a1e982c9.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +32-Male-Thai-Tawan-Vihokratana_jpg.rf.eb60fa3a00c83286227fdb78c04e467c.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +z4220863771590_9cf7e2bd92036ca75abebde81e8720ce_jpg.rf.eb6a4ce7d91b56a7fa8ec5a6f51a31ce.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +585_jpg.rf.ebdc16595c03f19582941e4d78b8e637.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_37_png_jpg.rf.eb997773557f1c8b5e18adf3b0f2f08f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-388-_jpeg_jpg.rf.ebed761c7f89d2fd425bcf7fa9cb4f49.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +55_jpg.rf.ec7c05f81073ca841d9b0b8d14e731ed.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-884-_jpeg_jpg.rf.ecee9a927b6ba584386d2485c87392fc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_79_jpg.rf.ed19bcd5bbe8a40586b8bddd13425b40.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-476-_jpeg_jpg.rf.ed0db45a04716fbf7ab48772b59320d2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +49-Male-Thai-Arawat-Reungwoot_jpg.rf.ed56eeabdee3d5534ad8b9ae4c65dfc2.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +137_jpg.rf.ed1636906f7f94af0025c46be8af0087.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +Berminyak-10-_jpeg_jpg.rf.edee461fe47235bfa5782f0a4e993095.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_453_jpg.rf.ef292f10b093cb9bcc0d66cf0b54028c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-238-_jpeg_jpg.rf.ee18b0d47486f6870971786afadb404e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2106011059_1_jpg.rf.ee9f956373f5175528ba7fa856c95a7b.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-738-_jpg.rf.eed45e4d203c1dc19d973337691d8a3e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_534_jpg.rf.efec0f2c165c23aa9756453c9eaf7069.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-571-_jpeg_jpg.rf.f07b866a0571bc6b8fd9dcf3ef573c4e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-51-_jpeg_jpg.rf.f03c5fc5ba123e78fa11c2513ae63bde.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_162_jpg.rf.f0c3d04d6f4119652800f60b362eb580.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_1_jpg.rf.f07c5b95a85aed63b65febc32ccba212.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +103887554_1_jpg.rf.f120d580869d55d19a40b4b7931c56de.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +268_jpg.rf.f110e655816c532f59df5aa98e451303.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +332_jpg.rf.f16aebeb0751cdf86bc49dbfece61e36.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +55_jpg.rf.f1f613adcbdddf29e3f933847426cc69.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +Image_8_jpg.rf.f1abe20f63fc13ca9bc7683c09bd2b0c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_604_jpg.rf.f271ed5a022029e318152b5ede2287f0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-669-_jpeg_jpg.rf.f2f0e25b047aaeea6b85e99d501b7f67.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_101_jpg.rf.f2fff9c5b5bcaf0d7be5d6dd67c91d19.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-270-_jpeg_jpg.rf.f3bdbb6c71e9319794b8891cdf051568.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +17_jpg.rf.f3423e0faee3ca26d56c68c7c6973a4f.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +174_jpg.rf.f42fd09f4d396365a55d3744b3ab61ae.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +acne-309-_jpeg_jpg.rf.f4358392f0c0830582e453a6767ff839.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +515_jpg.rf.f4f8a2399c3eebad7e12c1f0ae50643d.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +berminyak__-55-_JPG_jpg.rf.f48961a68d52a8b785857de2af604f20.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +501_jpg.rf.f5b6c2488a24d71684188f1e078a6046.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle2_98_png_jpg.rf.f5941396d21501ecc7ecd089e622ac44.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-28-_jpeg_jpg.rf.f5d1ffaf590b5bc5aaa0e84e5cd224cd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_537_jpg.rf.f5febf817997cdfa65848118bd06c564.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_162_jpg.rf.f6f84724dddafefba83b86bef2073c00.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-353-_jpeg_jpg.rf.f6f429fc74f9e4a099e8337e42d5e588.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +304_jpg.rf.f68148f12af8c93d14f68d569aeeda04.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +240_F_370704964_B4de4R8108nyUq6Nt0LuHyQQmamBmgEo_jpg.rf.f669ff1b2baf5414cd829bfcebd101c7.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-600-_jpeg_jpg.rf.f7be98b739532ac7b2c98093c59ba561.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-397-_jpg.rf.f70f9a801844f1cd3b548be7ee6add34.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily10_jpg.rf.f7ec9bf2e039c71b8f18c3c7a96404c8.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +26-Male-Thai-Siraphop-Manithikhun_jpg.rf.f7b6e22d5134983f84dfe6b44838a17e.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +8_jpg.rf.f82fc3f5feb7d5c0ea9c8ef01632dc9e.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +75_jpg.rf.f8421363462790194bcfbf8c26632d3b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +45-Male-Thai-Nawat-Kulrattanarak_jpg.rf.f87ddf2429d52349d65b926c9ea83df9.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_29_jpg.rf.f883a8eb4ee231d941f52fefcbe2fe9e.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +59_jpg.rf.f86f23e9abfad4659cc93b948b0a6a4d.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-737-_jpeg_jpg.rf.f8b2eaa67910380bb6b240437c667a54.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_634_jpg.rf.f89552c8381ca5df0046024ad401f1e5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +571_jpg.rf.f882d3ede83341727c75567920512ed1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-831-_jpeg_jpg.rf.f8f9cf5c1c9255dd6da9a4dbc01b6cde.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-438-_jpeg_jpg.rf.f96023c3327e58e988a5448ee5780326.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-45-_jpg.rf.f94a287b2503c95231f9b0fb45e82a9b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-435-_jpg.rf.f96fa001c35621a85c2e453d031cea6f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_92_jpg.rf.f974a5657e31e6030110a8c942337e5e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-521-_jpeg_jpg.rf.f995ae9ebee6f5c94572c7364a4f1203.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_519_jpg.rf.f99a881b41035695d3527e6431e59464.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-58-_jpg.rf.fa05810b9c1ae5919e48b30a2b6cefe6.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_608_jpg.rf.fa7997958aaf0c7736a53a317e4808f3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-22-_jpg.rf.fb23c1dbb9bf84fbf8ebdde698de1964.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +53_jpg.rf.fc1d2d5b515a1e44b6bbcb306063016b.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Kering-18-_jpeg_jpg.rf.fa56f09d2936e37a96e45665301b8cb9.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +13_jpg.rf.fb51b53ff2ef532547687945e5513fc9.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-127-_jpeg_jpg.rf.fc9ee2317ca857f88c80cabb3d091e31.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +34-Female-South-Korean-Yoon-Ji-Kim_jpg.rf.fb4c1edb98d80aff9047d7a7101c1381.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_205_jpg.rf.fcbda012e3391a47ded0dba55d409c2c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +344_jpg.rf.fca5d33cc2e903f47255baf9511a277f.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +f8930779-f8a1-485e-8534-0cd881cfa142_jpg.rf.fd00f958786b5f7346c8bcbc99ba68ef.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_16_jpg.rf.fcb6dd5590ab3c1aeedf467287314c95.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +21_jpg.rf.feb31c9ddf8a65307b038b391fbff003.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_483_jpg.rf.fed065a97996b55b355553dd6ea185cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +180_jpg.rf.fe8ade91ae26433848bbe1d1f55e0ce1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-542-_jpg.rf.ff373f22fbbc1bd8c15117512e618a7d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak-49_jpeg_jpg.rf.ff430a842b6767db0ecc6381d56c1c84.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-356-_jpeg_jpg.rf.ff69fca66b3ced8da4f08bcd654dee36.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-841-_jpeg_jpg.rf.ffdbe270feb68c278320435559b4ffc3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-839-_jpg.rf.ff5a2a2974721bbde2005698a3b33160.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-221-_jpg.rf.fffcea273600b517d7c1862980d68b34.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +271_jpg.rf.faa78ff600f5bd9dc17e18aa6e38ec75.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_375_jpg.rf.00083eee568272cf061817a154589619.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-619-_jpeg_jpg.rf.00186adcc3841febe372c4e617321010.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-711-_jpg.rf.0060154ef396037ecdba9b63121d5851.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_112_jpg.rf.00369257b04b996d2a5c81b214282fd5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-580-_jpeg_jpg.rf.006476f6ede1f27eaea152bdd8b624c5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28_jpg.rf.019fe42534b5602d40e18505b25058a0.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-595-_jpg.rf.0215a8256494f976f3affd39cfc13497.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +356_jpg.rf.018b7a0f079c05e2c83d5e94cc4717cf.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +39-Male-Thai-Patchata-Nampan_jpg.rf.00ba3d20830445e92a99de7c171a082a.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +436_jpg.rf.02308735230c5005921ad1b5710ab38e.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +acne-772-_jpeg_jpg.rf.02425ffb9e87a089f942ad2b61fd2d8e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +_2094077383_jpg.rf.0294919abd320e66ef2ffded5f02a6b4.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +681_jpg.rf.0278d5d39f8c0265a82f10b6d41f4635.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +levle2_85_jpg.rf.03335aaa2a49f54d827557c5354eced3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +ac1_jpg.rf.03555a3104c450408bfb3ae82e11bb7a.jpg, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-129-_jpg.rf.04138a3ae55df8f99eeba5451efdca76.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_180_jpg.rf.04c60ba255c807d40d4195f528030e52.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +96d4f22b-3c75-4972-ab92-7fd6d12f9f75_jpg.rf.0388aa44370c544862af008303f9dbf7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-814-_jpeg_jpg.rf.053dd2fd99696bfa899b88f4b95bd141.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +c741a5ac-b87c-4a78-8bf3-5ce831bca681_jpg.rf.050d9149e2d223b3f7a94ba245453ef4.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-369-_jpg.rf.046b275326e0de8c1bcf6445f2b3b92b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_92_jpeg_jpg.rf.059b26acce0c64577ce642767a5b3454.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_261_jpg.rf.059e8c875db8cb798f5b23ca00e4657e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +174_jpg.rf.061837de84c1b007f18307991142b972.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_606_jpg.rf.05b1b97f8cda8773aa0f50ad1afe79ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-350-_jpeg_jpg.rf.06c8af0a48a33dfca548cd62258bfe11.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +678_jpg.rf.0837a6acee3e02d8a3e754618835774e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_5_jpg.rf.06693be0722f8b853033507af6fdd29b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +37-Male-South-Korean-Joon-Yeol-Ryu_jpg.rf.083430be288031643d664ca629eac1af.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +kering_-28-_jpg.rf.0892575dba8d146a426ade81d6b29a10.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-399-_jpg.rf.089d2f8074ece94d49fe859ddf2e27e4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-710-_jpg.rf.09106a2bf856d0d1998838a77893238f.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering__-70-_jpg.rf.086f63c45fbdaa316a54ab84f4744bc2.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-646-_jpeg_jpg.rf.0a5365149eca336ce56d7cdd6d8a67b4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_97_jpg.rf.09cdb55adab74e160d50e05eca1215fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-44-_jpg.rf.0984c893ebe37531a9ceab109c6bfb35.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-31-_jpg.rf.0a183f1b3579609812c1b8d74ab9b5d7.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_320_jpg.rf.0a8125ac51fe87045673ab66ab36f825.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_266_jpg.rf.0a842724c9c1c44f928ec4b06fc48db4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Screenshot-2023-03-29-145456_png_jpg.rf.0ac745be5eba24fddaec7ace79f9bf71.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-796-_jpg.rf.0a8706402ab88d13b4891442b820194f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_339_jpg.rf.0b14f4012868dee4b97e5ce2f227c090.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily35_jpg.rf.0b9f3faafaab7e5ac929fb61ebf77721.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-269-_jpg.rf.0bc769dddc6d302d1f1761b4d6e6709b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-242-_jpg.rf.0d2dc08ed1bbcabbde1ae285d508a4d9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-282-_jpeg_jpg.rf.0dc2c4471feccc02c3b110d98dd1822a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_506_jpg.rf.0d899464c890112db2ac4ef9e473f180.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering__-23-_jpg.rf.0da9d012c4ae9bbada04583fc0c66d3d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering__-22-_jpg.rf.0de4e1c1219dda19971d391079db3a26.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_480_jpg.rf.0ecd1a8e137aef63446574899302addd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-837-_jpeg_jpg.rf.0ed8585f37c49cff6cd35b478b432564.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-656-_jpg.rf.0dcf5983d0956929b0efb6450004d8cf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +75_jpg.rf.0ed93e88a3e3bfce0b4426cdbfaa2c3a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_579_jpg.rf.0f0b34c42bd350927531ea3ca5162e80.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-278-_jpeg_jpg.rf.0fc50ddbf3856140ce005780c6a5ca81.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_420_png_jpg.rf.0efa97241aeb8d87ba7a7863bb68d5d8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_489_jpg.rf.0f8fa7d1ada07f40cf8d8cd7a7e4658a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-507-_jpeg_jpg.rf.0ffe6827917be18ed568d569da317221.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak211_jpg.rf.11300eed976e4737228ad34369548ddc.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-708-_jpeg_jpg.rf.0fe2e337076324d5b5f9831b86030e7d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +28ed8e03-dc85-43e4-872d-183a8ef13a47_jpg.rf.1051da882f91223760d11f7e91f39320.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +22-Male-Thai-Chayapol-Jutamas_jpg.rf.115c0997ec1bbf3142839e883b89e8bb.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +240_F_505136963_IXrvryaszca1gr3AdLK025QSTCPe22u5_jpg.rf.12ddeb6edd91ff741c717a7fdcab51a5.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-473-_jpg.rf.1285b191ea28059ebfa8f3e666581992.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +923126c5-16a0-48ed-969e-0b43af0f3c1e_jpg.rf.11a80783d48499b0e428de89a0df828b.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Berminyak-14-_jpeg_jpg.rf.135b4894458f8e32cefc08449785f692.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle2_130_jpg.rf.13996a1f2c42dfce020d22ff698211d9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +654_jpg.rf.13dfde14326eb23d7e83190b6c78107b.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +levle0_296_jpg.rf.14115a6426f40eb74dca10e9efe83d86.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-799-_jpeg_jpg.rf.154981c92e057f3c3e0f493040b9d2e0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +59_jpg.rf.1446b3c09fea7fcea9e6e2bb5e8489a6.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering_-27-_jpg.rf.15e63ff5b1594f7d63d95c77a3a3fd3d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-341-_jpeg_jpg.rf.14d5b7721eefafc2d23238c3c7b19e50.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_512_jpg.rf.16386a88c809af60c2dd8953e5a8f2e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_101_jpg.rf.16570344788cbd8702e662143da48f15.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-686-_jpg.rf.167ebc1ef6c23646fb85ee859db13b80.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +128_jpg.rf.169cae41ea87354aa54718e13d6efd25.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_531_jpg.rf.169ca39941784f44261fb8fd1a372814.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_351_jpg.rf.169cd83b5f87952e343fd466ea505cc0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +328_jpg.rf.16f539d3c284463ab5b588770445ee76.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +01F3MMWFKMEV4ZTBMDTVCRNY93_jpeg_jpg.rf.16bf3cf3aad2995c29894f13342ad0d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +91_jpg.rf.1721dc69527c883fe7254f874c4fc8be.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +588_jpg.rf.1707107eb1fe7652a51765e3a7cfe042.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +8_jpg.rf.178389c1137031b54695b8ddd738fa3f.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_56_png_jpg.rf.183301c53f3f96335ab4eab5d9c6b453.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_147_jpeg_jpg.rf.1733b71018c9269920c941992d08b7cc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-440-_jpeg_jpg.rf.179ca3a75caf7cfdfb4851eb3f280b11.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_533_jpg.rf.18fbb0438e913bd66f3d9db8cc7cd7cd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-31-_jpg.rf.17bb9e65ee2f0de7b4a55e6bc79875ef.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Kering-19-_JPG_jpg.rf.197b0fbf8086d4f5a1ede4449b103e51.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +251_jpeg_jpg.rf.194ae839bab3c3833f35336f1eaad083.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +112_jpg.rf.19a6dd38a6f12e6b9250df9ef21d26fc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Berminyak-13-_jpg.rf.1a4cf4e82a9b57c8bcd1fc27d0c02c0a.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_340_jpg.rf.1c2050eb4d8dff5f3e83be250205b458.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily9_jpg.rf.1c12b73e9ba8472499b6f80cc7670552.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-791-_jpg.rf.1c03781f7591d3f1a630c2d28b1917d2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-488-_jpg.rf.1a816b0bcafa71b85236e55ac1460a65.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-351-_jpeg_jpg.rf.1cb4852fee79b773d07e78144d04a0e6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2719469801_1_jpg.rf.1c3a36e12e4e53011b485db85d75df81.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-473-_jpeg_jpg.rf.1dd0a2d5a80161124753fee6e01a84ea.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-70-_jpg.rf.1c5d026ca9bca16f7db27ef99132bd17.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_268_jpg.rf.1e26acb287f674c7cb50afa9219196d5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +595_jpg.rf.1e0feefc7548556db7ca99eb8aa27e17.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering-10-_jpg.rf.1f9d0041237fd9f8ba91f300cbb7c445.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +oily-115-_jpg.rf.1f4d952e834f6faa0c002fa1ae9fefaa.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-375-_jpg.rf.1fcee5ee17970d8a4b899e18a24df58d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_210_jpg.rf.202493482028e1e9b5e4d36b958b6c52.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_152_jpeg_jpg.rf.200b1213a53645eb49b48a374c570c08.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_523_jpg.rf.1faa97c41595bb42954c01cdd288f2dd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-43-_jpg.rf.20b37035ee299104f77b421648ef0cc8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +300_jpg.rf.20ac1a76f804bbe1f31d3fd7e54170c2.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle2_18_jpg.rf.20e3cb6f50435d4be4ceeff0be498d6f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-243-_jpeg_jpg.rf.20af7370b93da4d244e99955431c1e47.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +583_jpg.rf.21098c1d9ad409d1d3672ee60f686e52.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_360_jpg.rf.20fe13cb694da353e64d99339f18fbe7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_180_jpg.rf.2112d0defc3c3228f9912b24703f16a5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +43_jpg.rf.20ff67cccb671061ad208a323c89b93d.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +acne-160-_jpeg_jpg.rf.21df78aabd394367a8b0c68221f95054.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_74_jpg.rf.21eaa6caaf6eb3ab1224811759034b9c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +651_jpg.rf.2181b7e16c6b9f16e59aef953650335c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-537-_jpg.rf.213721da3b39f547951550aca31ab7c2.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_154_jpg.rf.22858c086bbbef301b9164d09d652c70.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2947222498_1_jpg.rf.22a44148cadac4a8e13c01f63eb66d4f.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +d221d207-16d9-4ebc-b08e-3a5b9ee7f1ce_jpg.rf.22ee813dec557fed52f59e592c11b40d.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +141_jpg.rf.22ff026284ab02586bbb24594a87dbd4.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_279_jpg.rf.22a670dfd0fbdb6386747939aa3c8a52.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-376-_jpg.rf.23c5013a265e0be85789fb52e7ceb1ca.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_3_jpg.rf.238944800a770a395f7bd4aca4919e63.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +321_jpg.rf.239ccbac6d2534873e3e736177044859.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +303_jpg.rf.23ec7d81bda5ae1a74d10f84b5303691.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Berminyak-19-_jpeg_jpg.rf.242b432f65301b5e9209e271e7461fbd.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_186_png_jpg.rf.2424b39a2c94e143ebcea4bdc125d60f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +455_jpg.rf.24671786fe6a363f9e4baef823ae83a6.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +levle0_198_jpg.rf.257636a45f4e9eabb8421c03fe9f9dae.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-219-_JPG_jpg.rf.25e210c87bd1acafce637e7a71c6f746.jpg, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0 +201_jpg.rf.269ad1a9550da14cddfe77859c1c5349.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-814-_jpg.rf.272972a6793f7e9abe95e1b24f45d8a4.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-8-_jpg.rf.2889478677b4e77b8aaca7874322ce9a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-346-_jpg.rf.2730f00c5815ea24f56a7199df2be26e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-299-_jpg.rf.28d093ba0d574eae94394ab9941f9865.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-731-_jpg.rf.29aaf0fcd959cac4721441732aff4fbe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_223_jpg.rf.29f3223bbeaf5f52221a35de9efeaefe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +440_jpg.rf.2a7221ead3523584b8bcf98d1b373a18.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_468_jpg.rf.2a1886720e6607732e10c5ace865a7c1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle2_115_jpg.rf.28aff844ac135f70baa664e168255354.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-356-_jpg.rf.2a9ad5f7ee775458ea2c0632681d3979.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +598_jpg.rf.2a9af8a21edb8baefe59bd881870ebb0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +246_jpg.rf.2b4ca0065cfc334645b1e7a8b16a34bd.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_468_jpg.rf.2afa32de7bce33b9119e9bf1827246ca.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +120_jpg.rf.2b55be318bea1087117c17878d35143a.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +oily-259-_jpg.rf.2c1750e5e0b65e69a804a760b9b3eec7.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +200_jpg.rf.2ba0df9d0a0b27782c2a35cc75e9691d.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-648-_jpeg_jpg.rf.2c568563e9a521909eadd85ebd210d0d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-76-_jpeg_jpg.rf.2c76bd3263a77810f09152d7c4a7f54d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_52_jpg.rf.2cc737b23f9b73754509e81e6e2348f7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-41-_jpg.rf.2c8c159d7e479ab2863222e8dc25582b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering__-42-_jpg.rf.2cf764f43ee5af4523c4c4e77cc00918.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-371-_jpg.rf.2dc97b07523c6f6462391cdd67131fb9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-151-_jpg.rf.2dc87177cc9ad41542682d4b58795cae.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +621_jpg.rf.2d85bc70343ba89ba9480413f4995d77.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +kering-54-_jpg.rf.2de014fa3d18ef87c4e69a6e45704a4e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle1_229_jpg.rf.2ebbcf06feb4204a99526dd9c7215b70.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_511_jpg.rf.2f45cc07978197db249ec0b6c0b48643.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-302-_jpg.rf.2ecb5867921f31f045aa44ffbfb7dc78.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_491_jpg.rf.2dec42b63e410259befa468578ed0a74.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +download_jpg.rf.2f4b0ee9e318de43fe376945d68ca1ad.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-363-_jpeg_jpg.rf.2f8fab8b3881fdf4495c85242f30ba98.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-494-_jpg.rf.2f7a33b5cdce2122f9117ff3d3f1acc9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +27-Male-Thai-Nawasch-Phupantachsee_jpg.rf.30428a7ae0676ad90666b719c3b36388.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_7_jpg.rf.30457ea3e01a71158582947e95d52c74.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_599_jpg.rf.31058b6e7422dccc38990aed12d758af.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +29_jpeg_jpg.rf.3061c78e7de63f1e09e57f5a49c26639.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +Image_121_png_jpg.rf.314fd60b743546d8c83db0668c2bd034.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +2437904540_1_jpg.rf.311ffff9bff48e03d46a31d0c933633c.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +Kering-35-_jpg.rf.31400d088b9c61ef66738ebe5e4549ac.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_189_jpg.rf.31a1a4dbbe8645d9379dbd1cdaf606c4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +185_jpg.rf.3190c78a8e06316fac6a09de536c0725.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_422_jpg.rf.3349afeefecb27e0dfd7adf4aee68e05.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +350_jpg.rf.331f000765e9daa686469ee121124792.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-855-_jpeg_jpg.rf.337afb97444278f9d3274db51d280b8b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-91-_jpg.rf.33c70bbe76e47b157063c855a4c8bea4.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +e4a70949-16e3-4156-8c91-f74f39e7f581_jpg.rf.33f7d87bdde9270121afd59be50ce245.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +kering_-128-_jpg.rf.34dd8eb2b207104ffb4c196a80caf233.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-789-_jpeg_jpg.rf.346c269e3a0c7d12a720bd071f0e0be8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +683_jpg.rf.33da2f3cc6ac80a3af7db0e1275a98ae.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +levle0_439_jpg.rf.363df9bd96203cacba67fe6adc5ab4bb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +152_jpg.rf.350271bd3f0324215cf2b7a0b792c13c.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +49_jpg.rf.3566377bc80593428217266c839652a2.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-608-_jpg.rf.35d87b5fd467c1133135beb917e7f43d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_46_jpg.rf.366db7de500e7fb8bf3672cb0cdce798.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +81_jpg.rf.369ff6c5dbcc1ec142fe7b483153eae5.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +23_jpg.rf.36446bc74c194c0c2299eac58ddd4d5c.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +255_jpg.rf.36a7d680fa8ac164540a442f74135845.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_95_jpg.rf.37b8734d968cd371db601ffdb8415f99.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-150-_jpg.rf.37809a921fb1833c10d90741c2cd9366.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-840-_jpeg_jpg.rf.3747bbe19ff90c9520783b039f264e5b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-42-_png_jpg.rf.37c0d12427c4503fa575c34cafd4804b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_536_jpg.rf.38d1ceab05e312a6de7a9a49a48215a8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_440_png_jpg.rf.397bda41f6649df916b7af6cc2315320.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +321_jpg.rf.393e91a388d786b8557f6e7c2f7829c6.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-508-_jpg.rf.37d80625fa47a8f5bfc21dc01d90b21d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_589_jpg.rf.39c3527b150e947bea34eabb93baab43.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +374_jpg.rf.3af2ad169874544bcfafa1c45c00ece0.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +189_jpg.rf.3b23be2d7184aa37c3a1358f5a91f6ca.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_522_jpg.rf.3acb258fb5be31867ae586f72823524a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_87_jpg.rf.3b7815c000c0b9e6a990f0b35b497b96.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-110-_jpg.rf.3c75be753f83d50089be3fadd50674dd.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +dry-137-_jpg.rf.3c3e7e26c13fe12aaa295bb43f6eab19.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-352-_jpg.rf.3b6498adb9331313d8c0ddd9531e2fe3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +4ce9b714-aa22-477f-b97b-b43ba91ec765_jpg.rf.3d60289ebc45ff1f203fb569722b4dd8.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-587-_jpg.rf.3d030dcd692c92e5c63adfeff9da2bac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-808-_jpg.rf.3cd775f35f8cd1881b59c102b404944f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_198_jpg.rf.3d95a119ad26b6d881b26eda46848ede.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +01F3MMWHQX27K1VQ2DNNKGJZRV_jpeg_jpg.rf.3e2ded9cf8abb34ab11475326ed5694a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +5_jpg.rf.3e5144355021cc0f85574b10a4c161c8.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_108_jpg.rf.3e21ce60119a7761d9373eca80012469.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-399-_jpeg_jpg.rf.3df3e91cd68f4f03475f6ad308764f07.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-425-_jpg.rf.3e59f26c040929fe9885656ce518ed1d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-317-_jpeg_jpg.rf.3fdcc7bbfd8846d6cb7ab116a92d1e11.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_38_jpg.rf.3f5f0e412af1426abe7edd0807cac58f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +381_jpg.rf.3e6306e3a62224299da9612bc21e394c.jpg, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0 +acne-281-_jpg.rf.3f717113b4e39121fdff570b09467518.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-44-_jpg.rf.402ee617800bfff01ff25f844318826b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_169_jpg.rf.409ee5bad0e95d5214f53380d44e461c.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle3_89_jpg.rf.3ffa2422491cd62579f8660df451d9a3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_122_jpg.rf.40e2a5f20a701cfbecdfbad46273c082.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +667_jpg.rf.40a6bc15c6b1b858f3eaa9b1d7af3e2b.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +61_jpg.rf.40bad1d7262ab677729f0ca7c80aa68d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry-skin_72_jpeg_jpg.rf.41392607d7d422c762bcf7f7a72dc313.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-730-_jpeg_jpg.rf.423e8fda1655519549040a6a8dfc3ec7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_22_jpg.rf.41f36056aedbc00bdbb131bab8cd85b4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-701-_jpg.rf.422043d728b99dcb614c985ddc290837.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +81_jpg.rf.41e6a8559ddcfb5491f7cf24243fe995.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle0_12_jpg.rf.424e7fe954591d707510823ffba607e3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-57-_jpg.rf.424a755a60efe5e01384d2304150db70.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +619_jpg.rf.42763326114f6b986587096dc9650573.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +levle2_73_jpg.rf.4336c25ce7682b5ba11fbdde669d4ffd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_474_jpg.rf.42c1a181a2b4f94c827facd7aef5b4f5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-355-_jpg.rf.4328570da2ad1ed4c813bffe6d14c712.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Kering-34-_jpeg_jpg.rf.42db17107f14c6ba78d1c23d9007c47b.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +d1b665ea-ea41-43c9-b1d5-afc7531603f0_jpg.rf.432f7eda0e30dd12e3fc55856d8ec026.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_134_jpeg_jpg.rf.4395e23c4696afdb27beccdb6e35f3ab.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_382_jpg.rf.43d5250d975e1d59ac12f38da2d4aeb9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +26-Male-Chinese-Ming-Hao-Hou_jpg.rf.43599a939a7bf9a0747754af1c4df9d0.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_264_jpg.rf.43eafc50b80ce708dc3566207f7ff4cc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +263_jpeg_jpg.rf.44a2f4b64d2b6451a4135f834ef358ee.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +28-Male-South-Korean-Dong-Hyun-Lim_jpg.rf.450e2d2a010d3ae8b5affc525a52eade.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +acne-576-_jpg.rf.45556b7a4efa3dfff80e2b7dc4ef7dad.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-56-_jpeg_jpg.rf.4592876fca49dadcb55d5d627dadba43.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +233ebc8e-5bda-40d2-929c-c63ba5ebf96d_jpg.rf.45e25e6a70e0834ebaa2a64be562a657.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-200-_jpeg_jpg.rf.4601c49c9bc049bcf70535fcf9bd02a0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_331_jpg.rf.4606eb2616f5000a76861b411d5e9871.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-prone-skin_169_jpeg_jpg.rf.4618647e579f72ef7ddafb5e8ed30d92.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-853-_jpeg_jpg.rf.466f28b44915a0d2d0acfc45b23e1807.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_8_jpg.rf.46be4e72f9f02e77b8fc4bbd350dfcdb.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_327_jpg.rf.464b79bc6c3d39387d3069bfaf84ffb1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +berminyak__-30-_jpg.rf.47107d7b7158fcd9f75f26b6a551c4c8.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-489-_jpeg_jpg.rf.471a9eb2ca8d735b97e6cc8a33e7a55e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_592_jpg.rf.4806e8ec16ecda4bc1fe907d46221a3d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_68_jpg.rf.480fae199b0f9d46c4974f820f5a1ba0.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +277_jpg.rf.47c222ef2a0f06b34a28e72cec881189.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 +Image_131_png_jpg.rf.487a6751210df7a42bbb4685fb41f73a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +kering_-114-_jpg.rf.489eff56ba455ecbc674e17385738e3e.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-280-_jpeg_jpg.rf.486f74b5bf5b0b1732df3ec8ff14f646.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_182_jpg.rf.48cfe55ed5c677f326fe3dab1d062056.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-291-_jpg.rf.496e241906ea0d3c802d111c63e29d9d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_34_jpeg_jpg.rf.4a12aa0e7008b47feb2f656f8b1616fe.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-49-_jpeg_jpg.rf.4a34b7112472217e5bccb89e0b66719c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-448-_jpg.rf.49b0d8727b88d7e485f292ffd4ed115f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering_-95-_jpg.rf.4a56c52c91be17f5d31da59fb807891a.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-590-_jpg.rf.4b03e81e92c0c94a9d56d170465e64fe.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-785-_jpeg_jpg.rf.4b49f54700cb587abf98ab25770ace7f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +39-Male-South-Korean-Je-Hoon-Lee_jpg.rf.4b48844a38470fb61c1dfda0bf8e33ac.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +persistent-erythema_3249_jpg.rf.4c35595a4df8e70a9e58954dce9083e3.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +levle1_281_jpg.rf.4bf355b4a7e05eb4f4d11b763995f024.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_250_png_jpg.rf.4c15ec6e0e60ac22ecb2192f41e8a89a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +117_jpg.rf.4b5ab984e160f72a0cfe6a41383d466c.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_597_jpg.rf.4cd1932e56b2b09d12d077b0fa13ad65.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +533_jpg.rf.4c48a7b1db6d2f36d93e81c65cf866e3.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +acne-63-_jpeg_jpg.rf.4cdb105bb84e3aef3545f9df952070e4.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-278-_jpg.rf.4cef28a88aa70dc84f56d0503aa0e4c7.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-337-_jpeg_jpg.rf.4cf5ec6729b25a06d84aad011b29b065.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_363_jpg.rf.4cfc2eef39dbd29c97b9ce40cc3f96aa.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-231-_jpg.rf.4d28a726949f3f331684d4fc189735b9.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-202-_jpg.rf.4d1a0c2a08a3f2dbb45d72ece5c64ebf.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +1380edc8-cb4f-4a34-8d83-0159a2e15cb9_jpg.rf.4d3383e29902ada491281547b2fbe002.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_494_jpg.rf.4d3b0da8785d61c488173237c8533cbc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +322_jpg.rf.4db0c5fbe77e6b123a931954f49b1265.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering__-20-_JPG_jpg.rf.4d770623134a241614aeb3750305c236.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_174_jpg.rf.4e2745f47c08e4c28b741aea6491716f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_359_jpg.rf.4dddc8d12af1bd94504c4de730c0779c.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +227_jpg.rf.4dc6b12006f842907f28e29000f58d7e.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +Image_45_jpg.rf.4e640f97197e56f0b883de7ec080ac44.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-729-_jpeg_jpg.rf.4df666985161d0c65be274754d504844.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-872-_jpeg_jpg.rf.4e75252e73aa9f4efc4cac2bb7c83e51.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak133_jpg.rf.4f44340183bac224c56a12f4693c072d.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +acne-451-_jpg.rf.4ffe891608da171d19f429724d67f695.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +roseca1_jpg.rf.4e6c85d9ce5eb1aeb7e9e9fc4fb04750.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0 +acne-875-_jpeg_jpg.rf.4fd8a607c84c07873bec04a143ea42b1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +29-Male-Japanese-Eiji-Akaso_jpg.rf.5017f2aa91a55e3e4bacf17ddd212e7d.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +Image_97_jpg.rf.5061013dc0db74744c4be491143d8a61.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +oily-244-_jpg.rf.5063bda8f00e0217fc4be09819b28452.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle3_64_jpg.rf.50a02138ab0b24dc073f091cf1470370.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-435-_jpeg_jpg.rf.50cacf951d74961efec9438f1f588912.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +10_jpg.rf.5061f2f24e20e75eebdd420c9ac57050.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +105_jpg.rf.5144d3ab94ee29be86fa5f7d62ad7599.jpg, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0 +8d81d218-70fd-423e-9e8a-a734de960456_jpg.rf.51b518c63d870258238fdf2f62311061.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +Image_63_png_jpg.rf.511f9876f8ecafa5e703bb66c11a4f80.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +kering-51-_jpg.rf.515d36ddea8aa6b9c9515008f5d18acb.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle0_504_jpg.rf.53de93e66f2e6e11d1b94588e736701a.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +53_jpg.rf.52548f48f2048455958790b0863bf108.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +berminyak__-56-_JPG_jpg.rf.53af5fd32b5f062359630b17b65d7bc9.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle0_54_jpg.rf.5402895fec478df9e72101dd4d3b96b8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +137_jpg.rf.543e0cd8d40c082bbf46ba6a9be6d620.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +acne-261-_jpeg_jpg.rf.54d52954169d8af8873d7ef3ed31e268.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +88_jpg.rf.54587db9fb48c052573d766cae8774cd.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +acne-513-_jpeg_jpg.rf.55985a98b308221a306ddeaef855bb1f.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_509_jpg.rf.5623ec8fe43ab6e6063b3c0f85319d92.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-881-_jpeg_jpg.rf.56891dd5eb04d3ebdc6d6b27867b5e1b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +2ed1ef53-c137-460c-959e-7bf7fe5f4f02_jpg.rf.55f09a16aa16950ec91552ec50217947.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +548_jpg.rf.56013685e01da39eadfc141f139c3727.jpg, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0 +28-Male-Chinese-Wei-Deng_jpg.rf.57792c5a6c304db4e1bdd42e81013601.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_45_jpg.rf.56ce31d00ecac13cb1a139e81fdbaebb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +495_jpg.rf.5732e709bb6d6b87e9410922d1c1809a.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1 +acne-283-_jpg.rf.56b67cde8c294ad82e3c7aef41122fad.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_14-2-_jpg.rf.5783ab4873aaa6894dd61783ab8cb4b5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_14_jpg.rf.58742b3d7b668d0dcd57a2367e39e5a4.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-641-_jpg.rf.58106354ab5fa27e6dcb201386b6d9d1.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +348_jpg.rf.58942e395e3ce72504179b9e24feccba.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0 +dry35_jpg.rf.58b0a77a5ad5162d840bbd577552bcea.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +levle2_58_jpg.rf.5958dc98798ccb07bcdec62ee21b7a54.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +509_jpg.rf.591b8d3b74adb0658167a4345d097d99.jpg, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0 +53_jpg.rf.5a428d5543b1a575332bd28f46055cee.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle0_248_jpg.rf.59c267ffdf8a03a989eeff59af29d9dd.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-532-_jpg.rf.5bf5ec57816cc88624aa187ec05f25c5.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Berminyak-25-_jpeg_jpg.rf.5af5b1375d6bb4452c36c936de072258.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +levle1_459_jpg.rf.5a8318de4e87d7407f0b9525ea4edfeb.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +fad4426a-1fec-402e-b524-1f30b5df7f8a_jpg.rf.5c2c7bbbfa46f16692bf9b262e2850f7.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +levle1_623_jpg.rf.5cabc40b9f873726ca1df04a11a6ee0d.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +107_jpg.rf.5ca66428c9c1b7157e05b8f2d1f34305.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +kering-4-_jpg.rf.5d0f144f6d05742fc4fd7542dc1de9bc.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +Image_124_jpg.rf.5cc36fd11f6aa98b8d84c0a1cb72917e.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +acne-400-_jpeg_jpg.rf.5d288176e5cf17d8ee15d9633c08ee45.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-206-_jpeg_jpg.rf.5cf848684d34b7fc1c2263deb5482471.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +dry19_jpg.rf.5cd1ea9f24426fdba422ea1b9c4e06fe.jpg, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0 +cce63be7-9a54-46ed-97c7-6f8881f84ab3_jpg.rf.5e8dd4baa8b8a876e244edc569356e0a.jpg, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0 +10_jpeg_jpg.rf.5f58057c0d03d635858bd19c57a5a220.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +levle1_26_jpg.rf.5e330622bd85542cbd14bf9e61021662.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +36-Male-South-Korean-Young-Kwang-Kim_jpg.rf.6000c01fdb4b4010869425b66dbabaf6.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle0_350_jpg.rf.5f6afa712f2168de47fcfbfb418b7fac.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +262_jpg.rf.5ecc877ea9a2a90c13b7cecb92d070bc.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-634-_jpeg_jpg.rf.5f77c954bf2e9b38df913d6507ed5439.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_43_jpg.rf.60468864c3c756da94df4c1ba5899581.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +182_jpg.rf.5fc1d21c101aa35406cb4e2ab99d0c98.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +24_jpg.rf.606ed093bb23c47b9d416e0d82cb7257.jpg, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0 +65_jpg.rf.607cd2acf7985d01fb15adc412fdd6f1.jpg, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 +106_jpg.rf.60a783f918dc73daf5ad836120943da3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_427_jpg.rf.60338fd18f0bc2372a634dd5189bd00b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +Image_119_jpg.rf.6016b20678935811b392775db9a7c153.jpg, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 +42-Male-South-Korean-Rae-Won-Kim_jpg.rf.609bae086d48d5c55a7dc3b4e8e61183.jpg, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0 +levle1_260_jpg.rf.60c6cbe2f3bd619f4ea95c3fe060c4c3.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_413_jpg.rf.610834979efe5a859f4df8124c27d05b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-174-_jpeg_jpg.rf.60f8c31e3ead8ee113aa04a25b700d06.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +oily-233-_JPG_jpg.rf.6143d1ef7c7c84115774ed04d8a1f52e.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-49-_jpg.rf.6079e043daf0590544abdd40bb807c34.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle0_149_jpg.rf.614e93661ebe56a475130342d694a74b.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +levle1_492_jpg.rf.61955f4930a7e2c82c34410fb9fdd2f8.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +acne-815-_jpeg_jpg.rf.617e704aa9c69ffbe280984d2e8fd266.jpg, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 +182_jpg.rf.615900aae11181ed791be4e62a21eb0d.jpg, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1 diff --git a/tests/images/MM2A_46_R_T.png b/tests/images/MM2A_46_R_T.png new file mode 100644 index 00000000..8674705d Binary files /dev/null and b/tests/images/MM2A_46_R_T.png differ diff --git a/tests/images/file_example_TIFF_1MB.tiff b/tests/images/file_example_TIFF_1MB.tiff new file mode 100644 index 00000000..e8826452 Binary files /dev/null and b/tests/images/file_example_TIFF_1MB.tiff differ diff --git a/tests/images/whatsnew.avif b/tests/images/whatsnew.avif new file mode 100644 index 00000000..86540657 Binary files /dev/null and b/tests/images/whatsnew.avif differ diff --git a/tests/manual/debugme.py b/tests/manual/debugme.py index 762dc225..27dc91ed 100644 --- a/tests/manual/debugme.py +++ b/tests/manual/debugme.py @@ -5,6 +5,7 @@ os.environ["ROBOFLOW_CONFIG_DIR"] = f"{thisdir}/data/.config" from roboflow.roboflowpy import _argparser # noqa: E402 +from roboflow import Roboflow # import requests # requests.urllib3.disable_warnings() @@ -12,13 +13,14 @@ rootdir = os.path.abspath(f"{thisdir}/../..") sys.path.append(rootdir) -if __name__ == "__main__": + +def run_cli(): parser = _argparser() # args = parser.parse_args(["login"]) # args = parser.parse_args(f"upload {thisdir}/../datasets/chess -w wolfodorpythontests -p chess".split()) # noqa: E501 // docs args = parser.parse_args( # ["login"] - # "download https://universe.roboflow.com/gdit/aerial-airport".split() + "download -f yolov8 https://universe.roboflow.com/gdit/aerial-airport".split() # "project list -w wolfodorpythontests".split() # "project get cultura-pepino-dark".split() # "workspace list".split() @@ -42,6 +44,35 @@ # f"import {thisdir}/data/cultura-pepino-yolov5pytorch -w wolfodorpythontests -p yellow-auto -c 100 -n papaiasso".split() # noqa: E501 // docs # f"import {thisdir}/../datasets/mosquitos -w wolfodorpythontests -p yellow-auto -n papaiasso".split() # noqa: E501 // docs # f"deployment list".split() # noqa: E501 // docs - f"import -w tonyprivate -p meh-plvrv {thisdir}/../datasets/paligemma/".split() # noqa: E501 // docs + # f"import -w tonyprivate -p meh-plvrv {thisdir}/../datasets/paligemma/".split() # noqa: E501 // docs ) args.func(args) + + +def run_api_train(): + rf = Roboflow() + project = rf.workspace("meh3").project("mosquitobao") + version_number = project.generate_version( + settings={ + "augmentation": { + "bbblur": {"pixels": 1.5}, + "image": {"versions": 2}, + }, + "preprocessing": { + "auto-orient": True, + }, + } + ) + # version_number = "61" + print(version_number) + version = project.version(version_number) + model = version.train( + speed="fast", # Options: "fast" (default) or "accurate" (paid feature) + checkpoint=None, # Use a specific checkpoint to continue training + ) + print(model) + + +if __name__ == "__main__": + # run_cli() + run_api_train() diff --git a/tests/manual/demo_image_metadata.py b/tests/manual/demo_image_metadata.py new file mode 100644 index 00000000..2ed17166 --- /dev/null +++ b/tests/manual/demo_image_metadata.py @@ -0,0 +1,95 @@ +"""Manual demo/smoke test for the image metadata SDK wrappers (DATAMAN-337). + +Usage (staging): + API_URL=https://api.roboflow.one ROBOFLOW_API_KEY= \ + .venv/bin/python tests/manual/demo_image_metadata.py + +Optional env: RF_WORKSPACE (default model-evaluation-workspace), +RF_PROJECT (default penguin-finder). + +Exercises workspace.update_image_metadata, project.update_image_metadata, +batch_update_image_metadata (no-wait + wait=True with a bogus id), server-side +validation errors, and cleans up everything it wrote. +""" + +import os +import time + +import roboflow +from roboflow.adapters.rfapi import RoboflowError + +WORKSPACE = os.environ.get("RF_WORKSPACE", "model-evaluation-workspace") +PROJECT = os.environ.get("RF_PROJECT", "penguin-finder") +TAG = f"smoke-{int(time.time())}" + +rf = roboflow.Roboflow() +workspace = rf.workspace(WORKSPACE) +project = workspace.project(PROJECT) + +ids = [r["id"] for r in project.search(fields=["id"], limit=2)] +assert len(ids) == 2, f"need 2 images in {WORKSPACE}/{PROJECT}, got {len(ids)}" +print(f"image ids: {ids}, tag: {TAG}") + +# --- Single image (workspace) --- +print("=== workspace.update_image_metadata ===") +r = workspace.update_image_metadata(ids[0], metadata={"smoke_key": "v1"}, add_tags=[TAG]) +assert r == {"success": True} +print("ok: single update succeeded") + +# --- Single image (project alias) --- +print("=== project.update_image_metadata ===") +r = project.update_image_metadata(ids[0], metadata={"smoke_project": True}) +assert r == {"success": True} +print("ok: project alias update succeeded") + +# --- Batch, fire-and-forget --- +print("=== batch (no wait) + get_async_task ===") +r = workspace.batch_update_image_metadata([{"imageId": ids[0], "addTags": [TAG]}]) +assert "taskId" in r and "url" in r +print("ok: batch enqueued (taskId + url returned)") +for _ in range(20): + status = workspace.get_async_task(r["taskId"]) + if status["status"] not in ("created", "running"): + break + time.sleep(3) +assert status["status"] == "completed" +print("ok: async task completed") + +# --- Batch, wait=True, with one bogus id -> partial success --- +print("=== batch (wait=True) with bogus id ===") +updates = [{"imageId": i, "metadata": {"smoke_batch": "yes"}} for i in ids] +updates.append({"imageId": "bogus-does-not-exist", "addTags": [TAG]}) +final = workspace.batch_update_image_metadata(updates, wait=True, timeout=300) +assert final["status"] == "completed" +assert final["result"]["succeeded"] == 2 +assert final["result"]["failedItems"][0]["imageId"] == "bogus-does-not-exist" +print(f"ok: partial success (succeeded={int(final['result']['succeeded'])}, failed={int(final['result']['failed'])})") + +# --- Server-side validation surfaces as RoboflowError --- +print("=== validation errors ===") +for kwargs, expect in [ + ({"add_tags": ["bad tag spaces"]}, "Invalid tag"), + ({}, "At least one of"), +]: + try: + workspace.update_image_metadata(ids[0], **kwargs) + raise AssertionError(f"expected RoboflowError containing {expect!r}") + except RoboflowError as e: + assert expect in str(e) + print(f"ok: {expect!r} surfaced") + +# --- Cleanup --- +print("=== cleanup ===") +cleanup = [ + { + "imageId": i, + "removeMetadata": ["smoke_key", "smoke_project", "smoke_batch"], + "removeTags": [TAG], + } + for i in ids +] +final = workspace.batch_update_image_metadata(cleanup, wait=True, timeout=300) +assert final["result"]["succeeded"] == 2 +print("ok: cleanup batch succeeded on both images") + +print("\nALL CHECKS PASSED") diff --git a/tests/manual/demo_workspace_search.py b/tests/manual/demo_workspace_search.py new file mode 100644 index 00000000..94c23057 --- /dev/null +++ b/tests/manual/demo_workspace_search.py @@ -0,0 +1,42 @@ +"""Manual demo for workspace-level search (DATAMAN-163). + +Usage: + python tests/manual/demo_workspace_search.py + +Uses staging credentials from CLAUDE.md. +""" + +import os + +import roboflow + +thisdir = os.path.dirname(os.path.abspath(__file__)) +os.environ["ROBOFLOW_CONFIG_DIR"] = f"{thisdir}/data/.config" + +WORKSPACE = "model-evaluation-workspace" + +rf = roboflow.Roboflow() +ws = rf.workspace(WORKSPACE) + +# --- Single page search --- +print("=== Single page search ===") +page = ws.search("project:false", page_size=5) +print(f"Total results: {page['total']}") +print(f"Results in this page: {len(page['results'])}") +print(f"Continuation token: {page.get('continuationToken')}") +for img in page["results"]: + print(f" - {img.get('filename', 'N/A')}") + +# --- Paginated search_all --- +print("\n=== Paginated search_all (page_size=3, max 2 pages) ===") +count = 0 +for page_results in ws.search_all("*", page_size=3): + count += 1 + print(f"Page {count}: {len(page_results)} results") + for img in page_results: + print(f" - {img.get('filename', 'N/A')}") + if count >= 2: + print("(stopping after 2 pages for demo)") + break + +print("\nDone.") diff --git a/tests/manual/demo_zip_upload.py b/tests/manual/demo_zip_upload.py new file mode 100644 index 00000000..19ee09ac --- /dev/null +++ b/tests/manual/demo_zip_upload.py @@ -0,0 +1,122 @@ +"""Manual validation for the zip upload flow on Workspace.upload_dataset. + +Edit the constants below, then uncomment the scenario you want to run. +""" + +from __future__ import annotations + +import os +import sys +import time + +thisdir = os.path.dirname(os.path.abspath(__file__)) +rootdir = os.path.abspath(f"{thisdir}/../..") +sys.path.insert(0, rootdir) + +from roboflow import Roboflow # noqa: E402 +from roboflow.adapters import rfapi # noqa: E402 + +# ---- edit these ----------------------------------------------------------- +# Reads from env by default; set directly if you prefer. +API_KEY = os.environ.get("ROBOFLOW_API_KEY", "") +WORKSPACE = os.environ.get("ROBOFLOW_WORKSPACE", "rodrigo-xn5xn") +PROJECT = os.environ.get("ROBOFLOW_PROJECT", "small-od") + +ZIP_PATH = os.path.expanduser("~/Downloads/instance-seg.coco-segmentation.zip") +DIR_PATH = os.path.expanduser("~/Downloads/instance-seg.coco-segmentation") +# For the `status` scenario, paste the task_id returned by the `no_wait` run +TASK_ID = "" +# --------------------------------------------------------------------------- + + +def _batch(tag: str) -> str: + return f"zip-demo-{tag}-{int(time.time())}" + + +def scenario_zip_path(workspace) -> None: + print(f"\n=== scenario: zip_path (file={ZIP_PATH}) ===") + result = workspace.upload_dataset( + dataset_path=ZIP_PATH, + project_name=PROJECT, + batch_name=_batch("zip"), + ) + print(f"result: {result}") + + +def scenario_dir_default(workspace) -> None: + """Directory without use_zip_upload β€” legacy per-image flow (returns None).""" + print(f"\n=== scenario: dir_default (dir={DIR_PATH}) ===") + result = workspace.upload_dataset( + dataset_path=DIR_PATH, + project_name=PROJECT, + batch_name=_batch("dir-peritem"), + ) + print(f"result: {result} (expected: None -- per-image flow)") + + +def scenario_dir_zip_opt_in(workspace) -> None: + """Directory with use_zip_upload=True β€” SDK zips client-side.""" + print(f"\n=== scenario: dir_zip_opt_in (dir={DIR_PATH}) ===") + result = workspace.upload_dataset( + dataset_path=DIR_PATH, + project_name=PROJECT, + batch_name=_batch("dir-zip"), + use_zip_upload=True, + ) + print(f"result: {result}") + + +def scenario_no_wait(workspace) -> None: + print(f"\n=== scenario: no_wait (file={ZIP_PATH}) ===") + result = workspace.upload_dataset( + dataset_path=ZIP_PATH, + project_name=PROJECT, + batch_name=_batch("nowait"), + wait=False, + ) + print(f"result: {result}") + print(f"-> paste this task_id into TASK_ID and run scenario_status: {result['task_id']}") + + +def scenario_status(workspace) -> None: + print(f"\n=== scenario: status (task_id={TASK_ID}) ===") + status = rfapi.get_zip_upload_status(API_KEY, workspace.url, TASK_ID) + print(f"status: {status}") + + +def scenario_with_tags_and_split(workspace) -> None: + print(f"\n=== scenario: tags + split (file={ZIP_PATH}) ===") + result = workspace.upload_dataset( + dataset_path=ZIP_PATH, + project_name=PROJECT, + batch_name=_batch("tagged"), + split="train", + tags=["reviewed", "batch-q4"], + ) + print(f"result: {result}") + + +def scenario_prediction_per_image(workspace) -> None: + """Prediction upload always uses per-image flow (zip flow doesn't support it).""" + print(f"\n=== scenario: prediction_per_image (dir={DIR_PATH}) ===") + result = workspace.upload_dataset( + dataset_path=DIR_PATH, + project_name=PROJECT, + batch_name=_batch("pred"), + is_prediction=True, + ) + print(f"result: {result} (expected: None -- per-image flow)") + + +if __name__ == "__main__": + rf = Roboflow(api_key=API_KEY) + workspace = rf.workspace(WORKSPACE) + + # Uncomment the scenario you want to run: + # scenario_zip_path(workspace) + # scenario_dir_default(workspace) + scenario_dir_zip_opt_in(workspace) + # scenario_no_wait(workspace) + # scenario_status(workspace) + # scenario_with_tags_and_split(workspace) + # scenario_prediction_per_image(workspace) diff --git a/tests/manual/uselocal b/tests/manual/uselocal index 8c8bf9ca..f9c0b713 100644 --- a/tests/manual/uselocal +++ b/tests/manual/uselocal @@ -1,8 +1,15 @@ #!/bin/env bash -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cp $SCRIPT_DIR/data/.config-staging $SCRIPT_DIR/data/.config -export API_URL=https://localhost.roboflow.one -export APP_URL=https://localhost.roboflow.one +SCRIPT_PATH="${BASH_SOURCE[0]}" +[ -n "$ZSH_VERSION" ] && SCRIPT_PATH="${(%):-%x}" +SCRIPT_DIR="$( cd "$( dirname "$SCRIPT_PATH" )" && pwd )" +cp "$SCRIPT_DIR/data/.config-staging" "$SCRIPT_DIR/data/.config" +export API_URL=https://localapi.roboflow.one +export APP_URL=https://localapp.roboflow.one export DEDICATED_DEPLOYMENT_URL=https://staging.roboflow.cloud export ROBOFLOW_CONFIG_DIR=$SCRIPT_DIR/data/.config +MKCERT_ROOT_CA=$HOME/Library/Application\ Support/mkcert/rootCA.pem +if [ -f "$MKCERT_ROOT_CA" ]; then + export REQUESTS_CA_BUNDLE=$MKCERT_ROOT_CA + export CURL_CA_BUNDLE=$MKCERT_ROOT_CA +fi # need to set it in /etc/hosts to the IP of host.docker.internal! diff --git a/tests/models/test_instance_segmentation.py b/tests/models/test_instance_segmentation.py index 6c1dfc60..b6a8759f 100644 --- a/tests/models/test_instance_segmentation.py +++ b/tests/models/test_instance_segmentation.py @@ -46,7 +46,7 @@ class TestInstanceSegmentation(unittest.TestCase): dataset_id = "test-123" version = "23" - api_url = f"https://outline.roboflow.com/{dataset_id}/{version}" + api_url = f"https://serverless.roboflow.com/{dataset_id}/{version}" _default_params = { "api_key": api_key, @@ -142,3 +142,23 @@ def test_predict_with_non_200_response_raises_http_error(self): with self.assertRaises(HTTPError): instance.predict(image_path) + + @responses.activate + def test_predict_with_numpy_array(self): + # Create a simple numpy array image + import numpy as np + + image_array = np.zeros((100, 100, 3), dtype=np.uint8) # Create a black image + image_array[30:70, 30:70] = 255 # Add a white square + + instance = InstanceSegmentationModel(self.api_key, self.version_id) + + responses.add(responses.POST, self.api_url, json=MOCK_RESPONSE) + group = instance.predict(image_array) + self.assertIsInstance(group, PredictionGroup) + + request = responses.calls[0].request + self.assertEqual(request.method, "POST") + self.assertRegex(request.url, rf"^{self.api_url}") + self.assertDictEqual(request.params, self._default_params) + self.assertIsNotNone(request.body) diff --git a/tests/models/test_keypoint_detection.py b/tests/models/test_keypoint_detection.py new file mode 100644 index 00000000..f49be85f --- /dev/null +++ b/tests/models/test_keypoint_detection.py @@ -0,0 +1,73 @@ +import json +import os +import unittest +from pathlib import Path + +import responses +from dotenv import load_dotenv + +from roboflow.config import KEYPOINT_DETECTION_MODEL +from roboflow.models.keypoint_detection import KeypointDetectionModel +from roboflow.util.prediction import PredictionGroup + +load_dotenv(Path("../../.env")) + + +with open(Path("tests/annotations/keypoint-detection-annotations/MM2A_46_R_T_predictions.json")) as f: + MOCK_RESPONSE = json.load(f) + + +class TestKeypointDetection(unittest.TestCase): + api_key = os.getenv("ROBOFLOW_API_KEY", "test-api-key") + workspace = os.getenv("WORKSPACE_ID") + dataset_id = os.getenv("PROJECT_NAME") + version = "1" + + api_url = f"https://serverless.roboflow.com/{dataset_id}/{version}" + + _default_params = {"api_key": api_key, "confidence": "40", "name": "YOUR_IMAGE.jpg"} + + def setUp(self): + super().setUp() + self.version_id = f"{self.workspace}/{self.dataset_id}/{self.version}" + + def test_init_sets_attributes(self): + instance = KeypointDetectionModel(self.api_key, self.version_id, version=self.version) + + self.assertEqual(instance.id, self.version_id) + self.assertEqual(instance.version, self.version) + self.assertEqual(instance.base_url, "https://serverless.roboflow.com/") + + @responses.activate + def test_predict_local_image(self): + instance = KeypointDetectionModel(self.api_key, self.version_id, version=self.version) + + responses.add(responses.POST, self.api_url, json=MOCK_RESPONSE, status=200) + + result = instance.predict("tests/images/MM2A_46_R_T.png") + + self.assertIsInstance(result, PredictionGroup) + self.assertEqual(len(result.predictions), len(MOCK_RESPONSE["predictions"])) + self.assertEqual(result.predictions[0]["prediction_type"], KEYPOINT_DETECTION_MODEL) + self.assertIn("keypoints", result.predictions[0].json()) + + @responses.activate + def test_predict_with_confidence(self): + instance = KeypointDetectionModel(self.api_key, self.version_id, version=self.version) + + responses.add(responses.POST, self.api_url, json=MOCK_RESPONSE, status=200) + + result = instance.predict("tests/images/MM2A_46_R_T.png", confidence=30) + + self.assertIsInstance(result, PredictionGroup) + request = responses.calls[0].request + self.assertEqual(request.params["confidence"], "30") + + @responses.activate + def test_predict_error_response(self): + instance = KeypointDetectionModel(self.api_key, self.version_id, version=self.version) + + responses.add(responses.POST, self.api_url, json={"error": "Invalid API key"}, status=401) + + with self.assertRaises(Exception): + instance.predict("tests/images/MM2A_46_R_T.png") diff --git a/tests/models/test_vlm.py b/tests/models/test_vlm.py new file mode 100644 index 00000000..56c48dc2 --- /dev/null +++ b/tests/models/test_vlm.py @@ -0,0 +1,82 @@ +"""Unit tests for roboflow.models.vlm.VLMModel.""" + +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +from roboflow.models.vlm import VLMModel + + +class TestVLMModel(unittest.TestCase): + def _make(self) -> VLMModel: + return VLMModel(api_key="k", id="ws/proj/3", name="proj", version="3") + + @patch("roboflow.models.vlm.check_image_url", return_value=True) + @patch("roboflow.models.vlm.requests.get") + def test_predict_url_returns_raw_dict(self, mock_get: MagicMock, _chk: MagicMock) -> None: + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: {"response": {">": "box"}}, + ) + model = self._make() + result = model.predict("https://example.com/img.jpg") + + self.assertEqual(result, {"response": {">": "box"}}) + called_url = mock_get.call_args[0][0] + self.assertIn("https://serverless.roboflow.com/proj/3", called_url) + self.assertIn("api_key=k", called_url) + self.assertIn("image=", called_url) + + @patch("roboflow.models.vlm.check_image_url", return_value=True) + @patch("roboflow.models.vlm.requests.get") + def test_predict_forwards_extra_kwargs_as_query(self, mock_get: MagicMock, _chk: MagicMock) -> None: + mock_get.return_value = MagicMock(status_code=200, json=lambda: {"ok": True}) + self._make().predict("https://example.com/img.jpg", prompt="caption") + + called_url = mock_get.call_args[0][0] + self.assertIn("prompt=caption", called_url) + + @patch("roboflow.models.vlm.check_image_url", return_value=True) + @patch("roboflow.models.vlm.requests.get") + def test_predict_non_200_raises(self, mock_get: MagicMock, _chk: MagicMock) -> None: + mock_get.return_value = MagicMock(status_code=401, text="unauthorized") + with self.assertRaises(Exception) as ctx: + self._make().predict("https://example.com/img.jpg") + self.assertIn("unauthorized", str(ctx.exception)) + + @patch("roboflow.models.vlm.os.path.exists", return_value=True) + @patch("roboflow.models.vlm.Image.open") + @patch("roboflow.models.vlm.requests.post") + def test_predict_local_path_posts_base64( + self, mock_post: MagicMock, mock_open: MagicMock, _exists: MagicMock + ) -> None: + mock_img = MagicMock() + mock_img.convert.return_value = mock_img + + def _save(buf: object, **_kw: object) -> None: + buf.write(b"fakejpeg") # type: ignore[attr-defined] + + mock_img.save.side_effect = _save + mock_open.return_value = mock_img + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"ok": True}) + + result = self._make().predict("/tmp/x.jpg") + self.assertEqual(result, {"ok": True}) + _, kwargs = mock_post.call_args + self.assertEqual(kwargs["headers"], {"Content-Type": "application/x-www-form-urlencoded"}) + self.assertIsInstance(kwargs["data"], str) + + def test_predict_missing_local_file_raises(self) -> None: + with self.assertRaises(Exception) as ctx: + self._make().predict("/definitely/not/a/real/path.jpg") + self.assertIn("does not exist", str(ctx.exception)) + + def test_endpoint_uses_id_parts_when_version_unset(self) -> None: + model = VLMModel(api_key="k", id="ws/proj/7") + model.version = None + self.assertEqual(model._endpoint(), "https://serverless.roboflow.com/proj/7") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 00000000..f4aec47a --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,319 @@ +"""Tests for Device, devicesapi adapter, and Workspace device methods.""" + +from __future__ import annotations + +import unittest +from typing import Any, Dict +from unittest.mock import MagicMock, patch + +from roboflow.adapters import devicesapi +from roboflow.adapters.devicesapi import ( + DeviceApiError, + DeviceAuthError, + DeviceBadRequestError, + DeviceNotFoundError, + DeviceRateLimitedError, +) +from roboflow.core.device import Device + +API_KEY = "fake-key" +WORKSPACE = "ws-1" +DEVICE_ID = "dev-abc" + + +def _mock_response(status: int, payload: Any) -> MagicMock: + response = MagicMock() + response.status_code = status + response.json.return_value = payload + response.text = "" if isinstance(payload, dict) else str(payload) + return response + + +class TestDevicesApiUrlBuilding(unittest.TestCase): + """The adapter must build correct workspace-scoped /devices/v2/* URLs.""" + + @patch("roboflow.adapters.devicesapi.requests.get") + def test_list_devices_url(self, mock_get): + mock_get.return_value = _mock_response(200, {"data": []}) + result = devicesapi.list_devices(API_KEY, WORKSPACE) + called_url = mock_get.call_args[0][0] + self.assertIn(f"/{WORKSPACE}/devices/v2", called_url) + self.assertIn(f"api_key={API_KEY}", called_url) + self.assertEqual(result, {"data": []}) + + @patch("roboflow.adapters.devicesapi.requests.get") + def test_get_device_url(self, mock_get): + mock_get.return_value = _mock_response(200, {"id": DEVICE_ID}) + devicesapi.get_device(API_KEY, WORKSPACE, DEVICE_ID) + called_url = mock_get.call_args[0][0] + self.assertIn(f"/{WORKSPACE}/devices/v2/{DEVICE_ID}", called_url) + + @patch("roboflow.adapters.devicesapi.requests.get") + def test_list_device_streams_returns_envelope(self, mock_get): + mock_get.return_value = _mock_response(200, {"data": [{"id": "s1"}]}) + result = devicesapi.list_device_streams(API_KEY, WORKSPACE, DEVICE_ID) + called_url = mock_get.call_args[0][0] + self.assertIn(f"/{WORKSPACE}/devices/v2/{DEVICE_ID}/streams", called_url) + self.assertEqual(result, {"data": [{"id": "s1"}]}) + + @patch("roboflow.adapters.devicesapi.requests.get") + def test_logs_csv_serialization(self, mock_get): + mock_get.return_value = _mock_response(200, {"data": [], "pagination": {}}) + devicesapi.get_device_logs( + API_KEY, + WORKSPACE, + DEVICE_ID, + service=["a", "b"], + severity=["INFO", "WARN"], + limit=50, + ) + called_url = mock_get.call_args[0][0] + # csv-serialized list params; characters URL-encoded by urllib + self.assertIn("service=a%2Cb", called_url) + self.assertIn("severity=INFO%2CWARN", called_url) + self.assertIn("limit=50", called_url) + + @patch("roboflow.adapters.devicesapi.requests.get") + def test_telemetry_time_period(self, mock_get): + mock_get.return_value = _mock_response(200, {"buckets": []}) + devicesapi.get_device_telemetry(API_KEY, WORKSPACE, DEVICE_ID, time_period="7d") + called_url = mock_get.call_args[0][0] + self.assertIn("time_period=7d", called_url) + + @patch("roboflow.adapters.devicesapi.requests.get") + def test_events_passes_cursor_unparsed(self, mock_get): + mock_get.return_value = _mock_response(200, {"data": [], "pagination": {}}) + # Cursors are opaque base64url strings; must round-trip without parsing. + cursor = "eyJ0aW1lc3RhbXAiOiAiMjAyNi0wNC0yMyAxMDowMDowMCJ9" + devicesapi.get_device_events(API_KEY, WORKSPACE, DEVICE_ID, cursor=cursor, direction="forward") + called_url = mock_get.call_args[0][0] + self.assertIn(f"cursor={cursor}", called_url) + self.assertIn("direction=forward", called_url) + + @patch("roboflow.adapters.devicesapi.requests.post") + def test_create_device_body_field_names(self, mock_post): + mock_post.return_value = _mock_response(201, {"deviceId": "d1", "installId": "i1"}) + devicesapi.create_device( + API_KEY, + WORKSPACE, + device_name="Cam 1", + device_type="edge", + workflow_id="wf-1", + tags=["a"], + offline_mode=True, + source_device_id="other", + ) + body = mock_post.call_args.kwargs["json"] + self.assertEqual(body["device_name"], "Cam 1") + self.assertEqual(body["device_type"], "edge") + self.assertEqual(body["workflow_id"], "wf-1") + self.assertEqual(body["tags"], ["a"]) + self.assertTrue(body["offline_mode"]) + # Body field is camelCase per docs/api/deployments/overview.md + self.assertEqual(body["sourceDeviceId"], "other") + + @patch("roboflow.adapters.devicesapi.requests.post") + @patch("roboflow.adapters.devicesapi.requests.get") + def test_requests_use_default_timeout(self, mock_get, mock_post): + mock_get.return_value = _mock_response(200, {"data": [], "pagination": {}}) + mock_post.return_value = _mock_response(201, {"deviceId": "d1", "installId": "i1"}) + + devicesapi.list_devices(API_KEY, WORKSPACE) + devicesapi.create_device(API_KEY, WORKSPACE, device_name="Cam 1") + devicesapi.get_device(API_KEY, WORKSPACE, DEVICE_ID) + devicesapi.get_device_config(API_KEY, WORKSPACE, DEVICE_ID) + devicesapi.get_device_config_history(API_KEY, WORKSPACE, DEVICE_ID) + devicesapi.list_device_streams(API_KEY, WORKSPACE, DEVICE_ID) + devicesapi.get_device_stream(API_KEY, WORKSPACE, DEVICE_ID, "s1") + devicesapi.get_device_logs(API_KEY, WORKSPACE, DEVICE_ID) + devicesapi.get_device_telemetry(API_KEY, WORKSPACE, DEVICE_ID) + devicesapi.get_device_events(API_KEY, WORKSPACE, DEVICE_ID) + + for call in mock_get.call_args_list: + self.assertEqual(call.kwargs["timeout"], devicesapi.DEFAULT_TIMEOUT) + for call in mock_post.call_args_list: + self.assertEqual(call.kwargs["timeout"], devicesapi.DEFAULT_TIMEOUT) + + +class TestDevicesApiErrors(unittest.TestCase): + """Each non-2xx HTTP status maps to a typed exception.""" + + def _expect(self, status: int, expected_cls: type) -> None: + with patch("roboflow.adapters.devicesapi.requests.get") as mock_get: + mock_get.return_value = _mock_response(status, {"error": "bad"}) + with self.assertRaises(expected_cls) as ctx: + devicesapi.get_device(API_KEY, WORKSPACE, DEVICE_ID) + self.assertEqual(ctx.exception.status_code, status) + + def test_400_bad_request(self) -> None: + self._expect(400, DeviceBadRequestError) + + def test_401_auth(self) -> None: + self._expect(401, DeviceAuthError) + + def test_403_auth(self) -> None: + self._expect(403, DeviceAuthError) + + def test_404_not_found(self) -> None: + self._expect(404, DeviceNotFoundError) + + def test_404_missing_scope_is_auth(self) -> None: + # validateToken.js returns 404 + GraphMethodException when the api_key + # is valid for the workspace but lacks the device:read/update scope. + body = {"error": {"type": "GraphMethodException", "message": "scope missing"}} + with patch("roboflow.adapters.devicesapi.requests.get") as mock_get: + mock_get.return_value = _mock_response(404, body) + with self.assertRaises(DeviceAuthError) as ctx: + devicesapi.get_device(API_KEY, WORKSPACE, DEVICE_ID) + self.assertEqual(ctx.exception.status_code, 404) + + def test_429_rate_limit(self) -> None: + self._expect(429, DeviceRateLimitedError) + + def test_500_generic(self) -> None: + self._expect(500, DeviceApiError) + + def test_500_truncates_huge_response_body(self) -> None: + # Server-side 500s sometimes return a multi-KB HTML stack trace. The + # adapter must cap that before it lands in str(exc). + huge_body = "X" * 10_000 # 10x the cap + with patch("roboflow.adapters.devicesapi.requests.get") as mock_get: + response = MagicMock() + response.status_code = 500 + response.json.side_effect = ValueError("not JSON") + response.text = huge_body + mock_get.return_value = response + with self.assertRaises(DeviceApiError) as ctx: + devicesapi.get_device(API_KEY, WORKSPACE, DEVICE_ID) + msg = str(ctx.exception) + self.assertLess(len(msg), len(huge_body)) + self.assertTrue(msg.endswith("…[truncated]")) + + +class TestDeviceClass(unittest.TestCase): + """Device exposes the per-device sub-resources.""" + + def setUp(self) -> None: + self.info: Dict[str, Any] = { + "id": DEVICE_ID, + "name": "Cam 1", + "status": "online", + "type": "edge", + "tags": ["floor-1"], + } + self.device = Device(API_KEY, WORKSPACE, self.info) + + def test_init_caches_summary_fields(self) -> None: + self.assertEqual(self.device.id, DEVICE_ID) + self.assertEqual(self.device.name, "Cam 1") + self.assertEqual(self.device.status, "online") + self.assertEqual(self.device.type, "edge") + self.assertEqual(self.device.tags, ["floor-1"]) + + @patch("roboflow.adapters.devicesapi.get_device_config") + def test_config_calls_adapter(self, mock_config) -> None: + mock_config.return_value = {"device_id": DEVICE_ID, "config": {}} + result = self.device.config() + mock_config.assert_called_once_with(API_KEY, WORKSPACE, DEVICE_ID) + self.assertEqual(result["device_id"], DEVICE_ID) + + @patch("roboflow.adapters.devicesapi.get_device_config_history") + def test_config_history_passes_cursor(self, mock_hist) -> None: + mock_hist.return_value = {"data": [], "pagination": {}} + self.device.config_history(limit=20, cursor="2026-04-23T10:00:00Z") + mock_hist.assert_called_once_with(API_KEY, WORKSPACE, DEVICE_ID, limit=20, cursor="2026-04-23T10:00:00Z") + + @patch("roboflow.adapters.devicesapi.list_device_streams") + def test_streams(self, mock_streams) -> None: + mock_streams.return_value = {"data": [{"id": "s1"}]} + self.assertEqual(self.device.streams(), [{"id": "s1"}]) + + @patch("roboflow.adapters.devicesapi.get_device_stream") + def test_stream(self, mock_stream) -> None: + mock_stream.return_value = {"id": "s1"} + self.device.stream("s1") + mock_stream.assert_called_once_with(API_KEY, WORKSPACE, DEVICE_ID, "s1") + + @patch("roboflow.adapters.devicesapi.get_device_logs") + def test_logs_forwards_kwargs(self, mock_logs) -> None: + mock_logs.return_value = {"data": [], "pagination": {}} + self.device.logs(severity=["ERROR"], limit=10) + kwargs = mock_logs.call_args.kwargs + self.assertEqual(kwargs["severity"], ["ERROR"]) + self.assertEqual(kwargs["limit"], 10) + + @patch("roboflow.adapters.devicesapi.get_device_telemetry") + def test_telemetry(self, mock_tel) -> None: + mock_tel.return_value = {"buckets": []} + self.device.telemetry("1h") + mock_tel.assert_called_once_with(API_KEY, WORKSPACE, DEVICE_ID, time_period="1h") + + @patch("roboflow.adapters.devicesapi.get_device_events") + def test_events_forwards_all_filters(self, mock_events) -> None: + mock_events.return_value = {"data": [], "pagination": {}} + self.device.events( + entity_type="stream", + entity_id="pipe-1", + event="stream_started", + start_time="2026-04-01T00:00:00Z", + end_time="2026-04-30T00:00:00Z", + limit=200, + cursor="opaque", + direction="forward", + ) + kwargs = mock_events.call_args.kwargs + self.assertEqual(kwargs["entity_type"], "stream") + self.assertEqual(kwargs["entity_id"], "pipe-1") + self.assertEqual(kwargs["event"], "stream_started") + self.assertEqual(kwargs["limit"], 200) + self.assertEqual(kwargs["cursor"], "opaque") + self.assertEqual(kwargs["direction"], "forward") + + @patch("roboflow.adapters.devicesapi.get_device") + def test_refresh_updates_fields(self, mock_get) -> None: + mock_get.return_value = {"id": DEVICE_ID, "name": "Cam 1 (renamed)", "status": "offline", "tags": []} + self.device.refresh() + self.assertEqual(self.device.name, "Cam 1 (renamed)") + self.assertEqual(self.device.status, "offline") + self.assertEqual(self.device.tags, []) + + +class TestWorkspaceDeviceMethods(unittest.TestCase): + """Workspace.devices() / .device() / .create_device() route through the adapter.""" + + def setUp(self) -> None: + from roboflow.core.workspace import Workspace + + info = {"workspace": {"name": "Test", "url": WORKSPACE, "projects": []}} + self.workspace = Workspace(info=info, api_key=API_KEY, default_workspace=WORKSPACE, model_format="yolov8") + + @patch("roboflow.adapters.devicesapi.list_devices") + def test_devices_returns_device_objects(self, mock_list) -> None: + mock_list.return_value = {"data": [{"id": "a"}, {"id": "b"}]} + devices = self.workspace.devices() + self.assertEqual(len(devices), 2) + self.assertIsInstance(devices[0], Device) + self.assertEqual(devices[0].id, "a") + + @patch("roboflow.adapters.devicesapi.get_device") + def test_device_returns_single(self, mock_get) -> None: + mock_get.return_value = {"id": DEVICE_ID, "name": "Cam"} + device = self.workspace.device(DEVICE_ID) + self.assertIsInstance(device, Device) + self.assertEqual(device.id, DEVICE_ID) + self.assertEqual(device.name, "Cam") + + @patch("roboflow.adapters.devicesapi.create_device") + def test_create_device_forwards_kwargs(self, mock_create) -> None: + mock_create.return_value = {"deviceId": "d1", "installId": "i1"} + result = self.workspace.create_device("Cam 1", device_type="edge", workflow_id="wf-1", tags=["a"]) + self.assertEqual(result["deviceId"], "d1") + kwargs = mock_create.call_args.kwargs + self.assertEqual(kwargs["device_name"], "Cam 1") + self.assertEqual(kwargs["device_type"], "edge") + self.assertEqual(kwargs["workflow_id"], "wf-1") + self.assertEqual(kwargs["tags"], ["a"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_model_eval.py b/tests/test_model_eval.py new file mode 100644 index 00000000..40072bab --- /dev/null +++ b/tests/test_model_eval.py @@ -0,0 +1,318 @@ +"""Unit tests for the ModelEval SDK class and Workspace.evals/eval accessors.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + + +def _make_workspace(api_key="k", url="lee-sandbox"): + """Build a Workspace with the minimal info dict its constructor accepts.""" + from roboflow.core.workspace import Workspace + + info = { + "workspace": { + "name": "Test", + "url": url, + "projects": [], + "members": [], + } + } + return Workspace(info, api_key=api_key, default_workspace=url, model_format="yolov8") + + +class TestModelEvalConstruction(unittest.TestCase): + def test_apply_info_populates_attributes(self): + from roboflow.core.model_eval import ModelEval + + info = { + "evalId": "e1", + "status": "done", + "project": "my-project-slug", # URL slug β€” the public API only returns the slug + "versionId": "3", + "modelId": "m1", + "createdAt": "2025-01-01", + "summary": {"mAP": 0.9, "precision": 0.8, "recall": 0.85}, + } + ev = ModelEval("k", "ws", "e1", info=info) + + self.assertEqual(ev.id, "e1") + self.assertEqual(ev.status, "done") + self.assertEqual(ev.project, "my-project-slug") + self.assertEqual(ev.version_id, "3") + self.assertEqual(ev.model_id, "m1") + self.assertEqual(ev.created_at, "2025-01-01") + self.assertEqual(ev.summary["mAP"], 0.9) + + def test_construction_without_info(self): + from roboflow.core.model_eval import ModelEval + + ev = ModelEval("k", "ws", "e1") + self.assertEqual(ev.id, "e1") + self.assertIsNone(ev.status) + self.assertIsNone(ev.summary) + + +class TestModelEvalToDict(unittest.TestCase): + """`to_dict()` has two branches: with-payload (round-trip) and without-payload + (rebuild from attributes). Both need to behave correctly.""" + + def test_to_dict_round_trips_raw_payload_with_evalId_overlay(self): + from roboflow.core.model_eval import ModelEval + + # Server-payload path: the raw response is round-tripped (including any + # extra keys we don't surface as attrs), with `evalId` overlaid so legacy + # `id`-keyed responses still emit the DNA-aligned field. + info = { + "evalId": "e1", + "status": "done", + "project": "my-project-slug", + "versionId": "3", + "modelId": "m1", + "createdAt": "2025-01-01", + "summary": {"mAP": 0.9, "precision": 0.8, "recall": 0.85}, + "extraField": "preserved-by-roundtrip", + } + ev = ModelEval("k", "ws", "e1", info=info) + d = ev.to_dict() + # Round-trip preserves every server-side field, including ones we don't + # surface as attributes. + self.assertEqual(d["extraField"], "preserved-by-roundtrip") + self.assertEqual(d["project"], "my-project-slug") + self.assertEqual(d["evalId"], "e1") + self.assertEqual(d["summary"]["mAP"], 0.9) + + def test_to_dict_overlays_evalId_when_payload_used_legacy_id_key(self): + from roboflow.core.model_eval import ModelEval + + # Older server versions returned `id` instead of `evalId`. The SDK accepts + # both on the way in; on the way out it always emits `evalId`. + info = {"id": "e1-legacy", "status": "done", "project": "p"} + ev = ModelEval("k", "ws", "e1-legacy", info=info) + d = ev.to_dict() + self.assertEqual(d["evalId"], "e1-legacy") + + def test_to_dict_no_info_serialises_attrs_only_omitting_None(self): + from roboflow.core.model_eval import ModelEval + + # Constructor-only path (no `info=` payload, no `refresh()` call). + # Only attributes the caller sets get serialised; everything else is + # omitted rather than serialised as `null`. + ev = ModelEval("k", "ws", "e1") + d = ev.to_dict() + self.assertEqual(d, {"evalId": "e1"}) + + def test_to_dict_no_info_translates_attr_names_back_to_json_keys(self): + from roboflow.core.model_eval import ModelEval + + # Hand-construct an instance without an info payload, then mutate + # attributes (the way a user might before serialising for logging / + # comparison). `to_dict` should emit the JSON-side names, not the + # snake_case Python attr names. + ev = ModelEval("k", "ws", "e1") + ev.status = "done" + ev.project = "p" + ev.version_id = "3" + ev.model_id = "m1" + ev.created_at = "2025-01-01" + ev.summary = {"mAP": 0.9} + d = ev.to_dict() + self.assertEqual( + d, + { + "evalId": "e1", + "status": "done", + "project": "p", + "versionId": "3", # not version_id + "modelId": "m1", # not model_id + "createdAt": "2025-01-01", # not created_at + "summary": {"mAP": 0.9}, + }, + ) + + +class TestModelEvalRefresh(unittest.TestCase): + @patch("roboflow.adapters.rfapi.get_model_eval") + def test_refresh_updates_status_and_summary(self, mock_get): + from roboflow.core.model_eval import ModelEval + + mock_get.return_value = { + "evalId": "e1", + "status": "done", + "summary": {"mAP": 0.95}, + } + ev = ModelEval("k", "ws", "e1") + result = ev.refresh() + + self.assertIs(result, ev) # chainable + self.assertEqual(ev.status, "done") + self.assertEqual(ev.summary["mAP"], 0.95) + mock_get.assert_called_once_with("k", "ws", "e1") + + +class TestModelEvalPanelAccessors(unittest.TestCase): + """Each panel method delegates to the matching rfapi function with the right args.""" + + @patch("roboflow.adapters.rfapi.get_model_eval_map_results") + def test_map_results(self, mock_fn): + from roboflow.core.model_eval import ModelEval + + mock_fn.return_value = {"splits": {}} + ev = ModelEval("k", "ws", "e1") + result = ev.map_results() + + self.assertEqual(result, {"splits": {}}) + mock_fn.assert_called_once_with("k", "ws", "e1") + + @patch("roboflow.adapters.rfapi.get_model_eval_confidence_sweep") + def test_confidence_sweep(self, mock_fn): + from roboflow.core.model_eval import ModelEval + + mock_fn.return_value = {"splits": {}} + ModelEval("k", "ws", "e1").confidence_sweep() + + mock_fn.assert_called_once_with("k", "ws", "e1") + + @patch("roboflow.adapters.rfapi.get_model_eval_performance_by_class") + def test_performance_by_class_default_split(self, mock_fn): + from roboflow.core.model_eval import ModelEval + + mock_fn.return_value = {"classes": []} + ModelEval("k", "ws", "e1").performance_by_class() + mock_fn.assert_called_once_with("k", "ws", "e1", split=None) + + @patch("roboflow.adapters.rfapi.get_model_eval_performance_by_class") + def test_performance_by_class_with_split(self, mock_fn): + from roboflow.core.model_eval import ModelEval + + mock_fn.return_value = {"classes": []} + ModelEval("k", "ws", "e1").performance_by_class(split="valid") + mock_fn.assert_called_once_with("k", "ws", "e1", split="valid") + + @patch("roboflow.adapters.rfapi.get_model_eval_confusion_matrix") + def test_confusion_matrix(self, mock_fn): + from roboflow.core.model_eval import ModelEval + + mock_fn.return_value = {"matrix": []} + ModelEval("k", "ws", "e1").confusion_matrix(split="test", confidence=30) + mock_fn.assert_called_once_with("k", "ws", "e1", split="test", confidence=30) + + @patch("roboflow.adapters.rfapi.get_model_eval_vector_analysis") + def test_vector_analysis(self, mock_fn): + from roboflow.core.model_eval import ModelEval + + mock_fn.return_value = {"clusters": []} + ModelEval("k", "ws", "e1").vector_analysis(confidence=40) + mock_fn.assert_called_once_with("k", "ws", "e1", confidence=40) + + @patch("roboflow.adapters.rfapi.get_model_eval_image_predictions") + def test_image_predictions(self, mock_fn): + from roboflow.core.model_eval import ModelEval + + mock_fn.return_value = {"images": []} + ModelEval("k", "ws", "e1").image_predictions(split="valid", confidence=20, limit=50, offset=100) + mock_fn.assert_called_once_with("k", "ws", "e1", split="valid", confidence=20, limit=50, offset=100) + + @patch("roboflow.adapters.rfapi.get_model_eval_recommendations") + def test_recommendations(self, mock_fn): + from roboflow.core.model_eval import ModelEval + + mock_fn.return_value = {"recommendations": []} + ModelEval("k", "ws", "e1").recommendations() + mock_fn.assert_called_once_with("k", "ws", "e1") + + +class TestModelEvalErrors(unittest.TestCase): + """Typed errors from the adapter propagate through the SDK accessors.""" + + @patch("roboflow.adapters.rfapi.get_model_eval_map_results") + def test_not_done_error_propagates(self, mock_fn): + from roboflow.adapters import rfapi + from roboflow.core.model_eval import ModelEval + + mock_fn.side_effect = rfapi.ModelEvalNotDoneError("Eval still running") + ev = ModelEval("k", "ws", "e1") + with self.assertRaises(rfapi.ModelEvalNotDoneError): + ev.map_results() + + @patch("roboflow.adapters.rfapi.get_model_eval") + def test_refresh_404_propagates(self, mock_fn): + from roboflow.adapters import rfapi + from roboflow.core.model_eval import ModelEval + + mock_fn.side_effect = rfapi.ModelEvalNotFoundError("nope") + with self.assertRaises(rfapi.ModelEvalNotFoundError): + ModelEval("k", "ws", "e1").refresh() + + +class TestWorkspaceEvalAccessors(unittest.TestCase): + @patch("roboflow.adapters.rfapi.list_model_evals") + def test_evals_returns_modeleval_instances(self, mock_list): + from roboflow.core.model_eval import ModelEval + + mock_list.return_value = { + "evals": [ + {"evalId": "e1", "status": "done", "project": "my-project-slug"}, + {"evalId": "e2", "status": "running", "project": "my-project-slug"}, + ] + } + ws = _make_workspace() + result = ws.evals(status="done", limit=5) + + self.assertEqual(len(result), 2) + self.assertTrue(all(isinstance(e, ModelEval) for e in result)) + self.assertEqual(result[0].id, "e1") + self.assertEqual(result[0].status, "done") + self.assertEqual(result[1].id, "e2") + # Workspace forwards filters to the adapter + mock_list.assert_called_once_with( + "k", "lee-sandbox", project=None, version=None, model=None, status="done", limit=5 + ) + + @patch("roboflow.adapters.rfapi.list_model_evals") + def test_evals_passes_all_filters(self, mock_list): + mock_list.return_value = {"evals": []} + + ws = _make_workspace() + ws.evals(project="p1", version="3", model="m1", status="failed", limit=200) + + mock_list.assert_called_once_with( + "k", "lee-sandbox", project="p1", version="3", model="m1", status="failed", limit=200 + ) + + @patch("roboflow.adapters.rfapi.list_model_evals") + def test_evals_empty_list(self, mock_list): + mock_list.return_value = {"evals": []} + ws = _make_workspace() + self.assertEqual(ws.evals(), []) + + @patch("roboflow.adapters.rfapi.get_model_eval") + def test_eval_returns_populated_modeleval(self, mock_get): + from roboflow.core.model_eval import ModelEval + + mock_get.return_value = { + "evalId": "e1", + "status": "done", + "summary": {"mAP": 0.91}, + } + ws = _make_workspace() + ev = ws.eval("e1") + + self.assertIsInstance(ev, ModelEval) + self.assertEqual(ev.id, "e1") + self.assertEqual(ev.status, "done") + self.assertEqual(ev.summary["mAP"], 0.91) + mock_get.assert_called_once_with("k", "lee-sandbox", "e1") + + @patch("roboflow.adapters.rfapi.get_model_eval") + def test_eval_propagates_not_found(self, mock_get): + from roboflow.adapters import rfapi + + mock_get.side_effect = rfapi.ModelEvalNotFoundError("nope") + ws = _make_workspace() + with self.assertRaises(rfapi.ModelEvalNotFoundError): + ws.eval("bad") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_project.py b/tests/test_project.py index 84b99f96..747dd09c 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -1,18 +1,92 @@ +import json +import os +from unittest.mock import patch + +import requests import responses +from responses.matchers import json_params_matcher from roboflow import API_URL from roboflow.adapters.rfapi import AnnotationSaveError, ImageUploadError from roboflow.config import DEFAULT_BATCH_NAME -from tests import PROJECT_NAME, ROBOFLOW_API_KEY, RoboflowTest +from tests import PROJECT_NAME, ROBOFLOW_API_KEY, WORKSPACE_NAME, RoboflowTest, ordered class TestProject(RoboflowTest): + def _create_test_dataset(self, images=None): + """ + Create a test dataset with specified images or a default image + + Args: + images: List of image dictionaries. If None, a default image will be used. + + Returns: + Dictionary representing a parsed dataset + """ + if images is None: + images = [{"file": "image1.jpg", "split": "train", "annotationfile": {"file": "image1.xml"}}] + + return {"location": "/test/location/", "images": images} + + def _setup_upload_dataset_mocks( + self, + test_dataset=None, + image_return=None, + annotation_return=None, + project_created=False, + save_annotation_side_effect=None, + upload_image_side_effect=None, + ): + """ + Set up common mocks for upload_dataset tests + + Args: + test_dataset: The dataset to return from parsefolder. If None, creates a default dataset + image_return: Return value for upload_image. Default is successful upload + annotation_return: Return value for save_annotation. Default is successful annotation + project_created: Whether to simulate a newly created project + save_annotation_side_effect: Side effect function for save_annotation + upload_image_side_effect: Side effect function for upload_image + + Returns: + Dictionary of mock objects with start and stop methods + """ + if test_dataset is None: + test_dataset = self._create_test_dataset() + + if image_return is None: + image_return = ({"id": "test-id", "success": True}, 0.1, 0) + + if annotation_return is None: + annotation_return = ({"success": True}, 0.1, 0) + + # Create the mock objects + mocks = { + "parser": patch("roboflow.util.folderparser.parsefolder", return_value=test_dataset), + "upload": patch("roboflow.core.project.Project.upload_image", side_effect=upload_image_side_effect) + if upload_image_side_effect + else patch("roboflow.core.project.Project.upload_image", return_value=image_return), + "save_annotation": patch( + "roboflow.core.project.Project.save_annotation", side_effect=save_annotation_side_effect + ) + if save_annotation_side_effect + else patch("roboflow.core.project.Project.save_annotation", return_value=annotation_return), + "get_project": patch( + "roboflow.core.workspace.Workspace._get_or_create_project", return_value=(self.project, project_created) + ), + } + + return mocks + def test_check_valid_image_with_accepted_formats(self): images_to_test = [ "rabbit.JPG", "rabbit2.jpg", "hand-rabbit.PNG", "woodland-rabbit.png", + "file_example_TIFF_1MB.tiff", + "sky-rabbit.heic", + "whatsnew.avif", ] for image in images_to_test: @@ -21,7 +95,6 @@ def test_check_valid_image_with_accepted_formats(self): def test_check_valid_image_with_unaccepted_formats(self): images_to_test = [ "sky-rabbit.gif", - "sky-rabbit.heic", ] for image in images_to_test: @@ -30,7 +103,7 @@ def test_check_valid_image_with_unaccepted_formats(self): def test_upload_raises_upload_image_error(self): responses.add( responses.POST, - f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}" f"&batch={DEFAULT_BATCH_NAME}", + f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}&batch={DEFAULT_BATCH_NAME}", json={ "error": { "message": "Invalid image.", @@ -56,7 +129,7 @@ def test_upload_raises_upload_annotation_error(self): # Image upload responses.add( responses.POST, - f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}" f"&batch={DEFAULT_BATCH_NAME}", + f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}&batch={DEFAULT_BATCH_NAME}", json={"success": True, "id": image_id}, status=200, ) @@ -64,7 +137,7 @@ def test_upload_raises_upload_annotation_error(self): # Annotation responses.add( responses.POST, - f"{API_URL}/dataset/{PROJECT_NAME}/annotate/{image_id}?api_key={ROBOFLOW_API_KEY}" f"&name={image_name}", + f"{API_URL}/dataset/{PROJECT_NAME}/annotate/{image_id}?api_key={ROBOFLOW_API_KEY}&name={image_name}", json={ "error": { "message": "Image was already annotated.", @@ -82,3 +155,998 @@ def test_upload_raises_upload_annotation_error(self): ) self.assertEqual(str(error.exception), "Image was already annotated.") + + def test_upload_single_file_returns_result(self): + """upload() should return a list with the single_upload result dict for a single file (#254).""" + image_id = "test-upload-id" + + responses.add( + responses.POST, + f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}&batch={DEFAULT_BATCH_NAME}", + json={"success": True, "id": image_id}, + status=200, + ) + + result = self.project.upload("tests/images/rabbit.JPG") + + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + entry = result[0] + self.assertIsInstance(entry, dict) + self.assertEqual(entry["image"]["id"], image_id) + self.assertIn("upload_time", entry) + self.assertIn("upload_retry_attempts", entry) + + def test_upload_directory_returns_list_of_results(self): + """upload() should return a list of single_upload results for a directory (#254).""" + test_dir = "tests/images" + # Determine how many valid images are in the directory so we can mock + # exactly that many upload responses. + valid_images = [f for f in os.listdir(test_dir) if self.project.check_valid_image(os.path.join(test_dir, f))] + + for i, _ in enumerate(valid_images): + responses.add( + responses.POST, + f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}&batch={DEFAULT_BATCH_NAME}", + json={"success": True, "id": f"img-{i}"}, + status=200, + ) + + result = self.project.upload(test_dir) + + self.assertIsInstance(result, list) + self.assertEqual(len(result), len(valid_images)) + for i, entry in enumerate(result): + self.assertIsInstance(entry, dict) + self.assertEqual(entry["image"]["id"], f"img-{i}") + + def test_image_success(self): + image_id = "test-image-id" + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/images/{image_id}?api_key={ROBOFLOW_API_KEY}" + mock_response = { + "image": { + "id": image_id, + "name": "test_image.jpg", + "annotation": { + "key": "some-key", + "width": 640, + "height": 480, + "boxes": [{"label": "person", "x": 100, "y": 150, "width": 50, "height": 80}], + }, + "labels": ["person"], + "split": "train", + "tags": ["tag1", "tag2"], + "created": 1616161616, + "urls": { + "original": "https://example.com/image.jpg", + "thumb": "https://example.com/thumb.jpg", + "annotation": "https://example.com/annotation.json", + }, + "embedding": [0.1, 0.2, 0.3], + } + } + + responses.add(responses.GET, expected_url, json=mock_response, status=200) + + image_details = self.project.image(image_id) + + self.assertIsInstance(image_details, dict) + self.assertEqual(image_details["id"], image_id) + self.assertEqual(image_details["name"], "test_image.jpg") + self.assertIn("annotation", image_details) + self.assertIn("labels", image_details) + self.assertEqual(image_details["split"], "train") + + def test_image_not_found(self): + image_id = "nonexistent-image-id" + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/images/{image_id}?api_key={ROBOFLOW_API_KEY}" + mock_response = {"error": "Image not found."} + + responses.add(responses.GET, expected_url, json=mock_response, status=404) + + with self.assertRaises(RuntimeError) as context: + self.project.image(image_id) + + self.assertIn("HTTP error occurred while fetching image details", str(context.exception)) + + def test_image_invalid_json_response(self): + image_id = "invalid-json-image-id" + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/images/{image_id}?api_key={ROBOFLOW_API_KEY}" + invalid_json = "Invalid JSON response" + + responses.add(responses.GET, expected_url, body=invalid_json, status=200) + + with self.assertRaises(requests.exceptions.JSONDecodeError) as context: + self.project.image(image_id) + + self.assertIn("Expecting value", str(context.exception)) + + def test_create_annotation_job_success(self): + job_name = "Test Job" + batch_id = "test-batch-123" + num_images = 10 + labeler_email = "labeler@example.com" + reviewer_email = "reviewer@example.com" + + expected_response = { + "success": True, + "job": { + "id": "job-123", + "name": job_name, + "batch": batch_id, + "num_images": num_images, + "labeler": labeler_email, + "reviewer": reviewer_email, + "status": "created", + "created": 1616161616, + }, + } + + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/jobs?api_key={ROBOFLOW_API_KEY}" + + responses.add( + responses.POST, + expected_url, + json=expected_response, + status=200, + match=[ + json_params_matcher( + { + "name": job_name, + "batch": batch_id, + "num_images": num_images, + "labelerEmail": labeler_email, + "reviewerEmail": reviewer_email, + } + ) + ], + ) + + result = self.project.create_annotation_job( + name=job_name, + batch_id=batch_id, + num_images=num_images, + labeler_email=labeler_email, + reviewer_email=reviewer_email, + ) + + self.assertEqual(result, expected_response) + self.assertTrue(result["success"]) + self.assertEqual(result["job"]["id"], "job-123") + self.assertEqual(result["job"]["name"], job_name) + + def test_create_annotation_job_error(self): + job_name = "Test Job" + batch_id = "invalid-batch" + num_images = 10 + labeler_email = "labeler@example.com" + reviewer_email = "reviewer@example.com" + + error_response = {"error": "Batch not found"} + + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/jobs?api_key={ROBOFLOW_API_KEY}" + + responses.add(responses.POST, expected_url, json=error_response, status=404) + + with self.assertRaises(RuntimeError) as context: + self.project.create_annotation_job( + name=job_name, + batch_id=batch_id, + num_images=num_images, + labeler_email=labeler_email, + reviewer_email=reviewer_email, + ) + + self.assertEqual(str(context.exception), "Batch not found") + + @ordered + @responses.activate + def test_project_upload_dataset(self): + """Test upload_dataset functionality with various scenarios""" + test_scenarios = [ + { + "name": "string_annotationdesc", + "dataset": [{"file": "test_image.jpg", "split": "train", "annotationfile": "string_annotation.txt"}], + "params": {"num_workers": 1}, + "assertions": {}, + }, + { + "name": "success_basic", + "dataset": [ + {"file": "image1.jpg", "split": "train", "annotationfile": {"file": "image1.xml"}}, + {"file": "image2.jpg", "split": "valid", "annotationfile": {"file": "image2.xml"}}, + ], + "params": {}, + "assertions": {"parser": [("/test/dataset",)], "upload": {"count": 2}, "save_annotation": {"count": 2}}, + "image_return": ({"id": "test-id-1", "success": True}, 0.1, 0), + }, + { + "name": "custom_parameters", + "dataset": None, + "params": { + "num_workers": 2, + "project_license": "CC BY 4.0", + "project_type": "classification", + "batch_name": "test-batch", + "num_retries": 3, + }, + "assertions": {"upload": {"count": 1, "kwargs": {"batch_name": "test-batch", "num_retry_uploads": 3}}}, + }, + { + "name": "explicit_split_overrides_parsed_directory_splits", + "dataset": [ + {"file": "image1.jpg", "split": "train"}, + {"file": "image2.jpg", "split": "test"}, + ], + "params": {"split": "valid", "num_workers": 1}, + "assertions": {"upload": {"count": 2, "kwargs": {"split": "valid"}}}, + }, + { + "name": "project_creation", + "dataset": None, + "params": {"project_name": "new-project"}, + "assertions": {}, + "project_created": True, + }, + { + "name": "with_labelmap", + "dataset": [ + { + "file": "image1.jpg", + "split": "train", + "annotationfile": {"file": "image1.xml", "labelmap": "path/to/labelmap.json"}, + } + ], + "params": {}, + "assertions": {"save_annotation": {"count": 1}, "load_labelmap": {"count": 1}}, + "extra_mocks": [ + ( + "load_labelmap", + "roboflow.util.image_utils.load_labelmap", + {"return_value": {"old_label": "new_label"}}, + ) + ], + }, + { + "name": "concurrent_uploads", + "dataset": [{"file": f"image{i}.jpg", "split": "train"} for i in range(10)], + "params": {"num_workers": 5}, + "assertions": {"thread_pool": {"count": 1, "kwargs": {"max_workers": 5}}}, + "extra_mocks": [("thread_pool", "concurrent.futures.ThreadPoolExecutor", {})], + }, + {"name": "empty_dataset", "dataset": [], "params": {}, "assertions": {"upload": {"count": 0}}}, + { + "name": "raw_text_annotation", + "dataset": [ + { + "file": "image1.jpg", + "split": "train", + "annotationfile": {"rawText": "annotation content here", "format": "json"}, + } + ], + "params": {}, + "assertions": {"save_annotation": {"count": 1}}, + }, + { + "name": "with_predictions_flag_true", + "dataset": [ + {"file": "pred1.jpg", "split": "train", "annotationfile": {"file": "pred1.xml"}}, + {"file": "pred2.jpg", "split": "valid", "annotationfile": {"file": "pred2.xml"}}, + ], + "params": {"is_prediction": True}, + "assertions": { + "upload": {"count": 2}, + "save_annotation": {"count": 2, "kwargs": {"is_prediction": True}}, + }, + }, + { + "name": "with_predictions_flag_false", + "dataset": [ + {"file": "gt1.jpg", "split": "train", "annotationfile": {"file": "gt1.xml"}}, + ], + "params": {"is_prediction": False}, + "assertions": { + "upload": {"count": 1}, + "save_annotation": {"count": 1, "kwargs": {"is_prediction": False}}, + }, + }, + { + "name": "predictions_with_batch", + "dataset": [ + {"file": "batch_pred.jpg", "split": "train", "annotationfile": {"file": "batch_pred.xml"}}, + ], + "params": { + "is_prediction": True, + "batch_name": "prediction-batch", + "num_retries": 2, + }, + "assertions": { + "upload": { + "count": 1, + "kwargs": { + "batch_name": "prediction-batch", + "num_retry_uploads": 2, + }, + }, + "save_annotation": { + "count": 1, + "kwargs": { + "is_prediction": True, + "job_name": "prediction-batch", + "num_retry_uploads": 2, + }, + }, + }, + }, + ] + + error_cases = [ + { + "name": "image_upload_error", + "side_effect": { + "upload_image_side_effect": lambda *args, **kwargs: (_ for _ in ()).throw( + ImageUploadError("Failed to upload image") + ) + }, + "params": {"num_workers": 1}, + }, + { + "name": "annotation_upload_error", + "side_effect": { + "save_annotation_side_effect": lambda *args, **kwargs: (_ for _ in ()).throw( + AnnotationSaveError("Failed to save annotation") + ) + }, + "params": {"num_workers": 1}, + }, + ] + + for scenario in test_scenarios: + test_dataset = ( + self._create_test_dataset(scenario.get("dataset")) if scenario.get("dataset") is not None else None + ) + + extra_mocks = {} + if "extra_mocks" in scenario: + for mock_name, target, config in scenario.get("extra_mocks", []): + extra_mocks[mock_name] = patch(target, **config) + + mocks = self._setup_upload_dataset_mocks( + test_dataset=test_dataset, + image_return=scenario.get("image_return"), + project_created=scenario.get("project_created", False), + ) + + mock_objects = {} + for name, mock in mocks.items(): + mock_objects[name] = mock.start() + + for name, mock in extra_mocks.items(): + mock_objects[name] = mock.start() + + try: + params = {"dataset_path": "/test/dataset", "project_name": PROJECT_NAME} + params.update(scenario.get("params", {})) + + self.workspace.upload_dataset(**params) + + for mock_name, assertion in scenario.get("assertions", {}).items(): + if isinstance(assertion, list): + mock_obj = mock_objects.get(mock_name) + call_args_list = [args for args, _ in mock_obj.call_args_list] + for expected_args in assertion: + self.assertIn(expected_args, call_args_list) + elif isinstance(assertion, dict): + mock_obj = mock_objects.get(mock_name) + if "count" in assertion: + self.assertEqual(mock_obj.call_count, assertion["count"]) + if "kwargs" in assertion and mock_obj.call_count > 0: + _, kwargs = mock_obj.call_args + for key, value in assertion["kwargs"].items(): + self.assertEqual(kwargs.get(key), value) + finally: + for mock in list(mocks.values()) + list(extra_mocks.values()): + mock.stop() + + for case in error_cases: + mocks = self._setup_upload_dataset_mocks(**case.get("side_effect", {})) + + for mock in mocks.values(): + mock.start() + + try: + params = {"dataset_path": "/test/dataset", "project_name": PROJECT_NAME} + params.update(case.get("params", {})) + self.workspace.upload_dataset(**params) + finally: + for mock in mocks.values(): + mock.stop() + + def test_get_batches_success(self): + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/batches?api_key={ROBOFLOW_API_KEY}" + mock_response = { + "batches": [ + { + "name": "Uploaded on 11/22/22 at 1:39 pm", + "numJobs": 2, + "images": 115, + "uploaded": {"_seconds": 1669146024, "_nanoseconds": 818000000}, + "id": "batch-1", + }, + { + "numJobs": 0, + "images": 11, + "uploaded": {"_seconds": 1669236873, "_nanoseconds": 47000000}, + "name": "Upload via API", + "id": "batch-2", + }, + ] + } + + responses.add(responses.GET, expected_url, json=mock_response, status=200) + + batches = self.project.get_batches() + + self.assertIsInstance(batches, dict) + self.assertIn("batches", batches) + self.assertEqual(len(batches["batches"]), 2) + self.assertEqual(batches["batches"][0]["id"], "batch-1") + self.assertEqual(batches["batches"][0]["name"], "Uploaded on 11/22/22 at 1:39 pm") + self.assertEqual(batches["batches"][0]["images"], 115) + self.assertEqual(batches["batches"][0]["numJobs"], 2) + self.assertEqual(batches["batches"][1]["id"], "batch-2") + self.assertEqual(batches["batches"][1]["name"], "Upload via API") + + def test_get_batches_error(self): + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/batches?api_key={ROBOFLOW_API_KEY}" + error_response = {"error": "Cannot retrieve batches"} + + responses.add(responses.GET, expected_url, json=error_response, status=404) + + with self.assertRaises(RuntimeError) as context: + self.project.get_batches() + + self.assertEqual(str(context.exception), "Cannot retrieve batches") + + def test_get_batch_success(self): + batch_id = "batch-123" + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/batches/{batch_id}?api_key={ROBOFLOW_API_KEY}" + mock_response = { + "batch": { + "name": "Uploaded on 11/22/22 at 1:39 pm", + "numJobs": 2, + "images": 115, + "uploaded": {"_seconds": 1669146024, "_nanoseconds": 818000000}, + "id": batch_id, + } + } + + responses.add(responses.GET, expected_url, json=mock_response, status=200) + + batch = self.project.get_batch(batch_id) + + self.assertIsInstance(batch, dict) + self.assertIn("batch", batch) + self.assertEqual(batch["batch"]["id"], batch_id) + self.assertEqual(batch["batch"]["name"], "Uploaded on 11/22/22 at 1:39 pm") + self.assertEqual(batch["batch"]["images"], 115) + self.assertEqual(batch["batch"]["numJobs"], 2) + self.assertIn("uploaded", batch["batch"]) + + def test_get_batch_error(self): + batch_id = "nonexistent-batch" + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/batches/{batch_id}?api_key={ROBOFLOW_API_KEY}" + error_response = {"error": "Batch not found"} + + responses.add(responses.GET, expected_url, json=error_response, status=404) + + with self.assertRaises(RuntimeError) as context: + self.project.get_batch(batch_id) + + self.assertEqual(str(context.exception), "Batch not found") + + def test_delete_images_success(self): + image_ids = ["image1.jpg", "image2.jpg"] + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/images?api_key={ROBOFLOW_API_KEY}" + + responses.add( + responses.DELETE, + expected_url, + status=204, + match=[ + json_params_matcher( + { + "images": image_ids, + } + ) + ], + ) + + self.project.delete_images(image_ids=image_ids) + + def test_delete_images_error(self): + image_ids = ["image1.jpg", "image2.jpg"] + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/images?api_key={ROBOFLOW_API_KEY}" + error_response = {"error": "Failed to delete images"} + + responses.add( + responses.DELETE, + expected_url, + json=error_response, + status=400, + match=[ + json_params_matcher( + { + "images": image_ids, + } + ) + ], + ) + + with self.assertRaises(RuntimeError) as context: + self.project.delete_images(image_ids=image_ids) + + self.assertEqual(str(context.exception), "Failed to delete images") + + def test_update_image_metadata_delegates_with_workspace_slug(self): + with patch("roboflow.adapters.rfapi.update_image_metadata") as mock_update: + mock_update.return_value = {"success": True} + + result = self.project.update_image_metadata( + "img-1", + metadata={"camera_id": "cam001"}, + add_tags=["reviewed"], + ) + + self.assertEqual(result, {"success": True}) + mock_update.assert_called_once_with( + api_key=ROBOFLOW_API_KEY, + workspace_url=WORKSPACE_NAME, + image_id="img-1", + metadata={"camera_id": "cam001"}, + remove_metadata=None, + add_tags=["reviewed"], + remove_tags=None, + ) + + def test_classification_dataset_upload(self): + from roboflow.util import folderparser + + classification_folder = "tests/datasets/corrosion-singlelabel-classification" + # Parse with classification flag to get inferred annotations + parsed_dataset = folderparser.parsefolder(classification_folder, is_classification=True) + + # Create a mock project with classification type + self.project.type = "classification" + annotation_calls = [] + + def capture_annotation_calls(annotation_path, **kwargs): + annotation_calls.append({"annotation_path": annotation_path, "image_id": kwargs.get("image_id")}) + return ({"success": True}, 0.1, 0) + + mocks = { + "parser": patch("roboflow.util.folderparser.parsefolder", return_value=parsed_dataset), + "upload": patch( + "roboflow.core.project.Project.upload_image", + return_value=({"id": "test-id", "success": True}, 0.1, 0), + ), + "save_annotation": patch( + "roboflow.core.project.Project.save_annotation", side_effect=capture_annotation_calls + ), + "get_project": patch( + "roboflow.core.workspace.Workspace._get_or_create_project", return_value=(self.project, False) + ), + } + mock_objects = {} + for name, mock in mocks.items(): + mock_objects[name] = mock.start() + try: + self.workspace.upload_dataset(dataset_path=classification_folder, project_name=PROJECT_NAME, num_workers=1) + self.assertEqual(mock_objects["upload"].call_count, 10) + self.assertEqual(len(annotation_calls), 10) + + corrosion_count = sum(1 for call in annotation_calls if call["annotation_path"] == "Corrosion") + no_corrosion_count = sum(1 for call in annotation_calls if call["annotation_path"] == "no-corrosion") + self.assertEqual(corrosion_count, 5) + self.assertEqual(no_corrosion_count, 5) + + for call in annotation_calls: + self.assertIn(call["annotation_path"], ["Corrosion", "no-corrosion"]) + finally: + for mock in mocks.values(): + mock.stop() + + def test_classification_edge_cases(self): + edge_case_dataset = [ + # These should not get annotations + {"file": "root_img.jpg", "split": "train", "dirname": "/"}, + {"file": "dot_img.jpg", "split": "train", "dirname": "/."}, + # These should get annotations from folder structure + { + "file": "nested.jpg", + "split": "train", + "dirname": "/train/defects/rust/severe", + "annotationfile": {"type": "classification_folder", "classification_label": "severe"}, + }, + { + "file": "normal.jpg", + "split": "train", + "dirname": "/train/good", + "annotationfile": {"type": "classification_folder", "classification_label": "good"}, + }, + ] + self.project.type = "classification" + annotation_calls = [] + + def capture_annotation_calls(annotation_path, **kwargs): + annotation_calls.append(annotation_path) + return ({"success": True}, 0.1, 0) + + test_dataset = self._create_test_dataset(edge_case_dataset) + mocks = self._setup_upload_dataset_mocks( + test_dataset=test_dataset, save_annotation_side_effect=capture_annotation_calls + ) + for mock in mocks.values(): + mock.start() + try: + self.workspace.upload_dataset(dataset_path="/test/dataset", project_name=PROJECT_NAME, num_workers=1) + self.assertEqual(len(annotation_calls), 2) + self.assertIn("severe", annotation_calls) + self.assertIn("good", annotation_calls) + finally: + for mock in mocks.values(): + mock.stop() + + def test_multilabel_classification_dataset_upload(self): + from roboflow.util import folderparser + + multilabel_folder = "tests/datasets/skinproblem-multilabel-classification" + parsed_dataset = folderparser.parsefolder(multilabel_folder, is_classification=True) + + self.project.type = "classification" + self.project.multilabel = True + annotation_calls = [] + + def capture_annotation_calls(annotation_path, **kwargs): + annotation_calls.append(annotation_path) + return ({"success": True}, 0.1, 0) + + mocks = { + "parser": patch("roboflow.util.folderparser.parsefolder", return_value=parsed_dataset), + "upload": patch( + "roboflow.core.project.Project.upload_image", + return_value=({"id": "test-id", "success": True}, 0.1, 0), + ), + "save_annotation": patch( + "roboflow.core.project.Project.save_annotation", side_effect=capture_annotation_calls + ), + "get_project": patch( + "roboflow.core.workspace.Workspace._get_or_create_project", return_value=(self.project, False) + ), + } + for mock in mocks.values(): + mock.start() + try: + self.workspace.upload_dataset(dataset_path=multilabel_folder, project_name=PROJECT_NAME, num_workers=1) + self.assertEqual(len(annotation_calls), len(parsed_dataset["images"])) + for call in annotation_calls: + labels = json.loads(call) + self.assertIsInstance(labels, list) + self.assertGreater(len(labels), 0) + finally: + for mock in mocks.values(): + mock.stop() + + def test_search_with_annotation_job_params(self): + """Test that annotation_job and annotation_job_id parameters are properly included in search requests""" + # Test 1: Search with annotation_job=True + expected_url = f"{API_URL}/{WORKSPACE_NAME}/{PROJECT_NAME}/search?api_key={ROBOFLOW_API_KEY}" + mock_response = { + "results": [ + {"id": "image1", "name": "test1.jpg", "created": 1616161616, "labels": ["person"]}, + {"id": "image2", "name": "test2.jpg", "created": 1616161617, "labels": ["car"]}, + ] + } + + responses.add( + responses.POST, + expected_url, + json=mock_response, + status=200, + match=[ + json_params_matcher( + { + "offset": 0, + "limit": 100, + "batch": False, + "annotation_job": True, + "fields": ["id", "created", "name", "labels"], + } + ) + ], + ) + + results = self.project.search(annotation_job=True) + self.assertEqual(len(results), 2) + self.assertEqual(results[0]["id"], "image1") + + # Test 2: Search with annotation_job_id + test_job_id = "job_123456" + responses.add( + responses.POST, + expected_url, + json=mock_response, + status=200, + match=[ + json_params_matcher( + { + "offset": 0, + "limit": 100, + "batch": False, + "annotation_job_id": test_job_id, + "fields": ["id", "created", "name", "labels"], + } + ) + ], + ) + + results = self.project.search(annotation_job_id=test_job_id) + self.assertEqual(len(results), 2) + + # Test 3: Search with both parameters + responses.add( + responses.POST, + expected_url, + json=mock_response, + status=200, + match=[ + json_params_matcher( + { + "offset": 0, + "limit": 50, + "batch": False, + "annotation_job": False, + "annotation_job_id": test_job_id, + "prompt": "dog", + "fields": ["id", "created", "name", "labels"], + } + ) + ], + ) + + results = self.project.search(prompt="dog", annotation_job=False, annotation_job_id=test_job_id, limit=50) + self.assertEqual(len(results), 2) + + # Test 4: Verify parameters are not included when None + responses.add( + responses.POST, + expected_url, + json=mock_response, + status=200, + match=[ + json_params_matcher( + { + "offset": 0, + "limit": 100, + "batch": False, + "fields": ["id", "created", "name", "labels"], + # annotation_job and annotation_job_id should NOT be in the payload + } + ) + ], + ) + + # This should pass because json_params_matcher only checks that the + # specified keys match, it doesn't fail if additional keys are missing + results = self.project.search() + self.assertEqual(len(results), 2) + + +class TestZipUpload(RoboflowTest): + def _rfapi_mocks(self, get_status_side_effect=None, get_status_return=None): + import_target = "roboflow.core.workspace.rfapi" + init_mock = patch( + f"{import_target}.init_zip_upload", + return_value={"signedUrl": "https://signed.example/upload", "taskId": "task-123"}, + ) + put_mock = patch(f"{import_target}.upload_zip_to_signed_url", return_value=None) + if get_status_side_effect is not None: + status_mock = patch(f"{import_target}.get_zip_upload_status", side_effect=get_status_side_effect) + else: + status_mock = patch( + f"{import_target}.get_zip_upload_status", + return_value=get_status_return or {"status": "completed", "result": {"ok": True}}, + ) + project_mock = patch( + "roboflow.core.workspace.Workspace._get_or_create_project", + return_value=(self.project, False), + ) + return {"init": init_mock, "put": put_mock, "status": status_mock, "project": project_mock} + + def test_zip_path_passthrough(self): + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as fh: + fh.write(b"fake zip") + zip_path = fh.name + + mocks = self._rfapi_mocks() + zip_dir_mock = patch("roboflow.core.workspace._zip_directory") + started = {name: m.start() for name, m in mocks.items()} + started["zip_dir"] = zip_dir_mock.start() + try: + result = self.workspace.upload_dataset(dataset_path=zip_path, project_name=PROJECT_NAME) + self.assertEqual(result, {"status": "completed", "result": {"ok": True}}) + started["init"].assert_called_once() + started["put"].assert_called_once() + started["zip_dir"].assert_not_called() + put_args, _ = started["put"].call_args + self.assertEqual(put_args[0], "https://signed.example/upload") + self.assertEqual(put_args[1], zip_path) + finally: + for m in list(mocks.values()) + [zip_dir_mock]: + m.stop() + import os as _os + + if _os.path.exists(zip_path): + _os.unlink(zip_path) + + def test_directory_with_use_zip_upload_zips_and_cleans_up(self): + import os as _os + import tempfile + + # Pre-create a temp zip path to be returned by _zip_directory + fd, fake_zip = tempfile.mkstemp(suffix=".zip", prefix="roboflow-upload-") + _os.close(fd) + with open(fake_zip, "wb") as fh: + fh.write(b"fake zip payload") + + src_dir = tempfile.mkdtemp() + try: + mocks = self._rfapi_mocks() + zip_dir_mock = patch("roboflow.core.workspace._zip_directory", return_value=fake_zip) + started = {name: m.start() for name, m in mocks.items()} + started["zip_dir"] = zip_dir_mock.start() + try: + self.workspace.upload_dataset(dataset_path=src_dir, project_name=PROJECT_NAME, use_zip_upload=True) + started["zip_dir"].assert_called_once_with(src_dir) + started["init"].assert_called_once() + self.assertFalse(_os.path.exists(fake_zip), "temp zip was not cleaned up") + finally: + for m in list(mocks.values()) + [zip_dir_mock]: + m.stop() + finally: + if _os.path.exists(fake_zip): + _os.unlink(fake_zip) + if _os.path.isdir(src_dir): + _os.rmdir(src_dir) + + def test_directory_default_stays_on_per_image(self): + import tempfile + + src_dir = tempfile.mkdtemp() + try: + rfapi_mocks = self._rfapi_mocks() + per_image = { + "parser": patch( + "roboflow.util.folderparser.parsefolder", + return_value={"location": "/tmp/", "images": []}, + ), + } + started = {name: m.start() for name, m in {**rfapi_mocks, **per_image}.items()} + try: + result = self.workspace.upload_dataset(dataset_path=src_dir, project_name=PROJECT_NAME) + self.assertIsNone(result) + started["init"].assert_not_called() + started["parser"].assert_called_once() + finally: + for m in list(rfapi_mocks.values()) + list(per_image.values()): + m.stop() + finally: + import os as _os + + _os.rmdir(src_dir) + + def test_use_zip_upload_with_is_prediction_raises(self): + import tempfile + + from roboflow.adapters.rfapi import RoboflowError + + src_dir = tempfile.mkdtemp() + try: + mocks = self._rfapi_mocks() + started = {name: m.start() for name, m in mocks.items()} + try: + with self.assertRaises(RoboflowError): + self.workspace.upload_dataset( + dataset_path=src_dir, + project_name=PROJECT_NAME, + use_zip_upload=True, + is_prediction=True, + ) + started["init"].assert_not_called() + finally: + for m in mocks.values(): + m.stop() + finally: + import os as _os + + _os.rmdir(src_dir) + + def test_wait_false_returns_task_id_without_polling(self): + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as fh: + fh.write(b"fake") + zip_path = fh.name + + mocks = self._rfapi_mocks() + started = {name: m.start() for name, m in mocks.items()} + try: + result = self.workspace.upload_dataset(dataset_path=zip_path, project_name=PROJECT_NAME, wait=False) + self.assertEqual(result, {"task_id": "task-123", "status": "pending"}) + started["status"].assert_not_called() + finally: + for m in mocks.values(): + m.stop() + import os as _os + + if _os.path.exists(zip_path): + _os.unlink(zip_path) + + def test_poll_loop_completes(self): + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as fh: + fh.write(b"fake") + zip_path = fh.name + + responses_seq = [ + {"status": "running", "progress": {"current": "10%"}}, + {"status": "completed", "result": {"imageCount": 5}}, + ] + mocks = self._rfapi_mocks(get_status_side_effect=responses_seq) + sleep_mock = patch("roboflow.core.workspace.time.sleep", return_value=None) + started = {name: m.start() for name, m in mocks.items()} + started["sleep"] = sleep_mock.start() + try: + result = self.workspace.upload_dataset(dataset_path=zip_path, project_name=PROJECT_NAME, poll_interval=0.0) + self.assertEqual(result, {"status": "completed", "result": {"imageCount": 5}}) + self.assertEqual(started["status"].call_count, 2) + finally: + for m in list(mocks.values()) + [sleep_mock]: + m.stop() + import os as _os + + if _os.path.exists(zip_path): + _os.unlink(zip_path) + + def test_poll_loop_timeout(self): + import tempfile + + from roboflow.adapters.rfapi import RoboflowError + + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as fh: + fh.write(b"fake") + zip_path = fh.name + + mocks = self._rfapi_mocks(get_status_return={"status": "running"}) + # Make time.monotonic advance past the deadline on the second call. + monotonic_values = iter([1000.0, 1000.0, 9999.0]) + monotonic_mock = patch("roboflow.core.workspace.time.monotonic", side_effect=lambda: next(monotonic_values)) + sleep_mock = patch("roboflow.core.workspace.time.sleep", return_value=None) + started = {name: m.start() for name, m in mocks.items()} + started["monotonic"] = monotonic_mock.start() + started["sleep"] = sleep_mock.start() + try: + with self.assertRaises(RoboflowError): + self.workspace.upload_dataset( + dataset_path=zip_path, project_name=PROJECT_NAME, poll_timeout=1.0, poll_interval=0.0 + ) + finally: + for m in list(mocks.values()) + [monotonic_mock, sleep_mock]: + m.stop() + import os as _os + + if _os.path.exists(zip_path): + _os.unlink(zip_path) diff --git a/tests/test_queries.py b/tests/test_queries.py index c9a26ed0..267010ab 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -47,7 +47,7 @@ def test_project_methods(self): # Upload image responses.add( responses.POST, - f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}" f"&batch={DEFAULT_BATCH_NAME}", + f"{API_URL}/dataset/{PROJECT_NAME}/upload?api_key={ROBOFLOW_API_KEY}&batch={DEFAULT_BATCH_NAME}", json={"duplicate": True, "id": "hbALkCFdNr9rssgOUXug"}, status=200, ) @@ -60,7 +60,9 @@ def test_project_methods(self): self.assertEqual(len(version_information), 2) self.assertIsNone(print_versions) self.assertTrue(all(map(lambda x: isinstance(x, Version), list_versions))) - self.assertIsNone(upload) + self.assertIsInstance(upload, list) + self.assertEqual(len(upload), 1) + self.assertEqual(upload[0]["image"]["id"], "hbALkCFdNr9rssgOUXug") @ordered def test_version_fields(self): diff --git a/tests/test_rfapi.py b/tests/test_rfapi.py index ad92210f..d0106312 100644 --- a/tests/test_rfapi.py +++ b/tests/test_rfapi.py @@ -1,11 +1,22 @@ +import json import os import unittest import urllib -from unittest.mock import patch +from unittest.mock import mock_open, patch import responses -from roboflow.adapters.rfapi import upload_image +from roboflow.adapters.rfapi import ( + RoboflowError, + create_training_v2, + delete_version_training, + get_train_recipe, + get_training, + list_trainings_for_version, + resolve_version_training_id, + restore_trash_item, + upload_image, +) from roboflow.config import API_URL, DEFAULT_BATCH_NAME @@ -21,10 +32,8 @@ class TestUploadImage(unittest.TestCase): IMAGE_NAME_HOSTED = os.path.basename(IMAGE_PATH_HOSTED) @responses.activate - @patch("roboflow.util.image_utils.file2jpeg") - def test_upload_image_local(self, mock_file2jpeg): - mock_file2jpeg.return_value = b"image_data" - + @patch("roboflow.adapters.rfapi.open", new_callable=mock_open, read_data=b"image_data") + def test_upload_image_local(self, _mock_file): scenarios = [ { "desc": "with batch_name", @@ -121,9 +130,307 @@ def test_upload_image_hosted(self): result = upload_image(self.API_KEY, self.PROJECT_URL, self.IMAGE_PATH_HOSTED, **upload_image_payload) self.assertTrue(result["success"], msg=f"Failed in scenario: {scenario['desc']}") + @responses.activate + @patch("roboflow.adapters.rfapi.open", new_callable=mock_open, read_data=b"image_data") + def test_upload_image_local_with_metadata(self, _mock_file): + metadata = {"camera_id": "cam001", "location": "warehouse"} + expected_url = ( + f"{API_URL}/dataset/{self.PROJECT_URL}/upload?" + f"api_key={self.API_KEY}&batch={urllib.parse.quote_plus(DEFAULT_BATCH_NAME)}" + f"&tag=lonely-tag" + ) + responses.add(responses.POST, expected_url, json={"success": True}, status=200) + + result = upload_image( + self.API_KEY, + self.PROJECT_URL, + self.IMAGE_PATH_LOCAL, + tag_names=self.TAG_NAMES_LOCAL, + metadata=metadata, + ) + self.assertTrue(result["success"]) + + # Verify metadata was sent as a multipart field + request_body = responses.calls[0].request.body + self.assertIn(b'"camera_id"', request_body) + self.assertIn(b'"warehouse"', request_body) + + @responses.activate + def test_upload_image_hosted_with_metadata(self): + metadata = {"camera_id": "cam001", "location": "warehouse"} + metadata_encoded = urllib.parse.quote_plus(json.dumps(metadata)) + expected_url = ( + f"{API_URL}/dataset/{self.PROJECT_URL}/upload?" + f"api_key={self.API_KEY}&name={self.IMAGE_NAME_HOSTED}" + f"&split=train&image={urllib.parse.quote_plus(self.IMAGE_PATH_HOSTED)}" + f"&batch={urllib.parse.quote_plus(DEFAULT_BATCH_NAME)}" + f"&tag=tag1&tag=tag2&metadata={metadata_encoded}" + ) + responses.add(responses.POST, expected_url, json={"success": True}, status=200) + + result = upload_image( + self.API_KEY, + self.PROJECT_URL, + self.IMAGE_PATH_HOSTED, + hosted_image=True, + tag_names=self.TAG_NAMES_HOSTED, + metadata=metadata, + ) + self.assertTrue(result["success"]) + + @responses.activate + def test_upload_image_local_uploads_original_bytes(self): + """Server-side dedup relies on the SDK uploading the file bytes exactly as-is.""" + import tempfile + + raw_bytes = b"\x89PNG\r\n\x1a\nfake-png-bytes-not-decodable-as-jpeg" + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf: + tf.write(raw_bytes) + tmp_path = tf.name + + try: + expected_url = ( + f"{API_URL}/dataset/{self.PROJECT_URL}/upload?" + f"api_key={self.API_KEY}&batch={urllib.parse.quote_plus(DEFAULT_BATCH_NAME)}" + ) + responses.add(responses.POST, expected_url, json={"success": True}, status=200) + + result = upload_image(self.API_KEY, self.PROJECT_URL, tmp_path) + self.assertTrue(result["success"]) + + request_body = responses.calls[0].request.body + self.assertIn(raw_bytes, request_body) + self.assertIn(b"image/png", request_body) + finally: + os.unlink(tmp_path) + def _reset_responses(self): responses.reset() +class TestV2Trainings(unittest.TestCase): + API_KEY = "test_api_key" + WORKSPACE = "test-workspace" + PROJECT = "test-project" + VERSION = "3" + BASE_URL = f"{API_URL}/{WORKSPACE}/{PROJECT}/{VERSION}/v2/trainings" + + RECIPE_RESPONSE = { + "modelType": "rfdetr-medium", + "family": "rf-detr", + "taskType": "object-detection", + "schema": {"hyperparameters": [{"key": "lr", "type": "float"}]}, + "template": { + "schema_version": 1, + "input": {}, + "online_preprocessing": [], + "online_augmentation": {"splits": ["train"], "steps": []}, + "source_version": {}, + "hyperparameters": {}, + }, + "usage": "...", + } + + def _request_query(self): + return dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(responses.calls[0].request.url).query)) + + def _request_body(self): + return json.loads(responses.calls[0].request.body) + + @responses.activate + def test_get_train_recipe(self): + responses.add(responses.GET, f"{self.BASE_URL}/recipe", json=self.RECIPE_RESPONSE, status=200) + + result = get_train_recipe(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, model_type="rfdetr-medium") + + self.assertEqual(result, self.RECIPE_RESPONSE) + query = self._request_query() + self.assertEqual(query["api_key"], self.API_KEY) + self.assertEqual(query["modelType"], "rfdetr-medium") + + @responses.activate + def test_get_train_recipe_raises_on_error(self): + responses.add(responses.GET, f"{self.BASE_URL}/recipe", json={"error": "bad model type"}, status=400) + + with self.assertRaises(RoboflowError): + get_train_recipe(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, model_type="nope") + + @responses.activate + def test_create_training_v2_sends_only_provided_keys_in_camel_case(self): + responses.add( + responses.POST, + self.BASE_URL, + json={"trainingId": "abc123", "status": "queued", "jobId": "job-1"}, + status=200, + ) + + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0002}} + result = create_training_v2( + self.API_KEY, + self.WORKSPACE, + self.PROJECT, + self.VERSION, + model_type="rfdetr-medium", + speed="fast", + checkpoint="ckpt", + epochs=10, + train_recipe=recipe, + ) + + self.assertEqual(result["trainingId"], "abc123") + self.assertEqual(self._request_query()["api_key"], self.API_KEY) + body = self._request_body() + self.assertEqual( + body, + { + "modelType": "rfdetr-medium", + "speed": "fast", + "checkpoint": "ckpt", + "epochs": 10, + "trainRecipe": recipe, + }, + ) + + @responses.activate + def test_create_training_v2_omits_none_keys(self): + responses.add(responses.POST, self.BASE_URL, json={"trainingId": "abc123"}, status=200) + + create_training_v2(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + self.assertEqual(self._request_body(), {}) + + @responses.activate + def test_create_training_v2_raises_on_error(self): + responses.add(responses.POST, self.BASE_URL, json={"error": "nope"}, status=500) + + with self.assertRaises(RoboflowError): + create_training_v2(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, model_type="rfdetr-medium") + + @responses.activate + def test_list_trainings_for_version_unwraps_trainings_key(self): + payload = {"trainings": [{"id": "t-1"}, {"id": "t-2"}]} + responses.add(responses.GET, self.BASE_URL, json=payload, status=200) + + result = list_trainings_for_version(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + self.assertEqual(result, payload["trainings"]) + self.assertEqual(self._request_query(), {"api_key": self.API_KEY}) + + @responses.activate + def test_list_trainings_for_version_raises_on_error(self): + responses.add(responses.GET, self.BASE_URL, json={"error": "nope"}, status=404) + + with self.assertRaises(RoboflowError): + list_trainings_for_version(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + @responses.activate + def test_get_training_with_training_id(self): + responses.add(responses.GET, f"{self.BASE_URL}/get", json={"trainingId": "t-1"}, status=200) + + result = get_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, training_id="t-1") + + self.assertEqual(result, {"trainingId": "t-1"}) + query = self._request_query() + self.assertEqual(query["api_key"], self.API_KEY) + self.assertEqual(query["trainingId"], "t-1") + + @responses.activate + def test_get_training_without_training_id(self): + responses.add(responses.GET, f"{self.BASE_URL}/get", json={"trainingId": "latest"}, status=200) + + result = get_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + self.assertEqual(result, {"trainingId": "latest"}) + self.assertNotIn("trainingId", self._request_query()) + + @responses.activate + def test_get_training_raises_on_error(self): + responses.add(responses.GET, f"{self.BASE_URL}/get", json={"error": "nope"}, status=404) + + with self.assertRaises(RoboflowError): + get_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, training_id="missing") + + +class TestTrainingTrash(unittest.TestCase): + API_KEY = "test_api_key" + WORKSPACE = "test-ws" + PROJECT = "test-project" + VERSION = "3" + + @responses.activate + def test_delete_version_training_deletes_the_training_resource(self): + expected_url = ( + f"{API_URL}/{self.WORKSPACE}/{self.PROJECT}/{self.VERSION}/v2/trainings/t-1?api_key={self.API_KEY}" + ) + payload = { + "deleted": True, + "type": "training", + "workspace": self.WORKSPACE, + "project": self.PROJECT, + "projectId": "ds-1", + "version": self.VERSION, + "trainingId": "t-1", + "trash": True, + } + responses.add(responses.DELETE, expected_url, json=payload, status=200) + + result = delete_version_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, training_id="t-1") + + self.assertEqual(result, payload) + + def test_delete_version_training_rejects_blank_training_id(self): + for blank in ["", " "]: + with self.assertRaises(ValueError): + delete_version_training(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, training_id=blank) + + def test_resolve_version_training_id_returns_supplied_id_without_listing(self): + resolved = resolve_version_training_id(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, "t-9") + self.assertEqual(resolved, "t-9") + + def test_resolve_version_training_id_rejects_blank_id(self): + for blank in ["", " "]: + with self.assertRaises(ValueError): + resolve_version_training_id(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION, blank) + + @responses.activate + def test_resolve_version_training_id_resolves_the_sole_run(self): + list_url = f"{API_URL}/{self.WORKSPACE}/{self.PROJECT}/{self.VERSION}/v2/trainings?api_key={self.API_KEY}" + responses.add(responses.GET, list_url, json={"trainings": [{"id": "t-1"}]}, status=200) + + resolved = resolve_version_training_id(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + + self.assertEqual(resolved, "t-1") + + @responses.activate + def test_resolve_version_training_id_raises_when_the_version_owns_several_runs(self): + list_url = f"{API_URL}/{self.WORKSPACE}/{self.PROJECT}/{self.VERSION}/v2/trainings?api_key={self.API_KEY}" + responses.add( + responses.GET, + list_url, + json={"trainings": [{"id": "t-1"}, {"id": "t-2"}]}, + status=200, + ) + + with self.assertRaises(RoboflowError) as ctx: + resolve_version_training_id(self.API_KEY, self.WORKSPACE, self.PROJECT, self.VERSION) + self.assertIn("MULTIPLE_TRAININGS", str(ctx.exception)) + self.assertIn("t-2", str(ctx.exception)) + + def test_restore_trash_item_rejects_blank_ids(self): + for blank in ["", " ", None]: + with self.assertRaises(ValueError): + restore_trash_item(self.API_KEY, self.WORKSPACE, "training", blank) + + @responses.activate + def test_restore_trash_item_restores_a_training(self): + expected_url = f"{API_URL}/{self.WORKSPACE}/trash/restore?api_key={self.API_KEY}" + responses.add(responses.POST, expected_url, json={"restored": True}, status=200) + + result = restore_trash_item(self.API_KEY, self.WORKSPACE, "training", "t-1") + + self.assertEqual(json.loads(responses.calls[0].request.body), {"type": "training", "id": "t-1"}) + self.assertEqual(result, {"restored": True}) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_search_export.py b/tests/test_search_export.py new file mode 100644 index 00000000..53703f2b --- /dev/null +++ b/tests/test_search_export.py @@ -0,0 +1,101 @@ +import io +import os +import shutil +import unittest +import zipfile + +import responses + +from roboflow.adapters.rfapi import RoboflowError +from roboflow.config import API_URL + + +class TestWorkspaceSearchExport(unittest.TestCase): + API_KEY = "test_key" + WORKSPACE = "test-ws" + DOWNLOAD_URL = "https://example.com/export.zip" + + @staticmethod + def _build_zip_bytes(files): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for filename, content in files.items(): + zip_file.writestr(filename, content) + return buffer.getvalue() + + def _make_workspace(self): + from roboflow.core.workspace import Workspace + + info = { + "workspace": { + "name": "Test", + "url": self.WORKSPACE, + "projects": [], + "members": [], + } + } + return Workspace(info, api_key=self.API_KEY, default_workspace=self.WORKSPACE, model_format="yolov8") + + def _register_responses(self, zip_bytes=b"", download_status=200): + export_url = f"{API_URL}/{self.WORKSPACE}/search/export?api_key={self.API_KEY}" + responses.add(responses.POST, export_url, json={"success": True, "link": "exp_abc"}, status=202) + + poll_url = f"{API_URL}/{self.WORKSPACE}/search/export/exp_abc?api_key={self.API_KEY}" + responses.add(responses.GET, poll_url, json={"ready": True, "link": self.DOWNLOAD_URL}, status=200) + + responses.add(responses.GET, self.DOWNLOAD_URL, body=zip_bytes, status=download_status) + + def test_mutual_exclusion(self): + ws = self._make_workspace() + with self.assertRaises(ValueError) as ctx: + ws.search_export(query="*", dataset="ds", annotation_group="ag") + self.assertIn("mutually exclusive", str(ctx.exception)) + + @responses.activate + def test_full_flow(self): + ws = self._make_workspace() + fake_zip = self._build_zip_bytes({"images/sample.jpg": "fake-image-data"}) + self._register_responses(fake_zip) + + location = "./test_search_export_output" + try: + result = ws.search_export(query="*", format="coco", location=location) + + expected_location = os.path.abspath(location) + self.assertEqual(result, expected_location) + self.assertTrue(os.path.exists(os.path.join(expected_location, "images", "sample.jpg"))) + self.assertFalse(os.path.exists(os.path.join(expected_location, "roboflow.zip"))) + finally: + if os.path.exists(location): + shutil.rmtree(location) + + @responses.activate + def test_download_http_error(self): + ws = self._make_workspace() + self._register_responses(download_status=403) + + with self.assertRaises(RoboflowError) as ctx: + ws.search_export(query="*", format="coco", location="./test_search_export_http_error") + + self.assertIn("Failed to download search export", str(ctx.exception)) + + @responses.activate + def test_no_extract(self): + ws = self._make_workspace() + fake_zip = self._build_zip_bytes({"images/sample.jpg": "fake-image-data"}) + self._register_responses(fake_zip) + + location = "./test_search_export_no_extract" + try: + result = ws.search_export(query="*", format="coco", location=location, extract_zip=False) + + expected_zip = os.path.join(os.path.abspath(location), "roboflow.zip") + self.assertEqual(result, expected_zip) + self.assertTrue(os.path.exists(expected_zip)) + finally: + if os.path.exists(location): + shutil.rmtree(location) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_slim_compat.py b/tests/test_slim_compat.py new file mode 100644 index 00000000..972eeacf --- /dev/null +++ b/tests/test_slim_compat.py @@ -0,0 +1,84 @@ +"""Tests for slim install compatibility. + +Verifies that the package can be imported and lightweight features work +even when heavy dependencies (PIL, opencv, numpy, matplotlib) are missing. + +In a full install, these tests verify the guards don't break normal behavior. +In a slim install, they verify graceful degradation. +""" + +import unittest + + +class TestSlimImport(unittest.TestCase): + """Verify that importing the package always succeeds.""" + + def test_import_roboflow(self): + import roboflow + + self.assertIsNotNone(roboflow.__version__) + + def test_import_vision_events_adapter(self): + from roboflow.adapters import vision_events_api + + self.assertTrue(callable(vision_events_api.write_event)) + self.assertTrue(callable(vision_events_api.write_batch)) + self.assertTrue(callable(vision_events_api.query)) + self.assertTrue(callable(vision_events_api.list_use_cases)) + self.assertTrue(callable(vision_events_api.get_custom_metadata_schema)) + self.assertTrue(callable(vision_events_api.upload_image)) + + def test_import_config(self): + from roboflow.config import API_URL + + self.assertIsInstance(API_URL, str) + + def test_import_rfapi(self): + from roboflow.adapters.rfapi import RoboflowError + + self.assertTrue(issubclass(RoboflowError, Exception)) + + def test_import_cli(self): + from roboflow.cli import app + + self.assertIsNotNone(app) + + +class TestSlimGracefulDegradation(unittest.TestCase): + """Verify that heavy features fail with clear errors when deps are missing. + + These tests only exercise the error path when PIL/opencv are absent. + In a full install they verify the guard exists but doesn't fire. + """ + + def test_workspace_always_available(self): + """Workspace imports cleanly even in slim mode.""" + import roboflow + + self.assertIsNotNone(roboflow.Workspace) + self.assertTrue(callable(roboflow.Workspace)) + + def test_project_guarded(self): + """Project is either a real class (full) or None (slim).""" + import roboflow + + self.assertTrue(roboflow.Project is None or callable(roboflow.Project)) + + def test_roboflow_project_guard(self): + """If Project is None (slim), calling project() raises ImportError.""" + import roboflow + + if roboflow.Project is not None: + self.skipTest("Full install, Project is available") + + rf = roboflow.Roboflow.__new__(roboflow.Roboflow) + rf.api_key = "test" + rf.current_workspace = "test" + + with self.assertRaises(ImportError) as ctx: + rf.project("test-project") + self.assertIn("pip install roboflow", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_training.py b/tests/test_training.py new file mode 100644 index 00000000..b8f32e89 --- /dev/null +++ b/tests/test_training.py @@ -0,0 +1,153 @@ +import unittest +from unittest.mock import patch + +from roboflow.config import ( + CLASSIFICATION_MODEL, + INSTANCE_SEGMENTATION_MODEL, + KEYPOINT_DETECTION_MODEL, + OBJECT_DETECTION_MODEL, + SEMANTIC_SEGMENTATION_MODEL, +) +from roboflow.core.training import TrainedModel, Training +from roboflow.models.classification import ClassificationModel +from roboflow.models.instance_segmentation import InstanceSegmentationModel +from roboflow.models.keypoint_detection import KeypointDetectionModel +from roboflow.models.object_detection import ObjectDetectionModel +from roboflow.models.semantic_segmentation import SemanticSegmentationModel + + +class TestTrainedModelPredict(unittest.TestCase): + def test_predict_routes_through_shared_inference_model_with_task_prediction_type(self): + cases = [ + ("yolov11", OBJECT_DETECTION_MODEL, "https://serverless.roboflow.com/ws/model-slug"), + ("yolov11-cls", CLASSIFICATION_MODEL, "https://serverless.roboflow.com/ws/model-slug"), + ("yolov11-seg", INSTANCE_SEGMENTATION_MODEL, "https://serverless.roboflow.com/ws/model-slug"), + ("yolov11-pose", KEYPOINT_DETECTION_MODEL, "https://serverless.roboflow.com/ws/model-slug"), + ("yolo26-sem", SEMANTIC_SEGMENTATION_MODEL, "https://segment.roboflow.com/ws/model-slug"), + ] + + for model_type, prediction_type, api_url in cases: + with self.subTest(model_type=model_type): + model = TrainedModel("key", "ws", "proj", "ws/model-slug", model_type=model_type) + with patch( + "roboflow.core.training.InferenceModel.predict", + autospec=True, + return_value="ok", + ) as predict: + result = model.predict("image.jpg", confidence=17, overlap=9, format="json") + + inference_model = predict.call_args.args[0] + self.assertEqual(result, "ok") + self.assertEqual(inference_model.api_url, api_url) + self.assertEqual(predict.call_args.kwargs["prediction_type"], prediction_type) + self.assertEqual(predict.call_args.kwargs["confidence"], 17) + self.assertEqual(predict.call_args.kwargs["overlap"], 9) + self.assertEqual(predict.call_args.kwargs["format"], "json") + + +class TestTrainedModelVideo(unittest.TestCase): + def test_predict_video_routes_through_task_appropriate_legacy_model(self): + cases = [ + ("yolov11", ObjectDetectionModel), + ("yolov11-cls", ClassificationModel), + ("yolov11-seg", InstanceSegmentationModel), + ("yolov11-pose", KeypointDetectionModel), + ("yolo26-sem", SemanticSegmentationModel), + ] + + for model_type, legacy_class in cases: + with self.subTest(model_type=model_type): + model = TrainedModel("key", "ws", "proj", "ws/model-slug", model_type=model_type) + with patch.object( + legacy_class, + "predict_video", + autospec=True, + return_value=("job-1", "signed-url", None), + ) as predict_video: + result = model.predict_video("video.mp4", fps=9) + + legacy_model = predict_video.call_args.args[0] + self.assertIsInstance(legacy_model, legacy_class) + self.assertEqual(legacy_model.id, "ws/proj/model-slug") + self.assertEqual(result, ("job-1", "signed-url", None)) + self.assertEqual(predict_video.call_args.kwargs["fps"], 9) + + def test_poll_reuses_the_predict_video_legacy_model(self): + model = TrainedModel("key", "ws", "proj", "ws/model-slug", model_type="yolov11") + + with ( + patch.object(ObjectDetectionModel, "predict_video", autospec=True, return_value=("job-1", "url", None)), + patch.object( + ObjectDetectionModel, "poll_until_video_results", autospec=True, return_value={"frames": []} + ) as poll, + ): + model.predict_video("video.mp4") + result = model.poll_until_video_results("job-1") + + self.assertEqual(result, {"frames": []}) + self.assertIs(poll.call_args.args[0], model._video_model()) + + +class TestTrainingModels(unittest.TestCase): + def test_models_are_cached_until_refresh(self): + training = Training("key", "ws", "proj", "1", {"trainingId": "training-1"}) + bundle = { + "status": "finished", + "modelType": "yolov11-cls", + "modelGroup": "group-1", + "modelIds": ["ws/model-slug"], + "models": [{"modelId": "ws/model-slug"}], + } + + with patch("roboflow.core.training.rfapi.get_training", return_value=bundle) as get_training: + first = training.models + second = training.models + training.refresh() + third = training.models + + self.assertIs(first, second) + self.assertEqual(first[0].model_id, "ws/model-slug") + self.assertEqual(first[0].model_type, "yolov11-cls") + self.assertEqual(third[0].model_id, "ws/model-slug") + self.assertEqual(third[0].model_type, "yolov11-cls") + self.assertEqual(training.status, "finished") + self.assertEqual(training.model_type, "yolov11-cls") + self.assertEqual(training.model_group, "group-1") + self.assertEqual(training.model_ids, ["ws/model-slug"]) + self.assertEqual(get_training.call_count, 3) + + +class TestTrainingTrash(unittest.TestCase): + def test_delete_moves_run_to_trash(self): + training = Training("key", "ws", "proj", "1", {"trainingId": "training-1"}) + + with patch( + "roboflow.core.training.rfapi.delete_version_training", + return_value={"deleted": True, "type": "training", "trainingId": "training-1", "trash": True}, + ) as delete: + result = training.delete() + + delete.assert_called_once_with("key", "ws", "proj", "1", training_id="training-1") + self.assertTrue(result["trash"]) + + def test_restore_rejects_a_blank_training_id(self): + training = Training("key", "ws", "proj", "1", {"trainingId": " "}) + + with self.assertRaises(ValueError): + training.restore() + + def test_restore_goes_through_the_shared_trash_route(self): + training = Training("key", "ws", "proj", "1", {"trainingId": "training-1"}) + + with patch( + "roboflow.core.training.rfapi.restore_trash_item", + return_value={"restored": True}, + ) as restore: + result = training.restore() + + restore.assert_called_once_with("key", "ws", "training", "training-1") + self.assertTrue(result["restored"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_version.py b/tests/test_version.py index f13479bc..4cb66e91 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,14 +1,33 @@ import os import unittest -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import MagicMock, patch import requests import responses +from roboflow.adapters import rfapi +from roboflow.config import ( + TYPE_CLASSICATION, + TYPE_INSTANCE_SEGMENTATION, + TYPE_KEYPOINT_DETECTION, + TYPE_OBJECT_DETECTION, + TYPE_SEMANTIC_SEGMENTATION, +) from roboflow.core.version import Version, unwrap_version_id +from roboflow.models.object_detection import ObjectDetectionModel from tests.helpers import get_version +def mock_generating_url_response(generating_url): + """Helper function to mock the generating URL response that's repeated across tests.""" + responses.add( + responses.GET, + generating_url, + json={"version": {"generating": False, "progress": 1.0, "images": 10}}, + ) + + class TestDownload(unittest.TestCase): def setUp(self): super().setUp() @@ -24,42 +43,29 @@ def setUp(self): @responses.activate def test_download_raises_exception_on_bad_request(self): responses.add(responses.GET, self.api_url, status=404, json={"error": "Broken"}) - responses.add( - responses.GET, - self.generating_url, - json={"version": {"generating": False, "progress": 1.0}}, - ) - - with self.assertRaises(RuntimeError): + mock_generating_url_response(self.generating_url) + with self.assertRaises(rfapi.RoboflowError): self.version.download("coco") @responses.activate def test_download_raises_exception_on_api_failure(self): responses.add(responses.GET, self.api_url, status=500) - responses.add( - responses.GET, - self.generating_url, - json={"version": {"generating": False, "progress": 1.0}}, - ) - with self.assertRaises(requests.exceptions.HTTPError): + mock_generating_url_response(self.generating_url) + with self.assertRaises(rfapi.RoboflowError): self.version.download("coco") @responses.activate @patch.object(Version, "_Version__download_zip") - @patch.object(Version, "_Version__extract_zip") + @patch("roboflow.core.version.extract_zip") @patch.object(Version, "_Version__reformat_yaml") def test_download_returns_dataset(self, *_): responses.add(responses.GET, self.api_url, json={"export": {"link": None}}) - responses.add( - responses.GET, - self.generating_url, - json={"version": {"generating": False, "progress": 1.0}}, - ) + mock_generating_url_response(self.generating_url) dataset = self.version.download("coco", location="/my-spot") self.assertEqual(dataset.name, self.version.name) self.assertEqual(dataset.version, self.version.version) self.assertEqual(dataset.model_format, "coco") - self.assertEqual(dataset.location, "/my-spot") + self.assertEqual(dataset.location, os.path.abspath("/my-spot")) class TestExport(unittest.TestCase): @@ -76,12 +82,13 @@ def setUp(self): @responses.activate def test_export_returns_true_on_api_success(self): - responses.add(responses.GET, self.api_url, status=200) responses.add( responses.GET, - self.generating_url, - json={"version": {"generating": False, "progress": 1.0}}, + self.api_url, + status=200, + json={"export": {"link": "https://api.roboflow.com/test-workspace/test-project/4/test-format"}}, ) + mock_generating_url_response(self.generating_url) export = self.version.export("test-format") request = responses.calls[0].request @@ -92,23 +99,15 @@ def test_export_returns_true_on_api_success(self): @responses.activate def test_export_raises_error_on_bad_request(self): responses.add(responses.GET, self.api_url, status=400, json={"error": "BROKEN!!"}) - responses.add( - responses.GET, - self.generating_url, - json={"version": {"generating": False, "progress": 1.0}}, - ) - with self.assertRaises(RuntimeError): + mock_generating_url_response(self.generating_url) + with self.assertRaises(rfapi.RoboflowError): self.version.export("test-format") @responses.activate def test_export_raises_error_on_api_failure(self): responses.add(responses.GET, self.api_url, status=500) - responses.add( - responses.GET, - self.generating_url, - json={"version": {"generating": False, "progress": 1.0}}, - ) - with self.assertRaises(requests.exceptions.HTTPError): + mock_generating_url_response(self.generating_url) + with self.assertRaises(rfapi.RoboflowError): self.version.export("test-format") @@ -128,21 +127,13 @@ def setUp(self, *_): @responses.activate def test_get_download_location_with_env_variable(self, *_): - responses.add( - responses.GET, - self.generating_url, - json={"version": {"generating": False, "progress": 1.0}}, - ) + mock_generating_url_response(self.generating_url) with patch.dict(os.environ, {"DATASET_DIRECTORY": "/my/exports"}, clear=True): self.assertEqual(self.get_download_location(), "/my/exports/Test-Dataset-3") @responses.activate def test_get_download_location_without_env_variable(self, *_): - responses.add( - responses.GET, - self.generating_url, - json={"version": {"generating": False, "progress": 1.0}}, - ) + mock_generating_url_response(self.generating_url) self.assertEqual(self.get_download_location(), "Test-Dataset-3") @@ -161,11 +152,7 @@ def setUp(self): @responses.activate def test_get_download_url(self): - responses.add( - responses.GET, - self.generating_url, - json={"version": {"generating": False, "progress": 1.0}}, - ) + mock_generating_url_response(self.generating_url) url = self.get_download_url("yolo1337") self.assertEqual(url, "https://api.roboflow.com/test-workspace/test-project/3/yolo1337") @@ -220,3 +207,266 @@ def test_unwrap_version_id_when_only_version_id_is_given() -> None: # then assert result == "3" + + +class TestValidateAgainstProjectType(unittest.TestCase): + def _version(self, project_type): + return get_version(type=project_type) + + def test_detection_project_accepts_plain_yolo(self): + self._version(TYPE_OBJECT_DETECTION)._validate_against_project_type("yolov11") + + def test_detection_project_accepts_rfdetr_detection(self): + self._version(TYPE_OBJECT_DETECTION)._validate_against_project_type("rfdetr-medium") + + def test_detection_project_rejects_seg_model(self): + with self.assertRaises(ValueError): + self._version(TYPE_OBJECT_DETECTION)._validate_against_project_type("yolov11-seg") + + def test_detection_project_rejects_rfdetr_seg(self): + with self.assertRaises(ValueError): + self._version(TYPE_OBJECT_DETECTION)._validate_against_project_type("rfdetr-seg-medium") + + def test_instance_seg_project_accepts_seg_model(self): + self._version(TYPE_INSTANCE_SEGMENTATION)._validate_against_project_type("yolov11-seg") + + def test_instance_seg_project_accepts_rfdetr_seg(self): + self._version(TYPE_INSTANCE_SEGMENTATION)._validate_against_project_type("rfdetr-seg-medium") + + def test_instance_seg_project_rejects_detection(self): + with self.assertRaises(ValueError): + self._version(TYPE_INSTANCE_SEGMENTATION)._validate_against_project_type("yolov11") + + def test_keypoint_project_accepts_pose_model(self): + self._version(TYPE_KEYPOINT_DETECTION)._validate_against_project_type("yolov11-pose") + + def test_keypoint_project_rejects_detection(self): + with self.assertRaises(ValueError): + self._version(TYPE_KEYPOINT_DETECTION)._validate_against_project_type("yolov11") + + def test_classification_project_accepts_cls(self): + self._version(TYPE_CLASSICATION)._validate_against_project_type("yolov11-cls") + + def test_semantic_seg_project_accepts_sem_model(self): + self._version(TYPE_SEMANTIC_SEGMENTATION)._validate_against_project_type("yolo26-sem") + + def test_semantic_seg_project_rejects_detection(self): + with self.assertRaises(ValueError): + self._version(TYPE_SEMANTIC_SEGMENTATION)._validate_against_project_type("yolov11") + + def test_semantic_seg_project_rejects_instance_seg(self): + with self.assertRaises(ValueError): + self._version(TYPE_SEMANTIC_SEGMENTATION)._validate_against_project_type("yolov11-seg") + + def test_instance_seg_project_rejects_sem_model(self): + with self.assertRaises(ValueError): + self._version(TYPE_INSTANCE_SEGMENTATION)._validate_against_project_type("yolo26-sem") + + def test_detection_project_rejects_sem_model(self): + with self.assertRaises(ValueError): + self._version(TYPE_OBJECT_DETECTION)._validate_against_project_type("yolo26-sem") + + def test_classification_project_rejects_detection(self): + with self.assertRaises(ValueError): + self._version(TYPE_CLASSICATION)._validate_against_project_type("yolov11") + + +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): + version = get_version() + self.assertIsNone(version._model) + + @patch( + "roboflow.adapters.rfapi.get_version", + side_effect=requests.exceptions.ConnectionError("network down"), + ) + def test_construction_survives_request_layer_failure(self, _mock_get_version: MagicMock): + # A transient/mocked request failure must not break basic version retrieval. + version = get_version() + self.assertIsNone(version._model) + + @patch("roboflow.adapters.rfapi.get_version", side_effect=AssertionError("get_version should not be called")) + def test_legacy_model_is_derived_from_payload(self, _mock_get_version: MagicMock): + version = get_version(type=TYPE_OBJECT_DETECTION, model={"id": "test-workspace/test-project/2"}) + self.assertIsInstance(version._model, ObjectDetectionModel) + + +class TestMMPVCompatibility(unittest.TestCase): + @patch("roboflow.adapters.rfapi.get_version", return_value={"version": {}}) + def test_model_property_is_deprecated_and_does_not_enumerate_models(self, _mock_get_version: MagicMock): + version = get_version() + with patch.object(Version, "models", side_effect=AssertionError("models should not be called")): + with self.assertWarns(DeprecationWarning): + self.assertIsNone(version.model) + + @patch("roboflow.adapters.rfapi.get_version", return_value={"version": {}}) + def test_models_returns_union_across_trainings(self, _mock_get_version: MagicMock): + version = get_version() + a, b, c = object(), object(), object() + training_one = SimpleNamespace(models=[a, b]) + training_two = SimpleNamespace(models=[c]) + with patch.object(Version, "trainings", return_value=[training_one, training_two]): + self.assertEqual(version.models(), [a, b, c]) + + @patch.object(Version, "_Version__wait_if_generating") + @patch("roboflow.adapters.rfapi.create_training_v2") + @patch("roboflow.adapters.rfapi.get_version", return_value={"version": {}}) + def test_create_training_returns_v2_training( + self, + _mock_get_version: MagicMock, + mock_create_training: MagicMock, + _mock_wait_if_generating: MagicMock, + ): + mock_create_training.return_value = { + "trainingId": "training-1", + "status": "running", + "modelType": "yolov11", + } + version = get_version(version_number="4") + + training = version.create_training(speed="fast", model_type=None, checkpoint="ckpt", epochs=10) + + mock_create_training.assert_called_once_with( + api_key="test-api-key", + workspace_url="test-workspace", + project_url="test-project", + version="4", + speed="fast", + checkpoint="ckpt", + model_type=None, + epochs=10, + train_recipe=None, + ) + self.assertEqual(training.training_id, "training-1") + self.assertEqual(training.status, "running") + self.assertEqual(training.model_type, "yolov11") + + +class V2TrainingRecipeTestCase(unittest.TestCase): + """Base fixture for v2 train-recipe tests: an offline Version fixture.""" + + RECIPE_RESPONSE = { + "modelType": "rfdetr-medium", + "family": "rf-detr", + "taskType": "object-detection", + "schema": {"hyperparameters": [{"key": "lr", "type": "float"}]}, + "template": { + "schema_version": 1, + "input": {}, + "online_preprocessing": [], + "online_augmentation": {"splits": ["train"], "steps": []}, + "source_version": {}, + "hyperparameters": {}, + }, + "usage": "...", + } + + def setUp(self): + super().setUp() + self.version = get_version( + project_name="Test Dataset", + id="test-workspace/test-project/2", + version_number="4", + ) + + +class TestDescribeTrainRecipe(V2TrainingRecipeTestCase): + def test_describe_train_recipe_passes_through(self): + with patch.object(rfapi, "get_train_recipe", return_value=self.RECIPE_RESPONSE) as mock_recipe: + result = self.version.describe_train_recipe("rfdetr-medium") + + self.assertEqual(result, self.RECIPE_RESPONSE) + mock_recipe.assert_called_once_with( + api_key="test-api-key", + workspace_url="test-workspace", + project_url="test-project", + version="4", + model_type="rfdetr-medium", + ) + + +class TestCreateTrainingWithRecipe(V2TrainingRecipeTestCase): + """The train_recipe extension of Version.create_training.""" + + CREATE_RESPONSE = {"trainingId": "t-1", "status": "queued", "jobId": "job-1"} + NOT_GENERATING = {"version": {"generating": False, "progress": 1.0, "images": 10}} + + def _create(self, **kwargs): + with ( + patch.object(rfapi, "get_version", return_value=self.NOT_GENERATING), + patch.object(rfapi, "get_train_recipe", return_value=self.RECIPE_RESPONSE) as mock_recipe, + patch.object(rfapi, "create_training_v2", return_value=self.CREATE_RESPONSE) as mock_create, + patch.object(Version, "export", return_value=True) as mock_export, + ): + result = self.version.create_training(**kwargs) + return result, mock_recipe, mock_create, mock_export + + def test_create_training_requires_model_type_for_train_recipe(self): + # Raised before any network call: neither create nor generation polling runs. + with patch.object(rfapi, "create_training_v2") as mock_create: + with self.assertRaises(ValueError): + self.version.create_training(train_recipe={"schema_version": 1}) + mock_create.assert_not_called() + + def test_recipe_kwargs_pass_through_to_canonical_create(self): + result, mock_recipe, mock_create, mock_export = self._create( + model_type="rfdetr-medium", + epochs=10, + speed="fast", + checkpoint="ckpt", + ) + + self.assertEqual(result.training_id, "t-1") + self.assertEqual(result.status, "queued") + mock_recipe.assert_not_called() + mock_export.assert_called_once_with("coco") + mock_create.assert_called_once_with( + api_key="test-api-key", + workspace_url="test-workspace", + project_url="test-project", + version="4", + speed="fast", + checkpoint="ckpt", + model_type="rfdetr-medium", + epochs=10, + train_recipe=None, + ) + + def test_explicit_recipe_submitted_as_is_without_describe(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.5}} + _, mock_recipe, mock_create, mock_export = self._create(model_type="rfdetr-medium", train_recipe=recipe) + + mock_recipe.assert_not_called() # no describe fetch on the explicit-recipe path + mock_export.assert_called_once_with("coco") # model_type is required, so export is ensured + self.assertEqual(mock_create.call_args.kwargs["train_recipe"], recipe) + self.assertEqual(mock_create.call_args.kwargs["model_type"], "rfdetr-medium") + + def test_epochs_folded_into_explicit_recipe(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.5}} + _, mock_recipe, mock_create, _ = self._create(model_type="rfdetr-medium", train_recipe=recipe, epochs=50) + + mock_recipe.assert_not_called() + submitted = mock_create.call_args.kwargs["train_recipe"] + self.assertEqual(submitted["hyperparameters"], {"lr": 0.5, "epochs": 50}) + self.assertEqual(mock_create.call_args.kwargs["epochs"], 50) + # The caller's recipe dict is not mutated by the fold. + self.assertEqual(recipe["hyperparameters"], {"lr": 0.5}) + + def test_explicit_recipe_epochs_wins_over_argument(self): + recipe = {"schema_version": 1, "hyperparameters": {"epochs": 25}} + _, _, mock_create, _ = self._create(model_type="rfdetr-medium", train_recipe=recipe, epochs=50) + + submitted = mock_create.call_args.kwargs["train_recipe"] + self.assertEqual(submitted["hyperparameters"]["epochs"], 25) + + def test_epochs_fold_creates_hyperparameters_in_explicit_recipe(self): + _, _, mock_create, _ = self._create(model_type="rfdetr-medium", train_recipe={"schema_version": 1}, epochs=50) + + submitted = mock_create.call_args.kwargs["train_recipe"] + self.assertEqual(submitted["hyperparameters"], {"epochs": 50}) + + def test_export_skipped_when_format_already_present(self): + self.version.exports = ["coco"] + _, _, _, mock_export = self._create(model_type="rfdetr-medium") + mock_export.assert_not_called() diff --git a/tests/test_vision_events.py b/tests/test_vision_events.py new file mode 100644 index 00000000..0ab2bb7c --- /dev/null +++ b/tests/test_vision_events.py @@ -0,0 +1,636 @@ +import json +import os +import tempfile +import unittest + +import responses + +from roboflow.adapters.rfapi import RoboflowError +from roboflow.config import API_URL + +# The vision events API does not include workspace in the URL. +# Auth is via Bearer token; workspace is derived server-side from the API key. +_BASE = f"{API_URL}/vision-events" + + +class TestVisionEvents(unittest.TestCase): + API_KEY = "test_key" + WORKSPACE = "test-ws" + + def _make_workspace(self): + from roboflow.core.workspace import Workspace + + info = { + "workspace": { + "name": "Test", + "url": self.WORKSPACE, + "projects": [], + "members": [], + } + } + return Workspace(info, api_key=self.API_KEY, default_workspace=self.WORKSPACE, model_format="yolov8") + + def _assert_bearer_auth(self, call_index=0): + auth = responses.calls[call_index].request.headers.get("Authorization") + self.assertEqual(auth, f"Bearer {self.API_KEY}") + + # --- write_vision_event --- + + @responses.activate + def test_write_event(self): + responses.add(responses.POST, _BASE, json={"eventId": "evt-001"}, status=201) + + ws = self._make_workspace() + event = { + "eventId": "evt-001", + "eventType": "quality_check", + "useCaseId": "uc-1", + "timestamp": "2024-01-15T10:00:00Z", + "eventData": {"result": "pass"}, + } + result = ws.write_vision_event(event) + + self.assertEqual(result["eventId"], "evt-001") + self._assert_bearer_auth() + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent["eventId"], "evt-001") + self.assertEqual(sent["eventType"], "quality_check") + self.assertEqual(sent["useCaseId"], "uc-1") + self.assertEqual(sent["eventData"], {"result": "pass"}) + + @responses.activate + def test_write_event_passthrough(self): + """The event dict must be sent to the server unchanged (no filtering or transformation).""" + responses.add(responses.POST, _BASE, json={"eventId": "e1"}, status=201) + + ws = self._make_workspace() + event = { + "eventId": "e1", + "eventType": "safety_alert", + "useCaseId": "warehouse-safety", + "timestamp": "2024-06-01T12:00:00Z", + "deviceId": "cam-5", + "streamId": "stream-a", + "workflowId": "wf-1", + "images": [{"sourceId": "src-1", "label": "frame"}], + "eventData": {"alertType": "fire", "severity": "high"}, + "customMetadata": {"zone": "B3", "temperature": 42.5, "active": True}, + } + ws.write_vision_event(event) + + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent, event) + + @responses.activate + def test_write_event_error(self): + responses.add(responses.POST, _BASE, json={"error": "forbidden"}, status=403) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.write_vision_event({"eventId": "x", "eventType": "custom", "useCaseId": "s", "timestamp": "t"}) + + # --- write_vision_events_batch --- + + @responses.activate + def test_write_batch(self): + responses.add(responses.POST, f"{_BASE}/batch", json={"created": 2, "eventIds": ["e1", "e2"]}, status=201) + + ws = self._make_workspace() + events = [ + {"eventId": "e1", "eventType": "custom", "useCaseId": "s", "timestamp": "2024-01-15T10:00:00Z"}, + {"eventId": "e2", "eventType": "custom", "useCaseId": "s", "timestamp": "2024-01-15T10:01:00Z"}, + ] + result = ws.write_vision_events_batch(events) + + self.assertEqual(result["created"], 2) + self.assertEqual(result["eventIds"], ["e1", "e2"]) + self._assert_bearer_auth() + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(len(sent["events"]), 2) + + @responses.activate + def test_write_batch_error(self): + responses.add(responses.POST, f"{_BASE}/batch", json={"error": "validation"}, status=400) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.write_vision_events_batch([{"bad": "event"}]) + + # --- query_vision_events --- + + @responses.activate + def test_query_basic(self): + body = { + "events": [{"eventId": "e1"}, {"eventId": "e2"}], + "nextCursor": None, + "hasMore": False, + "lookbackDays": 14, + } + responses.add(responses.POST, f"{_BASE}/query", json=body, status=200) + + ws = self._make_workspace() + result = ws.query_vision_events("my-use-case") + + self.assertEqual(len(result["events"]), 2) + self.assertFalse(result["hasMore"]) + self._assert_bearer_auth() + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent["useCaseId"], "my-use-case") + + @responses.activate + def test_query_with_filters(self): + body = {"events": [], "nextCursor": None, "hasMore": False, "lookbackDays": 14} + responses.add(responses.POST, f"{_BASE}/query", json=body, status=200) + + ws = self._make_workspace() + ws.query_vision_events( + "my-uc", + event_type="quality_check", + start_time="2024-01-01T00:00:00Z", + end_time="2024-02-01T00:00:00Z", + limit=10, + cursor="abc123", + deviceId={"operator": "eq", "value": "cam-01"}, + ) + + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent["useCaseId"], "my-uc") + self.assertEqual(sent["eventType"], "quality_check") + self.assertEqual(sent["startTime"], "2024-01-01T00:00:00Z") + self.assertEqual(sent["endTime"], "2024-02-01T00:00:00Z") + self.assertEqual(sent["limit"], 10) + self.assertEqual(sent["cursor"], "abc123") + self.assertEqual(sent["deviceId"], {"operator": "eq", "value": "cam-01"}) + + @responses.activate + def test_query_with_event_types_plural(self): + body = {"events": [], "nextCursor": None, "hasMore": False, "lookbackDays": 14} + responses.add(responses.POST, f"{_BASE}/query", json=body, status=200) + + ws = self._make_workspace() + ws.query_vision_events("uc", event_types=["quality_check", "safety_alert"]) + + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent["eventTypes"], ["quality_check", "safety_alert"]) + self.assertNotIn("eventType", sent) + + @responses.activate + def test_query_omits_none_params(self): + """Optional params that are None must not appear in the payload.""" + body = {"events": [], "nextCursor": None, "hasMore": False, "lookbackDays": 14} + responses.add(responses.POST, f"{_BASE}/query", json=body, status=200) + + ws = self._make_workspace() + ws.query_vision_events("uc") + + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent, {"useCaseId": "uc"}) + + @responses.activate + def test_query_error(self): + responses.add(responses.POST, f"{_BASE}/query", json={"error": "unauthorized"}, status=401) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.query_vision_events("my-uc") + + # --- query_all_vision_events --- + + @responses.activate + def test_query_all_single_page(self): + body = { + "events": [{"eventId": "e1"}], + "nextCursor": None, + "hasMore": False, + "lookbackDays": 14, + } + responses.add(responses.POST, f"{_BASE}/query", json=body, status=200) + + ws = self._make_workspace() + pages = list(ws.query_all_vision_events("my-uc")) + + self.assertEqual(len(pages), 1) + self.assertEqual(pages[0][0]["eventId"], "e1") + + @responses.activate + def test_query_all_multiple_pages(self): + page1 = {"events": [{"eventId": "e1"}], "nextCursor": "cursor2", "hasMore": True, "lookbackDays": 14} + page2 = {"events": [{"eventId": "e2"}], "nextCursor": None, "hasMore": False, "lookbackDays": 14} + responses.add(responses.POST, f"{_BASE}/query", json=page1, status=200) + responses.add(responses.POST, f"{_BASE}/query", json=page2, status=200) + + ws = self._make_workspace() + pages = list(ws.query_all_vision_events("my-uc")) + + self.assertEqual(len(pages), 2) + self.assertEqual(pages[0][0]["eventId"], "e1") + self.assertEqual(pages[1][0]["eventId"], "e2") + + # Verify cursor was sent in second request + sent2 = json.loads(responses.calls[1].request.body) + self.assertEqual(sent2["cursor"], "cursor2") + + @responses.activate + def test_query_all_forwards_filters(self): + """Filters must be forwarded to every page request, not just the first.""" + page1 = {"events": [{"eventId": "e1"}], "nextCursor": "c2", "hasMore": True, "lookbackDays": 14} + page2 = {"events": [{"eventId": "e2"}], "nextCursor": None, "hasMore": False, "lookbackDays": 14} + responses.add(responses.POST, f"{_BASE}/query", json=page1, status=200) + responses.add(responses.POST, f"{_BASE}/query", json=page2, status=200) + + ws = self._make_workspace() + list(ws.query_all_vision_events("uc", event_type="quality_check", limit=1)) + + sent1 = json.loads(responses.calls[0].request.body) + sent2 = json.loads(responses.calls[1].request.body) + + # Both requests should have the filter + self.assertEqual(sent1["eventType"], "quality_check") + self.assertEqual(sent2["eventType"], "quality_check") + # Second request should also have the cursor + self.assertNotIn("cursor", sent1) + self.assertEqual(sent2["cursor"], "c2") + + @responses.activate + def test_query_all_empty(self): + body = {"events": [], "nextCursor": None, "hasMore": False, "lookbackDays": 14} + responses.add(responses.POST, f"{_BASE}/query", json=body, status=200) + + ws = self._make_workspace() + pages = list(ws.query_all_vision_events("my-uc")) + + self.assertEqual(len(pages), 0) + + # --- list_vision_event_use_cases --- + + @responses.activate + def test_list_use_cases(self): + body = { + "useCases": [ + {"id": "uc-1", "name": "QA", "status": "active"}, + ], + "lookbackDays": 14, + } + responses.add(responses.GET, f"{_BASE}/use-cases", json=body, status=200) + + ws = self._make_workspace() + result = ws.list_vision_event_use_cases() + + self.assertEqual(len(result["useCases"]), 1) + self.assertEqual(result["useCases"][0]["name"], "QA") + self._assert_bearer_auth() + + @responses.activate + def test_list_use_cases_with_status(self): + body = {"useCases": [], "lookbackDays": 14} + responses.add(responses.GET, f"{_BASE}/use-cases", json=body, status=200) + + ws = self._make_workspace() + result = ws.list_vision_event_use_cases(status="inactive") + + self.assertEqual(len(result["useCases"]), 0) + # Verify status was sent as query param + self.assertIn("status=inactive", responses.calls[0].request.url) + + @responses.activate + def test_list_use_cases_legacy_solutions_response(self): + responses.add( + responses.GET, + f"{_BASE}/use-cases", + json={"solutions": [{"id": "uc-legacy", "name": "Legacy"}], "lookbackDays": 14}, + status=200, + ) + + ws = self._make_workspace() + result = ws.list_vision_event_use_cases() + self.assertEqual(result["useCases"][0]["id"], "uc-legacy") + + @responses.activate + def test_list_use_cases_error(self): + responses.add(responses.GET, f"{_BASE}/use-cases", json={"error": "forbidden"}, status=403) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.list_vision_event_use_cases() + + # --- create_vision_event_use_case --- + + @responses.activate + def test_create_use_case(self): + responses.add( + responses.POST, + f"{_BASE}/use-cases", + json={"id": "new-uc", "name": "My Use Case"}, + status=201, + ) + + ws = self._make_workspace() + result = ws.create_vision_event_use_case("My Use Case") + + self.assertEqual(result["id"], "new-uc") + self.assertEqual(result["name"], "My Use Case") + self._assert_bearer_auth() + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent["name"], "My Use Case") + + @responses.activate + def test_create_use_case_error(self): + responses.add(responses.POST, f"{_BASE}/use-cases", json={"error": "duplicate name"}, status=409) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.create_vision_event_use_case("Existing Name") + + # --- rename_vision_event_use_case --- + + @responses.activate + def test_rename_use_case(self): + responses.add( + responses.PUT, + f"{_BASE}/use-cases/uc-1", + json={"id": "uc-1", "name": "Renamed"}, + status=200, + ) + + ws = self._make_workspace() + result = ws.rename_vision_event_use_case("uc-1", "Renamed") + + self.assertEqual(result["id"], "uc-1") + self.assertEqual(result["name"], "Renamed") + self._assert_bearer_auth() + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent["name"], "Renamed") + + @responses.activate + def test_rename_use_case_error(self): + responses.add(responses.PUT, f"{_BASE}/use-cases/nonexistent", json={"error": "not found"}, status=404) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.rename_vision_event_use_case("nonexistent", "New Name") + + # --- archive_vision_event_use_case --- + + @responses.activate + def test_archive_use_case(self): + responses.add( + responses.POST, + f"{_BASE}/use-cases/uc-1/archive", + json={"success": True}, + status=200, + ) + + ws = self._make_workspace() + result = ws.archive_vision_event_use_case("uc-1") + + self.assertTrue(result["success"]) + self._assert_bearer_auth() + + @responses.activate + def test_archive_use_case_error(self): + responses.add(responses.POST, f"{_BASE}/use-cases/nonexistent/archive", json={"error": "not found"}, status=404) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.archive_vision_event_use_case("nonexistent") + + # --- unarchive_vision_event_use_case --- + + @responses.activate + def test_unarchive_use_case(self): + responses.add( + responses.POST, + f"{_BASE}/use-cases/uc-1/unarchive", + json={"success": True}, + status=200, + ) + + ws = self._make_workspace() + result = ws.unarchive_vision_event_use_case("uc-1") + + self.assertTrue(result["success"]) + self._assert_bearer_auth() + + @responses.activate + def test_unarchive_use_case_error(self): + responses.add(responses.POST, f"{_BASE}/use-cases/uc-1/unarchive", json={"error": "already active"}, status=400) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.unarchive_vision_event_use_case("uc-1") + + # --- get_vision_event_metadata_schema --- + + @responses.activate + def test_get_metadata_schema(self): + body = { + "useCaseId": "manufacturing-qa", + "fields": { + "temperature": {"types": ["number"]}, + "zone": {"types": ["string"]}, + "active": {"types": ["boolean"]}, + }, + } + responses.add( + responses.GET, + f"{_BASE}/custom-metadata-schema/manufacturing-qa", + json=body, + status=200, + ) + + ws = self._make_workspace() + result = ws.get_vision_event_metadata_schema("manufacturing-qa") + + self.assertEqual(len(result["fields"]), 3) + self.assertEqual(result["fields"]["temperature"]["types"], ["number"]) + self._assert_bearer_auth() + + @responses.activate + def test_get_metadata_schema_error(self): + responses.add( + responses.GET, + f"{_BASE}/custom-metadata-schema/nonexistent", + json={"error": "not found"}, + status=404, + ) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.get_vision_event_metadata_schema("nonexistent") + + # --- upload_vision_event_image --- + + @responses.activate + def test_upload_image(self): + responses.add(responses.POST, f"{_BASE}/upload", json={"success": True, "sourceId": "src-123"}, status=201) + + ws = self._make_workspace() + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: + f.write(b"\xff\xd8\xff\xe0fake-jpeg-data") + tmp_path = f.name + + try: + result = ws.upload_vision_event_image(tmp_path) + self.assertEqual(result["sourceId"], "src-123") + self._assert_bearer_auth() + finally: + os.unlink(tmp_path) + + @responses.activate + def test_upload_image_uses_basename(self): + """When no name is provided, the multipart filename should be the basename of the path.""" + responses.add(responses.POST, f"{_BASE}/upload", json={"success": True, "sourceId": "src-789"}, status=201) + + ws = self._make_workspace() + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, prefix="myimage_") as f: + f.write(b"\xff\xd8\xff\xe0fake") + tmp_path = f.name + + try: + ws.upload_vision_event_image(tmp_path) + request_body = responses.calls[0].request.body + basename = os.path.basename(tmp_path).encode() + if isinstance(request_body, bytes): + self.assertIn(basename, request_body) + finally: + os.unlink(tmp_path) + + @responses.activate + def test_upload_image_with_metadata(self): + responses.add(responses.POST, f"{_BASE}/upload", json={"success": True, "sourceId": "src-456"}, status=201) + + ws = self._make_workspace() + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + f.write(b"\x89PNGfake-png-data") + tmp_path = f.name + + try: + result = ws.upload_vision_event_image( + tmp_path, + name="custom-name.png", + metadata={"camera_id": "cam-01"}, + ) + self.assertEqual(result["sourceId"], "src-456") + + request_body = responses.calls[0].request.body + # Verify metadata and name were included in the multipart body + if isinstance(request_body, bytes): + self.assertIn(b"cam-01", request_body) + self.assertIn(b"custom-name.png", request_body) + finally: + os.unlink(tmp_path) + + @responses.activate + def test_upload_image_error(self): + responses.add(responses.POST, f"{_BASE}/upload", json={"error": "forbidden"}, status=403) + + ws = self._make_workspace() + + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f: + f.write(b"data") + tmp_path = f.name + + try: + with self.assertRaises(RoboflowError): + ws.upload_vision_event_image(tmp_path) + finally: + os.unlink(tmp_path) + + +class TestVisionEventsAdapter(unittest.TestCase): + """Tests that call the adapter directly (no Workspace). Work in slim installs.""" + + API_KEY = "test_key" + + @responses.activate + def test_adapter_write_event(self): + from roboflow.adapters import vision_events_api + + responses.add(responses.POST, _BASE, json={"eventId": "e1", "created": True}, status=201) + result = vision_events_api.write_event( + self.API_KEY, {"eventId": "e1", "eventType": "custom", "useCaseId": "uc"} + ) + self.assertEqual(result["eventId"], "e1") + self.assertEqual(responses.calls[0].request.headers["Authorization"], f"Bearer {self.API_KEY}") + + @responses.activate + def test_adapter_write_batch(self): + from roboflow.adapters import vision_events_api + + responses.add(responses.POST, f"{_BASE}/batch", json={"created": 1, "eventIds": ["e1"]}, status=201) + result = vision_events_api.write_batch(self.API_KEY, [{"eventId": "e1"}]) + self.assertEqual(result["created"], 1) + + @responses.activate + def test_adapter_query(self): + from roboflow.adapters import vision_events_api + + body = {"events": [{"eventId": "e1"}], "nextCursor": None, "hasMore": False, "lookbackDays": 14} + responses.add(responses.POST, f"{_BASE}/query", json=body, status=200) + result = vision_events_api.query(self.API_KEY, {"useCaseId": "uc"}) + self.assertEqual(len(result["events"]), 1) + + @responses.activate + def test_adapter_list_use_cases(self): + from roboflow.adapters import vision_events_api + + body = {"useCases": [{"id": "uc-1", "name": "QA"}], "lookbackDays": 14} + responses.add(responses.GET, f"{_BASE}/use-cases", json=body, status=200) + result = vision_events_api.list_use_cases(self.API_KEY) + self.assertEqual(len(result["useCases"]), 1) + + @responses.activate + def test_adapter_get_metadata_schema(self): + from roboflow.adapters import vision_events_api + + body = {"useCaseId": "uc-1", "fields": {"temp": {"types": ["number"]}}} + responses.add(responses.GET, f"{_BASE}/custom-metadata-schema/uc-1", json=body, status=200) + result = vision_events_api.get_custom_metadata_schema(self.API_KEY, "uc-1") + self.assertEqual(result["fields"]["temp"]["types"], ["number"]) + + @responses.activate + def test_adapter_create_use_case(self): + from roboflow.adapters import vision_events_api + + responses.add(responses.POST, f"{_BASE}/use-cases", json={"id": "uc-new", "name": "Test"}, status=201) + result = vision_events_api.create_use_case(self.API_KEY, "Test") + self.assertEqual(result["id"], "uc-new") + + @responses.activate + def test_adapter_rename_use_case(self): + from roboflow.adapters import vision_events_api + + responses.add(responses.PUT, f"{_BASE}/use-cases/uc-1", json={"id": "uc-1", "name": "New"}, status=200) + result = vision_events_api.rename_use_case(self.API_KEY, "uc-1", "New") + self.assertEqual(result["name"], "New") + + @responses.activate + def test_adapter_archive_use_case(self): + from roboflow.adapters import vision_events_api + + responses.add(responses.POST, f"{_BASE}/use-cases/uc-1/archive", json={"success": True}, status=200) + result = vision_events_api.archive_use_case(self.API_KEY, "uc-1") + self.assertTrue(result["success"]) + + @responses.activate + def test_adapter_unarchive_use_case(self): + from roboflow.adapters import vision_events_api + + responses.add(responses.POST, f"{_BASE}/use-cases/uc-1/unarchive", json={"success": True}, status=200) + result = vision_events_api.unarchive_use_case(self.API_KEY, "uc-1") + self.assertTrue(result["success"]) + + @responses.activate + def test_adapter_error_raises_roboflow_error(self): + from roboflow.adapters import vision_events_api + + responses.add(responses.POST, _BASE, json={"error": "forbidden"}, status=403) + with self.assertRaises(RoboflowError): + vision_events_api.write_event(self.API_KEY, {"eventId": "x"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace.py b/tests/test_workspace.py new file mode 100644 index 00000000..5c33b5b9 --- /dev/null +++ b/tests/test_workspace.py @@ -0,0 +1,180 @@ +"""Unit tests for module-level helpers in roboflow.core.workspace.""" + +import os +import tempfile +import unittest +import zipfile +from unittest.mock import patch + +from roboflow.core.workspace import Workspace, _zip_directory + + +def _make_workspace(): + return Workspace( + {"workspace": {"name": "Test", "projects": [], "url": "test-ws"}}, + api_key="test-key", + default_workspace="test-ws", + model_format="yolov8", + ) + + +class TestZipDirectory(unittest.TestCase): + def test_filters_hidden_and_junk_entries(self): + with tempfile.TemporaryDirectory() as src: + # Real content + with open(os.path.join(src, "sample.jpg"), "wb") as fh: + fh.write(b"jpg bytes") + # Hidden / junk files at the top level + with open(os.path.join(src, ".DS_Store"), "wb") as fh: + fh.write(b"x") + with open(os.path.join(src, "Thumbs.db"), "wb") as fh: + fh.write(b"x") + # macOS junk directory + mac_dir = os.path.join(src, "__MACOSX") + os.mkdir(mac_dir) + with open(os.path.join(mac_dir, "whatever.txt"), "wb") as fh: + fh.write(b"x") + # Hidden directory + hidden_dir = os.path.join(src, ".hidden") + os.mkdir(hidden_dir) + with open(os.path.join(hidden_dir, "inside.txt"), "wb") as fh: + fh.write(b"x") + + zip_path = _zip_directory(src) + try: + with zipfile.ZipFile(zip_path) as zf: + names = set(zf.namelist()) + self.assertEqual(names, {"sample.jpg"}) + finally: + os.unlink(zip_path) + + +class TestWorkspaceAsyncTasks(unittest.TestCase): + @patch("roboflow.adapters.rfapi.fork_project") + def test_fork_project_uses_workspace_destination(self, mock_fork): + workspace = _make_workspace() + mock_fork.return_value = {"taskId": "task-1", "url": "poll-url"} + + result = workspace.fork_project(url="source-ws/source-project") + + self.assertEqual(result, {"taskId": "task-1", "url": "poll-url"}) + mock_fork.assert_called_once_with( + "test-key", + "test-ws", + url="source-ws/source-project", + source_project_slug=None, + ) + + @patch("roboflow.adapters.rfapi.fork_project") + def test_fork_project_accepts_explicit_source_slug(self, mock_fork): + workspace = _make_workspace() + mock_fork.return_value = {"taskId": "task-1", "url": "poll-url"} + + workspace.fork_project(source_project_slug="source-project") + + mock_fork.assert_called_once_with( + "test-key", + "test-ws", + url=None, + source_project_slug="source-project", + ) + + @patch("roboflow.adapters.rfapi.get_async_task") + def test_get_async_task_uses_workspace_destination(self, mock_get): + workspace = _make_workspace() + mock_get.return_value = {"taskId": "task-1", "status": "running"} + + result = workspace.get_async_task("task-1") + + self.assertEqual(result, {"taskId": "task-1", "status": "running"}) + mock_get.assert_called_once_with("test-key", "test-ws", "task-1") + + +class TestWorkspaceImageMetadata(unittest.TestCase): + @patch("roboflow.adapters.rfapi.update_image_metadata") + def test_update_image_metadata_delegates(self, mock_update): + workspace = _make_workspace() + mock_update.return_value = {"success": True} + + result = workspace.update_image_metadata( + "img-1", + metadata={"quality": 95}, + remove_metadata=["old"], + add_tags=["reviewed"], + remove_tags=["pending"], + ) + + self.assertEqual(result, {"success": True}) + mock_update.assert_called_once_with( + api_key="test-key", + workspace_url="test-ws", + image_id="img-1", + metadata={"quality": 95}, + remove_metadata=["old"], + add_tags=["reviewed"], + remove_tags=["pending"], + ) + + @patch("roboflow.adapters.rfapi.update_image_metadata") + def test_update_image_metadata_propagates_error(self, mock_update): + from roboflow.adapters.rfapi import RoboflowError + + workspace = _make_workspace() + mock_update.side_effect = RoboflowError('{"error": {"message": "Invalid tag"}}') + + with self.assertRaises(RoboflowError): + workspace.update_image_metadata("img-1", add_tags=["bad tag"]) + + @patch("roboflow.adapters.rfapi.batch_update_image_metadata") + def test_batch_update_returns_enqueue_response_without_wait(self, mock_batch): + workspace = _make_workspace() + mock_batch.return_value = {"taskId": "task-9", "url": "https://api.test/poll"} + updates = [{"imageId": "img-1", "addTags": ["t"]}] + + result = workspace.batch_update_image_metadata(updates) + + self.assertEqual(result, {"taskId": "task-9", "url": "https://api.test/poll"}) + mock_batch.assert_called_once_with( + api_key="test-key", + workspace_url="test-ws", + updates=updates, + ) + + @patch("roboflow.core.async_tasks.time.sleep", lambda *_: None) + @patch("roboflow.adapters.rfapi.get_async_task_at") + @patch("roboflow.adapters.rfapi.batch_update_image_metadata") + def test_batch_update_wait_polls_until_terminal(self, mock_batch, mock_poll): + workspace = _make_workspace() + mock_batch.return_value = {"taskId": "task-9", "url": "https://api.test/poll"} + final = { + "taskId": "task-9", + "status": "completed", + "result": {"totalProcessed": 2, "succeeded": 2, "failed": 0, "failedItems": []}, + } + mock_poll.side_effect = [ + {"taskId": "task-9", "status": "running"}, + final, + ] + + result = workspace.batch_update_image_metadata([{"imageId": "img-1", "addTags": ["t"]}], wait=True) + + self.assertEqual(result, final) + self.assertEqual(mock_poll.call_count, 2) + mock_poll.assert_called_with("test-key", "https://api.test/poll") + + @patch("roboflow.core.async_tasks.time.sleep", lambda *_: None) + @patch("roboflow.adapters.rfapi.get_async_task") + @patch("roboflow.adapters.rfapi.batch_update_image_metadata") + def test_batch_update_wait_falls_back_to_task_id_poll(self, mock_batch, mock_get): + workspace = _make_workspace() + mock_batch.return_value = {"taskId": "task-9"} # no polling url in response + mock_get.return_value = {"taskId": "task-9", "status": "completed", "result": {}} + + result = workspace.batch_update_image_metadata([{"imageId": "img-1", "addTags": ["t"]}], wait=True) + + self.assertEqual(result["status"], "completed") + mock_get.assert_called_once_with("test-key", "test-ws", "task-9") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace_search.py b/tests/test_workspace_search.py new file mode 100644 index 00000000..8fdd9d51 --- /dev/null +++ b/tests/test_workspace_search.py @@ -0,0 +1,138 @@ +import json +import unittest + +import responses + +from roboflow.adapters.rfapi import RoboflowError +from roboflow.config import API_URL + + +class TestWorkspaceSearch(unittest.TestCase): + API_KEY = "test_key" + WORKSPACE = "test-ws" + SEARCH_URL = f"{API_URL}/{WORKSPACE}/search/v1?api_key={API_KEY}" + + def _make_workspace(self): + from roboflow.core.workspace import Workspace + + info = { + "workspace": { + "name": "Test", + "url": self.WORKSPACE, + "projects": [], + "members": [], + } + } + return Workspace(info, api_key=self.API_KEY, default_workspace=self.WORKSPACE, model_format="yolov8") + + # --- search() tests --- + + @responses.activate + def test_search_basic(self): + body = { + "results": [{"filename": "a.jpg"}, {"filename": "b.jpg"}], + "total": 2, + "continuationToken": None, + } + responses.add(responses.POST, self.SEARCH_URL, json=body, status=200) + + ws = self._make_workspace() + result = ws.search("tag:review") + + self.assertEqual(result["total"], 2) + self.assertEqual(len(result["results"]), 2) + self.assertIsNone(result["continuationToken"]) + + # Verify request payload + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent["query"], "tag:review") + self.assertEqual(sent["pageSize"], 50) + self.assertEqual(sent["fields"], ["tags", "projects", "filename"]) + self.assertNotIn("continuationToken", sent) + + @responses.activate + def test_search_with_continuation_token(self): + body = {"results": [{"filename": "c.jpg"}], "total": 3, "continuationToken": None} + responses.add(responses.POST, self.SEARCH_URL, json=body, status=200) + + ws = self._make_workspace() + ws.search("*", continuation_token="tok_abc") + + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent["continuationToken"], "tok_abc") + + @responses.activate + def test_search_custom_fields(self): + body = {"results": [], "total": 0, "continuationToken": None} + responses.add(responses.POST, self.SEARCH_URL, json=body, status=200) + + ws = self._make_workspace() + ws.search("*", fields=["filename", "embedding"]) + + sent = json.loads(responses.calls[0].request.body) + self.assertEqual(sent["fields"], ["filename", "embedding"]) + + @responses.activate + def test_search_api_error(self): + responses.add(responses.POST, self.SEARCH_URL, json={"error": "unauthorized"}, status=401) + + ws = self._make_workspace() + with self.assertRaises(RoboflowError): + ws.search("tag:review") + + # --- search_all() tests --- + + @responses.activate + def test_search_all_single_page(self): + body = { + "results": [{"filename": "a.jpg"}, {"filename": "b.jpg"}], + "total": 2, + "continuationToken": None, + } + responses.add(responses.POST, self.SEARCH_URL, json=body, status=200) + + ws = self._make_workspace() + pages = list(ws.search_all("*")) + + self.assertEqual(len(pages), 1) + self.assertEqual(len(pages[0]), 2) + + @responses.activate + def test_search_all_multiple_pages(self): + page1 = { + "results": [{"filename": "a.jpg"}], + "total": 2, + "continuationToken": "tok_page2", + } + page2 = { + "results": [{"filename": "b.jpg"}], + "total": 2, + "continuationToken": None, + } + responses.add(responses.POST, self.SEARCH_URL, json=page1, status=200) + responses.add(responses.POST, self.SEARCH_URL, json=page2, status=200) + + ws = self._make_workspace() + pages = list(ws.search_all("*", page_size=1)) + + self.assertEqual(len(pages), 2) + self.assertEqual(pages[0][0]["filename"], "a.jpg") + self.assertEqual(pages[1][0]["filename"], "b.jpg") + + # Verify second request used the continuation token + sent2 = json.loads(responses.calls[1].request.body) + self.assertEqual(sent2["continuationToken"], "tok_page2") + + @responses.activate + def test_search_all_empty_results(self): + body = {"results": [], "total": 0, "continuationToken": None} + responses.add(responses.POST, self.SEARCH_URL, json=body, status=200) + + ws = self._make_workspace() + pages = list(ws.search_all("*")) + + self.assertEqual(len(pages), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/util/test_folderparser.py b/tests/util/test_folderparser.py index d4d497cb..4f9ddb5b 100644 --- a/tests/util/test_folderparser.py +++ b/tests/util/test_folderparser.py @@ -1,4 +1,6 @@ import json +import os +import tempfile import unittest from os.path import abspath, dirname @@ -66,6 +68,143 @@ def test_paligemma_format(self): ) assert testImage["annotationfile"]["rawText"] == expected + def test_parse_classification_folder_structure(self): + classification_folder = f"{thisdir}/../datasets/corrosion-singlelabel-classification" + parsed = folderparser.parsefolder(classification_folder, is_classification=False) + for img in parsed["images"]: + self.assertIsNone(img.get("annotationfile")) + + parsed_classification = folderparser.parsefolder(classification_folder, is_classification=True) + corrosion_images = [i for i in parsed_classification["images"] if "Corrosion" in i["dirname"]] + self.assertTrue(len(corrosion_images) > 0) + for img in corrosion_images: + self.assertIsNotNone(img.get("annotationfile")) + self.assertEqual(img["annotationfile"]["type"], "classification_folder") + self.assertEqual(img["annotationfile"]["classification_label"], "Corrosion") + no_corrosion_images = [i for i in parsed_classification["images"] if "no-corrosion" in i["dirname"]] + self.assertTrue(len(no_corrosion_images) > 0) + for img in no_corrosion_images: + self.assertIsNotNone(img.get("annotationfile")) + self.assertEqual(img["annotationfile"]["type"], "classification_folder") + self.assertEqual(img["annotationfile"]["classification_label"], "no-corrosion") + + def test_parse_multilabel_classification_csv(self): + folder = f"{thisdir}/../datasets/skinproblem-multilabel-classification" + parsed = folderparser.parsefolder(folder, is_classification=True) + images = {img["name"]: img for img in parsed["images"]} + img1 = images.get("101_jpg.rf.ffb91e580c891eb04b715545274b2469.jpg") + self.assertIsNotNone(img1) + self.assertEqual(img1["annotationfile"]["type"], "classification_multilabel") + self.assertEqual(set(img1["annotationfile"]["labels"]), {"Blackheads"}) + + def test_coco_with_subdir_file_name_should_match_annotations(self): + # COCO file_name includes a subdirectory, but the actual image is at dataset root. + with tempfile.TemporaryDirectory() as tmpdir: + # Create nested image path: /2/100002/img.jpeg + image_name = "example_2_100002_02f2f7c6e15f09b401575ae6.jpeg" + image_relpath = os.path.join("2", "100002", image_name) + image_path = os.path.join(tmpdir, image_name) + # Create an empty image file (content not used by parser) + open(image_path, "wb").close() + + # Create COCO annotation JSON at dataset root, referencing the image with subdir in file_name + coco = { + "info": {}, + "licenses": [], + "categories": [{"id": 1, "name": "thing"}], + "images": [ + { + "id": 10000000, + "file_name": image_relpath.replace(os.sep, "/"), + "width": 800, + "height": 533, + } + ], + "annotations": [ + { + "id": 1, + "image_id": 10000000, + "category_id": 1, + "bbox": [10, 10, 100, 50], + "area": 5000, + "segmentation": [], + "iscrowd": 0, + } + ], + } + coco_path = os.path.join(tmpdir, "_annotations.coco.json") + with open(coco_path, "w") as f: + json.dump(coco, f) + + parsed = folderparser.parsefolder(tmpdir) + # Image entries store file with a leading slash relative to root + expected_file_key = f"/{image_name}" + img_entries = [i for i in parsed["images"] if i["file"] == expected_file_key] + self.assertTrue(len(img_entries) == 1) + img_entry = img_entries[0] + + # Expect annotationfile to be populated, but this currently fails due to basename-only matching + self.assertIsNotNone(img_entry.get("annotationfile")) + + def test_coco_root_annotation_matches_images_in_subdirs(self): + """Test that COCO annotation at root can match images in subdirectories. + + This tests the fix for the bug where annotation file dirname (/) didn't match + image dirname (/1/100001), causing annotations to not be found. + """ + with tempfile.TemporaryDirectory() as tmpdir: + # Create image in subdirectory + subdir = os.path.join(tmpdir, "1", "100001") + os.makedirs(subdir, exist_ok=True) + image_name = "image.jpeg" + image_path = os.path.join(subdir, image_name) + open(image_path, "wb").close() + + # Create COCO annotation at root referencing image with subdirectory path + coco = { + "info": {}, + "licenses": [], + "categories": [{"id": 1, "name": "object"}], + "images": [ + { + "id": 10000000, + "file_name": "1/100001/image.jpeg", + "width": 800, + "height": 600, + } + ], + "annotations": [ + { + "id": 1, + "image_id": 10000000, + "category_id": 1, + "bbox": [10, 20, 100, 200], + "area": 20000, + "segmentation": [[10, 20, 110, 20, 110, 220, 10, 220]], + "iscrowd": 0, + } + ], + } + coco_path = os.path.join(tmpdir, "_annotations.coco.json") + with open(coco_path, "w") as f: + json.dump(coco, f) + + parsed = folderparser.parsefolder(tmpdir) + + # Find the image + img_entries = [i for i in parsed["images"] if image_name in i["file"]] + self.assertEqual(len(img_entries), 1, "Should find exactly one image") + img_entry = img_entries[0] + + # Verify annotation was matched + self.assertIsNotNone(img_entry.get("annotationfile"), "Image should have annotation") + + # Verify annotation content + ann_data = json.loads(img_entry["annotationfile"]["rawText"]) + self.assertEqual(len(ann_data["images"]), 1, "Should have one image reference") + self.assertEqual(len(ann_data["annotations"]), 1, "Should have one annotation") + self.assertEqual(ann_data["annotations"][0]["bbox"], [10, 20, 100, 200]) + def _assertJsonMatchesFile(actual, filename): with open(filename) as file: diff --git a/tests/util/test_image_utils.py b/tests/util/test_image_utils.py index 5a17fe37..33dcfe4e 100644 --- a/tests/util/test_image_utils.py +++ b/tests/util/test_image_utils.py @@ -2,7 +2,7 @@ import responses -from roboflow.util.image_utils import check_image_path, check_image_url +from roboflow.util.image_utils import check_image_path, check_image_url, load_labelmap class TestCheckImagePath(unittest.TestCase): @@ -29,7 +29,14 @@ def test_invalid_url(self): for path in paths: self.assertFalse(check_image_url(path)) + @responses.activate def test_url_not_found(self): - url = "https://example.com/notfound.png" + url = "https://roboflow.com/not-found.png" responses.add(responses.HEAD, url, status=404) self.assertFalse(check_image_url(url)) + + +class TestLoadLabelmap(unittest.TestCase): + def test_yaml_dict_names(self): + labelmap = load_labelmap("tests/annotations/dict_names.yaml") + self.assertEqual(labelmap, {0: "cat", 1: "dog", 2: "fish"}) diff --git a/tests/util/test_model_processor.py b/tests/util/test_model_processor.py new file mode 100644 index 00000000..da39b96e --- /dev/null +++ b/tests/util/test_model_processor.py @@ -0,0 +1,1033 @@ +import json +import os +import sys +import tarfile +import tempfile +import types +import unittest +import zipfile +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +from roboflow.config import TASK_CLS, TASK_DET, TASK_OBB, TASK_POSE, TASK_SEG, TASK_SEM +from roboflow.util import model_processor +from roboflow.util.model_processor import ( + _RFDETR_MODEL_TYPE_TO_CLASS, + MissingFileError, + ModelPackagingError, + SizeMismatchError, + TaskMismatchError, + UnsupportedModelError, + _checkpoint_args_as_dict, + _detect_rfdetr_task, + _detect_yolo_task, + _filtered_args, + _infer_yolo_size, + _is_ptl_checkpoint, + _legacy_yolo_args, + _require_rfdetr, + _resolve_rfdetr_variant, + _resolve_yolo_size, + _rfdetr_checkpoint_pe_size, + _write_rfdetr_class_names, + get_classnames_txt_for_rfdetr, + package_custom_weights, + package_custom_weights_interactive, + process, + task_of_model_type, + validate_model_type_for_project, +) + + +class _FakeModel: + """Stand-in for an Ultralytics model_instance; only __class__.__name__ matters.""" + + +def _make_fake(name: str): + return type(name, (_FakeModel,), {})() + + +class TaskOfModelTypeTest(unittest.TestCase): + def test_detect_defaults(self): + self.assertEqual(task_of_model_type("yolov11"), TASK_DET) + self.assertEqual(task_of_model_type("rfdetr-base"), TASK_DET) + self.assertEqual(task_of_model_type("rfdetr-medium"), TASK_DET) + self.assertEqual(task_of_model_type("yolov8"), TASK_DET) + + def test_segment(self): + self.assertEqual(task_of_model_type("yolov11-seg"), TASK_SEG) + self.assertEqual(task_of_model_type("rfdetr-seg-medium"), TASK_SEG) + self.assertEqual(task_of_model_type("yolov7-seg"), TASK_SEG) + + def test_pose(self): + self.assertEqual(task_of_model_type("yolov11-pose"), TASK_POSE) + self.assertEqual(task_of_model_type("rfdetr-keypoint-preview"), TASK_POSE) + + def test_classify(self): + self.assertEqual(task_of_model_type("yolov11-cls"), TASK_CLS) + + def test_semantic(self): + self.assertEqual(task_of_model_type("yolo26-sem"), TASK_SEM) + + def test_obb(self): + self.assertEqual(task_of_model_type("yolov11-obb"), TASK_OBB) + + +class DetectYoloTaskTest(unittest.TestCase): + def test_ultralytics_class_names(self): + cases = { + "SegmentationModel": TASK_SEG, + "PoseModel": TASK_POSE, + "ClassificationModel": TASK_CLS, + "OBBModel": TASK_OBB, + "DetectionModel": TASK_DET, + } + for cls_name, expected in cases.items(): + self.assertEqual(_detect_yolo_task(_make_fake(cls_name)), expected, cls_name) + + def test_semantic_segmentation_model(self): + self.assertEqual(_detect_yolo_task(_make_fake("SemanticSegmentationModel")), TASK_SEM) + + def test_unrecognized_returns_none(self): + self.assertIsNone(_detect_yolo_task(_make_fake("SomeOtherModel"))) + self.assertIsNone(_detect_yolo_task(None)) + + +class DetectRfdetrTaskTest(unittest.TestCase): + def test_segmentation_model_names(self): + for name in ("RFDETRSegNano", "RFDETRSegSmall", "RFDETRSegMedium", "RFDETRSegLarge"): + self.assertEqual(_detect_rfdetr_task({"model_name": name}), TASK_SEG, name) + + def test_detection_model_names(self): + for name in ("RFDETRNano", "RFDETRSmall", "RFDETRMedium", "RFDETRLarge", "RFDETRXLarge"): + self.assertEqual(_detect_rfdetr_task({"model_name": name}), TASK_DET, name) + + def test_keypoint_model_names(self): + self.assertEqual(_detect_rfdetr_task({"model_name": "RFDETRKeypointPreview"}), TASK_POSE) + + def test_keypoint_args_fallback(self): + # The deploy bundle from export_for_roboflow carries `args` but not + # `model_name`; a non-empty `num_keypoints_per_class` marks a keypoint model. + self.assertEqual(_detect_rfdetr_task({"args": SimpleNamespace(num_keypoints_per_class=[0, 17])}), TASK_POSE) + self.assertEqual(_detect_rfdetr_task({"args": {"num_keypoints_per_class": [0, 17]}}), TASK_POSE) + # Empty / absent keypoint schema must NOT be treated as a keypoint model. + self.assertEqual( + _detect_rfdetr_task({"args": {"num_keypoints_per_class": [], "segmentation_head": False}}), TASK_DET + ) + + def test_segmentation_head_fallback(self): + # Roboflow-hosted rf-detr .pt downloads lack `model_name` but always carry + # `args.segmentation_head`. Cover both namespace and dict shapes. + self.assertEqual(_detect_rfdetr_task({"args": SimpleNamespace(segmentation_head=True)}), TASK_SEG) + self.assertEqual(_detect_rfdetr_task({"args": SimpleNamespace(segmentation_head=False)}), TASK_DET) + self.assertEqual(_detect_rfdetr_task({"args": {"segmentation_head": True}}), TASK_SEG) + self.assertEqual(_detect_rfdetr_task({"args": {"segmentation_head": False}}), TASK_DET) + + def test_model_name_preferred_over_args(self): + # When both are present, model_name wins (matches rf-detr's loader). + ckpt = {"model_name": "RFDETRNano", "args": SimpleNamespace(segmentation_head=True)} + self.assertEqual(_detect_rfdetr_task(ckpt), TASK_DET) + + def test_unrecognized_returns_none(self): + self.assertIsNone(_detect_rfdetr_task({})) + self.assertIsNone(_detect_rfdetr_task({"model_name": None})) + self.assertIsNone(_detect_rfdetr_task({"args": SimpleNamespace(other=1)})) + + def test_scalar_args_do_not_raise_typeerror(self): + # A corrupt checkpoint storing args as a bare scalar must not escape the + # ModelPackagingError contract with a raw vars() TypeError. + self.assertIsNone(_detect_rfdetr_task({"args": 640})) + + +class CheckpointArgsAsDictTest(unittest.TestCase): + def test_coerces_dict_namespace_none_and_scalar(self): + self.assertEqual(_checkpoint_args_as_dict({"a": 1}), {"a": 1}) + self.assertEqual(_checkpoint_args_as_dict(SimpleNamespace(a=1)), {"a": 1}) + self.assertEqual(_checkpoint_args_as_dict(None), {}) + self.assertEqual(_checkpoint_args_as_dict(640), {}) + self.assertEqual(_checkpoint_args_as_dict(["not", "a", "dict"]), {}) + + +class FilteredArgsTest(unittest.TestCase): + def test_keeps_only_upload_keys_from_dict_or_namespace(self): + self.assertEqual( + _filtered_args({"model": "m", "imgsz": 640, "batch": 8, "lr0": 0.01}), + {"model": "m", "imgsz": 640, "batch": 8}, + ) + self.assertEqual(_filtered_args(SimpleNamespace(imgsz=320, batch=4, extra=1)), {"imgsz": 320, "batch": 4}) + + def test_scalar_or_none_args_do_not_raise(self): + # A corrupt .args must coerce to {} instead of raising a raw TypeError. + self.assertEqual(_filtered_args(None), {}) + self.assertEqual(_filtered_args(640), {}) + + +class GetClassnamesTxtForRfdetrTest(unittest.TestCase): + def _classnames(self, args): + with tempfile.TemporaryDirectory() as model_path: + get_classnames_txt_for_rfdetr(model_path, "weights.pt", checkpoint={"args": args}) + with open(os.path.join(model_path, "class_names.txt")) as f: + return f.read().splitlines() + + def test_dict_args(self): + self.assertEqual(self._classnames({"class_names": ["cat", "dog"]}), ["background_class83422", "cat", "dog"]) + + def test_namespace_args(self): + self.assertEqual( + self._classnames(SimpleNamespace(class_names=["cat", "dog"])), + ["background_class83422", "cat", "dog"], + ) + + +class ValidateModelTypeForProjectTest(unittest.TestCase): + def test_rejects_detection_for_classification(self): + with self.assertRaises(TaskMismatchError) as ctx: + validate_model_type_for_project("yolov8", "classification", "widgets") + self.assertIn("classification", str(ctx.exception)) + self.assertIn("task 'cls'", str(ctx.exception)) + + def test_task_mismatch_is_a_value_error(self): + # Callers that caught the historical ValueError keep working. + with self.assertRaises(ValueError): + validate_model_type_for_project("yolov8", "classification", "widgets") + + def test_unknown_project_type_is_ignored(self): + validate_model_type_for_project("yolov8", "some-new-type", "widgets") + + +class LegacyYoloArgsTest(unittest.TestCase): + def test_reports_missing_batch_size(self): + with self.assertRaises(ModelPackagingError) as ctx: + _legacy_yolo_args({"imgsz": 640}, Path("opt.yaml")) + self.assertIn("batch_size", str(ctx.exception)) + + def test_reports_missing_image_size(self): + with self.assertRaises(ModelPackagingError) as ctx: + _legacy_yolo_args({"batch_size": 8}, Path("opt.yaml")) + self.assertIn("imgsz", str(ctx.exception)) + + def test_accepts_either_image_size_key(self): + self.assertEqual(_legacy_yolo_args({"imgsz": 640, "batch_size": 8}, Path("x")), {"imgsz": 640, "batch": 8}) + self.assertEqual(_legacy_yolo_args({"img_size": 416, "batch_size": 4}, Path("x")), {"imgsz": 416, "batch": 4}) + + +class _FakeYoloModel: + def __init__(self, yaml): + self.yaml = yaml + + +class InferYoloSizeTest(unittest.TestCase): + def test_from_depth_width_multiples(self): + model = _FakeYoloModel({"depth_multiple": 0.33, "width_multiple": 0.25}) + self.assertEqual(_infer_yolo_size(model), "n") + + def test_explicit_scale_letter_wins(self): + model = _FakeYoloModel({"scale": "m", "depth_multiple": 0.67, "width_multiple": 0.75}) + self.assertEqual(_infer_yolo_size(model), "m") + + def test_unknown_returns_none(self): + self.assertIsNone(_infer_yolo_size(_FakeYoloModel({}))) + + +class ResolveYoloSizeTest(unittest.TestCase): + def test_fills_bare_family_from_architecture(self): + warnings: list = [] + model = _FakeYoloModel({"depth_multiple": 0.33, "width_multiple": 0.25}) + self.assertEqual(_resolve_yolo_size("yolov8", model, warnings), "yolov8n") + self.assertTrue(warnings and "Inferred model size 'yolov8n'" in warnings[0]) + + def test_preserves_task_suffix_when_filling(self): + model = _FakeYoloModel({"depth_multiple": 0.33, "width_multiple": 0.50}) + self.assertEqual(_resolve_yolo_size("yolov8-seg", model, []), "yolov8s-seg") + + def test_raises_when_size_cannot_be_inferred(self): + with self.assertRaises(SizeMismatchError) as ctx: + _resolve_yolo_size("yolov8", _FakeYoloModel({}), []) + self.assertIn("could not be inferred", str(ctx.exception)) + self.assertIn("yolov8n", str(ctx.exception)) + + def test_raises_on_declared_size_conflict(self): + model = _FakeYoloModel({"depth_multiple": 0.33, "width_multiple": 0.25}) + with self.assertRaises(SizeMismatchError) as ctx: + _resolve_yolo_size("yolov8m", model, []) + self.assertIn("yolov8n", str(ctx.exception)) + self.assertIn("allow_size_mismatch=True", str(ctx.exception)) + self.assertEqual(ctx.exception.requested, "yolov8m") + self.assertEqual(ctx.exception.detected, "yolov8n") + + def test_allow_mismatch_keeps_declared_size(self): + warnings: list = [] + model = _FakeYoloModel({"depth_multiple": 0.33, "width_multiple": 0.25}) + self.assertEqual(_resolve_yolo_size("yolov8m", model, warnings, allow_mismatch=True), "yolov8m") + self.assertTrue(warnings and "as requested" in warnings[0]) + + def test_keeps_user_size_when_not_inferable(self): + warnings: list = [] + self.assertEqual(_resolve_yolo_size("yolov8m", _FakeYoloModel({}), warnings), "yolov8m") + self.assertEqual(warnings, []) + + def test_keeps_matching_sized_type_without_warning(self): + warnings: list = [] + model = _FakeYoloModel({"depth_multiple": 0.33, "width_multiple": 0.25}) + self.assertEqual(_resolve_yolo_size("yolov8n", model, warnings), "yolov8n") + self.assertEqual(warnings, []) + + def test_bare_family_uninferable_raises_by_default(self): + with self.assertRaises(SizeMismatchError): + _resolve_yolo_size("yolov10", _FakeYoloModel({}), []) + + def test_bare_family_uninferable_proceeds_under_allow_mismatch(self): + # The interactive retry sets allow_mismatch=True after the user confirms; + # this branch must then converge (return) rather than raise forever. + warnings: list = [] + self.assertEqual( + _resolve_yolo_size("yolov10", _FakeYoloModel({}), warnings, allow_mismatch=True), + "yolov10", + ) + self.assertTrue(warnings and "bare family name" in warnings[0]) + + +class ResolveRfdetrVariantTest(unittest.TestCase): + def test_raises_on_size_conflict_naming_the_fit(self): + checkpoint = {"args": {"resolution": 384, "patch_size": 12}} + with self.assertRaises(SizeMismatchError) as ctx: + _resolve_rfdetr_variant("rfdetr-seg-nano", checkpoint, []) + self.assertIn("rfdetr-seg-small", str(ctx.exception)) + self.assertIn("32x32", str(ctx.exception)) + self.assertIn("allow_size_mismatch=True", str(ctx.exception)) + + def test_allow_mismatch_keeps_requested_variant(self): + warnings: list = [] + checkpoint = {"args": {"resolution": 384, "patch_size": 12}} + resolved = _resolve_rfdetr_variant("rfdetr-seg-nano", checkpoint, warnings, allow_mismatch=True) + self.assertEqual(resolved, "rfdetr-seg-nano") + self.assertTrue(warnings and "as requested" in warnings[0]) + + def test_keeps_matching_grid_without_warning(self): + warnings: list = [] + checkpoint = {"args": {"positional_encoding_size": 32}} + self.assertEqual(_resolve_rfdetr_variant("rfdetr-seg-small", checkpoint, warnings), "rfdetr-seg-small") + self.assertEqual(warnings, []) + + def test_warns_but_allows_custom_resolution(self): + warnings: list = [] + resolved = _resolve_rfdetr_variant("rfdetr-seg-nano", {"args": {"positional_encoding_size": 99}}, warnings) + self.assertEqual(resolved, "rfdetr-seg-nano") + self.assertTrue(warnings and "matches no known RF-DETR variant" in warnings[0]) + + def test_pe_size_derived_from_position_embeddings_tensor(self): + class FakeTensor: + shape = (1, 1025, 384) + + checkpoint = {"model": {"backbone.0.encoder.encoder.embeddings.position_embeddings": FakeTensor()}} + self.assertEqual(_rfdetr_checkpoint_pe_size(checkpoint), 32) + + +class WriteRfdetrClassNamesTest(unittest.TestCase): + def test_fails_cleanly_without_checkpoint_args(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(MissingFileError) as ctx: + _write_rfdetr_class_names(Path(tmp), Path(tmp), checkpoint={}) + self.assertIn("does not include args with class_names", str(ctx.exception)) + + def test_does_not_mutate_existing_class_names_file(self): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as build: + source = Path(tmp) / "class_names.txt" + source.write_text("cat\ndog\n") + output = _write_rfdetr_class_names(Path(tmp), Path(build), checkpoint={}) + self.assertEqual(source.read_text(), "cat\ndog\n") + self.assertEqual( + output.read_text().splitlines(), + ["background_class83422", "cat", "dog"], + ) + + +def _fake_torch(load_result, calls=None): + module = types.ModuleType("torch") + + def load(path, **kwargs): + if calls is not None: + calls.append((Path(path), kwargs)) + return load_result + + def save(obj, path): + Path(path).write_bytes(b"fake-state-dict") + + module.load = load + module.save = save + return module + + +def _import_patch(modules): + def _import(module_name, install_hint): + return modules[module_name] + + return mock.patch.object(model_processor, "_import_required_module", side_effect=_import) + + +def _write_yolonas_inputs(model_dir: Path): + weights = model_dir / "weights" / "best.pt" + weights.parent.mkdir() + weights.write_bytes(b"checkpoint") + (model_dir / "opt.yaml").write_text("imgsz: 640\nbatch_size: 8\narchitecture: yolo_nas_s\n") + + +class PackageCustomWeightsTest(unittest.TestCase): + """Contract tests for the public non-interactive helper.""" + + def _package_yolonas(self, model_dir: Path, **kwargs): + calls: list = [] + torch = _fake_torch({"processing_params": {"class_names": ["widget"]}}, calls) + with _import_patch({"torch": torch}): + bundle = package_custom_weights("yolonas", str(model_dir), **kwargs) + return bundle, calls + + def test_never_prompts_or_exits(self): + prompt_guard = mock.patch( + "builtins.input", side_effect=AssertionError("package_custom_weights must not prompt") + ) + exit_guard = mock.patch.object(sys, "exit", side_effect=AssertionError("package_custom_weights must not exit")) + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + _write_yolonas_inputs(model_dir) + with prompt_guard, exit_guard: + bundle, calls = self._package_yolonas(model_dir) + try: + self.assertTrue(bundle.archive_path.exists()) + self.assertEqual(calls[0][1], {"weights_only": False, "map_location": "cpu"}) + finally: + bundle.cleanup() + + def test_does_not_write_into_model_path(self): + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + _write_yolonas_inputs(model_dir) + before = sorted(path for path in model_dir.rglob("*")) + bundle, _ = self._package_yolonas(model_dir) + try: + self.assertEqual(sorted(path for path in model_dir.rglob("*")), before) + self.assertNotEqual(bundle.build_dir, model_dir) + self.assertTrue(bundle.owns_build_dir) + finally: + bundle.cleanup() + self.assertFalse(bundle.build_dir.exists()) + + def test_explicit_build_dir_is_used_and_not_cleaned_up(self): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as build: + model_dir = Path(tmp) + _write_yolonas_inputs(model_dir) + bundle, _ = self._package_yolonas(model_dir, build_dir=build) + self.assertEqual(bundle.build_dir, Path(build).resolve()) + self.assertFalse(bundle.owns_build_dir) + bundle.cleanup() + self.assertTrue(bundle.archive_path.exists()) + + def test_owned_build_dir_is_removed_on_failure(self): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as fake_build: + with mock.patch.object(model_processor.tempfile, "mkdtemp", return_value=fake_build): + with self.assertRaises(UnsupportedModelError): + package_custom_weights("not-a-model", tmp) + self.assertFalse(Path(fake_build).exists()) + + def test_missing_model_path_raises_missing_file(self): + with self.assertRaises(MissingFileError): + package_custom_weights("yolonas", "/nonexistent/path/for/test") + + def test_family_must_be_a_prefix_not_a_substring(self): + # 'foo-yolov8n' merely contains a family token; the backend would + # reject it after upload, so the gate must reject it up front. + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(UnsupportedModelError): + package_custom_weights("foo-yolov8n", tmp) + + def test_yolonas_requires_exact_model_type(self): + # Only 'yolonas' is valid; a suffixed typo like 'yolonas-foo' passes the + # family prefix gate but must be rejected before upload, not by the backend. + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(UnsupportedModelError): + package_custom_weights("yolonas-foo", tmp) + + def test_rfdetr_falls_back_to_discovered_checkpoint(self): + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "other.pt").write_bytes(b"checkpoint") + torch = _fake_torch({"args": {"class_names": ["widget"]}}) + with _import_patch({"torch": torch}): + bundle = package_custom_weights("rfdetr-base", str(model_dir)) + try: + self.assertTrue(any("other.pt" in warning for warning in bundle.warnings)) + with zipfile.ZipFile(bundle.archive_path) as archive: + self.assertIn("weights.pt", archive.namelist()) + self.assertIn("class_names.txt", archive.namelist()) + finally: + bundle.cleanup() + + def test_rfdetr_keypoint_exported_checkpoint_packages(self): + # The primary feature: an exported keypoint deploy-checkpoint (non-PTL shape, + # args carries class_names + num_keypoints_per_class) packages successfully as + # 'rfdetr-keypoint-preview'. num_keypoints_per_class marks it pose, which matches + # the model_type's task, so it passes the task check and copies weights.pt. + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "weights.pt").write_bytes(b"checkpoint") + torch = _fake_torch({"args": {"class_names": ["goal"], "num_keypoints_per_class": [0, 17]}}) + with _import_patch({"torch": torch}): + bundle = package_custom_weights("rfdetr-keypoint-preview", str(model_dir), filename="weights.pt") + try: + self.assertEqual(bundle.model_type, "rfdetr-keypoint-preview") + with zipfile.ZipFile(bundle.archive_path) as archive: + names = archive.namelist() + self.assertIn("weights.pt", names) + self.assertIn("class_names.txt", names) + finally: + bundle.cleanup() + + def test_rfdetr_keypoint_checkpoint_rejected_as_detection_type(self): + # The same keypoint checkpoint uploaded under a detection model_type is a task + # mismatch (pose != detect) and must be rejected, not silently packaged. + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "weights.pt").write_bytes(b"checkpoint") + torch = _fake_torch({"args": {"class_names": ["goal"], "num_keypoints_per_class": [0, 17]}}) + with _import_patch({"torch": torch}): + with self.assertRaises(TaskMismatchError): + package_custom_weights("rfdetr-base", str(model_dir), filename="weights.pt") + + def test_rfdetr_without_any_checkpoint_raises(self): + with tempfile.TemporaryDirectory() as tmp: + torch = _fake_torch({}) + with _import_patch({"torch": torch}): + with self.assertRaises(MissingFileError): + package_custom_weights("rfdetr-base", tmp) + + def test_rfdetr_bare_state_dict_without_args_fails_before_upload(self): + # A stripped inference checkpoint ({"model": state_dict} with no "args") + # would package fine but fail Roboflow's server-side conversion with an + # opaque KeyError: 'args'. Packaging must reject it up front. + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "weights.pt").write_bytes(b"checkpoint") + torch = _fake_torch({"model": {"backbone.weight": object()}}) + with _import_patch({"torch": torch}): + with self.assertRaises(ModelPackagingError) as ctx: + package_custom_weights("rfdetr-base", str(model_dir), filename="weights.pt") + self.assertIn("args", str(ctx.exception)) + self.assertIn("state_dict", str(ctx.exception)) + + def test_yolonas_bare_state_dict_without_class_names_fails_before_upload(self): + # A bare YOLO-NAS state_dict lacks processing_params.class_names; it must + # raise an actionable error instead of a raw KeyError/TypeError. + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "weights").mkdir() + (model_dir / "weights" / "best.pt").write_bytes(b"checkpoint") + torch = _fake_torch({"backbone.weight": object()}) # bare state_dict, no processing_params + with _import_patch({"torch": torch}): + with self.assertRaises(ModelPackagingError) as ctx: + package_custom_weights("yolonas", str(model_dir)) + self.assertIn("class_names", str(ctx.exception)) + self.assertIn("state_dict", str(ctx.exception)) + + def test_rfdetr_explicit_missing_filename_does_not_fall_back(self): + # A typo'd explicit filename must fail loudly, not silently upload a + # different checkpoint that happens to be in the directory. + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "some_other_checkpoint.pth").write_bytes(b"checkpoint") + torch = _fake_torch({"args": {"class_names": ["widget"]}}) + with _import_patch({"torch": torch}): + with self.assertRaises(MissingFileError): + package_custom_weights("rfdetr-base", str(model_dir), filename="checkpoint_epoch50.pth") + + def test_rfdetr_legacy_deploy_layout_does_not_self_copy(self): + # Legacy deploy passes build_dir=model_path with a top-level weights.pt; + # copying weights.pt onto itself would raise shutil.SameFileError. + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "weights.pt").write_bytes(b"checkpoint") + torch = _fake_torch({"args": {"class_names": ["widget"]}}) + with _import_patch({"torch": torch}): + bundle = package_custom_weights( + "rfdetr-base", str(model_dir), filename="weights.pt", build_dir=model_dir + ) + with zipfile.ZipFile(bundle.archive_path) as archive: + self.assertIn("weights.pt", archive.namelist()) + + def test_yolov8_full_flow_builds_artifacts(self): + checkpoint_names = {1: "dog", 0: "cat"} + + class DetectionModel: + names = checkpoint_names + nc = 2 + yaml = {"nc": 2, "depth_multiple": 0.33, "width_multiple": 0.25} + args = {"model": "yolov8n.yaml", "imgsz": 640, "batch": 16, "lr0": 0.01} + + def state_dict(self): + return {"weight": b"w"} + + fake_ultralytics = types.ModuleType("ultralytics") + fake_ultralytics.__version__ = "8.0.196" + fake_torch = _fake_torch({"model": DetectionModel()}) + + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + weights = model_dir / "weights" / "best.pt" + weights.parent.mkdir() + weights.write_bytes(b"checkpoint") + + with ( + _import_patch({"torch": fake_torch, "ultralytics": fake_ultralytics}), + mock.patch.dict(sys.modules, {"ultralytics": fake_ultralytics}), + ): + bundle = package_custom_weights("yolov8", str(model_dir)) + try: + self.assertEqual(bundle.model_type, "yolov8n") + self.assertTrue(any("Inferred model size 'yolov8n'" in warning for warning in bundle.warnings)) + with zipfile.ZipFile(bundle.archive_path) as archive: + artifacts = json.loads(archive.read("model_artifacts.json")) + self.assertIn("state_dict.pt", archive.namelist()) + self.assertEqual(artifacts["names"], ["cat", "dog"]) + self.assertEqual(artifacts["model_type"], "yolov8n") + self.assertEqual(artifacts["ultralytics_version"], "8.0.196") + self.assertEqual(artifacts["args"], {"model": "yolov8n.yaml", "imgsz": 640, "batch": 16}) + finally: + bundle.cleanup() + + def test_rejects_absolute_filename(self): + # A hosted caller (the MCP server) forwards filename verbatim; an absolute + # path β€” POSIX or Windows-style, regardless of the packaging host's OS β€” + # must be rejected rather than reading weights from outside model_path. + with tempfile.TemporaryDirectory() as tmp: + for bad in ("/etc/passwd", "C:\\Windows\\System32\\config"): + with self.assertRaises(ModelPackagingError) as ctx: + package_custom_weights("yolonas", tmp, filename=bad) + self.assertIn("absolute", str(ctx.exception)) + + def test_rejects_filename_escaping_model_path(self): + # '..' segments must not let the caller escape the model directory. + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) / "model" + model_dir.mkdir() + (Path(tmp) / "secret.pt").write_bytes(b"outside") + with self.assertRaises(ModelPackagingError) as ctx: + package_custom_weights("yolonas", str(model_dir), filename="../secret.pt") + self.assertIn("outside model_path", str(ctx.exception)) + + def test_rejects_filename_pointing_at_a_directory(self): + # '' / '.' resolve to model_path itself and a subdirectory stays inside + # it; all three would otherwise reach torch.load() and leak a raw + # IsADirectoryError outside the ModelPackagingError contract. + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "weights").mkdir() + for bad in ("", ".", "weights"): + with self.assertRaises(ModelPackagingError) as ctx: + package_custom_weights("yolonas", tmp, filename=bad) + self.assertIn("not a directory", str(ctx.exception)) + + def _attempt_ultralytics_yolo(self, model_type, checkpoint): + fake_ultralytics = types.ModuleType("ultralytics") + fake_ultralytics.__version__ = "8.3.0" + fake_torch = _fake_torch(checkpoint) + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + weights = model_dir / "weights" / "best.pt" + weights.parent.mkdir() + weights.write_bytes(b"checkpoint") + with ( + _import_patch({"torch": fake_torch, "ultralytics": fake_ultralytics}), + mock.patch.dict(sys.modules, {"ultralytics": fake_ultralytics}), + ): + # allow_dependency_mismatch keeps a version mismatch from masking the + # completeness error under test. + return package_custom_weights(model_type, str(model_dir), allow_dependency_mismatch=True) + + def test_yolov8_missing_nc_attr_raises_packaging_error(self): + # A stripped Ultralytics checkpoint that lacks model.nc must raise a + # ModelPackagingError, not a raw AttributeError (an opaque 500 in the MCP). + class DetectionModel: + names = {0: "cat"} + yaml = {"nc": 1, "depth_multiple": 0.33, "width_multiple": 0.25} + args = {"imgsz": 640} + + def state_dict(self): + return {} + + with self.assertRaises(ModelPackagingError) as ctx: + self._attempt_ultralytics_yolo("yolov8n", {"model": DetectionModel()}) + self.assertIn("nc", str(ctx.exception)) + + def test_yolov8_missing_args_attr_raises_packaging_error(self): + class DetectionModel: + names = {0: "cat"} + nc = 1 + yaml = {"nc": 1, "depth_multiple": 0.33, "width_multiple": 0.25} + + def state_dict(self): + return {} + + with self.assertRaises(ModelPackagingError) as ctx: + self._attempt_ultralytics_yolo("yolov8n", {"model": DetectionModel()}) + self.assertIn("args", str(ctx.exception)) + + def test_yolov8_missing_yaml_attr_raises_packaging_error(self): + class DetectionModel: + names = {0: "cat"} + nc = 1 + args = {"imgsz": 640} + + def state_dict(self): + return {} + + with self.assertRaises(ModelPackagingError) as ctx: + self._attempt_ultralytics_yolo("yolov8n", {"model": DetectionModel()}) + self.assertIn("yaml", str(ctx.exception)) + + def test_yolov11_missing_train_args_raises_packaging_error(self): + # yolov10/11/12/26 read args from checkpoint["train_args"]; a checkpoint + # without it must fail with the ModelPackagingError contract. + class DetectionModel: + names = {0: "cat"} + yaml = {"nc": 1, "scale": "n"} + + def state_dict(self): + return {} + + with self.assertRaises(ModelPackagingError) as ctx: + self._attempt_ultralytics_yolo("yolov11n", {"model": DetectionModel()}) + self.assertIn("train_args", str(ctx.exception)) + + def test_yolov11_missing_nc_in_model_yaml_raises_packaging_error(self): + class DetectionModel: + names = {0: "cat"} + yaml = {"scale": "n"} + + def state_dict(self): + return {} + + with self.assertRaises(ModelPackagingError) as ctx: + self._attempt_ultralytics_yolo("yolov11n", {"model": DetectionModel(), "train_args": {"imgsz": 640}}) + self.assertIn("nc", str(ctx.exception)) + + def test_rfdetr_multiple_discovered_checkpoints_warns_which_used(self): + # With no explicit filename and several checkpoints present, discovery is + # ambiguous; the warning must name the candidates and which one was used. + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "alpha.pt").write_bytes(b"checkpoint") + (model_dir / "beta.pth").write_bytes(b"checkpoint") + torch = _fake_torch({"args": {"class_names": ["widget"]}}) + with _import_patch({"torch": torch}): + bundle = package_custom_weights("rfdetr-base", str(model_dir)) + try: + warning = "\n".join(bundle.warnings) + self.assertIn("multiple", warning) + self.assertIn("alpha.pt", warning) + self.assertIn("beta.pth", warning) + finally: + bundle.cleanup() + + +class ProcessHuggingfaceTest(unittest.TestCase): + """Packaging for HuggingFace-backed models (paligemma / florence-2).""" + + def test_missing_companion_files_raise_missing_file(self): + # A safetensors checkpoint without its tokenizer/preprocessor companions + # now raises MissingFileError instead of prompting for the files. + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "model.safetensors").write_bytes(b"weights") + with mock.patch("builtins.input", side_effect=AssertionError("must not prompt")): + with self.assertRaises(MissingFileError) as ctx: + package_custom_weights("florence-2-base", str(model_dir)) + message = str(ctx.exception) + self.assertIn("tokenizer.json", message) + self.assertIn("preprocessor_config.json", message) + + def test_no_model_file_raises_missing_file(self): + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "readme.txt").write_text("no weights here") + with self.assertRaises(MissingFileError) as ctx: + package_custom_weights("paligemma-3b-pt-224", str(model_dir)) + self.assertIn("safetensors", str(ctx.exception)) + + def test_npz_checkpoint_packages_into_tar(self): + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + (model_dir / "model.npz").write_bytes(b"npz-bytes") + bundle = package_custom_weights("paligemma-3b-pt-224", str(model_dir)) + try: + self.assertTrue(bundle.archive_path.name.endswith(".tar")) + with tarfile.open(bundle.archive_path) as tar: + self.assertIn("model.npz", tar.getnames()) + finally: + bundle.cleanup() + + def test_unsupported_huggingface_type_raises(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(UnsupportedModelError): + package_custom_weights("florence-2-tiny", str(tmp)) + + +class ProcessCompatTest(unittest.TestCase): + """The legacy process() entry point keeps its historical contract.""" + + def test_packages_into_model_path_and_returns_tuple(self): + calls: list = [] + torch = _fake_torch({"processing_params": {"class_names": ["widget"]}}, calls) + with tempfile.TemporaryDirectory() as tmp: + model_dir = Path(tmp) + _write_yolonas_inputs(model_dir) + with _import_patch({"torch": torch}): + zip_file_name, model_type = process("yolonas", str(model_dir), "weights/best.pt") + + self.assertEqual(zip_file_name, "roboflow_deploy.zip") + self.assertEqual(model_type, "yolonas") + # Historical side effects: artifacts and archive land in model_path. + self.assertTrue((model_dir / "roboflow_deploy.zip").exists()) + self.assertTrue((model_dir / "model_artifacts.json").exists()) + self.assertTrue((model_dir / "state_dict.pt").exists()) + + def test_prompts_and_retries_on_mismatch_like_before(self): + error = model_processor.DependencyMismatchError( + "wrong ultralytics", + dependency="ultralytics", + required="ultralytics==8.0.196", + installed="8.3.0", + ) + bundle = model_processor.ModelUploadBundle( + archive_path=Path("roboflow_deploy.zip"), + build_dir=Path("."), + model_type="yolov8n", + ) + outcomes = [error, bundle] + + def fake_package(*args, **kwargs): + outcome = outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + self.assertTrue(kwargs["allow_dependency_mismatch"]) + return outcome + + with ( + mock.patch.object(model_processor, "package_custom_weights", side_effect=fake_package), + mock.patch("builtins.input", return_value="y"), + mock.patch("builtins.print"), + ): + zip_file_name, model_type = process("yolov8m", "/models", "weights/best.pt") + + self.assertEqual(zip_file_name, "roboflow_deploy.zip") + self.assertEqual(model_type, "yolov8n") + + +class PackageCustomWeightsInteractiveTest(unittest.TestCase): + def _bundle(self): + return model_processor.ModelUploadBundle( + archive_path=Path("roboflow_deploy.zip"), + build_dir=Path("."), + model_type="yolov8n", + warnings=("some warning",), + ) + + def test_retries_with_size_override_on_confirmation(self): + error = SizeMismatchError("size conflict", requested="yolov8m", detected="yolov8n") + outcomes = [error, self._bundle()] + + def fake_package(*args, **kwargs): + outcome = outcomes.pop(0) + if isinstance(outcome, Exception): + raise outcome + self.assertTrue(kwargs["allow_size_mismatch"]) + return outcome + + with ( + mock.patch.object(model_processor, "package_custom_weights", side_effect=fake_package), + mock.patch("builtins.input", return_value="y"), + mock.patch("builtins.print"), + ): + bundle = package_custom_weights_interactive("yolov8m", "/models") + self.assertEqual(bundle.model_type, "yolov8n") + + def test_reraises_when_user_declines(self): + error = SizeMismatchError("size conflict", requested="yolov8m") + with ( + mock.patch.object(model_processor, "package_custom_weights", side_effect=error), + mock.patch("builtins.input", return_value="n"), + mock.patch("builtins.print"), + ): + with self.assertRaises(SizeMismatchError): + package_custom_weights_interactive("yolov8m", "/models") + + +class RfdetrModelTypeToClassTest(unittest.TestCase): + def test_representative_mappings(self): + self.assertEqual(_RFDETR_MODEL_TYPE_TO_CLASS["rfdetr-seg-medium"], "RFDETRSegMedium") + self.assertEqual(_RFDETR_MODEL_TYPE_TO_CLASS["rfdetr-base"], "RFDETRBase") + self.assertEqual(_RFDETR_MODEL_TYPE_TO_CLASS["rfdetr-keypoint-preview"], "RFDETRKeypointPreview") + + def test_keys_are_rfdetr_types_and_values_are_class_names(self): + for model_type, class_name in _RFDETR_MODEL_TYPE_TO_CLASS.items(): + self.assertTrue(model_type.startswith("rfdetr-"), model_type) + self.assertTrue(class_name.startswith("RFDETR"), class_name) + # Segmentation types map to Seg classes (and detection types must not). + self.assertEqual("seg" in model_type, "Seg" in class_name, model_type) + + +class IsPtlCheckpointTest(unittest.TestCase): + def test_true_when_lightning_version_present(self): + self.assertTrue(_is_ptl_checkpoint({"pytorch-lightning_version": "2.1.0", "args": {}})) + + def test_false_for_plain_checkpoint(self): + self.assertFalse(_is_ptl_checkpoint({"args": {}, "model": {}})) + + def test_false_for_non_dict(self): + self.assertFalse(_is_ptl_checkpoint(None)) + self.assertFalse(_is_ptl_checkpoint(SimpleNamespace(**{"pytorch-lightning_version": "2.1.0"}))) + + +class _StubBundleModel: + """Stub rf-detr model whose export_for_roboflow writes a dummy bundle on disk.""" + + def __init__(self, class_names=("cat", "dog")): + self.class_names = list(class_names) + + def export_for_roboflow(self, output_dir): + (Path(output_dir) / "weights.pt").write_bytes(b"rebuilt-weights") + (Path(output_dir) / "class_names.txt").write_text("\n".join(self.class_names) + "\n") + + +def _make_fake_rfdetr(*, from_checkpoint_raises=False, capabilities=True): + """Build a fake ``rfdetr`` module for injection via sys.modules.""" + stub_model = _StubBundleModel() + calls = {"from_checkpoint": 0, "fallback_constructed": 0, "constructor_kwargs": None} + + class _RFDETR: + @staticmethod + def from_checkpoint(path): + calls["from_checkpoint"] += 1 + if from_checkpoint_raises: + raise ValueError("cannot infer model class") + return stub_model + + class _SizedModel(_StubBundleModel): + def __init__(self, *, pretrain_weights): + super().__init__() + calls["fallback_constructed"] += 1 + calls["constructor_kwargs"] = {"pretrain_weights": pretrain_weights} + + module = SimpleNamespace() + module.RFDETR = _RFDETR + # The fallback resolves the subclass by name via _RFDETR_MODEL_TYPE_TO_CLASS, + # e.g. "rfdetr-seg-medium" -> getattr(rfdetr, "RFDETRSegMedium") and + # "rfdetr-keypoint-preview" -> getattr(rfdetr, "RFDETRKeypointPreview"). + module.RFDETRSegMedium = _SizedModel + module.RFDETRKeypointPreview = _SizedModel + if capabilities: + _RFDETR.export_for_roboflow = _StubBundleModel.export_for_roboflow # capability marker + module._calls = calls + return module + + +class RequireRfdetrTest(unittest.TestCase): + def test_raises_when_not_installed(self): + with mock.patch.dict(sys.modules, {"rfdetr": None}): + with self.assertRaises(ModelPackagingError) as ctx: + _require_rfdetr() + self.assertIn("pip install", str(ctx.exception).lower()) + self.assertIn("rfdetr", str(ctx.exception).lower()) + + def test_raises_when_capability_missing(self): + fake = SimpleNamespace(RFDETR=type("RFDETR", (), {})) + with mock.patch.dict(sys.modules, {"rfdetr": fake}): + with self.assertRaises(ModelPackagingError) as ctx: + _require_rfdetr() + self.assertIn("upgrade", str(ctx.exception).lower()) + + def test_returns_module_when_capable(self): + fake = _make_fake_rfdetr() + with mock.patch.dict(sys.modules, {"rfdetr": fake}): + self.assertIs(_require_rfdetr(), fake) + + +class PackageRfdetrPtlTest(unittest.TestCase): + """PyTorch-Lightning rf-detr checkpoints are rebuilt via rfdetr into build_dir.""" + + def _package(self, model_type, fake_rfdetr, *, segmentation_head=False, num_keypoints_per_class=None): + with tempfile.TemporaryDirectory() as model_dir: + (Path(model_dir) / "checkpoint_best_ema.pth").write_bytes(b"raw-ptl") + args = {"segmentation_head": segmentation_head, "class_names": ["cat", "dog"]} + if num_keypoints_per_class is not None: + args["num_keypoints_per_class"] = num_keypoints_per_class + ckpt = {"pytorch-lightning_version": "2.1.0", "args": args} + torch = _fake_torch(ckpt) + with _import_patch({"torch": torch}), mock.patch.dict(sys.modules, {"rfdetr": fake_rfdetr}): + bundle = package_custom_weights(model_type, model_dir, filename="checkpoint_best_ema.pth") + try: + with zipfile.ZipFile(bundle.archive_path) as archive: + names = archive.namelist() + finally: + bundle.cleanup() + return bundle, names + + def test_from_checkpoint_success_produces_bundle(self): + fake = _make_fake_rfdetr() + bundle, names = self._package("rfdetr-base", fake) + self.assertEqual(bundle.model_type, "rfdetr-base") + self.assertEqual(fake._calls["from_checkpoint"], 1) + self.assertEqual(fake._calls["fallback_constructed"], 0) + self.assertIn("weights.pt", names) + self.assertIn("class_names.txt", names) + + def test_from_checkpoint_valueerror_falls_back_to_model_type(self): + fake = _make_fake_rfdetr(from_checkpoint_raises=True) + bundle, names = self._package("rfdetr-seg-medium", fake, segmentation_head=True) + self.assertEqual(bundle.model_type, "rfdetr-seg-medium") + self.assertEqual(fake._calls["from_checkpoint"], 1) + self.assertEqual(fake._calls["fallback_constructed"], 1) + self.assertIn("weights.pt", names) + + def test_keypoint_from_checkpoint_success_produces_bundle(self): + # A keypoint PTL checkpoint (args.num_keypoints_per_class marks pose, matching + # the 'rfdetr-keypoint-preview' model_type) rebuilds via rfdetr.from_checkpoint. + fake = _make_fake_rfdetr() + bundle, names = self._package("rfdetr-keypoint-preview", fake, num_keypoints_per_class=[0, 17]) + self.assertEqual(bundle.model_type, "rfdetr-keypoint-preview") + self.assertEqual(fake._calls["from_checkpoint"], 1) + self.assertEqual(fake._calls["fallback_constructed"], 0) + self.assertIn("weights.pt", names) + self.assertIn("class_names.txt", names) + + def test_keypoint_from_checkpoint_valueerror_falls_back_to_model_type(self): + # When from_checkpoint can't infer the class, the fallback resolves the + # RFDETRKeypointPreview subclass from _RFDETR_MODEL_TYPE_TO_CLASS and rebuilds. + fake = _make_fake_rfdetr(from_checkpoint_raises=True) + bundle, names = self._package("rfdetr-keypoint-preview", fake, num_keypoints_per_class=[0, 17]) + self.assertEqual(bundle.model_type, "rfdetr-keypoint-preview") + self.assertEqual(fake._calls["from_checkpoint"], 1) + self.assertEqual(fake._calls["fallback_constructed"], 1) + self.assertIn("weights.pt", names) + self.assertIn("class_names.txt", names) + + def test_ptl_path_raises_when_rfdetr_absent(self): + with tempfile.TemporaryDirectory() as model_dir: + (Path(model_dir) / "checkpoint_best_ema.pth").write_bytes(b"raw-ptl") + ckpt = {"pytorch-lightning_version": "2.1.0", "args": {"segmentation_head": False}} + torch = _fake_torch(ckpt) + with _import_patch({"torch": torch}), mock.patch.dict(sys.modules, {"rfdetr": None}): + with self.assertRaises(ModelPackagingError): + package_custom_weights("rfdetr-base", model_dir, filename="checkpoint_best_ema.pth") + + def test_keypoint_checkpoint_rejected_before_export(self): + # A keypoint checkpoint is rejected by the task check before any rfdetr use. + with tempfile.TemporaryDirectory() as model_dir: + (Path(model_dir) / "checkpoint_best_ema.pth").write_bytes(b"raw-ptl") + ckpt = {"pytorch-lightning_version": "2.1.0", "model_name": "RFDETRKeypointPreview"} + torch = _fake_torch(ckpt) + with _import_patch({"torch": torch}): + with self.assertRaises(TaskMismatchError): + package_custom_weights("rfdetr-base", model_dir, filename="checkpoint_best_ema.pth") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/util/test_train_recipe.py b/tests/util/test_train_recipe.py new file mode 100644 index 00000000..1419f463 --- /dev/null +++ b/tests/util/test_train_recipe.py @@ -0,0 +1,30 @@ +import unittest + +from roboflow.util.train_recipe import fold_epochs_into_recipe + + +class TestFoldEpochsIntoRecipe(unittest.TestCase): + def test_epochs_folded_into_hyperparameters(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0002}} + folded = fold_epochs_into_recipe(recipe, 50) + self.assertEqual(folded["hyperparameters"], {"lr": 0.0002, "epochs": 50}) + self.assertEqual(folded["schema_version"], 1) + + def test_epochs_does_not_clobber_explicit_hyperparameter(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0002, "epochs": 25}} + folded = fold_epochs_into_recipe(recipe, 50) + self.assertEqual(folded["hyperparameters"]["epochs"], 25) + + def test_epochs_fold_creates_missing_hyperparameters_key(self): + # Hand-written recipes may omit the hyperparameters key entirely. + folded = fold_epochs_into_recipe({"schema_version": 1}, 50) + self.assertEqual(folded["hyperparameters"], {"epochs": 50}) + + def test_input_recipe_is_not_mutated(self): + recipe = {"schema_version": 1, "hyperparameters": {"lr": 0.0002}} + fold_epochs_into_recipe(recipe, 50) + self.assertEqual(recipe, {"schema_version": 1, "hyperparameters": {"lr": 0.0002}}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/util/test_versions.py b/tests/util/test_versions.py index 89250d75..5a7803f2 100644 --- a/tests/util/test_versions.py +++ b/tests/util/test_versions.py @@ -1,7 +1,7 @@ import unittest from importlib import import_module -from roboflow.util.versions import get_wrong_dependencies_versions +from roboflow.util.versions import get_model_format, get_wrong_dependencies_versions class TestVersions(unittest.TestCase): @@ -23,3 +23,21 @@ def test_wrong_dependencies_versions(self): wrong_dependencies_versions = get_wrong_dependencies_versions([test]) is_correct_dep = len(wrong_dependencies_versions) == 0 self.assertEqual(is_correct_dep, expected_result) + + +class TestGetModelFormat(unittest.TestCase): + def test_get_model_format_with_various_ids(self): + cases = [ + ("yolov5v2s", "yolov5pytorch"), + ("yolov11n", "yolov5pytorch"), + ("rf-detr-nas-parent", "coco"), + ("rfdetr-nano", "coco"), + ("vit-base-patch16-224-in21k", "folder"), + ("resnet14", "folder"), + ("resenet38", "yolov5pytorch"), + ("invlid-type", "yolov5pytorch"), + ] + + for model_type, expected_format in cases: + with self.subTest(model_type=model_type): + self.assertEqual(get_model_format(model_type), expected_format)