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 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"].
@@ -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,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,6 @@
1
+ requests>=2.28
2
+ tqdm>=4.60
3
+
4
+ [torch]
5
+ torch
6
+ torchvision
@@ -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__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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(" ") == []