imagescv 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imagescv-0.1.0/LICENSE +24 -0
- imagescv-0.1.0/PKG-INFO +64 -0
- imagescv-0.1.0/README.md +37 -0
- imagescv-0.1.0/imagescv/__init__.py +12 -0
- imagescv-0.1.0/imagescv/_version.py +1 -0
- imagescv-0.1.0/imagescv/client.py +284 -0
- imagescv-0.1.0/imagescv/dataset.py +55 -0
- imagescv-0.1.0/imagescv.egg-info/PKG-INFO +64 -0
- imagescv-0.1.0/imagescv.egg-info/SOURCES.txt +16 -0
- imagescv-0.1.0/imagescv.egg-info/dependency_links.txt +1 -0
- imagescv-0.1.0/imagescv.egg-info/requires.txt +6 -0
- imagescv-0.1.0/imagescv.egg-info/top_level.txt +1 -0
- imagescv-0.1.0/pyproject.toml +38 -0
- imagescv-0.1.0/setup.cfg +4 -0
- imagescv-0.1.0/tests/test_api_key.py +43 -0
- imagescv-0.1.0/tests/test_dataset.py +13 -0
- imagescv-0.1.0/tests/test_load_options.py +103 -0
- imagescv-0.1.0/tests/test_search.py +28 -0
imagescv-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 images.cv
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
The license above covers this library's code only. Each dataset on images.cv
|
|
24
|
+
has its own license; see imagescv.info("<name>")["license"].
|
imagescv-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imagescv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Download labeled image datasets in one line of Python.
|
|
5
|
+
Author: images.cv
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://images.cv
|
|
8
|
+
Project-URL: Documentation, https://images.cv/dashboard/api-keys
|
|
9
|
+
Project-URL: Source, https://github.com/images-cv/imagescv
|
|
10
|
+
Project-URL: Issues, https://github.com/images-cv/imagescv/issues
|
|
11
|
+
Keywords: dataset,images,computer-vision,machine-learning,image-classification,images.cv
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: requests>=2.28
|
|
22
|
+
Requires-Dist: tqdm>=4.60
|
|
23
|
+
Provides-Extra: torch
|
|
24
|
+
Requires-Dist: torch; extra == "torch"
|
|
25
|
+
Requires-Dist: torchvision; extra == "torch"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# imagescv
|
|
29
|
+
|
|
30
|
+
Download [images.cv](https://images.cv) datasets in one line.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install imagescv
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Free. Create an API key at <https://images.cv/dashboard/api-keys> (up to 25 dataset downloads per
|
|
37
|
+
day per account).
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import imagescv
|
|
41
|
+
imagescv.login("icv_...") # or set IMAGESCV_API_KEY
|
|
42
|
+
|
|
43
|
+
imagescv.search("firetruck") # [{"name": "firetruck", "images": 562, ...}]
|
|
44
|
+
imagescv.info("firetruck") # dataset card: images, classes, license, sources
|
|
45
|
+
|
|
46
|
+
ds = imagescv.load(
|
|
47
|
+
"firetruck",
|
|
48
|
+
size="size_128", # nochange, size_16, size_32, size_64, size_128, size_256
|
|
49
|
+
color="gray", # color or gray
|
|
50
|
+
augmentations=["rotate", "hflip"], # see imagescv.AUGMENTATIONS
|
|
51
|
+
augmentation_percent=30, # share of images augmented
|
|
52
|
+
split=(0.7, 0.2, 0.1), # train / val / test
|
|
53
|
+
download_path="data", # optional; default is the current folder
|
|
54
|
+
)
|
|
55
|
+
print(ds) # images, classes, splits
|
|
56
|
+
train = ds.to_torch("train") # needs: pip install imagescv[torch]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Datasets download into the current directory, in a folder like
|
|
60
|
+
`firetruck_size_128_color_70-20/` that contains `data/train|val|test/<class>/` and
|
|
61
|
+
`meta.json` (also available as `ds.meta`). Pass `download_path="D:/data"` to choose another
|
|
62
|
+
folder. Asking again for the same options reuses the folder instead of downloading again
|
|
63
|
+
(`force=True` re-downloads).
|
|
64
|
+
Dataset licenses vary; `info()["license"]` is "Unknown" when a source has none on file.
|
imagescv-0.1.0/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# imagescv
|
|
2
|
+
|
|
3
|
+
Download [images.cv](https://images.cv) datasets in one line.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install imagescv
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Free. Create an API key at <https://images.cv/dashboard/api-keys> (up to 25 dataset downloads per
|
|
10
|
+
day per account).
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
import imagescv
|
|
14
|
+
imagescv.login("icv_...") # or set IMAGESCV_API_KEY
|
|
15
|
+
|
|
16
|
+
imagescv.search("firetruck") # [{"name": "firetruck", "images": 562, ...}]
|
|
17
|
+
imagescv.info("firetruck") # dataset card: images, classes, license, sources
|
|
18
|
+
|
|
19
|
+
ds = imagescv.load(
|
|
20
|
+
"firetruck",
|
|
21
|
+
size="size_128", # nochange, size_16, size_32, size_64, size_128, size_256
|
|
22
|
+
color="gray", # color or gray
|
|
23
|
+
augmentations=["rotate", "hflip"], # see imagescv.AUGMENTATIONS
|
|
24
|
+
augmentation_percent=30, # share of images augmented
|
|
25
|
+
split=(0.7, 0.2, 0.1), # train / val / test
|
|
26
|
+
download_path="data", # optional; default is the current folder
|
|
27
|
+
)
|
|
28
|
+
print(ds) # images, classes, splits
|
|
29
|
+
train = ds.to_torch("train") # needs: pip install imagescv[torch]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Datasets download into the current directory, in a folder like
|
|
33
|
+
`firetruck_size_128_color_70-20/` that contains `data/train|val|test/<class>/` and
|
|
34
|
+
`meta.json` (also available as `ds.meta`). Pass `download_path="D:/data"` to choose another
|
|
35
|
+
folder. Asking again for the same options reuses the folder instead of downloading again
|
|
36
|
+
(`force=True` re-downloads).
|
|
37
|
+
Dataset licenses vary; `info()["license"]` is "Unknown" when a source has none on file.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""images.cv datasets in one line:
|
|
2
|
+
|
|
3
|
+
import imagescv
|
|
4
|
+
imagescv.login("icv_...") # or set IMAGESCV_API_KEY
|
|
5
|
+
ds = imagescv.load("firetruck")
|
|
6
|
+
print(ds.classes, ds.root)
|
|
7
|
+
"""
|
|
8
|
+
from ._version import __version__
|
|
9
|
+
from .client import login, info, load, search, SIZES, COLORS, AUGMENTATIONS
|
|
10
|
+
from .dataset import Dataset
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__", "login", "info", "load", "search", "Dataset", "SIZES", "COLORS", "AUGMENTATIONS"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import secrets
|
|
3
|
+
import shutil
|
|
4
|
+
import time
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from tqdm.auto import tqdm
|
|
10
|
+
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .dataset import Dataset
|
|
13
|
+
|
|
14
|
+
API_BASE_URL = os.environ.get("IMAGESCV_API_URL", "https://api.images.cv")
|
|
15
|
+
|
|
16
|
+
# The API sits behind Cloudflare, which blocks the default python-requests
|
|
17
|
+
# User-Agent. Same headers the Colab starter notebook uses.
|
|
18
|
+
_HEADERS = {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
"Accept": "application/json",
|
|
21
|
+
# Identifies the library honestly. The word "python" is avoided on purpose:
|
|
22
|
+
# the site's Cloudflare rule "Block Data Scraping Bots" blocks it.
|
|
23
|
+
"User-Agent": f"imagescv/{__version__} (+https://images.cv)",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_api_key = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def login(api_key):
|
|
30
|
+
"""Set the API key for this session (otherwise IMAGESCV_API_KEY is used)."""
|
|
31
|
+
global _api_key
|
|
32
|
+
_api_key = api_key
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
KEYS_URL = "https://images.cv/dashboard/api-keys"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _headers():
|
|
39
|
+
key = _api_key or os.environ.get("IMAGESCV_API_KEY")
|
|
40
|
+
if not key:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"No API key. Create a free one at {KEYS_URL}, then call imagescv.login('icv_...') "
|
|
43
|
+
"or set the IMAGESCV_API_KEY environment variable.")
|
|
44
|
+
h = dict(_HEADERS)
|
|
45
|
+
h["X-API-Key"] = key
|
|
46
|
+
return h
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _post(path, body, timeout=45, retries=2):
|
|
50
|
+
resp = None
|
|
51
|
+
for attempt in range(retries + 1):
|
|
52
|
+
try:
|
|
53
|
+
resp = requests.post(API_BASE_URL + path, json=body, headers=_headers(), timeout=timeout)
|
|
54
|
+
break
|
|
55
|
+
except requests.exceptions.RequestException as exc:
|
|
56
|
+
if attempt == retries:
|
|
57
|
+
raise RuntimeError(f"Network error calling {path}: {exc}") from exc
|
|
58
|
+
time.sleep(2 * (attempt + 1))
|
|
59
|
+
if "application/json" not in resp.headers.get("Content-Type", ""):
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
f"{path} returned non-JSON ({resp.status_code}); the request was probably "
|
|
62
|
+
f"blocked before reaching the API. First bytes: {resp.text[:120]!r}")
|
|
63
|
+
data = resp.json()
|
|
64
|
+
if resp.status_code == 401:
|
|
65
|
+
raise RuntimeError(f"API key rejected (invalid or revoked). Check or create one at {KEYS_URL}")
|
|
66
|
+
if resp.status_code == 429:
|
|
67
|
+
raise RuntimeError("Rate limited: " + str(data.get("error", "too many requests")))
|
|
68
|
+
if resp.status_code != 200 or data.get("isError"):
|
|
69
|
+
raise RuntimeError(f"{path} failed ({resp.status_code}): {data}")
|
|
70
|
+
return data
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
SIZES = ("nochange", "size_16", "size_32", "size_64", "size_128", "size_256")
|
|
74
|
+
COLORS = ("color", "gray")
|
|
75
|
+
AUGMENTATIONS = (
|
|
76
|
+
"rotate", "hflip", "vflip", "crop", "change_aspect_ratio", "perspective",
|
|
77
|
+
"edge_enhance", "max_filter", "min_filter", "sharpen", "smooth", "blur",
|
|
78
|
+
"brightness", "contrast", "saturation", "color_jitter", "shuffle_pixels",
|
|
79
|
+
"random_noise", "cutout", "jpeg_compression",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Same spellings the website resolves (stored value -> name, url).
|
|
83
|
+
_LICENSES = {
|
|
84
|
+
"cc": ("CC0: Public Domain", "https://creativecommons.org/publicdomain/zero/1.0/"),
|
|
85
|
+
"cc1": ("Database: Open Database, Contents: Database Contents", "https://opendatacommons.org/licenses/odbl/1-0/"),
|
|
86
|
+
"cc2": ("CC BY 4.0", "https://creativecommons.org/licenses/by/4.0/"),
|
|
87
|
+
"cc3": ("(c) Original Authors", None),
|
|
88
|
+
"nn": ("GPL 2", "http://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html"),
|
|
89
|
+
"nn1": ("GNU Lesser General Public License 3.0", "http://www.gnu.org/licenses/lgpl-3.0.html"),
|
|
90
|
+
"nn2": ("CC BY-NC-ND 4.0", "https://creativecommons.org/licenses/by-nc-nd/4.0/"),
|
|
91
|
+
"nn3": ("Academic Research (c) Original Authors", None),
|
|
92
|
+
}
|
|
93
|
+
_LICENSE_ALIASES = {"cc0": "cc", "nn0": "nn"}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve_license(value):
|
|
97
|
+
raw = (value or "").strip().lower()
|
|
98
|
+
return _LICENSES.get(_LICENSE_ALIASES.get(raw, raw))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def info(name):
|
|
102
|
+
"""The dataset card: image count, classes, sources and license.
|
|
103
|
+
|
|
104
|
+
license is the shared license name when every source agrees, "Mixed" when
|
|
105
|
+
sources have different known licenses, and "Unknown" when a source has none
|
|
106
|
+
on file (always check the source before use).
|
|
107
|
+
"""
|
|
108
|
+
d = _post("/get_items_image_category", {"category": name, "start_after": 0})["data"]
|
|
109
|
+
sources = []
|
|
110
|
+
for raw in d.get("dataSource") or []:
|
|
111
|
+
clean = raw[:-1] if raw.endswith("_") else raw
|
|
112
|
+
slug = clean.strip().lower().replace("_", "-").replace(" ", "-")
|
|
113
|
+
entry = {"name": clean.replace("_", " ").title(), "link": None, "license": None, "license_url": None}
|
|
114
|
+
try:
|
|
115
|
+
rows = _post("/get_data_sources", {"key": slug}).get("data") or []
|
|
116
|
+
except RuntimeError:
|
|
117
|
+
rows = []
|
|
118
|
+
if rows:
|
|
119
|
+
lic = _resolve_license(rows[0].get("license_type"))
|
|
120
|
+
entry.update(name=(rows[0].get("name") or clean).replace("_", " ").title(),
|
|
121
|
+
link=rows[0].get("link") or None)
|
|
122
|
+
if lic:
|
|
123
|
+
entry.update(license=lic[0], license_url=lic[1])
|
|
124
|
+
sources.append(entry)
|
|
125
|
+
|
|
126
|
+
names = {s["license"] for s in sources}
|
|
127
|
+
if sources and None not in names:
|
|
128
|
+
license_text = names.pop() if len(names) == 1 else "Mixed"
|
|
129
|
+
else:
|
|
130
|
+
license_text = "Unknown"
|
|
131
|
+
classes = d.get("mainCategory") or []
|
|
132
|
+
return {
|
|
133
|
+
"name": name,
|
|
134
|
+
"images": d.get("sumCounter") or 0,
|
|
135
|
+
"classes": len(classes),
|
|
136
|
+
"class_names": classes,
|
|
137
|
+
"license": license_text,
|
|
138
|
+
"sources": sources,
|
|
139
|
+
"premium": bool(d.get("is_premium")),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def search(query, min_images=-1, limit=50):
|
|
144
|
+
"""Find datasets whose name starts with, or whose category contains, the query.
|
|
145
|
+
|
|
146
|
+
Returns a list of dicts: name (use it with load()), images, parent.
|
|
147
|
+
Uses the same two lookups as the website search: the category and its
|
|
148
|
+
sub-categories (with image counts), plus name-prefix suggestions.
|
|
149
|
+
"""
|
|
150
|
+
q = (query or "").strip()
|
|
151
|
+
if not q:
|
|
152
|
+
return []
|
|
153
|
+
found = {}
|
|
154
|
+
for it in _post("/api/search_advanced", {"q": q, "minImages": min_images}).get("data") or []:
|
|
155
|
+
found[it["data_key"]] = {"name": it["data_key"], "images": it.get("count", 0),
|
|
156
|
+
"parent": it.get("parent")}
|
|
157
|
+
try:
|
|
158
|
+
resp = requests.get(API_BASE_URL + "/api/autocomplete", params={"q": q},
|
|
159
|
+
headers=_headers(), timeout=45)
|
|
160
|
+
suggestions = resp.json().get("data") or []
|
|
161
|
+
except (requests.exceptions.RequestException, ValueError):
|
|
162
|
+
suggestions = []
|
|
163
|
+
for s in suggestions:
|
|
164
|
+
key = s.get("main_category") if isinstance(s, dict) else None
|
|
165
|
+
if key and key not in found:
|
|
166
|
+
found[key] = {"name": key, "images": None, "parent": s.get("parent")}
|
|
167
|
+
rows = sorted(found.values(), key=lambda r: (-(r["images"] or 0), r["name"]))
|
|
168
|
+
return rows[:limit]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _wait_for_url(link_id, timeout, poll=3.0):
|
|
172
|
+
deadline = time.monotonic() + timeout
|
|
173
|
+
with tqdm(total=timeout, unit="s", desc="Preparing package") as bar:
|
|
174
|
+
while time.monotonic() < deadline:
|
|
175
|
+
url = _post("/get_link", {"link_id": link_id})["data"].get("public_url") or ""
|
|
176
|
+
if url:
|
|
177
|
+
bar.n = bar.total
|
|
178
|
+
bar.refresh()
|
|
179
|
+
return url
|
|
180
|
+
time.sleep(poll)
|
|
181
|
+
bar.update(poll)
|
|
182
|
+
raise RuntimeError(
|
|
183
|
+
f"Package was not ready after {timeout}s (job id {link_id}). Large datasets can take "
|
|
184
|
+
f"longer: try load(..., timeout=1800), or start with a smaller dataset.")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _stream(url, dest):
|
|
188
|
+
resp = requests.get(url, stream=True, timeout=60,
|
|
189
|
+
headers={"User-Agent": _HEADERS["User-Agent"]})
|
|
190
|
+
if resp.status_code != 200 or "html" in resp.headers.get("Content-Type", "").lower():
|
|
191
|
+
raise RuntimeError(f"Download failed ({resp.status_code})")
|
|
192
|
+
total = int(resp.headers.get("Content-Length", 0)) or None
|
|
193
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
with open(dest, "wb") as fh, tqdm(total=total, unit="B", unit_scale=True, desc="Downloading") as bar:
|
|
195
|
+
for chunk in resp.iter_content(1024 * 1024):
|
|
196
|
+
fh.write(chunk)
|
|
197
|
+
bar.update(len(chunk))
|
|
198
|
+
with open(dest, "rb") as fh:
|
|
199
|
+
if fh.read(2) != b"PK":
|
|
200
|
+
dest.unlink()
|
|
201
|
+
raise RuntimeError("Downloaded file is not a ZIP")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _safe_extract(zip_path, target):
|
|
205
|
+
target = Path(target).resolve()
|
|
206
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
207
|
+
for member in z.namelist():
|
|
208
|
+
if not str((target / member).resolve()).startswith(str(target)):
|
|
209
|
+
raise RuntimeError("Unsafe path in ZIP: " + member)
|
|
210
|
+
z.extractall(target)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def load(name, size="size_128", color="color", augmentations=None,
|
|
214
|
+
augmentation_percent=30, split=(0.7, 0.2, 0.1), download_path=None,
|
|
215
|
+
force=False, timeout=1800):
|
|
216
|
+
"""Download (or reuse the cached copy of) a dataset and return a Dataset.
|
|
217
|
+
|
|
218
|
+
size: one of SIZES ("nochange", "size_16" ... "size_256").
|
|
219
|
+
color: "color" or "gray".
|
|
220
|
+
augmentations: names from AUGMENTATIONS, e.g. ["rotate", "hflip", "blur"].
|
|
221
|
+
Each image gets one random augmentation from your list;
|
|
222
|
+
augmentation_percent (0-100) is the share of images augmented.
|
|
223
|
+
split: train/val/test fractions.
|
|
224
|
+
download_path: folder to download into (default: the current directory).
|
|
225
|
+
The dataset goes in <download_path>/<name>_<options>/, and asking for
|
|
226
|
+
the same options again reuses it instead of downloading again.
|
|
227
|
+
"""
|
|
228
|
+
if size not in SIZES:
|
|
229
|
+
raise ValueError(f"size must be one of {SIZES}")
|
|
230
|
+
if color not in COLORS:
|
|
231
|
+
raise ValueError(f"color must be one of {COLORS}")
|
|
232
|
+
augs = sorted(set(augmentations or []))
|
|
233
|
+
bad = [a for a in augs if a not in AUGMENTATIONS]
|
|
234
|
+
if bad:
|
|
235
|
+
raise ValueError(f"unknown augmentations {bad}; valid: {AUGMENTATIONS}")
|
|
236
|
+
if not 0 <= augmentation_percent <= 100:
|
|
237
|
+
raise ValueError("augmentation_percent must be between 0 and 100")
|
|
238
|
+
if len(split) != 3 or abs(sum(split) - 1.0) > 0.01:
|
|
239
|
+
raise ValueError("split must be three fractions that sum to 1, e.g. (0.7, 0.2, 0.1)")
|
|
240
|
+
|
|
241
|
+
slug = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
|
|
242
|
+
variant = f"{size}_{color}_{int(round(split[0] * 100))}-{int(round(split[1] * 100))}"
|
|
243
|
+
if augs:
|
|
244
|
+
variant += "_" + "-".join(augs) + f"_p{int(augmentation_percent)}"
|
|
245
|
+
root = Path(download_path) if download_path else Path.cwd()
|
|
246
|
+
folder = root / f"{slug}_{variant}"
|
|
247
|
+
package = folder # data/ and meta.json sit directly in this folder
|
|
248
|
+
marker = folder / ".complete"
|
|
249
|
+
|
|
250
|
+
if marker.exists() and not force:
|
|
251
|
+
return Dataset(name, package)
|
|
252
|
+
|
|
253
|
+
body = {
|
|
254
|
+
"output_size": size, "color_type": color, "images": [],
|
|
255
|
+
"categories": [name], "augmentations": [{"name": a, "checked": True} for a in augs],
|
|
256
|
+
"split_set": list(split),
|
|
257
|
+
"link_id": secrets.token_hex(20), "request_type": "CALL_FROM_SEARCH",
|
|
258
|
+
"user_email": [], "user_id": "", "augmentation_percent": int(augmentation_percent),
|
|
259
|
+
}
|
|
260
|
+
link_id = _post("/download_images", body)["data"].get("link_id")
|
|
261
|
+
if not link_id:
|
|
262
|
+
raise RuntimeError("The server did not accept the download job")
|
|
263
|
+
url = _wait_for_url(link_id, timeout)
|
|
264
|
+
zip_path = folder / "package.zip"
|
|
265
|
+
_stream(url, zip_path)
|
|
266
|
+
staging = folder / ".extract"
|
|
267
|
+
if staging.exists():
|
|
268
|
+
shutil.rmtree(staging)
|
|
269
|
+
_safe_extract(zip_path, staging)
|
|
270
|
+
# The server wraps everything in one images.cv_<id>/ folder; drop that
|
|
271
|
+
# wrapper so you get <folder>/data/train/... and <folder>/meta.json.
|
|
272
|
+
entries = list(staging.iterdir())
|
|
273
|
+
source = entries[0] if len(entries) == 1 and entries[0].is_dir() else staging
|
|
274
|
+
for item in source.iterdir():
|
|
275
|
+
target = folder / item.name
|
|
276
|
+
if target.is_dir():
|
|
277
|
+
shutil.rmtree(target)
|
|
278
|
+
elif target.exists():
|
|
279
|
+
target.unlink()
|
|
280
|
+
shutil.move(str(item), str(target))
|
|
281
|
+
shutil.rmtree(staging)
|
|
282
|
+
zip_path.unlink()
|
|
283
|
+
marker.write_text("ok")
|
|
284
|
+
return Dataset(name, package)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
_IMG = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _find_split_root(package):
|
|
7
|
+
"""The ZIP may nest the splits (e.g. data/train); find the folder that holds train/."""
|
|
8
|
+
package = Path(package)
|
|
9
|
+
for p in [package, *sorted(package.rglob("*"))]:
|
|
10
|
+
if p.is_dir() and (p / "train").is_dir():
|
|
11
|
+
return p
|
|
12
|
+
return package
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Dataset:
|
|
16
|
+
def __init__(self, name, package):
|
|
17
|
+
self.name = name
|
|
18
|
+
self.package = Path(package)
|
|
19
|
+
self.root = _find_split_root(self.package)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def meta(self):
|
|
23
|
+
"""The package's meta.json (counts, split, size, color, augmentation), or {}."""
|
|
24
|
+
path = self.package / "meta.json"
|
|
25
|
+
if not path.exists():
|
|
26
|
+
return {}
|
|
27
|
+
import json
|
|
28
|
+
return json.loads(path.read_text(encoding="utf8"))
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def splits(self):
|
|
32
|
+
return sorted(d.name for d in self.root.iterdir() if d.is_dir())
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def classes(self):
|
|
36
|
+
train = self.root / "train"
|
|
37
|
+
base = train if train.is_dir() else self.root
|
|
38
|
+
return sorted(d.name for d in base.iterdir() if d.is_dir())
|
|
39
|
+
|
|
40
|
+
def paths(self, split="train"):
|
|
41
|
+
base = self.root / split
|
|
42
|
+
return sorted(p for p in base.rglob("*") if p.suffix.lower() in _IMG)
|
|
43
|
+
|
|
44
|
+
def __len__(self):
|
|
45
|
+
return sum(len(self.paths(s)) for s in self.splits)
|
|
46
|
+
|
|
47
|
+
def to_torch(self, split="train", transform=None):
|
|
48
|
+
try:
|
|
49
|
+
from torchvision import datasets
|
|
50
|
+
except ImportError as exc:
|
|
51
|
+
raise ImportError("Install with: pip install imagescv[torch]") from exc
|
|
52
|
+
return datasets.ImageFolder(str(self.root / split), transform=transform)
|
|
53
|
+
|
|
54
|
+
def __repr__(self):
|
|
55
|
+
return f"Dataset({self.name!r}, {len(self)} images, {len(self.classes)} classes, splits={self.splits})"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imagescv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Download labeled image datasets in one line of Python.
|
|
5
|
+
Author: images.cv
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://images.cv
|
|
8
|
+
Project-URL: Documentation, https://images.cv/dashboard/api-keys
|
|
9
|
+
Project-URL: Source, https://github.com/images-cv/imagescv
|
|
10
|
+
Project-URL: Issues, https://github.com/images-cv/imagescv/issues
|
|
11
|
+
Keywords: dataset,images,computer-vision,machine-learning,image-classification,images.cv
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: requests>=2.28
|
|
22
|
+
Requires-Dist: tqdm>=4.60
|
|
23
|
+
Provides-Extra: torch
|
|
24
|
+
Requires-Dist: torch; extra == "torch"
|
|
25
|
+
Requires-Dist: torchvision; extra == "torch"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# imagescv
|
|
29
|
+
|
|
30
|
+
Download [images.cv](https://images.cv) datasets in one line.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install imagescv
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Free. Create an API key at <https://images.cv/dashboard/api-keys> (up to 25 dataset downloads per
|
|
37
|
+
day per account).
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import imagescv
|
|
41
|
+
imagescv.login("icv_...") # or set IMAGESCV_API_KEY
|
|
42
|
+
|
|
43
|
+
imagescv.search("firetruck") # [{"name": "firetruck", "images": 562, ...}]
|
|
44
|
+
imagescv.info("firetruck") # dataset card: images, classes, license, sources
|
|
45
|
+
|
|
46
|
+
ds = imagescv.load(
|
|
47
|
+
"firetruck",
|
|
48
|
+
size="size_128", # nochange, size_16, size_32, size_64, size_128, size_256
|
|
49
|
+
color="gray", # color or gray
|
|
50
|
+
augmentations=["rotate", "hflip"], # see imagescv.AUGMENTATIONS
|
|
51
|
+
augmentation_percent=30, # share of images augmented
|
|
52
|
+
split=(0.7, 0.2, 0.1), # train / val / test
|
|
53
|
+
download_path="data", # optional; default is the current folder
|
|
54
|
+
)
|
|
55
|
+
print(ds) # images, classes, splits
|
|
56
|
+
train = ds.to_torch("train") # needs: pip install imagescv[torch]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Datasets download into the current directory, in a folder like
|
|
60
|
+
`firetruck_size_128_color_70-20/` that contains `data/train|val|test/<class>/` and
|
|
61
|
+
`meta.json` (also available as `ds.meta`). Pass `download_path="D:/data"` to choose another
|
|
62
|
+
folder. Asking again for the same options reuses the folder instead of downloading again
|
|
63
|
+
(`force=True` re-downloads).
|
|
64
|
+
Dataset licenses vary; `info()["license"]` is "Unknown" when a source has none on file.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
imagescv/__init__.py
|
|
5
|
+
imagescv/_version.py
|
|
6
|
+
imagescv/client.py
|
|
7
|
+
imagescv/dataset.py
|
|
8
|
+
imagescv.egg-info/PKG-INFO
|
|
9
|
+
imagescv.egg-info/SOURCES.txt
|
|
10
|
+
imagescv.egg-info/dependency_links.txt
|
|
11
|
+
imagescv.egg-info/requires.txt
|
|
12
|
+
imagescv.egg-info/top_level.txt
|
|
13
|
+
tests/test_api_key.py
|
|
14
|
+
tests/test_dataset.py
|
|
15
|
+
tests/test_load_options.py
|
|
16
|
+
tests/test_search.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
imagescv
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "imagescv"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Download labeled image datasets in one line of Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "images.cv" }]
|
|
14
|
+
keywords = ["dataset", "images", "computer-vision", "machine-learning", "image-classification", "images.cv"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Image Recognition",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["requests>=2.28", "tqdm>=4.60"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
torch = ["torch", "torchvision"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://images.cv"
|
|
30
|
+
Documentation = "https://images.cv/dashboard/api-keys"
|
|
31
|
+
Source = "https://github.com/images-cv/imagescv"
|
|
32
|
+
Issues = "https://github.com/images-cv/imagescv/issues"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
packages = ["imagescv"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.dynamic]
|
|
38
|
+
version = { attr = "imagescv._version.__version__" }
|
imagescv-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import imagescv
|
|
4
|
+
from imagescv import client
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _Resp:
|
|
8
|
+
def __init__(self, status, payload):
|
|
9
|
+
self.status_code = status
|
|
10
|
+
self._payload = payload
|
|
11
|
+
self.headers = {"Content-Type": "application/json"}
|
|
12
|
+
self.text = ""
|
|
13
|
+
|
|
14
|
+
def json(self):
|
|
15
|
+
return self._payload
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_key_is_sent_in_x_api_key_header():
|
|
19
|
+
assert client._headers()["X-API-Key"] == "icv_test"
|
|
20
|
+
imagescv.login("icv_mine")
|
|
21
|
+
assert client._headers()["X-API-Key"] == "icv_mine"
|
|
22
|
+
assert "Authorization" not in client._headers()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_missing_key_gives_clear_error(monkeypatch):
|
|
26
|
+
monkeypatch.delenv("IMAGESCV_API_KEY")
|
|
27
|
+
with pytest.raises(RuntimeError, match="No API key"):
|
|
28
|
+
client._headers()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_rejected_key_and_daily_limit_messages(monkeypatch):
|
|
32
|
+
monkeypatch.setattr(client.requests, "post", lambda *a, **k: _Resp(401, {"error": "x"}))
|
|
33
|
+
with pytest.raises(RuntimeError, match="API key rejected"):
|
|
34
|
+
client._post("/get_link", {})
|
|
35
|
+
monkeypatch.setattr(client.requests, "post", lambda *a, **k: _Resp(429, {"error": "Daily download limit reached (25 per user)"}))
|
|
36
|
+
with pytest.raises(RuntimeError, match="Daily download limit"):
|
|
37
|
+
client._post("/download_images", {})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_user_agent_starts_with_imagescv_and_carries_the_version():
|
|
41
|
+
# The server's key gate matches on the "imagescv/" prefix.
|
|
42
|
+
assert client._HEADERS["User-Agent"].startswith("imagescv/")
|
|
43
|
+
assert imagescv.__version__ in client._HEADERS["User-Agent"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from imagescv.dataset import Dataset
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_nested_split_layout(tmp_path):
|
|
5
|
+
for split in ("train", "val"):
|
|
6
|
+
for cls in ("a", "b"):
|
|
7
|
+
d = tmp_path / "data" / split / cls
|
|
8
|
+
d.mkdir(parents=True)
|
|
9
|
+
(d / "1.png").write_bytes(b"x")
|
|
10
|
+
ds = Dataset("t", tmp_path)
|
|
11
|
+
assert ds.classes == ["a", "b"]
|
|
12
|
+
assert ds.splits == ["train", "val"]
|
|
13
|
+
assert len(ds) == 4
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
import imagescv
|
|
4
|
+
from imagescv import client
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_rejects_bad_options():
|
|
8
|
+
with pytest.raises(ValueError):
|
|
9
|
+
imagescv.load("cat", size="size_99")
|
|
10
|
+
with pytest.raises(ValueError):
|
|
11
|
+
imagescv.load("cat", color="sepia")
|
|
12
|
+
with pytest.raises(ValueError):
|
|
13
|
+
imagescv.load("cat", augmentations=["warp"])
|
|
14
|
+
with pytest.raises(ValueError):
|
|
15
|
+
imagescv.load("cat", augmentation_percent=150)
|
|
16
|
+
with pytest.raises(ValueError):
|
|
17
|
+
imagescv.load("cat", split=(0.9, 0.9, 0.9))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_request_body_and_download_path(monkeypatch, tmp_path):
|
|
21
|
+
sent = {}
|
|
22
|
+
|
|
23
|
+
def fake_post(path, body, **k):
|
|
24
|
+
sent[path] = body
|
|
25
|
+
return {"data": {"link_id": "x"}}
|
|
26
|
+
|
|
27
|
+
monkeypatch.setattr(client, "_post", fake_post)
|
|
28
|
+
monkeypatch.setattr(client, "_wait_for_url", lambda *a, **k: "http://z")
|
|
29
|
+
|
|
30
|
+
def fake_stream(url, dest):
|
|
31
|
+
import zipfile
|
|
32
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
with zipfile.ZipFile(dest, "w") as z:
|
|
34
|
+
z.writestr("data/train/a/1.png", b"x")
|
|
35
|
+
|
|
36
|
+
monkeypatch.setattr(client, "_stream", fake_stream)
|
|
37
|
+
ds = imagescv.load("cat", size="size_64", color="gray", augmentations=["blur", "rotate"],
|
|
38
|
+
augmentation_percent=40, download_path=tmp_path)
|
|
39
|
+
body = sent["/download_images"]
|
|
40
|
+
assert body["output_size"] == "size_64" and body["color_type"] == "gray"
|
|
41
|
+
assert body["augmentations"] == [{"name": "blur", "checked": True}, {"name": "rotate", "checked": True}]
|
|
42
|
+
assert body["augmentation_percent"] == 40
|
|
43
|
+
assert ds.package == tmp_path / "cat_size_64_gray_70-20_blur-rotate_p40"
|
|
44
|
+
assert ds.classes == ["a"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_default_download_path_is_cwd(monkeypatch, tmp_path):
|
|
48
|
+
monkeypatch.chdir(tmp_path)
|
|
49
|
+
monkeypatch.setattr(client, "_post", lambda *a, **k: {"data": {"link_id": "x"}})
|
|
50
|
+
monkeypatch.setattr(client, "_wait_for_url", lambda *a, **k: "http://z")
|
|
51
|
+
|
|
52
|
+
def fake_stream(url, dest):
|
|
53
|
+
import zipfile
|
|
54
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
with zipfile.ZipFile(dest, "w") as z:
|
|
56
|
+
z.writestr("data/train/a/1.png", b"x")
|
|
57
|
+
|
|
58
|
+
monkeypatch.setattr(client, "_stream", fake_stream)
|
|
59
|
+
ds = imagescv.load("cat")
|
|
60
|
+
assert ds.package.parent == tmp_path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_server_wrapper_folder_is_flattened(monkeypatch, tmp_path):
|
|
64
|
+
monkeypatch.setattr(client, "_post", lambda *a, **k: {"data": {"link_id": "x"}})
|
|
65
|
+
monkeypatch.setattr(client, "_wait_for_url", lambda *a, **k: "http://z")
|
|
66
|
+
|
|
67
|
+
def fake_stream(url, dest):
|
|
68
|
+
import zipfile
|
|
69
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
with zipfile.ZipFile(dest, "w") as z:
|
|
71
|
+
z.writestr("images.cv_abc/data/train/a/1.png", b"x")
|
|
72
|
+
z.writestr("images.cv_abc/meta.json", '{"amount_of_images": 1}')
|
|
73
|
+
|
|
74
|
+
monkeypatch.setattr(client, "_stream", fake_stream)
|
|
75
|
+
ds = imagescv.load("cat", download_path=tmp_path)
|
|
76
|
+
folder = tmp_path / "cat_size_128_color_70-20"
|
|
77
|
+
assert (folder / "data" / "train" / "a" / "1.png").exists()
|
|
78
|
+
assert (folder / "meta.json").exists()
|
|
79
|
+
assert not (folder / ".extract").exists()
|
|
80
|
+
assert ds.meta["amount_of_images"] == 1
|
|
81
|
+
assert ds.classes == ["a"]
|
|
82
|
+
|
|
83
|
+
# asking again reuses the folder, and force=True re-downloads cleanly
|
|
84
|
+
assert imagescv.load("cat", download_path=tmp_path).package == folder
|
|
85
|
+
imagescv.load("cat", download_path=tmp_path, force=True)
|
|
86
|
+
assert (folder / "data" / "train" / "a" / "1.png").exists()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_info_license_logic(monkeypatch):
|
|
90
|
+
def fake_post(path, body, **k):
|
|
91
|
+
if path == "/get_items_image_category":
|
|
92
|
+
return {"data": {"sumCounter": 100, "mainCategory": ["a", "b"], "dataSource": ["kaggle_x", "other"]}}
|
|
93
|
+
key = body["key"]
|
|
94
|
+
return {"data": [{"name": key, "link": "http://l", "license_type": "cc0" if key == "kaggle-x" else "cc2"}]}
|
|
95
|
+
|
|
96
|
+
monkeypatch.setattr(client, "_post", fake_post)
|
|
97
|
+
card = imagescv.info("cat")
|
|
98
|
+
assert card["images"] == 100 and card["classes"] == 2
|
|
99
|
+
assert card["license"] == "Mixed"
|
|
100
|
+
monkeypatch.setattr(client, "_post", lambda p, b, **k: (
|
|
101
|
+
{"data": {"sumCounter": 1, "mainCategory": [], "dataSource": ["s"]}} if "items" in p
|
|
102
|
+
else {"data": [{"name": "s", "license_type": ""}]}))
|
|
103
|
+
assert imagescv.info("cat")["license"] == "Unknown"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import imagescv
|
|
2
|
+
from imagescv import client
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class _Resp:
|
|
6
|
+
def __init__(self, payload):
|
|
7
|
+
self._p = payload
|
|
8
|
+
|
|
9
|
+
def json(self):
|
|
10
|
+
return self._p
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_search_merges_and_sorts(monkeypatch):
|
|
14
|
+
monkeypatch.setattr(client, "_post", lambda path, body, **k: {"data": [
|
|
15
|
+
{"data_key": "traffic_cone", "count": 600, "parent": None},
|
|
16
|
+
{"data_key": "cone", "count": 40, "parent": "Shape"},
|
|
17
|
+
]})
|
|
18
|
+
monkeypatch.setattr(client.requests, "get", lambda *a, **k: _Resp({"data": [
|
|
19
|
+
{"main_category": "cone", "parent": "Shape"},
|
|
20
|
+
{"main_category": "conehead", "parent": None},
|
|
21
|
+
]}))
|
|
22
|
+
rows = imagescv.search("cone")
|
|
23
|
+
assert [r["name"] for r in rows] == ["traffic_cone", "cone", "conehead"]
|
|
24
|
+
assert rows[2]["images"] is None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_search_empty_query():
|
|
28
|
+
assert imagescv.search(" ") == []
|