Vishwasaidev commited on
Commit
4e1884c
·
verified ·
1 Parent(s): 0abef32

Cartoon characters, expressions, props, outlines, story mode

Browse files
Files changed (7) hide show
  1. .gitattributes +1 -0
  2. README.md +43 -23
  3. demo_sheet.png +2 -2
  4. export_weights.py +83 -78
  5. handweights.py +403 -101
  6. story_sheet.png +3 -0
  7. weights.safetensors +2 -2
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  demo_sheet.png filter=lfs diff=lfs merge=lfs -text
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  demo_sheet.png filter=lfs diff=lfs merge=lfs -text
37
+ story_sheet.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -7,15 +7,21 @@ tags:
7
  - no-training
8
  - interpretability
9
  - toy-model
 
10
  ---
11
 
12
- # Hand Weights: a text-to-image model with no training
13
 
14
- Every one of this model's **4,071 parameters was written by hand** or derived from geometry.
15
- There is no dataset, no training run and no gradient descent. You can open the weights and
16
- say what each number does: row 12 of a plane tensor is literally the left edge of the triangle.
17
 
18
- **Try it:** [Space: Vishwasaidev/hand-weights](https://huggingface.co/spaces/Vishwasaidev/hand-weights)
 
 
 
 
 
19
 
20
  ![demo](demo_sheet.png)
21
 
@@ -23,22 +29,30 @@ say what each number does: row 12 of a plane tensor is literally the left edge o
23
 
24
  | Stage | What it is | Weights |
25
  |---|---|---|
26
- | Embedding | 83 words → 32-dim vectors with slots for shape, colour, position, size, sky, ground | `encoder.embedding` (83×32) |
27
- | Binder | gated recurrence: adjectives carry forward to the next object token, position phrases ("on the left") bind back to the last object | masks, no free weights |
28
  | Slot decoder | linear layers that fall back to per-shape default colour / size / height | `decoder.default_*` |
29
- | Renderer, layer 1 | half-plane neurons per pixel: `sigmoid(s·(d − nx·x − ny·y))` | `renderer.<shape>.part<i>…` rows of `(nx, ny, d)` |
30
- | Renderer, layer 2 | AND / OR / AND-NOT neurons that compose half-planes into circles (32 edges), stars (pentagon + 5 triangles), hearts (2 circles + triangle), moons (circle minus circle) | structure in metadata |
31
- | Renderer, layer 3 | linear shading neuron (light from top-left) + alpha-compositing gate | constants |
 
 
 
 
 
 
 
32
 
33
- The 4,020 numbers in `weights.safetensors` are the tensors; the remaining 51 are per-part colours stored in the
34
- `renderer_structure` metadata. Skies, water, grass and stars are hand-written formulas in the code, not neurons.
35
 
36
  ## Usage
37
 
38
  ```bash
39
- python handweights.py "a white moon and a gold star in a night sky" # -> out/<prompt>.png
 
40
  python handweights.py --demo
41
- python export_weights.py # regenerates weights.safetensors
42
  ```
43
 
44
  Requires only `numpy` and `pillow`. `handweights.py` is the source of truth; the safetensors file is an
@@ -46,19 +60,25 @@ inspectable dump of the same numbers (vocab, embedding layout and renderer struc
46
 
47
  ## Vocabulary
48
 
49
- - **Objects:** circle, square, triangle, star, moon, heart, house, tree, sun, cloud (+ synonyms like ball, box, pyramid, home)
50
- - **Colours:** red, green, blue, yellow, orange, purple, pink, white, black, brown, gray, cyan, gold, silver
51
- - **Positions:** left, right, top/above, bottom/below, center · **Sizes:** tiny, small, medium, big, huge
52
- - **Scenes:** day, sky, night, sunset, space · sea, grass, desert, snow (+ synonyms)
 
 
 
53
 
54
  ## Limitations
55
 
56
- - 83 known words; anything else is ignored (the app shows which).
57
- - No counting ("two clouds" draws one), no relations ("a sun above a house" just places both).
58
- - It can't learn or generalise. That's the trade-off: hand-writing weights only works when you can
59
- design what each neuron does, which only stays practical for a model this small and a task this structured.
 
60
 
61
  ## Why
62
 
63
  A response to claims that you can "set weights on a new model without training". You can, if the model
64
- is small and the task is designed so each neuron has a nameable job. This is what that looks like.
 
 
 
7
  - no-training
8
  - interpretability
9
  - toy-model
10
+ - childrens-books
11
  ---
12
 
13
+ # Hand Weights: a picture-book model with no training
14
 
15
+ Every one of this model's **27,875 parameters was written by hand** or derived from geometry.
16
+ There is no dataset, no training run and no gradient descent. You can open the weights and say what
17
+ each number does: one row of a plane tensor is literally the left edge of the cat's ear.
18
 
19
+ It draws simple children's-book scenes from a sentence, and turns a short story into one page per sentence,
20
+ keeping each character the same colour from page to page.
21
+
22
+ **Try it:** [Space: Vishwasaidev/hand-weights](https://huggingface.co/spaces/Vishwasaidev/hand-weights) (runs in your browser)
23
+
24
+ ![story](story_sheet.png)
25
 
26
  ![demo](demo_sheet.png)
27
 
 
29
 
30
  | Stage | What it is | Weights |
31
  |---|---|---|
32
+ | Embedding | 153 words → 46-dim vectors with slots for shape, pronoun, colour, position, size, expression, copula, conjunction, sky, ground | `encoder.embedding` (153×46) |
33
+ | Binder | gated recurrence with two registers: adjectives carry forward to the next object ("a **sleepy** cat"), position phrases bind back ("a cat **on the left**"), and after a copula adjectives bind back too ("the cat **was sleepy**") | masks, no free weights |
34
  | Slot decoder | linear layers that fall back to per-shape default colour / size / height | `decoder.default_*` |
35
+ | Layout | spreads unplaced objects; a prop that follows a character is held by it (grip point on the prop → hand or foot anchor on the character) | `layout.prop_grip_scale`, `layout.hand` |
36
+ | Renderer, layer 1 | half-plane neurons per pixel: `s·(d − nx·x − ny·y)` | `renderer.<shape>.part<i>…` rows of `(nx, ny, d)` |
37
+ | Renderer, layer 2 | min-pool over a leaf's half-planes (exact AND at any size), then OR / AND-NOT / AND neurons compose leaves: circles and ellipses (32 tangent planes), a 5-point star (pentagon + 5 triangles), smiles (ring ∧ half-plane) | structure in metadata |
38
+ | Renderer, layer 3 | sigmoid, linear shading neuron, alpha-compositing gate. **Ink outlines** are the same shape with every bias `d` grown by ~2.4 px, drawn first in ink | constants |
39
+
40
+ Characters (cat, bunny, bear, kid) are part stacks plus one of 20 face stacks (`renderer.face.<character>.<expression>`):
41
+ happy, sad (frown, brows, tear), sleepy (closed-arc eyes and a Z), surprised, angry.
42
+
43
+ The 27,188 numbers in `weights.safetensors` are the tensors; the rest are per-part colours and flags stored in the
44
+ `renderer_structure` metadata. Skies, rain, water, grass and stars are hand-written formulas in the code, not neurons.
45
 
46
+ **Story mode is plain code, not weights:** it splits sentences, resolves he/she/it/they to the previous page's
47
+ characters, remembers each character's colour, and carries the scene forward when a sentence doesn't name one.
48
 
49
  ## Usage
50
 
51
  ```bash
52
+ python handweights.py "a sleepy orange cat with a red balloon on the grass" # -> out/<prompt>.png
53
+ python handweights.py --story "The bunny was happy. She found a pink flower. They were sleepy at night."
54
  python handweights.py --demo
55
+ python export_weights.py # regenerates weights.safetensors
56
  ```
57
 
58
  Requires only `numpy` and `pillow`. `handweights.py` is the source of truth; the safetensors file is an
 
60
 
61
  ## Vocabulary
62
 
63
+ - **Characters:** cat, bunny, bear, kid (+ kitten, rabbit, teddy, child, boy, girl)
64
+ - **Feelings:** happy, sad, sleepy, surprised, angry (+ synonyms like tired, grumpy, crying, shocked)
65
+ - **Props they hold:** balloon, ball, flower, umbrella
66
+ - **Things:** sun, moon, star, cloud, house, tree, heart, circle, square, triangle
67
+ - **Colours:** red, orange, yellow, green, blue, purple, pink, white, black, brown, gray, cyan, gold, silver
68
+ - **Positions / sizes:** left, right, top, bottom, center · tiny, small, medium, big, huge
69
+ - **Scenes:** day, night, sunset, rain, space · grass, sea, desert, snow (+ synonyms like meadow, beach, winter)
70
 
71
  ## Limitations
72
 
73
+ - 153 known words; anything else is ignored (the app shows which).
74
+ - No counting ("two cats" draws one), no actions or poses beyond standing and holding, no relations
75
+ ("the cat sat under the tree" just places both). Every character faces forward.
76
+ - "It started to rain" resolves "it" to the last character. That's harmless here, but it shows the pronoun rule is naive.
77
+ - It can't learn or generalise. Every new character is roughly an hour of hand geometry.
78
 
79
  ## Why
80
 
81
  A response to claims that you can "set weights on a new model without training". You can, if the model
82
+ is small and the task is designed so each neuron has a nameable job. This is what that looks like, and the
83
+ trade-off is also its strength for children's books: the same character looks identical on every page,
84
+ and it can only ever draw what is in its parts list.
demo_sheet.png CHANGED

Git LFS Details

  • SHA256: e0626d0761a43f2c9e7774f32fd2ffd95c69e28bfe0ef7bcae92adf2fe1e5ae0
  • Pointer size: 131 Bytes
  • Size of remote file: 204 kB

Git LFS Details

  • SHA256: 45abe45fb6e36e77a75a7656cbea74e45a414a68be2b7739727441f52c13b4c7
  • Pointer size: 131 Bytes
  • Size of remote file: 276 kB
export_weights.py CHANGED
@@ -1,78 +1,83 @@
1
- """Dump every hand-written number in handweights.py to weights.safetensors.
2
-
3
- The safetensors format is an 8-byte little-endian header length, a JSON header,
4
- then raw tensor bytes, so it is written here with numpy alone.
5
- """
6
- import json
7
- import struct
8
- from pathlib import Path
9
-
10
- import numpy as np
11
-
12
- from handweights import DEF_COLOR, DEF_SIZE, DEF_Y, DIM, PARTS, SHAPES, VOCAB, count_params
13
-
14
- LAYOUT = {"shape_onehot": [0, len(SHAPES)], "is_object": len(SHAPES), "rgb_has_color": [len(SHAPES) + 1, len(SHAPES) + 5],
15
- "dx_dy_has_x_has_y": [len(SHAPES) + 5, len(SHAPES) + 9], "size_has_size": [len(SHAPES) + 9, len(SHAPES) + 11],
16
- "sky_onehot(day,night,sunset,space)": [len(SHAPES) + 11, len(SHAPES) + 15],
17
- "ground_onehot(sea,grass,desert,snow)": [len(SHAPES) + 15, DIM]}
18
-
19
-
20
- def leaves(g, name, out):
21
- """Flatten an AND/OR/MINUS tree into named plane tensors plus a structure string."""
22
- op, arg = g
23
- if op == "and":
24
- out[name] = arg
25
- return name
26
- return f"{op}(" + ", ".join(leaves(c, f"{name}.{i}", out) for i, c in enumerate(arg)) + ")"
27
-
28
-
29
- def collect():
30
- words = list(VOCAB)
31
- tensors = {
32
- "encoder.embedding": np.stack([VOCAB[w] for w in words]),
33
- "decoder.default_color": DEF_COLOR,
34
- "decoder.default_size": DEF_SIZE,
35
- "decoder.default_y": np.array([np.nan if y is None else y for y in DEF_Y], float),
36
- }
37
- structure = {}
38
- for shape, parts in PARTS.items():
39
- for i, (geom, col, alpha, shaded) in enumerate(parts):
40
- key = f"renderer.{shape}.part{i}"
41
- structure[key] = dict(geometry=leaves(geom, key, tensors), color=col if isinstance(col, str) else list(col),
42
- alpha=alpha, shaded=shaded)
43
- meta = {"vocab": json.dumps(words), "shapes": json.dumps(SHAPES), "embedding_layout": json.dumps(LAYOUT),
44
- "renderer_structure": json.dumps(structure),
45
- "plane_rows": "each row (nx, ny, d) is a half-plane neuron firing where nx*px + ny*py <= d",
46
- "parameter_count": str(count_params()), "trained": "false"}
47
- return tensors, meta
48
-
49
-
50
- def save_safetensors(tensors, meta, path):
51
- header, blobs, offset = {"__metadata__": meta}, [], 0
52
- for name, t in tensors.items():
53
- b = np.ascontiguousarray(t, dtype="<f4").tobytes()
54
- header[name] = {"dtype": "F32", "shape": list(t.shape), "data_offsets": [offset, offset + len(b)]}
55
- blobs.append(b)
56
- offset += len(b)
57
- h = json.dumps(header, separators=(",", ":")).encode()
58
- h += b" " * (-len(h) % 8)
59
- Path(path).write_bytes(struct.pack("<Q", len(h)) + h + b"".join(blobs))
60
-
61
-
62
- def load_safetensors(path):
63
- raw = Path(path).read_bytes()
64
- n = struct.unpack("<Q", raw[:8])[0]
65
- header = json.loads(raw[8:8 + n])
66
- meta = header.pop("__metadata__")
67
- data = raw[8 + n:]
68
- return {k: np.frombuffer(data[v["data_offsets"][0]:v["data_offsets"][1]], "<f4").reshape(v["shape"])
69
- for k, v in header.items()}, meta
70
-
71
-
72
- if __name__ == "__main__":
73
- out = Path(__file__).parent / "weights.safetensors"
74
- tensors, meta = collect()
75
- save_safetensors(tensors, meta, out)
76
- back, _ = load_safetensors(out)
77
- assert all(np.allclose(back[k], np.asarray(v, "<f4"), equal_nan=True) for k, v in tensors.items())
78
- print(f"{out.name}: {len(tensors)} tensors, {sum(t.size for t in tensors.values()):,} numbers, round-trip ok")
 
 
 
 
 
 
1
+ """Dump every hand-written number in handweights.py to weights.safetensors.
2
+
3
+ The safetensors format is an 8-byte little-endian header length, a JSON header,
4
+ then raw tensor bytes, so it is written here with numpy alone.
5
+ """
6
+ import json
7
+ import struct
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+
12
+ from handweights import (CLR, COL, DEF_COLOR, DEF_SIZE, DEF_Y, DIM, EXP, EXPRS, FACES, GND, GROUNDS, HAND, OBJ, POS,
13
+ PARTS, PRED, PROPS, REF, SHAPES, SIZ, SKIES, SKY, VOCAB, count_params)
14
+
15
+ LAYOUT = {"shape_onehot": [0, OBJ], "is_object": OBJ, "pronoun(1=singular,2=plural)": REF,
16
+ "rgb_has_color": [COL, COL + 4], "dx_dy_has_x_has_y": [POS, POS + 4], "size_has_size": [SIZ, SIZ + 2],
17
+ f"expression_onehot({','.join(EXPRS)})": [EXP, PRED], "copula": PRED, "conjunction": CLR,
18
+ f"sky_onehot({','.join(SKIES)})": [SKY, GND], f"ground_onehot({','.join(GROUNDS)})": [GND, DIM]}
19
+
20
+
21
+ def leaves(g, name, out):
22
+ """Flatten an AND/OR/MINUS tree into named plane tensors plus a structure string."""
23
+ op, arg = g
24
+ if op == "and":
25
+ out[name] = arg
26
+ return name
27
+ return f"{op}(" + ", ".join(leaves(c, f"{name}.{i}", out) for i, c in enumerate(arg)) + ")"
28
+
29
+
30
+ def collect():
31
+ words = list(VOCAB)
32
+ tensors = {
33
+ "encoder.embedding": np.stack([VOCAB[w] for w in words]),
34
+ "decoder.default_color": DEF_COLOR,
35
+ "decoder.default_size": DEF_SIZE,
36
+ "decoder.default_y": np.array([y if isinstance(y, float) else np.nan for y in DEF_Y], float),
37
+ "layout.prop_grip_scale": np.array([[*g, sc] for _, g, sc in PROPS.values()], float),
38
+ "layout.hand": np.array(list(HAND.values()), float),
39
+ }
40
+ structure = {}
41
+ groups = list(PARTS.items()) + [(f"face.{k}.{e}", parts) for (k, e), parts in sorted(FACES.items())]
42
+ for name, parts in groups:
43
+ for i, (geom, col, alpha, shaded, outline) in enumerate(parts):
44
+ key = f"renderer.{name}.part{i}"
45
+ structure[key] = dict(geometry=leaves(geom, key, tensors), color=col if isinstance(col, str) else list(col),
46
+ alpha=alpha, shaded=shaded, outline=outline)
47
+ meta = {"vocab": json.dumps(words), "shapes": json.dumps(SHAPES), "embedding_layout": json.dumps(LAYOUT),
48
+ "props": json.dumps(list(PROPS)), "default_y_special": "NaN = stands on the ground line",
49
+ "renderer_structure": json.dumps(structure),
50
+ "plane_rows": "each row (nx, ny, d) is a half-plane neuron firing where nx*px + ny*py <= d; a leaf = min-pool over its rows",
51
+ "parameter_count": str(count_params()), "trained": "false"}
52
+ return tensors, meta
53
+
54
+
55
+ def save_safetensors(tensors, meta, path):
56
+ header, blobs, offset = {"__metadata__": meta}, [], 0
57
+ for name, t in tensors.items():
58
+ b = np.ascontiguousarray(t, dtype="<f4").tobytes()
59
+ header[name] = {"dtype": "F32", "shape": list(t.shape), "data_offsets": [offset, offset + len(b)]}
60
+ blobs.append(b)
61
+ offset += len(b)
62
+ h = json.dumps(header, separators=(",", ":")).encode()
63
+ h += b" " * (-len(h) % 8)
64
+ Path(path).write_bytes(struct.pack("<Q", len(h)) + h + b"".join(blobs))
65
+
66
+
67
+ def load_safetensors(path):
68
+ raw = Path(path).read_bytes()
69
+ n = struct.unpack("<Q", raw[:8])[0]
70
+ header = json.loads(raw[8:8 + n])
71
+ meta = header.pop("__metadata__")
72
+ data = raw[8 + n:]
73
+ return {k: np.frombuffer(data[v["data_offsets"][0]:v["data_offsets"][1]], "<f4").reshape(v["shape"])
74
+ for k, v in header.items()}, meta
75
+
76
+
77
+ if __name__ == "__main__":
78
+ out = Path(__file__).parent / "weights.safetensors"
79
+ tensors, meta = collect()
80
+ save_safetensors(tensors, meta, out)
81
+ back, _ = load_safetensors(out)
82
+ assert all(np.allclose(back[k], np.asarray(v, "<f4"), equal_nan=True) for k, v in tensors.items())
83
+ print(f"{out.name}: {len(tensors)} tensors, {sum(t.size for t in tensors.values()):,} numbers, round-trip ok")
handweights.py CHANGED
@@ -3,39 +3,49 @@
3
  Every weight in this file was written by hand or derived from geometry.
4
  No dataset, no training, no gradients.
5
 
6
- python handweights.py "a red heart on the left and a yellow star in a night sky"
7
  python handweights.py --demo
 
8
 
9
  Architecture
10
- 1. Embedding table word -> 32-dim vector (shape / colour / position / size / scene slots)
11
- 2. Gated recurrent binder adjectives carry forward into the next object token,
12
- position phrases ("on the left") bind back to the last object
13
- 3. Slot decoder linear layers with default-colour / default-size matrices
14
- 4. Per-pixel renderer layer 1: half-plane neurons (w = edge normal, b = edge offset)
15
- layer 2: AND / OR / AND-NOT neurons that build shapes from half-planes
16
- layer 3: shading neuron + alpha-compositing gate
 
 
17
  """
18
  import re
19
  import sys
20
  from pathlib import Path
21
 
22
  import numpy as np
23
- from PIL import Image, ImageDraw
24
 
25
  RES = 384
26
  OUT = Path(__file__).parent / "out"
27
 
28
  # ------------------------------------------------------------------ embedding table
29
- SHAPES = ["circle", "square", "triangle", "star", "moon", "heart", "house", "tree", "sun", "cloud"]
30
- SKIES = ["day", "night", "sunset", "space"]
 
 
 
31
  GROUNDS = ["sea", "grass", "desert", "snow"]
32
 
33
  S0 = 0 # shape one-hot
34
  OBJ = S0 + len(SHAPES) # gate: "this token is an object"
35
- COL = OBJ + 1 # r, g, b, has_color
 
36
  POS = COL + 4 # dx, dy, has_x, has_y
37
  SIZ = POS + 4 # size, has_size
38
- SKY = SIZ + 2 # sky one-hot
 
 
 
39
  GND = SKY + len(SKIES) # ground one-hot
40
  DIM = GND + len(GROUNDS)
41
 
@@ -52,9 +62,13 @@ def word(names, entries):
52
 
53
  for i, s in enumerate(SHAPES):
54
  word(s, [(S0 + i, 1), (OBJ, 1)])
55
- for syn, s in [("ball dot planet", "circle"), ("box cube block", "square"), ("pyramid", "triangle"),
56
- ("home cottage hut", "house"), ("love", "heart"), ("bush", "tree")]:
 
 
57
  word(syn, [(S0 + SHAPES.index(s), 1), (OBJ, 1)])
 
 
58
 
59
  COLORS = {
60
  "red": (0.86, 0.16, 0.16), "green": (0.2, 0.62, 0.25), "blue": (0.2, 0.38, 0.85),
@@ -66,7 +80,7 @@ COLORS = {
66
  for name, (r, g, b) in COLORS.items():
67
  word(name, [(COL, r), (COL + 1, g), (COL + 2, b), (COL + 3, 1)])
68
 
69
- for names, axis, val in [("left", 0, -0.5), ("right", 1 - 1, 0.5), ("top upper above", 1, -0.5),
70
  ("bottom lower below", 1, 0.5)]:
71
  word(names, [(POS + axis, val), (POS + 2 + axis, 1)])
72
  word("center centre middle", [(POS + 2, 1), (POS + 3, 1)])
@@ -75,19 +89,27 @@ for names, s in [("tiny", 0.1), ("small little", 0.15), ("medium", 0.22), ("big
75
  ("huge giant enormous", 0.42)]:
76
  word(names, [(SIZ, s), (SIZ + 1, 1)])
77
 
 
 
 
 
 
 
 
78
  word("day sunny morning", [(SKY + 0, 1)])
79
  word("sky", [(SKY + 0, 0.5)]) # "sky" leans day but loses to "night sky"
80
  word("night starry", [(SKY + 1, 1)])
81
  word("sunset dusk evening", [(SKY + 2, 1)])
82
  word("space", [(SKY + 3, 1)])
 
83
  word("sea ocean water lake beach", [(GND + 0, 1)])
84
  word("grass field meadow park garden", [(GND + 1, 1)])
85
  word("desert sand dunes", [(GND + 2, 1)])
86
  word("snow snowy winter", [(GND + 3, 1)])
87
 
88
- FWD = np.zeros(DIM); FWD[[COL, COL + 1, COL + 2, COL + 3, SIZ, SIZ + 1]] = 1 # carried to next object
89
- BACK = np.zeros(DIM); BACK[POS:POS + 4] = 1 # bound to last object
90
- SCN = np.zeros(DIM); SCN[SKY:] = 1 # pooled scene vector
91
 
92
 
93
  def lookup(t):
@@ -101,6 +123,7 @@ def lookup(t):
101
  # ------------------------------------------------------------------ gated recurrent binder
102
  def encode(prompt):
103
  pend = np.zeros(DIM) # adjectives waiting for an object
 
104
  slots, scene, unknown = [], np.zeros(DIM), []
105
  for t in re.findall(r"[a-z]+", prompt.lower()):
106
  e = lookup(t)
@@ -108,27 +131,40 @@ def encode(prompt):
108
  unknown.append(t)
109
  continue
110
  g = e[OBJ]
 
111
  has_last = 1.0 if slots else 0.0
 
112
  if g:
113
  slots.append(pend + e)
114
  elif has_last:
115
- slots[-1] = slots[-1] + BACK * e
116
- pend = (1 - g) * (pend + FWD * e + (1 - has_last) * BACK * e)
117
  scene += SCN * e
118
  return slots, scene, unknown
119
 
120
 
121
  # ------------------------------------------------------------------ slot decoder weights
122
- # circle square tri star moon heart house tree sun cloud
123
  DEF_COLOR = np.array([
124
  (0.2, 0.38, 0.85), (0.86, 0.16, 0.16), (0.98, 0.55, 0.15), (0.98, 0.84, 0.2),
125
  (0.96, 0.93, 0.75), (0.9, 0.2, 0.35), (0.93, 0.87, 0.75), (0.2, 0.58, 0.25),
126
- (0.99, 0.8, 0.2), (0.97, 0.97, 0.98)])
127
- DEF_SIZE = np.array([0.22, 0.2, 0.22, 0.2, 0.16, 0.2, 0.26, 0.24, 0.16, 0.18])
128
- DEF_Y = np.array([0.0, 0.0, 0.0, -0.5, -0.55, 0.0, None, None, -0.55, -0.6], dtype=object)
 
 
 
 
 
 
 
 
 
 
 
129
 
130
 
131
  def decode(slot):
 
132
  k = int(np.argmax(slot[S0:OBJ]))
133
  onehot = slot[S0:OBJ] / max(slot[S0:OBJ].sum(), 1)
134
  hc = min(slot[COL + 3], 1)
@@ -137,18 +173,35 @@ def decode(slot):
137
  size = hs * slot[SIZ] / max(slot[SIZ + 1], 1) + (1 - hs) * onehot @ DEF_SIZE
138
  x = slot[POS] * 1.2 if slot[POS + 2] else None
139
  y = slot[POS + 1] * 1.2 if slot[POS + 3] else None
140
- return dict(kind=SHAPES[k], color=np.clip(color, 0, 1), size=size, x=x, y=y)
 
 
141
 
142
 
143
  def layout(objs):
144
- free = [o for o in objs if o["x"] is None]
 
 
 
 
 
145
  for o, x in zip(free, np.linspace(-0.55, 0.55, len(free)) if len(free) > 1 else [0.0] * len(free)):
146
  o["x"] = x
147
- for o in objs:
148
- if o["y"] is None:
149
- d = DEF_Y[SHAPES.index(o["kind"])]
150
- o["y"] = 0.55 - 0.9 * o["size"] if d is None else d # house/tree stand on the ground line
151
- o["x"], o["y"] = float(np.clip(o["x"], -0.85, 0.85)), float(np.clip(o["y"], -0.85, 0.85))
 
 
 
 
 
 
 
 
 
 
152
  return objs
153
 
154
 
@@ -159,6 +212,21 @@ def circle(cx, cy, r, n=32):
159
  return ("and", np.stack([np.cos(a), np.sin(a), r + np.cos(a) * cx + np.sin(a) * cy], 1))
160
 
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  def poly(verts):
163
  v = np.array(verts, float)
164
  c = v.mean(0)
@@ -176,6 +244,20 @@ def rect(cx, cy, w, h):
176
  return poly([(cx - w / 2, cy - h / 2), (cx + w / 2, cy - h / 2), (cx + w / 2, cy + h / 2), (cx - w / 2, cy + h / 2)])
177
 
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  def star5():
180
  out = [(np.cos(np.radians(-90 + 72 * k)), np.sin(np.radians(-90 + 72 * k))) for k in range(5)]
181
  inn = [(0.4 * np.cos(np.radians(-54 + 72 * k)), 0.4 * np.sin(np.radians(-54 + 72 * k))) for k in range(5)]
@@ -192,48 +274,159 @@ def sun_rays():
192
  return ("or", rays)
193
 
194
 
 
 
 
 
195
  ROOF, DOOR, WINDOW, TRUNK = (0.58, 0.2, 0.14), (0.38, 0.22, 0.12), (0.98, 0.92, 0.6), (0.45, 0.28, 0.12)
 
 
 
 
196
 
197
- # Each shape is a stack of parts: (geometry, colour ("main" or rgb), alpha, shaded)
198
  PARTS = {
199
- "circle": [(circle(0, 0, 1), "main", 1, True)],
200
- "square": [(rect(0, 0, 1.7, 1.7), "main", 1, True)],
201
- "triangle": [(poly([(0, -0.95), (1, 0.75), (-1, 0.75)]), "main", 1, True)],
202
- "star": [(star5(), "main", 1, True)],
203
- "moon": [(("minus", [circle(0, 0, 1), circle(0.45, -0.3, 0.85)]), "main", 1, True)],
204
- "heart": [(("or", [circle(-0.42, -0.3, 0.5), circle(0.42, -0.3, 0.5),
205
- poly([(-0.88, -0.15), (0.88, -0.15), (0, 1.0)])]), "main", 1, True)],
206
- "house": [(rect(0, 0.3, 1.4, 1.1), "main", 1, True),
207
- (poly([(0, -0.95), (0.95, -0.22), (-0.95, -0.22)]), ROOF, 1, True),
208
- (rect(0, 0.55, 0.32, 0.6), DOOR, 1, False),
209
- (rect(-0.42, 0.15, 0.3, 0.3), WINDOW, 1, False),
210
- (rect(0.42, 0.15, 0.3, 0.3), WINDOW, 1, False)],
211
- "tree": [(rect(0, 0.55, 0.22, 0.9), TRUNK, 1, True),
212
- (("or", [circle(0, -0.35, 0.55), circle(-0.38, 0, 0.45), circle(0.38, 0, 0.45)]), "main", 1, True)],
213
- "sun": [(circle(0, 0, 1.6), "main", 0.22, False),
214
- (sun_rays(), "main", 1, False),
215
- (circle(0, 0, 1), "main", 1, True)],
216
- "cloud": [(("or", [circle(-0.5, 0.1, 0.42), circle(0, -0.15, 0.58), circle(0.52, 0.12, 0.4),
217
- rect(0, 0.3, 1.0, 0.35)]), "main", 1, True)],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  }
219
  CASTS_SHADOW = {"circle", "square", "triangle", "heart", "house", "tree"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
 
222
  def sig(z):
223
  return 0.5 * (1 + np.tanh(0.5 * z))
224
 
225
 
226
- def mask(g, px, py, sharp):
227
  op, arg = g
228
- if op == "and": # layer 1 half-planes -> layer 2 AND neuron
229
- h = sig(sharp * (arg[:, 2, None, None] - arg[:, 0, None, None] * px - arg[:, 1, None, None] * py))
230
- return sig(12 * (h.sum(0) - (len(arg) - 0.5)))
231
- ms = [mask(c, px, py, sharp) for c in arg]
232
  if op == "or":
233
  return sig(12 * (sum(ms) - 0.5))
 
 
234
  return sig(12 * (ms[0] - ms[1] - 0.5)) # minus: AND-NOT neuron
235
 
236
 
 
 
 
 
 
 
237
  # ------------------------------------------------------------------ backgrounds
238
  U, V = np.meshgrid(np.linspace(-1, 1, RES), np.linspace(-1, 1, RES))
239
  HORIZON = 0.3
@@ -243,12 +436,22 @@ def lerp(a, b, t):
243
  return np.array(a)[None, None] * (1 - t[..., None]) + np.array(b)[None, None] * t[..., None]
244
 
245
 
 
 
 
 
246
  def stars(threshold):
247
- i, j = np.floor((U + 1) * RES / 3), np.floor((V + 1) * RES / 3)
248
- h = np.abs(np.modf(np.sin(i * 12.9898 + j * 78.233) * 43758.5453)[0])
249
  return ((h > threshold) * (h - threshold) / (1 - threshold) * 0.6 + (h > threshold) * 0.4)[..., None]
250
 
251
 
 
 
 
 
 
 
 
252
  def background(sky, ground):
253
  t = (V + 1) / 2
254
  if sky is None:
@@ -261,76 +464,181 @@ def background(sky, ground):
261
  elif sky == "sunset":
262
  img = lerp((0.22, 0.18, 0.45), (0.95, 0.45, 0.35), np.clip(t / 0.6, 0, 1))
263
  img = img + lerp((0, 0, 0), (0.05, 0.35, 0.1), np.clip((t - 0.6) / 0.4, 0, 1))
 
 
264
  else:
265
  img = np.clip(lerp((0.0, 0.0, 0.02), (0.04, 0.02, 0.08), t) + stars(0.97), 0, 1)
266
- if ground is None:
267
- return np.clip(img, 0, 1)
268
- wiggle = {"sea": 0 * U, "grass": 0.025 * np.sin(4 * U), "desert": 0.06 * np.sin(3 * U + 1),
269
- "snow": 0.035 * np.sin(2.5 * U + 2)}[ground]
270
- g = np.clip((V - HORIZON) / (1 - HORIZON), 0, 1)
271
- if ground == "sea":
272
- col = lerp((0.25, 0.55, 0.75), (0.04, 0.2, 0.42), g) + 0.05 * np.sin(70 * V + 4 * np.sin(7 * U))[..., None]
273
- elif ground == "grass":
274
- col = lerp((0.5, 0.74, 0.32), (0.2, 0.45, 0.15), g) + 0.025 * np.sin(150 * U + 20 * V)[..., None]
275
- elif ground == "desert":
276
- col = lerp((0.96, 0.82, 0.52), (0.84, 0.6, 0.34), g) + 0.04 * np.sin(9 * U - 14 * V)[..., None]
277
- else:
278
- col = lerp((0.98, 0.99, 1.0), (0.82, 0.88, 0.97), g)
279
- m = sig(RES * 0.6 * (V - HORIZON - wiggle))[..., None]
280
- return np.clip(img * (1 - m) + col * m, 0, 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
282
 
283
  # ------------------------------------------------------------------ render
284
- def render(prompt):
285
  slots, scene, unknown = encode(prompt)
286
- objs = layout([decode(s) for s in slots])
 
 
287
  sky = SKIES[int(np.argmax(scene[SKY:GND]))] if scene[SKY:GND].any() else None
288
  ground = GROUNDS[int(np.argmax(scene[GND:]))] if scene[GND:].any() else None
 
 
 
289
  if ground and sky is None:
290
  sky = "day"
291
  img = background(sky, ground)
292
  for o in objs:
293
  r = o["size"]
294
- sharp = 1.2 * r * RES / 2 # one pixel of edge softness in local units
295
- px, py = (U - o["x"]) / r, (V - o["y"]) / r
 
 
 
 
 
 
 
 
296
  if o["kind"] in CASTS_SHADOW:
297
- sh = sum(mask(g, px - 0.12, py - 0.12, sharp) * a for g, _, a, _ in PARTS[o["kind"]])
298
- sh = np.clip(sh, 0, 1)[..., None] * 0.22
299
- img = img * (1 - sh)
300
- for geom, col, alpha, shaded in PARTS[o["kind"]]:
301
- c = o["color"] if isinstance(col, str) else np.array(col)
302
- c = c[None, None] * (1 - 0.16 * (px + py))[..., None] if shaded else c[None, None]
 
 
 
303
  m = (mask(geom, px, py, sharp) * alpha)[..., None]
304
- img = img * (1 - m) + np.clip(c, 0, 1) * m
 
 
 
 
305
  return Image.fromarray((np.clip(img, 0, 1) * 255).astype(np.uint8)), objs, sky, ground, unknown
306
 
307
 
 
 
 
 
 
308
  def count_params():
309
  def geo(g):
310
  return g[1].size if g[0] == "and" else sum(geo(c) for c in g[1])
311
- shapes = sum(geo(g) + 3 for parts in PARTS.values() for g, *_ in parts)
312
- return len(VOCAB) * DIM + DEF_COLOR.size + DEF_SIZE.size + len(DEF_Y) + shapes
 
313
 
314
 
315
  def slug(p):
316
  return re.sub(r"[^a-z0-9]+", "_", p.lower()).strip("_")[:60]
317
 
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  DEMO = [
320
  "a yellow sun over the sea",
321
  "a red house and a green tree on grass",
322
- "a big pink heart",
323
  "a white moon and a gold star in a night sky",
324
- "a small purple triangle on the left and a big blue circle on the right",
325
  "an orange sun and a cloud over the desert at sunset",
326
- "an orange square in space",
327
- "a brown house in the snow with a tree on the right",
 
 
328
  ]
329
 
 
 
 
 
330
  if __name__ == "__main__":
331
  OUT.mkdir(exist_ok=True)
332
- prompts = DEMO if sys.argv[1:] == ["--demo"] else [" ".join(sys.argv[1:]) or DEMO[0]]
333
  print(f"hand-written parameters: {count_params():,}")
 
 
 
 
 
 
 
 
 
334
  tiles = []
335
  for p in prompts:
336
  im, objs, sky, ground, unknown = render(p)
@@ -338,14 +646,8 @@ if __name__ == "__main__":
338
  tiles.append((p, im))
339
  print(f"\n{p!r}\n sky={sky} ground={ground} ignored={unknown}")
340
  for o in objs:
341
- print(f" {o['kind']:<8} rgb={np.round(o['color'], 2)} size={o['size']:.2f} at ({o['x']:+.2f},{o['y']:+.2f})")
 
342
  if len(tiles) > 1:
343
- cols, cap = 4, 36
344
- sheet = Image.new("RGB", (cols * RES, ((len(tiles) + cols - 1) // cols) * (RES + cap)), "white")
345
- d = ImageDraw.Draw(sheet)
346
- for i, (p, im) in enumerate(tiles):
347
- x, y = (i % cols) * RES, (i // cols) * (RES + cap)
348
- sheet.paste(im, (x, y))
349
- d.text((x + 8, y + RES + 10), p[:58], fill="black")
350
- sheet.save(OUT / "demo_sheet.png")
351
  print(f"\nsheet -> {OUT / 'demo_sheet.png'}")
 
3
  Every weight in this file was written by hand or derived from geometry.
4
  No dataset, no training, no gradients.
5
 
6
+ python handweights.py "a sleepy orange cat with a red balloon on the grass"
7
  python handweights.py --demo
8
+ python handweights.py --story "The bunny was happy. She found a yellow balloon. Then it started to rain."
9
 
10
  Architecture
11
+ 1. Embedding table word -> vector (shape / colour / position / size / expression / scene slots)
12
+ 2. Gated recurrent binder adjectives carry forward into the next object token, position phrases
13
+ ("on the left") bind back to the last object, and after a copula
14
+ ("the cat was sleepy") adjectives bind back too
15
+ 3. Slot decoder linear layers with default-colour / default-size matrices
16
+ 4. Per-pixel renderer layer 1: half-plane neurons (w = edge normal, b = edge offset)
17
+ layer 2: min-pool (AND) / OR / AND-NOT neurons that build shapes from half-planes
18
+ layer 3: shading neuron + alpha-compositing gate; outlines are the same
19
+ shape with every bias grown by ~2 pixels, drawn first in ink
20
  """
21
  import re
22
  import sys
23
  from pathlib import Path
24
 
25
  import numpy as np
26
+ from PIL import Image, ImageDraw, ImageFont
27
 
28
  RES = 384
29
  OUT = Path(__file__).parent / "out"
30
 
31
  # ------------------------------------------------------------------ embedding table
32
+ SHAPES = ["circle", "square", "triangle", "star", "moon", "heart", "house", "tree", "sun", "cloud",
33
+ "cat", "bunny", "bear", "kid", "balloon", "ball", "flower", "umbrella"]
34
+ CHARS = {"cat", "bunny", "bear", "kid"}
35
+ EXPRS = ["happy", "sad", "sleepy", "surprised", "angry"]
36
+ SKIES = ["day", "night", "sunset", "space", "rain"]
37
  GROUNDS = ["sea", "grass", "desert", "snow"]
38
 
39
  S0 = 0 # shape one-hot
40
  OBJ = S0 + len(SHAPES) # gate: "this token is an object"
41
+ REF = OBJ + 1 # pronoun: 1 = he/she/it, 2 = they
42
+ COL = REF + 1 # r, g, b, has_color
43
  POS = COL + 4 # dx, dy, has_x, has_y
44
  SIZ = POS + 4 # size, has_size
45
+ EXP = SIZ + 2 # expression one-hot
46
+ PRED = EXP + len(EXPRS) # copula: following adjectives bind back
47
+ CLR = PRED + 1 # conjunction: clears the copula register
48
+ SKY = CLR + 1 # sky one-hot
49
  GND = SKY + len(SKIES) # ground one-hot
50
  DIM = GND + len(GROUNDS)
51
 
 
62
 
63
  for i, s in enumerate(SHAPES):
64
  word(s, [(S0 + i, 1), (OBJ, 1)])
65
+ for syn, s in [("dot planet", "circle"), ("box cube block", "square"), ("pyramid", "triangle"),
66
+ ("home cottage hut", "house"), ("love", "heart"), ("bush", "tree"),
67
+ ("kitty kitten", "cat"), ("rabbit bunnie", "bunny"), ("teddy", "bear"),
68
+ ("child children boy girl", "kid"), ("rose daisy", "flower")]:
69
  word(syn, [(S0 + SHAPES.index(s), 1), (OBJ, 1)])
70
+ word("he she it him her", [(OBJ, 1), (REF, 1)])
71
+ word("they them", [(OBJ, 1), (REF, 2)])
72
 
73
  COLORS = {
74
  "red": (0.86, 0.16, 0.16), "green": (0.2, 0.62, 0.25), "blue": (0.2, 0.38, 0.85),
 
80
  for name, (r, g, b) in COLORS.items():
81
  word(name, [(COL, r), (COL + 1, g), (COL + 2, b), (COL + 3, 1)])
82
 
83
+ for names, axis, val in [("left", 0, -0.5), ("right", 0, 0.5), ("top upper above", 1, -0.5),
84
  ("bottom lower below", 1, 0.5)]:
85
  word(names, [(POS + axis, val), (POS + 2 + axis, 1)])
86
  word("center centre middle", [(POS + 2, 1), (POS + 3, 1)])
 
89
  ("huge giant enormous", 0.42)]:
90
  word(names, [(SIZ, s), (SIZ + 1, 1)])
91
 
92
+ for names, e in [("happy smiling glad joyful cheerful", "happy"), ("sad crying unhappy upset lonely", "sad"),
93
+ ("sleepy tired asleep sleeping", "sleepy"), ("surprised shocked amazed scared", "surprised"),
94
+ ("angry mad grumpy cross", "angry")]:
95
+ word(names, [(EXP + EXPRS.index(e), 1)])
96
+ word("is was are were looks looked feels felt seems seemed became got", [(PRED, 1)])
97
+ word("and with but then while so", [(CLR, 1)])
98
+
99
  word("day sunny morning", [(SKY + 0, 1)])
100
  word("sky", [(SKY + 0, 0.5)]) # "sky" leans day but loses to "night sky"
101
  word("night starry", [(SKY + 1, 1)])
102
  word("sunset dusk evening", [(SKY + 2, 1)])
103
  word("space", [(SKY + 3, 1)])
104
+ word("rain rainy raining storm stormy", [(SKY + 4, 1)])
105
  word("sea ocean water lake beach", [(GND + 0, 1)])
106
  word("grass field meadow park garden", [(GND + 1, 1)])
107
  word("desert sand dunes", [(GND + 2, 1)])
108
  word("snow snowy winter", [(GND + 3, 1)])
109
 
110
+ FWD = np.zeros(DIM); FWD[[COL, COL + 1, COL + 2, COL + 3, SIZ, SIZ + 1]] = 1; FWD[EXP:PRED] = 1 # to next object
111
+ BACK = np.zeros(DIM); BACK[POS:POS + 4] = 1 # to last object
112
+ SCN = np.zeros(DIM); SCN[SKY:] = 1 # pooled scene
113
 
114
 
115
  def lookup(t):
 
123
  # ------------------------------------------------------------------ gated recurrent binder
124
  def encode(prompt):
125
  pend = np.zeros(DIM) # adjectives waiting for an object
126
+ pred = 0.0 # copula register: "was" makes the next adjectives bind back
127
  slots, scene, unknown = [], np.zeros(DIM), []
128
  for t in re.findall(r"[a-z]+", prompt.lower()):
129
  e = lookup(t)
 
131
  unknown.append(t)
132
  continue
133
  g = e[OBJ]
134
+ pred = max(pred * (1 - g) * (1 - e[CLR]), e[PRED])
135
  has_last = 1.0 if slots else 0.0
136
+ to_back = pred * has_last
137
  if g:
138
  slots.append(pend + e)
139
  elif has_last:
140
+ slots[-1] = slots[-1] + BACK * e + to_back * FWD * e
141
+ pend = (1 - g) * (pend + (1 - to_back) * FWD * e + (1 - has_last) * BACK * e)
142
  scene += SCN * e
143
  return slots, scene, unknown
144
 
145
 
146
  # ------------------------------------------------------------------ slot decoder weights
 
147
  DEF_COLOR = np.array([
148
  (0.2, 0.38, 0.85), (0.86, 0.16, 0.16), (0.98, 0.55, 0.15), (0.98, 0.84, 0.2),
149
  (0.96, 0.93, 0.75), (0.9, 0.2, 0.35), (0.93, 0.87, 0.75), (0.2, 0.58, 0.25),
150
+ (0.99, 0.8, 0.2), (0.97, 0.97, 0.98),
151
+ (0.98, 0.6, 0.25), (0.95, 0.94, 0.96), (0.62, 0.42, 0.25), (0.25, 0.55, 0.9),
152
+ (0.9, 0.2, 0.25), (0.95, 0.3, 0.25), (0.97, 0.5, 0.7), (0.3, 0.5, 0.9)])
153
+ DEF_SIZE = np.array([0.22, 0.2, 0.22, 0.2, 0.16, 0.2, 0.26, 0.24, 0.16, 0.18,
154
+ 0.34, 0.34, 0.34, 0.34, 0.15, 0.1, 0.1, 0.2])
155
+ # None: stands on the ground line like a house; "char": stands on it with feet at local y = 1
156
+ DEF_Y = np.array([0.0, 0.0, 0.0, -0.5, -0.55, 0.0, None, None, -0.55, -0.6,
157
+ "char", "char", "char", "char", -0.2, 0.45, 0.3, -0.1], dtype=object)
158
+
159
+ # props held by the previous character: (anchor on the character, grip point on the prop, scale)
160
+ PROPS = {"balloon": ("hand", (-1.4, 3.0), 0.45), "ball": ("foot", (0.0, 1.0), 0.25),
161
+ "flower": ("hand", (0.0, 1.6), 0.24), "umbrella": ("hand", (0.0, 2.4), 0.7)}
162
+ HAND = {"cat": (0.45, 0.35), "bunny": (0.45, 0.35), "bear": (0.5, 0.35), "kid": (0.56, 0.52)}
163
+ FOOT = (0.75, 1.0)
164
 
165
 
166
  def decode(slot):
167
+ ref = slot[REF]
168
  k = int(np.argmax(slot[S0:OBJ]))
169
  onehot = slot[S0:OBJ] / max(slot[S0:OBJ].sum(), 1)
170
  hc = min(slot[COL + 3], 1)
 
173
  size = hs * slot[SIZ] / max(slot[SIZ + 1], 1) + (1 - hs) * onehot @ DEF_SIZE
174
  x = slot[POS] * 1.2 if slot[POS + 2] else None
175
  y = slot[POS + 1] * 1.2 if slot[POS + 3] else None
176
+ ex = slot[EXP:PRED]
177
+ return dict(kind="ref" if ref else SHAPES[k], ref=int(ref), color=np.clip(color, 0, 1), size=size, x=x, y=y,
178
+ has_color=bool(hc), has_size=bool(hs), has_expr=bool(ex.any()), expr=EXPRS[int(np.argmax(ex))] if ex.any() else "happy")
179
 
180
 
181
  def layout(objs):
182
+ for i, o in enumerate(objs): # "a kid with a flower on the left": the place belongs to the kid
183
+ if i and o["kind"] in PROPS and objs[i - 1]["kind"] in CHARS and objs[i - 1]["x"] is None and o["x"] is not None:
184
+ objs[i - 1]["x"], objs[i - 1]["y"], o["x"], o["y"] = o["x"], o["y"] if objs[i - 1]["y"] is None else objs[i - 1]["y"], None, None
185
+ held = [i > 0 and o["kind"] in PROPS and o["x"] is None and objs[i - 1]["kind"] in CHARS
186
+ for i, o in enumerate(objs)]
187
+ free = [o for o, h in zip(objs, held) if o["x"] is None and not h]
188
  for o, x in zip(free, np.linspace(-0.55, 0.55, len(free)) if len(free) > 1 else [0.0] * len(free)):
189
  o["x"] = x
190
+ for o, h in zip(objs, held):
191
+ if h or o["y"] is not None:
192
+ continue
193
+ d = DEF_Y[SHAPES.index(o["kind"])]
194
+ o["y"] = 0.55 - 0.9 * o["size"] if d is None else 0.62 - o["size"] if d == "char" else d
195
+ for i, (o, h) in enumerate(zip(objs, held)):
196
+ if h: # put the prop's grip point on the character's hand / foot
197
+ c = objs[i - 1]
198
+ anchor, grip, scale = PROPS[o["kind"]]
199
+ ax, ay = HAND[c["kind"]] if anchor == "hand" else FOOT
200
+ o["size"] = c["size"] * scale
201
+ o["x"] = c["x"] + ax * c["size"] - grip[0] * o["size"]
202
+ o["y"] = c["y"] + ay * c["size"] - grip[1] * o["size"]
203
+ else:
204
+ o["x"], o["y"] = float(np.clip(o["x"], -0.85, 0.85)), float(np.clip(o["y"], -0.85, 0.85))
205
  return objs
206
 
207
 
 
212
  return ("and", np.stack([np.cos(a), np.sin(a), r + np.cos(a) * cx + np.sin(a) * cy], 1))
213
 
214
 
215
+ def ellipse(cx, cy, rx, ry, rot=0.0, n=32):
216
+ t = np.linspace(0, 2 * np.pi, n, endpoint=False)
217
+ nx, ny = np.cos(t) / rx, np.sin(t) / ry
218
+ norm = np.hypot(nx, ny)
219
+ nx, ny = nx / norm, ny / norm
220
+ d = nx * rx * np.cos(t) + ny * ry * np.sin(t)
221
+ c, s = np.cos(rot), np.sin(rot)
222
+ rx_, ry_ = c * nx - s * ny, s * nx + c * ny
223
+ return ("and", np.stack([rx_, ry_, d + rx_ * cx + ry_ * cy], 1))
224
+
225
+
226
+ def halfplane(nx, ny, d):
227
+ return ("and", np.array([[nx, ny, d]], float))
228
+
229
+
230
  def poly(verts):
231
  v = np.array(verts, float)
232
  c = v.mean(0)
 
244
  return poly([(cx - w / 2, cy - h / 2), (cx + w / 2, cy - h / 2), (cx + w / 2, cy + h / 2), (cx - w / 2, cy + h / 2)])
245
 
246
 
247
+ def seg(x1, y1, x2, y2, w):
248
+ d = np.array([x2 - x1, y2 - y1], float)
249
+ p = np.array([-d[1], d[0]]) / np.linalg.norm(d) * w / 2
250
+ a, b = np.array([x1, y1]), np.array([x2, y2])
251
+ return poly([a - p, b - p, b + p, a + p])
252
+
253
+
254
+ def arc(cx, cy, r, w, side, cut=0.0):
255
+ """Ring of width w, keeping only the part below (smile) or above (frown) the centre."""
256
+ ring = ("minus", [circle(cx, cy, r + w / 2), circle(cx, cy, r - w / 2)])
257
+ keep = halfplane(0, -1, -(cy + cut * r)) if side == "lower" else halfplane(0, 1, cy - cut * r)
258
+ return ("both", [ring, keep])
259
+
260
+
261
  def star5():
262
  out = [(np.cos(np.radians(-90 + 72 * k)), np.sin(np.radians(-90 + 72 * k))) for k in range(5)]
263
  inn = [(0.4 * np.cos(np.radians(-54 + 72 * k)), 0.4 * np.sin(np.radians(-54 + 72 * k))) for k in range(5)]
 
274
  return ("or", rays)
275
 
276
 
277
+ def P(geom, color="main", alpha=1.0, shaded=True, outline=False):
278
+ return (geom, color, alpha, shaded, outline)
279
+
280
+
281
  ROOF, DOOR, WINDOW, TRUNK = (0.58, 0.2, 0.14), (0.38, 0.22, 0.12), (0.98, 0.92, 0.6), (0.45, 0.28, 0.12)
282
+ INK, WHITE, PINK, BLUSH = (0.16, 0.12, 0.12), (1.0, 1.0, 1.0), (0.98, 0.62, 0.68), (0.98, 0.5, 0.55)
283
+ SKIN, HAIR, SHOE, LEAF, TEAR = (0.98, 0.8, 0.65), (0.38, 0.23, 0.12), (0.3, 0.2, 0.18), (0.3, 0.62, 0.25), (0.45, 0.72, 1.0)
284
+ SIDES = (-1, 1)
285
+ GROUND_SHADOW = P(ellipse(0, 1.0, 0.62, 0.09), INK, 0.16, False)
286
 
287
+ # Each shape is a stack of parts: (geometry, colour, alpha, shaded, outline)
288
  PARTS = {
289
+ "circle": [P(circle(0, 0, 1))],
290
+ "square": [P(rect(0, 0, 1.7, 1.7))],
291
+ "triangle": [P(poly([(0, -0.95), (1, 0.75), (-1, 0.75)]))],
292
+ "star": [P(star5())],
293
+ "moon": [P(("minus", [circle(0, 0, 1), circle(0.45, -0.3, 0.85)]))],
294
+ "heart": [P(("or", [circle(-0.42, -0.3, 0.5), circle(0.42, -0.3, 0.5),
295
+ poly([(-0.88, -0.15), (0.88, -0.15), (0, 1.0)])]))],
296
+ "house": [P(rect(0, 0.3, 1.4, 1.1)),
297
+ P(poly([(0, -0.95), (0.95, -0.22), (-0.95, -0.22)]), ROOF),
298
+ P(rect(0, 0.55, 0.32, 0.6), DOOR, shaded=False),
299
+ P(rect(-0.42, 0.15, 0.3, 0.3), WINDOW, shaded=False),
300
+ P(rect(0.42, 0.15, 0.3, 0.3), WINDOW, shaded=False)],
301
+ "tree": [P(rect(0, 0.55, 0.22, 0.9), TRUNK),
302
+ P(("or", [circle(0, -0.35, 0.55), circle(-0.38, 0, 0.45), circle(0.38, 0, 0.45)]))],
303
+ "sun": [P(circle(0, 0, 1.6), alpha=0.22, shaded=False),
304
+ P(sun_rays(), shaded=False),
305
+ P(circle(0, 0, 1))],
306
+ "cloud": [P(("or", [circle(-0.5, 0.1, 0.42), circle(0, -0.15, 0.58), circle(0.52, 0.12, 0.4),
307
+ rect(0, 0.3, 1.0, 0.35)]))],
308
+ # ---- characters: local units, feet on y = 1, ears up to about y = -1.15
309
+ "cat": [GROUND_SHADOW,
310
+ P(ellipse(0.62, 0.42, 0.12, 0.42, rot=0.5), outline=True),
311
+ P(ellipse(0, 0.5, 0.52, 0.5), outline=True),
312
+ P(ellipse(0, 0.6, 0.3, 0.3), "light", shaded=False),
313
+ *[P(ellipse(s * 0.25, 0.95, 0.17, 0.1), outline=True) for s in SIDES],
314
+ *[P(poly([(s * 0.62, -0.45), (s * 0.18, -0.8), (s * 0.55, -1.12)]), outline=True) for s in SIDES],
315
+ *[P(poly([(s * 0.5, -0.58), (s * 0.3, -0.74), (s * 0.5, -0.95)]), PINK, shaded=False) for s in SIDES],
316
+ P(ellipse(0, -0.32, 0.62, 0.52), outline=True),
317
+ P(poly([(-0.07, -0.27), (0.07, -0.27), (0, -0.19)]), PINK, shaded=False),
318
+ *[P(seg(s * 0.3, -0.2, s * 0.72, -0.27, 0.025), INK, shaded=False) for s in SIDES],
319
+ *[P(seg(s * 0.3, -0.12, s * 0.72, -0.08, 0.025), INK, shaded=False) for s in SIDES]],
320
+ "bunny": [GROUND_SHADOW,
321
+ P(circle(0.5, 0.72, 0.15), "light", outline=True),
322
+ *[P(ellipse(s * 0.22, -0.98, 0.14, 0.42, rot=s * 0.12), outline=True) for s in SIDES],
323
+ *[P(ellipse(s * 0.22, -0.96, 0.065, 0.3, rot=s * 0.12), PINK, shaded=False) for s in SIDES],
324
+ P(ellipse(0, 0.5, 0.5, 0.5), outline=True),
325
+ P(ellipse(0, 0.6, 0.28, 0.3), "light", shaded=False),
326
+ *[P(ellipse(s * 0.25, 0.95, 0.2, 0.1), outline=True) for s in SIDES],
327
+ P(ellipse(0, -0.3, 0.55, 0.48), outline=True),
328
+ P(ellipse(0, -0.22, 0.06, 0.045), PINK, shaded=False)],
329
+ "bear": [GROUND_SHADOW,
330
+ *[P(circle(s * 0.45, -0.72, 0.2), outline=True) for s in SIDES],
331
+ *[P(circle(s * 0.45, -0.72, 0.1), "light", shaded=False) for s in SIDES],
332
+ P(ellipse(0, 0.5, 0.58, 0.52), outline=True),
333
+ P(ellipse(0, 0.58, 0.34, 0.34), "light", shaded=False),
334
+ *[P(ellipse(s * 0.28, 0.95, 0.2, 0.11), outline=True) for s in SIDES],
335
+ P(ellipse(0, -0.3, 0.62, 0.55), outline=True),
336
+ P(ellipse(0, -0.1, 0.27, 0.2), "light", shaded=False),
337
+ P(ellipse(0, -0.19, 0.09, 0.06), INK, shaded=False)],
338
+ "kid": [GROUND_SHADOW,
339
+ *[P(rect(s * 0.15, 0.8, 0.14, 0.4), SKIN, outline=True) for s in SIDES],
340
+ *[P(ellipse(s * 0.17, 0.98, 0.15, 0.07), SHOE, outline=True) for s in SIDES],
341
+ *[P(seg(s * 0.28, 0.15, s * 0.55, 0.5, 0.14), SKIN, outline=True) for s in SIDES],
342
+ *[P(circle(s * 0.56, 0.52, 0.08), SKIN, outline=True) for s in SIDES],
343
+ P(poly([(-0.3, 0.02), (0.3, 0.02), (0.45, 0.7), (-0.45, 0.7)]), outline=True),
344
+ P(ellipse(0, -0.3, 0.57, 0.57), HAIR, outline=True),
345
+ P(ellipse(0, -0.3, 0.5, 0.5), SKIN, outline=True),
346
+ P(("both", [ellipse(0, -0.34, 0.53, 0.52), halfplane(0, 1, -0.5)]), HAIR)],
347
+ # ---- props
348
+ "balloon": [P(seg(0, 0.95, -1.4, 3.0, 0.04), INK, shaded=False),
349
+ P(poly([(-0.1, 1.06), (0.1, 1.06), (0, 0.9)]), outline=True),
350
+ P(ellipse(0, 0, 0.75, 0.95), outline=True),
351
+ P(ellipse(-0.28, -0.4, 0.12, 0.22, rot=0.5), WHITE, 0.7, False)],
352
+ "ball": [P(circle(0, 0, 1), outline=True),
353
+ P(("both", [circle(0, 0, 1), halfplane(0, 1, 0.22), halfplane(0, -1, 0.22)]), WHITE),
354
+ P(ellipse(-0.4, -0.45, 0.15, 0.1, rot=-0.6), WHITE, 0.7, False)],
355
+ "flower": [P(seg(0, 0.3, 0, 2.2, 0.12), LEAF, outline=True),
356
+ P(ellipse(0.32, 1.4, 0.3, 0.12, rot=-0.5), LEAF, outline=True),
357
+ *[P(circle(0.48 * np.cos(a), 0.48 * np.sin(a), 0.36), outline=True)
358
+ for a in np.radians(-90 + 72 * np.arange(5))],
359
+ P(circle(0, 0, 0.3), (0.98, 0.82, 0.25), outline=True)],
360
+ "umbrella": [P(seg(0, 0, 0, 2.6, 0.08), INK, shaded=False),
361
+ P(arc(0.2, 2.6, 0.2, 0.09, "lower"), INK, shaded=False),
362
+ P(("both", [ellipse(0, 0.05, 1.3, 1.0), halfplane(0, 1, 0.05)]), outline=True),
363
+ P(seg(0, -0.95, 0, -1.2, 0.08), INK, shaded=False)],
364
  }
365
  CASTS_SHADOW = {"circle", "square", "triangle", "heart", "house", "tree"}
366
+ EXTENT = {"balloon": 3.4, "umbrella": 3.0, "flower": 2.4, "bunny": 1.6, "sun": 1.7} # bbox half-size, local units
367
+
368
+ HEAD = {"cat": (0, -0.32, 0.55), "bunny": (0, -0.3, 0.5), "bear": (0, -0.3, 0.55), "kid": (0, -0.3, 0.45)}
369
+
370
+
371
+ def face(hx, hy, hr, expr):
372
+ ex, ey = 0.36 * hr, hy - 0.05 * hr
373
+ mx, my = hx, hy + 0.38 * hr
374
+ eyes = [hx - ex, hx + ex]
375
+ f = []
376
+ if expr == "surprised":
377
+ for x in eyes:
378
+ f += [P(circle(x, ey, 0.19 * hr), INK, shaded=False), P(circle(x, ey, 0.15 * hr), WHITE, shaded=False),
379
+ P(circle(x, ey + 0.02 * hr, 0.07 * hr), INK, shaded=False)]
380
+ f.append(P(ellipse(mx, my + 0.03 * hr, 0.1 * hr, 0.13 * hr), INK, shaded=False))
381
+ elif expr == "sleepy":
382
+ f += [P(arc(x, ey - 0.08 * hr, 0.11 * hr, 0.045 * hr, "lower", 0.1), INK, shaded=False) for x in eyes]
383
+ f.append(P(ellipse(mx, my, 0.05 * hr, 0.06 * hr), INK, shaded=False))
384
+ zx, zy, s, w = hx + 0.9 * hr, hy - 0.8 * hr, 0.25 * hr, 0.06 * hr
385
+ f += [P(seg(zx, zy, zx + s, zy, w), INK, shaded=False), P(seg(zx + s, zy, zx, zy + s, w), INK, shaded=False),
386
+ P(seg(zx, zy + s, zx + s, zy + s, w), INK, shaded=False)]
387
+ else:
388
+ for x in eyes:
389
+ f += [P(ellipse(x, ey, 0.12 * hr, 0.16 * hr), INK, shaded=False),
390
+ P(circle(x + 0.04 * hr, ey - 0.06 * hr, 0.045 * hr), WHITE, shaded=False)]
391
+ if expr == "happy":
392
+ f.append(P(arc(mx, my - 0.25 * hr, 0.25 * hr, 0.06 * hr, "lower", 0.35), INK, shaded=False))
393
+ f += [P(ellipse(hx + s * 0.58 * hr, hy + 0.22 * hr, 0.13 * hr, 0.08 * hr), BLUSH, 0.55, False) for s in SIDES]
394
+ else:
395
+ f.append(P(arc(mx, my + 0.2 * hr, 0.2 * hr, 0.06 * hr, "upper", 0.35), INK, shaded=False))
396
+ for s, x in zip(SIDES, eyes): # sad: outer brow ends drop; angry: inner ends drop
397
+ inner, outer = (ey - 0.24 * hr, ey - 0.32 * hr) if expr == "angry" else (ey - 0.32 * hr, ey - 0.24 * hr)
398
+ f.append(P(seg(x - s * 0.13 * hr, inner, x + s * 0.13 * hr, outer, 0.05 * hr), INK, shaded=False))
399
+ if expr == "sad":
400
+ f.append(P(ellipse(eyes[1] + 0.06 * hr, ey + 0.25 * hr, 0.05 * hr, 0.08 * hr), TEAR, shaded=False))
401
+ return f
402
+
403
+
404
+ FACES = {(k, e): face(*HEAD[k], e) for k in CHARS for e in EXPRS}
405
 
406
 
407
  def sig(z):
408
  return 0.5 * (1 + np.tanh(0.5 * z))
409
 
410
 
411
+ def mask(g, px, py, sharp, grow=0.0):
412
  op, arg = g
413
+ if op == "and": # layer 1 half-plane pre-activations -> layer 2 min-pool (exact AND at any size)
414
+ z = sharp * (arg[:, 2, None, None] + grow - arg[:, 0, None, None] * px - arg[:, 1, None, None] * py)
415
+ return sig(z.min(0))
416
+ ms = [mask(c, px, py, sharp, -grow if op == "minus" and i else grow) for i, c in enumerate(arg)]
417
  if op == "or":
418
  return sig(12 * (sum(ms) - 0.5))
419
+ if op == "both":
420
+ return sig(12 * (sum(ms) - (len(ms) - 0.5)))
421
  return sig(12 * (ms[0] - ms[1] - 0.5)) # minus: AND-NOT neuron
422
 
423
 
424
+ def part_color(spec, main):
425
+ if isinstance(spec, str):
426
+ return {"main": main, "light": main * 0.45 + 0.55, "dark": main * 0.65}[spec]
427
+ return np.array(spec)
428
+
429
+
430
  # ------------------------------------------------------------------ backgrounds
431
  U, V = np.meshgrid(np.linspace(-1, 1, RES), np.linspace(-1, 1, RES))
432
  HORIZON = 0.3
 
436
  return np.array(a)[None, None] * (1 - t[..., None]) + np.array(b)[None, None] * t[..., None]
437
 
438
 
439
+ def hash2(i, j):
440
+ return np.abs(np.modf(np.sin(i * 12.9898 + j * 78.233) * 43758.5453)[0])
441
+
442
+
443
  def stars(threshold):
444
+ h = hash2(np.floor((U + 1) * RES / 3), np.floor((V + 1) * RES / 3))
 
445
  return ((h > threshold) * (h - threshold) / (1 - threshold) * 0.6 + (h > threshold) * 0.4)[..., None]
446
 
447
 
448
+ def rain():
449
+ col = np.floor((U + 0.15 * V + 2) * RES / 9)
450
+ phase = np.modf((V + 1) * 1.5 + hash2(col, 1.0) * 5)[0]
451
+ frac = np.modf((U + 0.15 * V + 2) * RES / 9)[0]
452
+ return ((phase < 0.06) & (frac < 0.2)).astype(float)[..., None] * 0.7
453
+
454
+
455
  def background(sky, ground):
456
  t = (V + 1) / 2
457
  if sky is None:
 
464
  elif sky == "sunset":
465
  img = lerp((0.22, 0.18, 0.45), (0.95, 0.45, 0.35), np.clip(t / 0.6, 0, 1))
466
  img = img + lerp((0, 0, 0), (0.05, 0.35, 0.1), np.clip((t - 0.6) / 0.4, 0, 1))
467
+ elif sky == "rain":
468
+ img = lerp((0.5, 0.55, 0.62), (0.74, 0.77, 0.82), t)
469
  else:
470
  img = np.clip(lerp((0.0, 0.0, 0.02), (0.04, 0.02, 0.08), t) + stars(0.97), 0, 1)
471
+ if ground is not None:
472
+ wiggle = {"sea": 0 * U, "grass": 0.025 * np.sin(4 * U), "desert": 0.06 * np.sin(3 * U + 1),
473
+ "snow": 0.035 * np.sin(2.5 * U + 2)}[ground]
474
+ g = np.clip((V - HORIZON) / (1 - HORIZON), 0, 1)
475
+ if ground == "sea":
476
+ col = lerp((0.25, 0.55, 0.75), (0.04, 0.2, 0.42), g) + 0.05 * np.sin(70 * V + 4 * np.sin(7 * U))[..., None]
477
+ elif ground == "grass":
478
+ col = lerp((0.5, 0.74, 0.32), (0.2, 0.45, 0.15), g) + 0.025 * np.sin(150 * U + 20 * V)[..., None]
479
+ elif ground == "desert":
480
+ col = lerp((0.96, 0.82, 0.52), (0.84, 0.6, 0.34), g) + 0.04 * np.sin(9 * U - 14 * V)[..., None]
481
+ else:
482
+ col = lerp((0.98, 0.99, 1.0), (0.82, 0.88, 0.97), g)
483
+ m = sig(RES * 0.6 * (V - HORIZON - wiggle))[..., None]
484
+ img = img * (1 - m) + col * m
485
+ return np.clip(img, 0, 1)
486
+
487
+
488
+ # ------------------------------------------------------------------ story context (plain code, not weights)
489
+ class Story:
490
+ """Carries scene, character colours and the last cast across sentences, and resolves pronouns."""
491
+
492
+ def __init__(self):
493
+ self.colors, self.cast, self.sky, self.ground = {}, [], "day", "grass"
494
+
495
+ def resolve(self, objs):
496
+ out = []
497
+ for o in objs:
498
+ if o["kind"] == "ref":
499
+ for c in (self.cast[-1:] if o["ref"] == 1 else self.cast):
500
+ out.append(dict(c, expr=o["expr"], has_expr=o["has_expr"], x=o["x"], y=o["y"], size=o["size"] if o["has_size"] else c["size"]))
501
+ continue
502
+ if o["has_color"]:
503
+ self.colors[o["kind"]] = o["color"]
504
+ elif o["kind"] in self.colors:
505
+ o["color"] = self.colors[o["kind"]]
506
+ out.append(o)
507
+ first, dedup = {}, []
508
+ for o in out: # "it ... she was sad": one cat, keep the stated expression
509
+ if o["kind"] in CHARS and o["kind"] in first:
510
+ if o.get("has_expr"):
511
+ first[o["kind"]]["expr"] = o["expr"]
512
+ continue
513
+ first[o["kind"]] = o
514
+ dedup.append(o)
515
+ cast = [dict(kind=o["kind"], ref=0, color=o["color"], size=o["size"], has_color=True, has_size=False)
516
+ for o in dedup if o["kind"] in CHARS]
517
+ if cast:
518
+ self.cast = cast
519
+ return dedup
520
+
521
+
522
+ def split_story(text):
523
+ return [s.strip() for s in re.split(r"(?<=[.!?])\s+|\n+", text.strip()) if s.strip()]
524
 
525
 
526
  # ------------------------------------------------------------------ render
527
+ def render(prompt, story=None):
528
  slots, scene, unknown = encode(prompt)
529
+ objs = [decode(s) for s in slots]
530
+ objs = story.resolve(objs) if story else [o for o in objs if o["kind"] != "ref"]
531
+ objs = layout(objs)
532
  sky = SKIES[int(np.argmax(scene[SKY:GND]))] if scene[SKY:GND].any() else None
533
  ground = GROUNDS[int(np.argmax(scene[GND:]))] if scene[GND:].any() else None
534
+ if story:
535
+ sky, ground = sky or story.sky, ground or story.ground
536
+ story.sky, story.ground = sky, ground
537
  if ground and sky is None:
538
  sky = "day"
539
  img = background(sky, ground)
540
  for o in objs:
541
  r = o["size"]
542
+ ext = EXTENT.get(o["kind"], 1.8) * r
543
+ i0, i1 = max(0, int((o["y"] - ext + 1) / 2 * RES) - 3), min(RES, int((o["y"] + ext + 1) / 2 * RES) + 4)
544
+ j0, j1 = max(0, int((o["x"] - ext + 1) / 2 * RES) - 3), min(RES, int((o["x"] + ext + 1) / 2 * RES) + 4)
545
+ if i0 >= i1 or j0 >= j1:
546
+ continue
547
+ sub = img[i0:i1, j0:j1]
548
+ sharp = 4.5 * r * RES / 2 # ~1 px anti-aliased edge in local units
549
+ ink = 2.4 * 2 / (RES * r) # outline width: ~2.4 px in local units
550
+ px, py = (U[i0:i1, j0:j1] - o["x"]) / r, (V[i0:i1, j0:j1] - o["y"]) / r
551
+ parts = PARTS[o["kind"]] + FACES.get((o["kind"], o["expr"]), [])
552
  if o["kind"] in CASTS_SHADOW:
553
+ sh = sum(mask(g, px - 0.12, py - 0.12, sharp) * a for g, _, a, _, _ in parts)
554
+ sub = sub * (1 - np.clip(sh, 0, 1)[..., None] * 0.22)
555
+ for geom, col, alpha, shaded, outline in parts:
556
+ if outline:
557
+ m = mask(geom, px, py, sharp, ink)[..., None]
558
+ sub = sub * (1 - m) + np.array(INK) * m
559
+ c = part_color(col, o["color"])[None, None]
560
+ if shaded:
561
+ c = c * (1 - 0.16 * (px + py))[..., None]
562
  m = (mask(geom, px, py, sharp) * alpha)[..., None]
563
+ sub = sub * (1 - m) + np.clip(c, 0, 1) * m
564
+ img[i0:i1, j0:j1] = sub
565
+ if sky == "rain":
566
+ drops = rain()
567
+ img = img * (1 - drops) + np.array([0.85, 0.9, 1.0]) * drops
568
  return Image.fromarray((np.clip(img, 0, 1) * 255).astype(np.uint8)), objs, sky, ground, unknown
569
 
570
 
571
+ def render_story(text):
572
+ story = Story()
573
+ return [(s, render(s, story)) for s in split_story(text)]
574
+
575
+
576
  def count_params():
577
  def geo(g):
578
  return g[1].size if g[0] == "and" else sum(geo(c) for c in g[1])
579
+ parts = [p for ps in PARTS.values() for p in ps] + [p for ps in FACES.values() for p in ps]
580
+ return (len(VOCAB) * DIM + DEF_COLOR.size + DEF_SIZE.size + len(DEF_Y)
581
+ + sum(geo(g) + 3 for g, *_ in parts) + 3 * len(PROPS) + 2 * len(HAND))
582
 
583
 
584
  def slug(p):
585
  return re.sub(r"[^a-z0-9]+", "_", p.lower()).strip("_")[:60]
586
 
587
 
588
+ def caption_font(size=20):
589
+ for f in ["C:/Windows/Fonts/comic.ttf", "C:/Windows/Fonts/segoeui.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
590
+ try:
591
+ return ImageFont.truetype(f, size)
592
+ except OSError:
593
+ pass
594
+ return ImageFont.load_default(size=size)
595
+
596
+
597
+ def book_sheet(pages, cols=3, cap=64):
598
+ font = caption_font()
599
+ sheet = Image.new("RGB", (cols * (RES + 16) + 16, ((len(pages) + cols - 1) // cols) * (RES + cap + 16) + 16), "white")
600
+ d = ImageDraw.Draw(sheet)
601
+ for i, (text, im) in enumerate(pages):
602
+ x, y = 16 + (i % cols) * (RES + 16), 16 + (i // cols) * (RES + cap + 16)
603
+ sheet.paste(im, (x, y))
604
+ lines, line = [], ""
605
+ for w in text.split():
606
+ if d.textlength(line + " " + w, font=font) > RES - 8 and line:
607
+ lines.append(line)
608
+ line = w
609
+ else:
610
+ line = (line + " " + w).strip()
611
+ d.multiline_text((x + 4, y + RES + 8), "\n".join(lines + [line]), fill="black", font=font, spacing=4)
612
+ return sheet
613
+
614
+
615
  DEMO = [
616
  "a yellow sun over the sea",
617
  "a red house and a green tree on grass",
 
618
  "a white moon and a gold star in a night sky",
 
619
  "an orange sun and a cloud over the desert at sunset",
620
+ "a happy orange cat with a red balloon on the grass",
621
+ "a sleepy bear and a surprised bunny at night",
622
+ "a kid with a blue umbrella in the rain",
623
+ "a sad white bunny with a pink flower in the snow",
624
  ]
625
 
626
+ STORY = ("The orange cat was happy in the meadow. She found a red balloon. Then it started to rain and she was sad. "
627
+ "A kid with a yellow umbrella came by. The cat and the kid were happy together. "
628
+ "At night the cat was sleepy under the moon.")
629
+
630
  if __name__ == "__main__":
631
  OUT.mkdir(exist_ok=True)
 
632
  print(f"hand-written parameters: {count_params():,}")
633
+ if sys.argv[1:2] == ["--story"]:
634
+ pages = render_story(" ".join(sys.argv[2:]) or STORY)
635
+ for s, (_, objs, sky, ground, unknown) in pages:
636
+ print(f"\n{s!r}\n sky={sky} ground={ground} " + ", ".join(f"{o['expr']} {o['kind']}" if o["kind"] in CHARS
637
+ else o["kind"] for o in objs))
638
+ book_sheet([(s, r[0]) for s, r in pages]).save(OUT / "story_sheet.png")
639
+ print(f"\nbook -> {OUT / 'story_sheet.png'}")
640
+ sys.exit()
641
+ prompts = DEMO if sys.argv[1:] == ["--demo"] else [" ".join(sys.argv[1:]) or DEMO[0]]
642
  tiles = []
643
  for p in prompts:
644
  im, objs, sky, ground, unknown = render(p)
 
646
  tiles.append((p, im))
647
  print(f"\n{p!r}\n sky={sky} ground={ground} ignored={unknown}")
648
  for o in objs:
649
+ print(f" {o['kind']:<8} {o['expr'] if o['kind'] in CHARS else '':<9} rgb={np.round(o['color'], 2)} "
650
+ f"size={o['size']:.2f} at ({o['x']:+.2f},{o['y']:+.2f})")
651
  if len(tiles) > 1:
652
+ book_sheet(tiles, cols=4).save(OUT / "demo_sheet.png")
 
 
 
 
 
 
 
653
  print(f"\nsheet -> {OUT / 'demo_sheet.png'}")
story_sheet.png ADDED

Git LFS Details

  • SHA256: 168e01ff6050935370f5cff2d28f477dc66f60cfdb6e3b58a80d57cecc7cb90e
  • Pointer size: 131 Bytes
  • Size of remote file: 236 kB
weights.safetensors CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:5a3eb2f9828c1e0a3440c5889eb362e13ebe9aec0bae3f4100d5a118340acaf2
3
- size 23968
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6d1688a1972b6bdcff37f6df1a7a8059d651ddbbbfdbf47f6e0e02859422d553
3
+ size 178576