spacial 0.1.0__py3-none-any.whl

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.
spacial/__init__.py ADDED
@@ -0,0 +1,669 @@
1
+ """
2
+ Spacial — lightweight synthetic dataset image generation library.
3
+
4
+ Spacial generates images and their annotations (bounding boxes, segmentation)
5
+ using Pillow. It is intentionally minimal: no export formats, no training
6
+ framework, no scene graph. Just pixels and coordinates.
7
+
8
+ Typical usage
9
+ -------------
10
+ import spacial
11
+
12
+ spacial.init(w=640, h=480)
13
+ spacial.background("color", fill=(30, 30, 30))
14
+
15
+ spacial.shape("badge", w=80, h=80)
16
+ spacial.shape_add("badge", "circle", fill="#FF4500", cx=40, cy=40, r=38)
17
+
18
+ spacial.append("logo_01", "badge", x=50, y=50)
19
+ spacial.append("logo_02", "badge", x=200, y=50)
20
+
21
+ print(spacial.bbox())
22
+ spacial.save("output.png")
23
+ spacial.rm()
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import random
30
+ from typing import Any
31
+
32
+ from PIL import Image, ImageDraw
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Version
36
+ # ---------------------------------------------------------------------------
37
+
38
+ __version__ = "0.1.0"
39
+ __all__ = [
40
+ "init",
41
+ "background",
42
+ "shape",
43
+ "shape_add",
44
+ "append",
45
+ "bbox",
46
+ "seg",
47
+ "save",
48
+ "rm",
49
+ ]
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Internal state
53
+ # A single global state object keeps the public API flat and import-friendly.
54
+ # ---------------------------------------------------------------------------
55
+
56
+ _SUPPORTED_DEVICES = {"cpu", "cuda", "mps"}
57
+ _SUPPORTED_BG_TYPES = {"color", "gradient", "noise", "perlin", "img"}
58
+ _SUPPORTED_SHAPE_PRIMITIVES = {"circle", "rectangle", "img"}
59
+
60
+
61
+ class _State:
62
+ """Holds all mutable state for the current canvas."""
63
+
64
+ def __init__(self) -> None:
65
+ self.device: str = "cpu"
66
+ self.width: int = 1024
67
+ self.height: int = 1024
68
+ self.image: Image.Image = Image.new("RGB", (1024, 1024), (0, 0, 0))
69
+ # shape templates: name -> {"w": int, "h": int, "elements": list}
70
+ self.shapes: dict[str, dict[str, Any]] = {}
71
+ # placed objects: list of dicts with id, class, bbox, mask
72
+ self.objects: list[dict[str, Any]] = []
73
+
74
+ def reset(self) -> None:
75
+ self.image = Image.new("RGB", (self.width, self.height), (0, 0, 0))
76
+ self.objects = []
77
+
78
+
79
+ _state = _State()
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Colour helpers
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def _parse_color(value: Any) -> tuple[int, int, int]:
87
+ """Normalise a colour value to an (R, G, B) tuple.
88
+
89
+ Accepts:
90
+ - An RGB tuple/list: (255, 0, 0) or [255, 0, 0]
91
+ - A hex string: "#FF0000" or "FF0000"
92
+ """
93
+ if isinstance(value, (tuple, list)) and len(value) == 3:
94
+ r, g, b = int(value[0]), int(value[1]), int(value[2])
95
+ if not all(0 <= c <= 255 for c in (r, g, b)):
96
+ raise ValueError(f"RGB components must be 0–255, got {value!r}")
97
+ return (r, g, b)
98
+
99
+ if isinstance(value, str):
100
+ hex_str = value.lstrip("#")
101
+ if len(hex_str) != 6:
102
+ raise ValueError(f"Expected a 6-digit hex colour, got {value!r}")
103
+ return (int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))
104
+
105
+ raise TypeError(
106
+ f"Colour must be an RGB tuple or a hex string, got {type(value).__name__!r}"
107
+ )
108
+
109
+
110
+ def _make_fill_image(
111
+ w: int,
112
+ h: int,
113
+ fill: Any,
114
+ seed: int | None = None,
115
+ ) -> Image.Image:
116
+ """Rasterise a fill specification into an RGBA Pillow image.
117
+
118
+ Supported fill values:
119
+
120
+ * ``(R, G, B)`` or ``"#RRGGBB"`` – solid colour
121
+ * ``{"type": "gradient", "start": color, "end": color, "direction": "horizontal"|"vertical"}``
122
+ * ``{"type": "noise", "base": color, "scale": 0.0-1.0}``
123
+ * ``{"type": "perlin", "base": color, "scale": 0.0-1.0, "octaves": int}``
124
+ """
125
+ rng = random.Random(seed)
126
+
127
+ if isinstance(fill, dict):
128
+ fill_type = fill.get("type", "")
129
+
130
+ # ---- gradient -------------------------------------------------------
131
+ if fill_type == "gradient":
132
+ start = _parse_color(fill.get("start", (0, 0, 0)))
133
+ end = _parse_color(fill.get("end", (255, 255, 255)))
134
+ direction = fill.get("direction", "horizontal")
135
+ img = Image.new("RGB", (w, h))
136
+ pixels = img.load()
137
+ assert pixels is not None
138
+ steps = w if direction == "horizontal" else h
139
+ for i in range(steps):
140
+ t = i / max(steps - 1, 1)
141
+ r = int(start[0] + (end[0] - start[0]) * t)
142
+ g = int(start[1] + (end[1] - start[1]) * t)
143
+ b = int(start[2] + (end[2] - start[2]) * t)
144
+ for j in range(h if direction == "horizontal" else w):
145
+ if direction == "horizontal":
146
+ pixels[i, j] = (r, g, b)
147
+ else:
148
+ pixels[j, i] = (r, g, b)
149
+ return img.convert("RGBA")
150
+
151
+ # ---- noise ----------------------------------------------------------
152
+ if fill_type == "noise":
153
+ base = _parse_color(fill.get("base", (128, 128, 128)))
154
+ scale = float(fill.get("scale", 0.5))
155
+ img = Image.new("RGB", (w, h))
156
+ pixels = img.load()
157
+ assert pixels is not None
158
+ half = int(255 * scale * 0.5)
159
+ for y in range(h):
160
+ for x in range(w):
161
+ offset = rng.randint(-half, half)
162
+ pixels[x, y] = (
163
+ max(0, min(255, base[0] + offset)),
164
+ max(0, min(255, base[1] + offset)),
165
+ max(0, min(255, base[2] + offset)),
166
+ )
167
+ return img.convert("RGBA")
168
+
169
+ # ---- perlin (approximated with layered noise) ------------------------
170
+ if fill_type == "perlin":
171
+ base = _parse_color(fill.get("base", (128, 128, 128)))
172
+ scale = float(fill.get("scale", 0.5))
173
+ octaves = int(fill.get("octaves", 4))
174
+ img = Image.new("RGB", (w, h))
175
+ pixels = img.load()
176
+ assert pixels is not None
177
+
178
+ # Build layered value-noise grid as a simple perlin approximation
179
+ def _smoothstep(t: float) -> float:
180
+ return t * t * (3 - 2 * t)
181
+
182
+ def _lerp(a: float, b: float, t: float) -> float:
183
+ return a + (b - a) * t
184
+
185
+ noise_map = [[0.0] * h for _ in range(w)]
186
+ amplitude = 1.0
187
+ freq = 1
188
+ max_val = 0.0
189
+ for _ in range(octaves):
190
+ gw = max(2, w // freq)
191
+ gh = max(2, h // freq)
192
+ grid = [
193
+ [rng.uniform(-1, 1) for _ in range(gh)] for _ in range(gw)
194
+ ]
195
+ for y in range(h):
196
+ for x in range(w):
197
+ gx = (x / w) * (gw - 1)
198
+ gy = (y / h) * (gh - 1)
199
+ ix, iy = int(gx), int(gy)
200
+ fx = _smoothstep(gx - ix)
201
+ fy = _smoothstep(gy - iy)
202
+ ix1 = min(ix + 1, gw - 1)
203
+ iy1 = min(iy + 1, gh - 1)
204
+ v = _lerp(
205
+ _lerp(grid[ix][iy], grid[ix1][iy], fx),
206
+ _lerp(grid[ix][iy1], grid[ix1][iy1], fx),
207
+ fy,
208
+ )
209
+ noise_map[x][y] += v * amplitude
210
+ max_val += amplitude
211
+ amplitude *= 0.5
212
+ freq *= 2
213
+
214
+ half = int(255 * scale * 0.5)
215
+ for y in range(h):
216
+ for x in range(w):
217
+ t = (noise_map[x][y] / max_val + 1) * 0.5 # normalise to [0,1]
218
+ offset = int((t - 0.5) * 2 * half)
219
+ pixels[x, y] = (
220
+ max(0, min(255, base[0] + offset)),
221
+ max(0, min(255, base[1] + offset)),
222
+ max(0, min(255, base[2] + offset)),
223
+ )
224
+ return img.convert("RGBA")
225
+
226
+ raise ValueError(f"Unknown fill type {fill_type!r}")
227
+
228
+ # Solid colour
229
+ color = _parse_color(fill)
230
+ return Image.new("RGBA", (w, h), (*color, 255))
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Public API
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ def init(
239
+ device: str = "cpu",
240
+ w: int = 1024,
241
+ h: int = 1024,
242
+ ) -> None:
243
+ """Initialise Spacial for a new generation session.
244
+
245
+ Parameters
246
+ ----------
247
+ device:
248
+ Compute device – ``"cpu"`` (default), ``"cuda"``, or ``"mps"``.
249
+ ``"cuda"`` and ``"mps"`` are accepted but currently behave identically
250
+ to ``"cpu"``; GPU acceleration is reserved for a future release.
251
+ w:
252
+ Canvas width in pixels (default ``1024``).
253
+ h:
254
+ Canvas height in pixels (default ``1024``).
255
+
256
+ Example
257
+ -------
258
+ >>> spacial.init(device="cpu", w=640, h=480)
259
+ """
260
+ if device not in _SUPPORTED_DEVICES:
261
+ raise ValueError(
262
+ f"device must be one of {sorted(_SUPPORTED_DEVICES)!r}, got {device!r}"
263
+ )
264
+ if w <= 0 or h <= 0:
265
+ raise ValueError(f"Canvas dimensions must be positive, got w={w}, h={h}")
266
+
267
+ _state.device = device
268
+ _state.width = w
269
+ _state.height = h
270
+ _state.image = Image.new("RGB", (w, h), (0, 0, 0))
271
+ _state.shapes = {}
272
+ _state.objects = []
273
+
274
+
275
+ def background(
276
+ bg_type: str,
277
+ /,
278
+ *,
279
+ fill: Any = (0, 0, 0),
280
+ path: str | None = None,
281
+ seed: int | None = None,
282
+ **_kwargs: Any,
283
+ ) -> None:
284
+ """Fill the canvas with a background.
285
+
286
+ Parameters
287
+ ----------
288
+ bg_type:
289
+ One of ``"color"``, ``"gradient"``, ``"noise"``, ``"perlin"``,
290
+ or ``"img"``.
291
+ fill:
292
+ Colour or fill specification used by ``"color"``, ``"gradient"``,
293
+ ``"noise"``, and ``"perlin"`` types.
294
+
295
+ * Solid colour: ``(R, G, B)`` tuple or ``"#RRGGBB"`` hex string.
296
+ * Gradient: ``{"type": "gradient", "start": color, "end": color,
297
+ "direction": "horizontal"|"vertical"}``
298
+ * Noise: ``{"type": "noise", "base": color, "scale": 0.5}``
299
+ * Perlin: ``{"type": "perlin", "base": color, "scale": 0.5,
300
+ "octaves": 4}``
301
+ path:
302
+ Path to an image file (required for ``bg_type="img"``).
303
+ seed:
304
+ Optional random seed for reproducible noise/perlin backgrounds.
305
+
306
+ Example
307
+ -------
308
+ >>> spacial.background("color", fill="#1A1A2E")
309
+ >>> spacial.background("gradient", fill={
310
+ ... "type": "gradient",
311
+ ... "start": "#FF6B6B",
312
+ ... "end": "#4ECDC4",
313
+ ... "direction": "vertical",
314
+ ... })
315
+ >>> spacial.background("img", path="sky.jpg")
316
+ """
317
+ if bg_type not in _SUPPORTED_BG_TYPES:
318
+ raise ValueError(
319
+ f"bg_type must be one of {sorted(_SUPPORTED_BG_TYPES)!r}, got {bg_type!r}"
320
+ )
321
+
322
+ w, h = _state.width, _state.height
323
+
324
+ if bg_type == "img":
325
+ if path is None:
326
+ raise ValueError('path is required for bg_type="img"')
327
+ src = Image.open(path).convert("RGB").resize((w, h))
328
+ _state.image.paste(src, (0, 0))
329
+ return
330
+
331
+ # For "color", "gradient", "noise", "perlin" the fill dict may already
332
+ # carry the correct type, or we normalise a plain colour.
333
+ if bg_type in {"gradient", "noise", "perlin"} and not isinstance(fill, dict):
334
+ raise ValueError(
335
+ f'bg_type="{bg_type}" requires fill to be a dict, '
336
+ f"e.g. {{'type': '{bg_type}', ...}}"
337
+ )
338
+
339
+ if bg_type == "color":
340
+ effective_fill: Any = fill
341
+ else:
342
+ effective_fill = fill # already a dict
343
+
344
+ bg_img = _make_fill_image(w, h, effective_fill, seed=seed)
345
+ _state.image.paste(bg_img.convert("RGB"), (0, 0))
346
+
347
+
348
+ def shape(name: str, *, w: int, h: int) -> None:
349
+ """Register a reusable shape template.
350
+
351
+ Parameters
352
+ ----------
353
+ name:
354
+ Unique identifier for this shape template.
355
+ w:
356
+ Template width in pixels.
357
+ h:
358
+ Template height in pixels.
359
+
360
+ Example
361
+ -------
362
+ >>> spacial.shape("badge", w=80, h=80)
363
+ """
364
+ if w <= 0 or h <= 0:
365
+ raise ValueError(f"Shape dimensions must be positive, got w={w}, h={h}")
366
+
367
+ _state.shapes[name] = {"w": w, "h": h, "elements": []}
368
+
369
+
370
+ def shape_add(
371
+ name: str,
372
+ primitive: str,
373
+ /,
374
+ *,
375
+ fill: Any = (255, 255, 255),
376
+ path: str | None = None,
377
+ # Circle params
378
+ cx: int | None = None,
379
+ cy: int | None = None,
380
+ r: int | None = None,
381
+ # Rectangle params
382
+ x0: int | None = None,
383
+ y0: int | None = None,
384
+ x1: int | None = None,
385
+ y1: int | None = None,
386
+ # Image params
387
+ x: int = 0,
388
+ y: int = 0,
389
+ **_kwargs: Any,
390
+ ) -> None:
391
+ """Add a primitive element to an existing shape template.
392
+
393
+ Parameters
394
+ ----------
395
+ name:
396
+ Name of the shape template to modify.
397
+ primitive:
398
+ One of ``"circle"``, ``"rectangle"``, ``"img"``.
399
+ fill:
400
+ Colour or fill specification (ignored for ``primitive="img"``).
401
+ path:
402
+ Path to an image file (required for ``primitive="img"``).
403
+ cx, cy, r:
404
+ Centre x, centre y, and radius for circles.
405
+ x0, y0, x1, y1:
406
+ Bounding-box corners for rectangles.
407
+ x, y:
408
+ Top-left position for pasted images.
409
+
410
+ Example
411
+ -------
412
+ >>> spacial.shape("badge", w=80, h=80)
413
+ >>> spacial.shape_add("badge", "circle", fill="#FF4500", cx=40, cy=40, r=38)
414
+ >>> spacial.shape_add("badge", "rectangle", fill=(0, 0, 0),
415
+ ... x0=20, y0=60, x1=60, y1=68)
416
+ """
417
+ if name not in _state.shapes:
418
+ raise KeyError(f"Shape {name!r} is not defined. Call spacial.shape() first.")
419
+ if primitive not in _SUPPORTED_SHAPE_PRIMITIVES:
420
+ raise ValueError(
421
+ f"primitive must be one of {sorted(_SUPPORTED_SHAPE_PRIMITIVES)!r}, "
422
+ f"got {primitive!r}"
423
+ )
424
+
425
+ element: dict[str, Any] = {"primitive": primitive, "fill": fill}
426
+
427
+ if primitive == "circle":
428
+ if any(v is None for v in (cx, cy, r)):
429
+ raise ValueError("circle requires cx, cy, and r")
430
+ element.update({"cx": cx, "cy": cy, "r": r})
431
+
432
+ elif primitive == "rectangle":
433
+ if any(v is None for v in (x0, y0, x1, y1)):
434
+ raise ValueError("rectangle requires x0, y0, x1, y1")
435
+ element.update({"x0": x0, "y0": y0, "x1": x1, "y1": y1})
436
+
437
+ elif primitive == "img":
438
+ if path is None:
439
+ raise ValueError('img primitive requires path')
440
+ element.update({"path": path, "x": x, "y": y})
441
+
442
+ _state.shapes[name]["element_type"] = primitive
443
+ _state.shapes[name]["elements"].append(element)
444
+
445
+
446
+ def _render_shape(shape_name: str) -> Image.Image:
447
+ """Rasterise a shape template into an RGBA Pillow image."""
448
+ tmpl = _state.shapes[shape_name]
449
+ w, h = tmpl["w"], tmpl["h"]
450
+ canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0))
451
+ draw = ImageDraw.Draw(canvas)
452
+
453
+ for elem in tmpl["elements"]:
454
+ prim = elem["primitive"]
455
+
456
+ if prim == "circle":
457
+ color = _parse_color(elem["fill"])
458
+ cx_v: int = elem["cx"]
459
+ cy_v: int = elem["cy"]
460
+ r_v: int = elem["r"]
461
+ bbox_coords = [cx_v - r_v, cy_v - r_v, cx_v + r_v, cy_v + r_v]
462
+ draw.ellipse(bbox_coords, fill=(*color, 255))
463
+
464
+ elif prim == "rectangle":
465
+ color = _parse_color(elem["fill"])
466
+ draw.rectangle(
467
+ [elem["x0"], elem["y0"], elem["x1"], elem["y1"]],
468
+ fill=(*color, 255),
469
+ )
470
+
471
+ elif prim == "img":
472
+ src = Image.open(elem["path"]).convert("RGBA")
473
+ canvas.paste(src, (elem["x"], elem["y"]), src)
474
+
475
+ return canvas
476
+
477
+
478
+ def _render_object(
479
+ obj_class: str,
480
+ obj_id: str,
481
+ x: int,
482
+ y: int,
483
+ **kwargs: Any,
484
+ ) -> tuple[Image.Image, tuple[int, int, int, int]]:
485
+ """Render one object and return (RGBA image, (x1, y1, x2, y2) bbox)."""
486
+ w, h = _state.width, _state.height
487
+
488
+ # ---- Named shape template -------------------------------------------
489
+ if obj_class in _state.shapes:
490
+ rendered = _render_shape(obj_class)
491
+ sw, sh = rendered.size
492
+ x2 = min(w, x + sw)
493
+ y2 = min(h, y + sh)
494
+ return rendered, (x, y, x2, y2)
495
+
496
+ # ---- Inline image ---------------------------------------------------
497
+ if obj_class == "img":
498
+ path = kwargs.get("path")
499
+ if path is None:
500
+ raise ValueError('append with class="img" requires path=...')
501
+ img_w = int(kwargs.get("w", 0))
502
+ img_h = int(kwargs.get("h", 0))
503
+ src = Image.open(path).convert("RGBA")
504
+ if img_w > 0 and img_h > 0:
505
+ src = src.resize((img_w, img_h))
506
+ sw, sh = src.size
507
+ x2 = min(w, x + sw)
508
+ y2 = min(h, y + sh)
509
+ return src, (x, y, x2, y2)
510
+
511
+ # ---- Fallback: coloured rectangle -----------------------------------
512
+ fill = kwargs.get("fill", (200, 200, 200))
513
+ obj_w = int(kwargs.get("w", 64))
514
+ obj_h = int(kwargs.get("h", 64))
515
+ color = _parse_color(fill)
516
+ rect = Image.new("RGBA", (obj_w, obj_h), (*color, 255))
517
+ x2 = min(w, x + obj_w)
518
+ y2 = min(h, y + obj_h)
519
+ return rect, (x, y, x2, y2)
520
+
521
+
522
+ def append(
523
+ obj_id: str,
524
+ obj_class: str,
525
+ /,
526
+ *,
527
+ x: int = 0,
528
+ y: int = 0,
529
+ **kwargs: Any,
530
+ ) -> None:
531
+ """Place an object on the current canvas and record its annotation.
532
+
533
+ Parameters
534
+ ----------
535
+ obj_id:
536
+ Unique identifier for this instance (used in annotation output).
537
+ obj_class:
538
+ Either a registered shape name, ``"img"``, or any label used as a
539
+ fallback placeholder rectangle.
540
+ x:
541
+ Left edge of the object in canvas pixels.
542
+ y:
543
+ Top edge of the object in canvas pixels.
544
+ **kwargs:
545
+ Extra arguments forwarded to the renderer (e.g. ``path=``, ``w=``,
546
+ ``h=``, ``fill=``).
547
+
548
+ Example
549
+ -------
550
+ >>> spacial.append("car_001", "car", x=100, y=200, fill="#E63946", w=120, h=60)
551
+ >>> spacial.append("logo", "img", path="logo.png", x=10, y=10)
552
+ """
553
+ rendered, (x1, y1, x2, y2) = _render_object(obj_class, obj_id, x, y, **kwargs)
554
+ _state.image.paste(rendered.convert("RGB"), (x1, y1), rendered)
555
+
556
+ # Build a simple binary mask for segmentation
557
+ mask_img = Image.new("L", (_state.width, _state.height), 0)
558
+ # Paste the alpha channel of the rendered object as a mask patch
559
+ alpha = rendered.split()[-1] if rendered.mode == "RGBA" else Image.new("L", rendered.size, 255)
560
+ mask_img.paste(alpha, (x1, y1))
561
+ mask_data = list(mask_img.getdata())
562
+
563
+ _state.objects.append(
564
+ {
565
+ "id": obj_id,
566
+ "class": obj_class,
567
+ "bbox": [x1, y1, x2, y2],
568
+ "mask": mask_data, # flat list, row-major, 0/255
569
+ "mask_size": (_state.width, _state.height),
570
+ }
571
+ )
572
+
573
+
574
+ def bbox(obj_id: str | None = None) -> list[dict[str, Any]]:
575
+ """Return bounding-box annotations for all (or one) object(s).
576
+
577
+ Parameters
578
+ ----------
579
+ obj_id:
580
+ When provided, returns annotations for that object only.
581
+ When ``None`` (default), returns annotations for every object.
582
+
583
+ Returns
584
+ -------
585
+ list[dict]:
586
+ Each entry has keys ``"id"``, ``"class"``, and
587
+ ``"bbox"`` ``[x1, y1, x2, y2]``.
588
+
589
+ Example
590
+ -------
591
+ >>> spacial.bbox()
592
+ [{'id': 'car_001', 'class': 'car', 'bbox': [100, 200, 220, 260]}]
593
+ """
594
+ objects = _state.objects if obj_id is None else [
595
+ o for o in _state.objects if o["id"] == obj_id
596
+ ]
597
+ return [
598
+ {"id": o["id"], "class": o["class"], "bbox": o["bbox"]}
599
+ for o in objects
600
+ ]
601
+
602
+
603
+ def seg(obj_id: str | None = None) -> list[dict[str, Any]]:
604
+ """Return segmentation annotations for all (or one) object(s).
605
+
606
+ The mask is returned as a flat list of values (0 or 255) in row-major
607
+ order, matching the full canvas size. Convert to a numpy array with
608
+ ``np.array(entry["mask"]).reshape(h, w)`` if needed.
609
+
610
+ Parameters
611
+ ----------
612
+ obj_id:
613
+ When provided, returns annotations for that object only.
614
+
615
+ Returns
616
+ -------
617
+ list[dict]:
618
+ Each entry has keys ``"id"``, ``"class"``, ``"bbox"``,
619
+ ``"mask"`` (flat list), and ``"mask_size"`` ``(w, h)``.
620
+
621
+ Example
622
+ -------
623
+ >>> entries = spacial.seg()
624
+ >>> entries[0]["mask_size"]
625
+ (1024, 1024)
626
+ """
627
+ objects = _state.objects if obj_id is None else [
628
+ o for o in _state.objects if o["id"] == obj_id
629
+ ]
630
+ return [
631
+ {
632
+ "id": o["id"],
633
+ "class": o["class"],
634
+ "bbox": o["bbox"],
635
+ "mask": o["mask"],
636
+ "mask_size": o["mask_size"],
637
+ }
638
+ for o in objects
639
+ ]
640
+
641
+
642
+ def save(path: str) -> None:
643
+ """Save the current canvas to disk.
644
+
645
+ Parameters
646
+ ----------
647
+ path:
648
+ Output file path. The format is inferred from the extension
649
+ (``".png"``, ``".jpg"``, ``".jpeg"``, ``".bmp"``, ``".webp"`` etc.)
650
+ via Pillow.
651
+
652
+ Example
653
+ -------
654
+ >>> spacial.save("dataset/frame_001.png")
655
+ """
656
+ _state.image.save(path)
657
+
658
+
659
+ def rm() -> None:
660
+ """Clear the current canvas and all annotations.
661
+
662
+ Resets the image to a black canvas of the current dimensions and
663
+ removes all placed objects. Shape templates are preserved.
664
+
665
+ Example
666
+ -------
667
+ >>> spacial.rm()
668
+ """
669
+ _state.reset()
@@ -0,0 +1,545 @@
1
+ Metadata-Version: 2.4
2
+ Name: spacial
3
+ Version: 0.1.0
4
+ Summary: A lightweight synthetic dataset image generation library built on top of Pillow.
5
+ Author: Spacial Contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/your-org/spacial
8
+ Project-URL: Documentation, https://github.com/your-org/spacial/blob/main/docs.md
9
+ Project-URL: Issues, https://github.com/your-org/spacial/issues
10
+ Keywords: synthetic data,image generation,dataset,bounding box,segmentation,pillow
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ Provides-Extra: dev
26
+
27
+ # Spacial
28
+
29
+ > Lightweight synthetic dataset image generation — powered by Pillow.
30
+
31
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
33
+ [![Version](https://img.shields.io/badge/version-0.1.0-orange.svg)]()
34
+
35
+ Spacial is a small, focused library for generating synthetic training images and their annotations. You describe what to put on a canvas — backgrounds, shapes, images — and Spacial gives you back pixel-perfect bounding boxes and segmentation masks in plain Python data structures. No frameworks. No hidden config files. No magic.
36
+
37
+ ---
38
+
39
+ ## Contents
40
+
41
+ - [Installation](#installation)
42
+ - [Quick Start](#quick-start)
43
+ - [Design Philosophy](#design-philosophy)
44
+ - [API Reference](#api-reference)
45
+ - [init](#init)
46
+ - [background](#background)
47
+ - [shape / shape_add](#shape--shape_add)
48
+ - [append](#append)
49
+ - [bbox](#bbox)
50
+ - [seg](#seg)
51
+ - [save](#save)
52
+ - [rm](#rm)
53
+ - [Fill System](#fill-system)
54
+ - [Examples](#examples)
55
+ - [Exporting Annotations](#exporting-annotations)
56
+ - [Future Roadmap](#future-roadmap)
57
+
58
+ ---
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install spacial
64
+ ```
65
+
66
+ Spacial requires **Python 3.9+** and **Pillow ≥ 10.0**.
67
+
68
+ ---
69
+
70
+ ## Quick Start
71
+
72
+ ```python
73
+ import spacial
74
+
75
+ # 1. Set up a 640×480 canvas
76
+ spacial.init(w=640, h=480)
77
+
78
+ # 2. Dark gradient background
79
+ spacial.background("gradient", fill={
80
+ "type": "gradient",
81
+ "start": "#1A1A2E",
82
+ "end": "#16213E",
83
+ "direction": "vertical",
84
+ })
85
+
86
+ # 3. Define a reusable shape template
87
+ spacial.shape("car", w=120, h=60)
88
+ spacial.shape_add("car", "rectangle", fill="#E63946", x0=0, y0=10, x1=120, y1=60)
89
+ spacial.shape_add("car", "rectangle", fill="#222222", x0=20, y0=0, x1=100, y1=20)
90
+
91
+ # 4. Place two cars on the canvas
92
+ spacial.append("car_001", "car", x=50, y=200)
93
+ spacial.append("car_002", "car", x=350, y=300)
94
+
95
+ # 5. Get annotations
96
+ print(spacial.bbox())
97
+ # [
98
+ # {"id": "car_001", "class": "car", "bbox": [50, 200, 170, 260]},
99
+ # {"id": "car_002", "class": "car", "bbox": [350, 300, 470, 360]},
100
+ # ]
101
+
102
+ # 6. Save and reset
103
+ spacial.save("frame_001.png")
104
+ spacial.rm()
105
+ ```
106
+
107
+ ---
108
+
109
+ ## Design Philosophy
110
+
111
+ Spacial is deliberately narrow. It does exactly three things:
112
+
113
+ 1. **Generate images** — backgrounds, shapes, composited objects.
114
+ 2. **Generate bounding box annotations** — pixel-aligned `[x1, y1, x2, y2]`.
115
+ 3. **Generate segmentation annotations** — per-pixel binary masks.
116
+
117
+ Everything else — writing COCO JSON, YOLO `.txt` files, Pascal VOC XML, training loops, data augmentation — is intentionally left to you. Spacial integrates cleanly with whatever export or training pipeline you already have, because it only returns standard Python lists and dicts.
118
+
119
+ **Guiding principles:**
120
+
121
+ - **Flat API.** Everything is a module-level function. No classes to instantiate, no context managers to juggle.
122
+ - **Minimal dependencies.** Only Pillow. No NumPy required (though masks are trivial to convert).
123
+ - **Beginner friendly.** If you can write `spacial.append(...)` and `spacial.save(...)`, you have a dataset.
124
+ - **Standard types.** Returns `list`, `dict`, and `tuple` — no custom objects to unwrap.
125
+ - **Both colour notations.** RGB tuples `(255, 0, 0)` and hex strings `"#FF0000"` work everywhere a colour is expected.
126
+
127
+ ---
128
+
129
+ ## API Reference
130
+
131
+ ### `init`
132
+
133
+ ```python
134
+ spacial.init(device="cpu", w=1024, h=1024)
135
+ ```
136
+
137
+ Initialise Spacial and create a blank canvas.
138
+
139
+ | Parameter | Type | Default | Description |
140
+ |-----------|-------|---------|------------------------------------------------------|
141
+ | `device` | `str` | `"cpu"` | Compute device: `"cpu"`, `"cuda"`, or `"mps"`. `cuda` and `mps` are reserved for future GPU acceleration and currently behave identically to `cpu`. |
142
+ | `w` | `int` | `1024` | Canvas width in pixels. |
143
+ | `h` | `int` | `1024` | Canvas height in pixels. |
144
+
145
+ ---
146
+
147
+ ### `background`
148
+
149
+ ```python
150
+ spacial.background(bg_type, *, fill=..., path=None, seed=None)
151
+ ```
152
+
153
+ Fill the entire canvas with a background.
154
+
155
+ | Parameter | Type | Description |
156
+ |-----------|------------------|----------------------------------------------------------|
157
+ | `bg_type` | `str` | `"color"`, `"gradient"`, `"noise"`, `"perlin"`, `"img"` |
158
+ | `fill` | colour or `dict` | Colour value or fill spec (see [Fill System](#fill-system)) |
159
+ | `path` | `str \| None` | Path to source image — required for `bg_type="img"`. |
160
+ | `seed` | `int \| None` | Random seed for reproducible noise/perlin backgrounds. |
161
+
162
+ **Examples:**
163
+
164
+ ```python
165
+ # Solid colour
166
+ spacial.background("color", fill=(30, 30, 30))
167
+ spacial.background("color", fill="#1A1A2E")
168
+
169
+ # Horizontal gradient
170
+ spacial.background("gradient", fill={
171
+ "type": "gradient",
172
+ "start": "#FF6B6B",
173
+ "end": "#4ECDC4",
174
+ "direction": "horizontal",
175
+ })
176
+
177
+ # Seeded noise
178
+ spacial.background("noise", fill={
179
+ "type": "noise",
180
+ "base": (100, 100, 100),
181
+ "scale": 0.4,
182
+ }, seed=42)
183
+
184
+ # Perlin-like noise
185
+ spacial.background("perlin", fill={
186
+ "type": "perlin",
187
+ "base": "#2C3E50",
188
+ "scale": 0.6,
189
+ "octaves": 5,
190
+ }, seed=7)
191
+
192
+ # From an existing image file
193
+ spacial.background("img", path="sky.jpg")
194
+ ```
195
+
196
+ ---
197
+
198
+ ### `shape` / `shape_add`
199
+
200
+ ```python
201
+ spacial.shape(name, *, w, h)
202
+ spacial.shape_add(name, primitive, *, fill=..., **params)
203
+ ```
204
+
205
+ Define a reusable shape template by stacking primitives.
206
+
207
+ **`shape`**
208
+
209
+ | Parameter | Type | Description |
210
+ |-----------|-------|--------------------------------|
211
+ | `name` | `str` | Unique template name. |
212
+ | `w` | `int` | Template width in pixels. |
213
+ | `h` | `int` | Template height in pixels. |
214
+
215
+ **`shape_add` — primitives**
216
+
217
+ | Primitive | Required params | Description |
218
+ |---------------|-----------------------------------------|-------------------------------------|
219
+ | `"circle"` | `cx`, `cy`, `r` | Circle with centre and radius. |
220
+ | `"rectangle"` | `x0`, `y0`, `x1`, `y1` | Axis-aligned rectangle. |
221
+ | `"img"` | `path`, optionally `x`, `y` | Paste an image at an offset. |
222
+
223
+ All primitives accept a `fill` parameter (colour or fill spec).
224
+
225
+ **Example:**
226
+
227
+ ```python
228
+ spacial.shape("traffic_light", w=40, h=100)
229
+ spacial.shape_add("traffic_light", "rectangle", fill="#222222", x0=0, y0=0, x1=40, y1=100)
230
+ spacial.shape_add("traffic_light", "circle", fill="#FF0000", cx=20, cy=20, r=14)
231
+ spacial.shape_add("traffic_light", "circle", fill="#FFA500", cx=20, cy=50, r=14)
232
+ spacial.shape_add("traffic_light", "circle", fill="#00CC00", cx=20, cy=80, r=14)
233
+ ```
234
+
235
+ ---
236
+
237
+ ### `append`
238
+
239
+ ```python
240
+ spacial.append(obj_id, obj_class, *, x=0, y=0, **kwargs)
241
+ ```
242
+
243
+ Place an object on the canvas and record its annotation.
244
+
245
+ | Parameter | Type | Description |
246
+ |-------------|-------|-----------------------------------------------------------------------------|
247
+ | `obj_id` | `str` | Unique instance ID used in annotation output. |
248
+ | `obj_class` | `str` | A registered shape name, `"img"`, or any free-form label. |
249
+ | `x` | `int` | Left edge of the object in canvas pixels. |
250
+ | `y` | `int` | Top edge of the object in canvas pixels. |
251
+ | `**kwargs` | | Forwarded to the renderer: `path`, `w`, `h`, `fill`, etc. |
252
+
253
+ **Class resolution order:**
254
+
255
+ 1. If `obj_class` matches a registered shape name → render that template.
256
+ 2. If `obj_class == "img"` → load the image at `path=`.
257
+ 3. Otherwise → render a placeholder rectangle using `fill=`, `w=`, `h=`.
258
+
259
+ **Examples:**
260
+
261
+ ```python
262
+ # Named shape
263
+ spacial.append("tl_north", "traffic_light", x=100, y=50)
264
+
265
+ # Inline image
266
+ spacial.append("sponsor_logo", "img", path="logo.png", x=20, y=20)
267
+
268
+ # Placeholder (useful for layout testing)
269
+ spacial.append("unknown_001", "unknown", x=300, y=150, fill="#AAAAAA", w=80, h=80)
270
+ ```
271
+
272
+ ---
273
+
274
+ ### `bbox`
275
+
276
+ ```python
277
+ spacial.bbox(obj_id=None) -> list[dict]
278
+ ```
279
+
280
+ Return bounding-box annotations.
281
+
282
+ ```python
283
+ [
284
+ {
285
+ "id": "car_001",
286
+ "class": "car",
287
+ "bbox": [x1, y1, x2, y2] # pixel coordinates, inclusive corners
288
+ },
289
+ ...
290
+ ]
291
+ ```
292
+
293
+ Pass `obj_id="car_001"` to retrieve a single object's annotation.
294
+
295
+ ---
296
+
297
+ ### `seg`
298
+
299
+ ```python
300
+ spacial.seg(obj_id=None) -> list[dict]
301
+ ```
302
+
303
+ Return segmentation annotations.
304
+
305
+ ```python
306
+ [
307
+ {
308
+ "id": "car_001",
309
+ "class": "car",
310
+ "bbox": [x1, y1, x2, y2],
311
+ "mask": [...], # flat list, len == w * h, values 0 or 255
312
+ "mask_size": (w, h)
313
+ },
314
+ ...
315
+ ]
316
+ ```
317
+
318
+ **Converting the mask to a NumPy array** (NumPy is not a Spacial dependency, but it is easy to integrate):
319
+
320
+ ```python
321
+ import numpy as np
322
+
323
+ entries = spacial.seg()
324
+ w, h = entries[0]["mask_size"]
325
+ mask = np.array(entries[0]["mask"], dtype=np.uint8).reshape(h, w)
326
+ ```
327
+
328
+ ---
329
+
330
+ ### `save`
331
+
332
+ ```python
333
+ spacial.save(path)
334
+ ```
335
+
336
+ Save the current canvas to disk. The file format is inferred from the extension by Pillow (`.png`, `.jpg`, `.bmp`, `.webp`, etc.).
337
+
338
+ ```python
339
+ spacial.save("output/frame_042.png")
340
+ ```
341
+
342
+ ---
343
+
344
+ ### `rm`
345
+
346
+ ```python
347
+ spacial.rm()
348
+ ```
349
+
350
+ Clear the canvas to black and remove all placed objects. Shape templates are kept so you can reuse them in the next frame.
351
+
352
+ ---
353
+
354
+ ## Fill System
355
+
356
+ Wherever a `fill` parameter is accepted, Spacial understands two notations:
357
+
358
+ **Solid colour**
359
+
360
+ ```python
361
+ fill=(255, 99, 71) # RGB tuple
362
+ fill="#FF6347" # hex string (with or without #)
363
+ ```
364
+
365
+ **Gradient**
366
+
367
+ ```python
368
+ fill={
369
+ "type": "gradient",
370
+ "start": "#FF6B6B", # any colour value
371
+ "end": "#4ECDC4",
372
+ "direction": "horizontal", # or "vertical"
373
+ }
374
+ ```
375
+
376
+ **Noise** (uniform random per-pixel offsets from a base colour)
377
+
378
+ ```python
379
+ fill={
380
+ "type": "noise",
381
+ "base": (128, 128, 128),
382
+ "scale": 0.5, # 0.0 → no noise, 1.0 → maximum noise
383
+ }
384
+ ```
385
+
386
+ **Perlin** (layered smooth noise — good for terrain-like backgrounds)
387
+
388
+ ```python
389
+ fill={
390
+ "type": "perlin",
391
+ "base": "#2C3E50",
392
+ "scale": 0.6,
393
+ "octaves": 4, # more octaves → more detail
394
+ }
395
+ ```
396
+
397
+ ---
398
+
399
+ ## Examples
400
+
401
+ ### Minimal YOLO-style loop
402
+
403
+ ```python
404
+ import json
405
+ import spacial
406
+
407
+ spacial.init(w=416, h=416)
408
+
409
+ dataset = []
410
+ for i in range(100):
411
+ spacial.rm()
412
+ spacial.background("noise", fill={"type": "noise", "base": (80, 80, 80), "scale": 0.3}, seed=i)
413
+
414
+ spacial.shape("ball", w=32, h=32)
415
+ spacial.shape_add("ball", "circle", fill="#F72585", cx=16, cy=16, r=15)
416
+
417
+ x, y = i * 3 % 380, i * 7 % 380
418
+ spacial.append(f"ball_{i:04d}", "ball", x=x, y=y)
419
+
420
+ boxes = spacial.bbox()
421
+ spacial.save(f"images/frame_{i:04d}.png")
422
+ dataset.append({"frame": i, "annotations": boxes})
423
+
424
+ with open("annotations.json", "w") as f:
425
+ json.dump(dataset, f, indent=2)
426
+ ```
427
+
428
+ ### Multi-class scene
429
+
430
+ ```python
431
+ import spacial
432
+
433
+ spacial.init(w=800, h=600)
434
+ spacial.background("perlin", fill={
435
+ "type": "perlin",
436
+ "base": (34, 85, 34),
437
+ "scale": 0.5,
438
+ "octaves": 5,
439
+ }, seed=99)
440
+
441
+ # Define classes
442
+ spacial.shape("vehicle", w=100, h=50)
443
+ spacial.shape_add("vehicle", "rectangle", fill="#264653", x0=0, y0=10, x1=100, y1=50)
444
+ spacial.shape_add("vehicle", "rectangle", fill="#2A9D8F", x0=15, y0=0, x1=85, y1=20)
445
+
446
+ spacial.shape("pedestrian", w=20, h=50)
447
+ spacial.shape_add("pedestrian", "rectangle", fill="#E9C46A", x0=6, y0=0, x1=14, y1=12) # head
448
+ spacial.shape_add("pedestrian", "rectangle", fill="#F4A261", x0=4, y0=12, x1=16, y1=50) # body
449
+
450
+ # Populate scene
451
+ spacial.append("v_001", "vehicle", x=50, y=280)
452
+ spacial.append("v_002", "vehicle", x=400, y=320)
453
+ spacial.append("p_001", "pedestrian", x=250, y=260)
454
+ spacial.append("p_002", "pedestrian", x=310, y=270)
455
+ spacial.append("p_003", "pedestrian", x=600, y=290)
456
+
457
+ print(spacial.bbox())
458
+ spacial.save("scene.png")
459
+ ```
460
+
461
+ ### Pasting real images with segmentation
462
+
463
+ ```python
464
+ import spacial
465
+
466
+ spacial.init(w=512, h=512)
467
+ spacial.background("color", fill="#F0F0F0")
468
+
469
+ spacial.append("product_01", "img", path="product.png", x=128, y=128)
470
+
471
+ for entry in spacial.seg():
472
+ w, h = entry["mask_size"]
473
+ total = w * h
474
+ hit = sum(1 for v in entry["mask"] if v > 0)
475
+ print(f'{entry["id"]} covers {hit/total:.1%} of the canvas')
476
+
477
+ spacial.save("product_scene.png")
478
+ ```
479
+
480
+ ---
481
+
482
+ ## Exporting Annotations
483
+
484
+ Spacial returns plain Python dicts so you can convert to any format you need:
485
+
486
+ **YOLO `.txt`**
487
+
488
+ ```python
489
+ boxes = spacial.bbox()
490
+ W, H = 640, 480
491
+
492
+ with open("labels/frame_001.txt", "w") as f:
493
+ class_map = {"car": 0, "pedestrian": 1}
494
+ for obj in boxes:
495
+ x1, y1, x2, y2 = obj["bbox"]
496
+ cx = ((x1 + x2) / 2) / W
497
+ cy = ((y1 + y2) / 2) / H
498
+ bw = (x2 - x1) / W
499
+ bh = (y2 - y1) / H
500
+ cls = class_map.get(obj["class"], 0)
501
+ f.write(f"{cls} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n")
502
+ ```
503
+
504
+ **COCO-style JSON snippet**
505
+
506
+ ```python
507
+ boxes = spacial.bbox()
508
+ coco_annotations = [
509
+ {
510
+ "id": i,
511
+ "image_id": 42,
512
+ "category_id": 1,
513
+ "bbox": [b["bbox"][0], b["bbox"][1],
514
+ b["bbox"][2] - b["bbox"][0],
515
+ b["bbox"][3] - b["bbox"][1]],
516
+ "area": (b["bbox"][2] - b["bbox"][0]) * (b["bbox"][3] - b["bbox"][1]),
517
+ "iscrowd": 0,
518
+ }
519
+ for i, b in enumerate(boxes)
520
+ ]
521
+ ```
522
+
523
+ ---
524
+
525
+ ## Future Roadmap
526
+
527
+ Spacial is intentionally minimal today. Planned additions in future releases:
528
+
529
+ - **Shape nesting** — embed one named shape inside another to build hierarchical objects.
530
+ - **Transforms** — per-object rotation, scaling, and opacity.
531
+ - **GPU acceleration** — real CUDA/MPS paths for faster noise generation at high resolutions.
532
+ - **Z-ordering** — explicit depth control for overlapping objects.
533
+ - **Polygon segmentation** — return polygon contours in addition to binary masks.
534
+ - **Single-object annotation queries** — `spacial.bbox("car_001")` already supported; will expand.
535
+ - **Text primitive** — render text labels directly onto shapes.
536
+ - **Physics-based placement** — non-overlapping random placement helpers.
537
+ - **Built-in augmentations** — optional blur, brightness jitter, and crop directly in Spacial.
538
+
539
+ Spacial will never grow into a training framework or annotation exporter. Those concerns belong in your pipeline, not ours.
540
+
541
+ ---
542
+
543
+ ## License
544
+
545
+ MIT © Spacial Contributors
@@ -0,0 +1,5 @@
1
+ spacial/__init__.py,sha256=JxCa5VGqmoZJMCzMliuJNIJ1N9_WqlielfmTlwuf4us,21539
2
+ spacial-0.1.0.dist-info/METADATA,sha256=7UuV1clizdMmkC5J_MXpPBiu0I2jf1GBB8nVg8rzAkg,15848
3
+ spacial-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ spacial-0.1.0.dist-info/top_level.txt,sha256=eD6G_kDarPdiV4xK5YBgHlZQu6WnEx1bvQvSt-ohfrc,8
5
+ spacial-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ spacial