TechTush commited on
Commit
2e2ea5e
·
verified ·
1 Parent(s): 28a7568

Rangrez dashboard

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +13 -0
  2. Dockerfile +12 -0
  3. README.md +20 -4
  4. pyproject.toml +38 -0
  5. src/rangrez/__init__.py +2 -0
  6. src/rangrez/agent.py +180 -0
  7. src/rangrez/assisted.py +127 -0
  8. src/rangrez/camera.py +41 -0
  9. src/rangrez/campaign.py +244 -0
  10. src/rangrez/card.py +37 -0
  11. src/rangrez/colour.py +158 -0
  12. src/rangrez/esp32_emulator.py +213 -0
  13. src/rangrez/glimmer.py +73 -0
  14. src/rangrez/km.py +152 -0
  15. src/rangrez/make_card.py +40 -0
  16. src/rangrez/photoreal.py +240 -0
  17. src/rangrez/profiles.py +89 -0
  18. src/rangrez/py.typed +0 -0
  19. src/rangrez/recorder.py +65 -0
  20. src/rangrez/rig.py +116 -0
  21. src/rangrez/scenes/crumpled_strip.jpg +3 -0
  22. src/rangrez/scenes/daylight_window.jpg +3 -0
  23. src/rangrez/scenes/evening_warm.jpg +3 -0
  24. src/rangrez/scenes/kitchen_tube.jpg +3 -0
  25. src/rangrez/scenes/kurta_target.jpg +3 -0
  26. src/rangrez/scenes/lamp_desk.jpg +3 -0
  27. src/rangrez/scenes/light_box.jpg +3 -0
  28. src/rangrez/scenes/masks/crumpled_strip_paper.png +0 -0
  29. src/rangrez/scenes/masks/crumpled_strip_strip.png +0 -0
  30. src/rangrez/scenes/masks/daylight_window_paper.png +0 -0
  31. src/rangrez/scenes/masks/daylight_window_strip.png +0 -0
  32. src/rangrez/scenes/masks/evening_warm_background.png +0 -0
  33. src/rangrez/scenes/masks/evening_warm_paper.png +0 -0
  34. src/rangrez/scenes/masks/evening_warm_strip.png +0 -0
  35. src/rangrez/scenes/masks/kitchen_tube_paper.png +0 -0
  36. src/rangrez/scenes/masks/kitchen_tube_strip.png +0 -0
  37. src/rangrez/scenes/masks/kurta_target_background.png +0 -0
  38. src/rangrez/scenes/masks/kurta_target_paper.png +0 -0
  39. src/rangrez/scenes/masks/kurta_target_target.png +0 -0
  40. src/rangrez/scenes/masks/lamp_desk_paper.png +0 -0
  41. src/rangrez/scenes/masks/lamp_desk_strip.png +0 -0
  42. src/rangrez/scenes/masks/light_box_paper.png +0 -0
  43. src/rangrez/scenes/masks/light_box_strip.png +0 -0
  44. src/rangrez/scenes/masks/no_paper_strip.png +0 -0
  45. src/rangrez/scenes/masks/phone_flash_paper.png +0 -0
  46. src/rangrez/scenes/masks/phone_flash_strip.png +0 -0
  47. src/rangrez/scenes/masks/shadowy_paper.png +0 -0
  48. src/rangrez/scenes/masks/shadowy_strip.png +0 -0
  49. src/rangrez/scenes/masks/tilted_phone_paper.png +0 -0
  50. src/rangrez/scenes/masks/tilted_phone_strip.png +0 -0
.gitattributes CHANGED
@@ -33,3 +33,16 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip 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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip 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
+ src/rangrez/scenes/crumpled_strip.jpg filter=lfs diff=lfs merge=lfs -text
37
+ src/rangrez/scenes/daylight_window.jpg filter=lfs diff=lfs merge=lfs -text
38
+ src/rangrez/scenes/evening_warm.jpg filter=lfs diff=lfs merge=lfs -text
39
+ src/rangrez/scenes/kitchen_tube.jpg filter=lfs diff=lfs merge=lfs -text
40
+ src/rangrez/scenes/kurta_target.jpg filter=lfs diff=lfs merge=lfs -text
41
+ src/rangrez/scenes/lamp_desk.jpg filter=lfs diff=lfs merge=lfs -text
42
+ src/rangrez/scenes/light_box.jpg filter=lfs diff=lfs merge=lfs -text
43
+ src/rangrez/scenes/no_paper.jpg filter=lfs diff=lfs merge=lfs -text
44
+ src/rangrez/scenes/phone_flash.jpg filter=lfs diff=lfs merge=lfs -text
45
+ src/rangrez/scenes/shadowy.jpg filter=lfs diff=lfs merge=lfs -text
46
+ src/rangrez/scenes/tilted_phone.jpg filter=lfs diff=lfs merge=lfs -text
47
+ src/rangrez/scenes/vertical_strip.jpg filter=lfs diff=lfs merge=lfs -text
48
+ src/rangrez/scenes/with_target.jpg filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Rangrez dashboard as a Hugging Face Space: the full dye-lab loop on the simulator,
2
+ # every dip a real-looking phone photo. Meta models run only with the live passcode.
3
+ FROM python:3.12-slim
4
+ RUN pip install --no-cache-dir uv==0.9.5
5
+ WORKDIR /app
6
+ COPY pyproject.toml uv.lock README.md ./
7
+ COPY src ./src
8
+ RUN uv sync --frozen --no-dev && chmod -R a+rX /app
9
+ ENV RANGREZ_HOST=0.0.0.0 RANGREZ_PORT_HTTP=7860 RANGREZ_RECORD=0 RANGREZ_STEP_DELAY=1.2 \
10
+ HOME=/tmp PYTHONUNBUFFERED=1
11
+ EXPOSE 7860
12
+ CMD ["/app/.venv/bin/rangrez-lab"]
README.md CHANGED
@@ -1,10 +1,26 @@
1
  ---
2
  title: Rangrez
3
- emoji: 📉
4
- colorFrom: yellow
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Rangrez
3
+ emoji: 🧵
4
+ colorFrom: indigo
5
+ colorTo: yellow
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: apache-2.0
10
+ short_description: A self-driving dye lab on Meta models
11
  ---
12
 
13
+ # Rangrez: a self-driving dye lab
14
+
15
+ Pick a colour and press **Start campaign**. Rangrez mixes a recipe, dyes a cotton strip in its
16
+ simulated lab (a hidden dye lot that differs from the supplier's model), photographs it like a
17
+ phone would, measures it, and corrects itself until the cloth matches. Every dip photo is a
18
+ real-looking scene: desk lamps, daylight, tube lights, shadows, paper of unknown whiteness.
19
+
20
+ Without the live passcode it runs the offline policy and SAM masks cached for each scene.
21
+ With it, hosted **SAM 3.1** measures every photo and **Muse Wonder** runs the campaign,
22
+ and you can read a target colour from your own photo ("match this kurta").
23
+
24
+ One lab, shared by everyone viewing: if a campaign is running, you are watching it.
25
+
26
+ Code, hardware, firmware and evaluation: https://github.com/TusharTechs/rangrez
pyproject.toml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "rangrez"
3
+ version = "0.1.0"
4
+ description = "A self-driving dye lab: Meta models match any colour on real cotton"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Tushar Agarwal", email = "tusharaggarwal274@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fastapi>=0.162.0",
12
+ "meta-sam-parser>=0.1.0",
13
+ "numpy>=2.5.4",
14
+ "openai>=3.42.0",
15
+ "opencv-python-headless>=5.0.0.93",
16
+ "pillow>=13.0.0",
17
+ "pillow-heif>=1.8.1",
18
+ "pyserial>=3.5",
19
+ "python-dotenv>=1.2.4",
20
+ "python-multipart>=0.0.33",
21
+ "scipy>=1.18.1",
22
+ "uvicorn[standard]>=0.54.0",
23
+ "websockets>=17.4",
24
+ ]
25
+
26
+ [project.scripts]
27
+ rangrez-lab = "rangrez.server:main"
28
+ rangrez-card = "rangrez.make_card:main"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.13.1,<0.14.0"]
32
+ build-backend = "uv_build"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=9.2.0",
37
+ "ruff>=0.17.3",
38
+ ]
src/rangrez/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ def hello() -> str:
2
+ return "Hello from rangrez!"
src/rangrez/agent.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Muse Wonder runs the campaign; deterministic rules keep it honest.
2
+
3
+ The model chooses which tool to call and explains why in the lab notebook.
4
+ Code enforces the rest: the shot budget, the safety gate, and escalation to a
5
+ human after three dips in a row that fail to improve on the best match.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import time
13
+ from collections.abc import Callable
14
+
15
+ import numpy as np
16
+
17
+ from .campaign import Campaign
18
+ from .profiles import PROFILE
19
+ from .rig import DYES
20
+ from .vision import BASE_URL, ApiCall, CallLog
21
+
22
+ WONDER_MODEL = os.environ.get("RANGREZ_AGENT_MODEL", "muse-wonder-1.0")
23
+ NO_PROGRESS_LIMIT = 3
24
+ MIN_IMPROVEMENT_DE00 = 0.3 # smaller gains are within dip-to-dip noise
25
+ CORRECTION_ZONE_DE00 = 6.0
26
+ OUT_OF_GAMUT_STREAK = 2 # dips at the concentration cap that still miss
27
+
28
+ SYSTEM_PROMPT = """You are the colourist running Rangrez, a small self-driving dye lab for cotton.
29
+ Goal: match the target shade on a real cotton strip to CIEDE2000 <= the tolerance, in as few dips as possible.
30
+
31
+ You control the lab only through tools. Rules:
32
+ - Never invent a colour, concentration or measurement. Every number you state must come from a tool result.
33
+ - Before every run_shot, call suggest_recipes. You may pick a candidate or blend candidates, but explain why.
34
+ - After each shot with a usable measurement, call fit_model so the dye model learns this dye lot.
35
+ - For the first dip, if a glimmer candidate is offered, use it: Glimmer learned this dye lot from the lab's own dips.
36
+ - Far from the target, trust model_inverse. Once the best dip is within about dE00 6, prefer colourist_correction:
37
+ the model always predicts a perfect match but cannot capture everything about this dye lot (saturation, lot strength),
38
+ while the correction is learned from real dips.
39
+ - If a shot is flagged REJECT, do not trust its colour; re-run or change something and say what.
40
+ - Write one short notebook entry (write_note) per shot: hypothesis, what you changed, what you expect.
41
+ - If dips at the concentration cap still miss, the shade is outside this dye set's gamut: say so and report the closest dip.
42
+ - Stop with finish when the target is matched or the budget is spent. Use escalate if something looks physically wrong.
43
+ Dyes: {dyes}. {description}."""
44
+
45
+ TOOLS = [
46
+ {"type": "function", "function": {"name": "get_state", "description": "Target, shots so far, best match, model gains, stock levels.", "parameters": {"type": "object", "properties": {}}}},
47
+ {"type": "function", "function": {"name": "fit_model", "description": "Refit the Kubelka-Munk dye model to all valid shots.", "parameters": {"type": "object", "properties": {}}}},
48
+ {"type": "function", "function": {"name": "suggest_recipes", "description": "Candidate recipes from model inversion and colourist correction, each with predicted dE00 and gate status.", "parameters": {"type": "object", "properties": {}}}},
49
+ {"type": "function", "function": {"name": "run_shot", "description": "Dispense, dip a cotton strip, dry, photograph and measure. Gated by hard safety limits.", "parameters": {"type": "object", "properties": {
50
+ "recipe": {"type": "object", "properties": {d: {"type": "number"} for d in DYES}, "required": list(DYES)},
51
+ "dip_seconds": {"type": "number", "description": "30-300, default 90"},
52
+ "rationale": {"type": "string"}}, "required": ["recipe", "rationale"]}}},
53
+ {"type": "function", "function": {"name": "write_note", "description": "Append to the lab notebook.", "parameters": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}},
54
+ {"type": "function", "function": {"name": "escalate", "description": "Stop and hand over to the human colourist.", "parameters": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}},
55
+ {"type": "function", "function": {"name": "finish", "description": "End the campaign with a short summary.", "parameters": {"type": "object", "properties": {"summary": {"type": "string"}}, "required": ["summary"]}}},
56
+ ]
57
+
58
+
59
+ def _no_progress(c: Campaign) -> int:
60
+ """Dips since the best match last improved by more than measurement noise."""
61
+ best_de, since = float("inf"), 0
62
+ for s in c.shots:
63
+ if s.measurement.ok and s.delta_e < best_de - MIN_IMPROVEMENT_DE00:
64
+ best_de, since = s.delta_e, 0
65
+ else:
66
+ since += 1
67
+ return since
68
+
69
+
70
+ def _out_of_gamut(c: Campaign) -> bool:
71
+ """The last dips pushed a dye to its safety cap and still missed: unreachable."""
72
+ recent = [s for s in c.shots if s.measurement.ok][-OUT_OF_GAMUT_STREAK:]
73
+ if len(recent) < OUT_OF_GAMUT_STREAK:
74
+ return False
75
+ cap = 0.98 * c.rig.limits.max_conc_g_per_l
76
+ return all(max(s.recipe.conc) >= cap and s.delta_e > c.tolerance for s in recent)
77
+
78
+
79
+ def dispatch(c: Campaign, name: str, args: dict) -> dict:
80
+ if name == "get_state":
81
+ return c.state()
82
+ if name == "fit_model":
83
+ return c.fit_model()
84
+ if name == "suggest_recipes":
85
+ return c.suggest_recipes()
86
+ if name == "run_shot":
87
+ out = c.run_shot(args["recipe"], float(args.get("dip_seconds", 90.0)), args.get("rationale", ""))
88
+ if c.status == "running" and _out_of_gamut(c):
89
+ c.declare_out_of_gamut()
90
+ out["out_of_gamut"] = True
91
+ elif c.status == "running" and _no_progress(c) >= NO_PROGRESS_LIMIT:
92
+ c.escalate(f"{NO_PROGRESS_LIMIT} dips in a row without improving on the best match")
93
+ out["escalated"] = True
94
+ return out
95
+ if name == "write_note":
96
+ c.note(args["text"])
97
+ return {"ok": True}
98
+ if name == "escalate":
99
+ return c.escalate(args["reason"])
100
+ if name == "finish":
101
+ if c.status == "running":
102
+ c.status = "finished"
103
+ c.note(args.get("summary", ""), who="agent")
104
+ c.emit("status", status=c.status)
105
+ return {"ok": True}
106
+ return {"error": f"unknown tool {name}"}
107
+
108
+
109
+ def run_with_wonder(c: Campaign, log: CallLog | None = None, max_turns: int = 60) -> None:
110
+ """Drive the campaign with Muse Wonder over the Meta Model API."""
111
+ from openai import OpenAI
112
+
113
+ log = log or CallLog()
114
+ client = OpenAI(base_url=BASE_URL, api_key=os.environ["MODEL_API_KEY"], timeout=120, max_retries=2)
115
+ messages: list[dict] = [
116
+ {"role": "system", "content": SYSTEM_PROMPT.format(dyes=", ".join(DYES), description=PROFILE.description)},
117
+ {"role": "user", "content": "New campaign. Current state:\n" + json.dumps(c.state())},
118
+ ]
119
+ for _ in range(max_turns):
120
+ if c.status != "running":
121
+ return
122
+ started = time.monotonic()
123
+ try:
124
+ resp = client.chat.completions.create(model=WONDER_MODEL, messages=messages, tools=TOOLS)
125
+ except Exception as exc:
126
+ log.add(ApiCall(WONDER_MODEL, "plan next step", time.monotonic() - started, False, str(exc)))
127
+ raise
128
+ usage = getattr(resp, "usage", None)
129
+ log.add(ApiCall(WONDER_MODEL, "plan next step", time.monotonic() - started, True,
130
+ f"{getattr(usage, 'total_tokens', '?')} tokens"))
131
+ msg = resp.choices[0].message
132
+ messages.append(msg.model_dump(exclude_none=True))
133
+ if msg.content:
134
+ c.emit("thought", text=msg.content)
135
+ if not msg.tool_calls:
136
+ messages.append({"role": "user", "content": "Continue with a tool call, or call finish."})
137
+ continue
138
+ for call in msg.tool_calls:
139
+ try:
140
+ args = json.loads(call.function.arguments or "{}")
141
+ except json.JSONDecodeError:
142
+ args = {}
143
+ c.emit("tool", name=call.function.name, args=args)
144
+ result = dispatch(c, call.function.name, args)
145
+ messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result, default=float)})
146
+ if c.status == "running":
147
+ c.escalate("agent turn budget exhausted")
148
+
149
+
150
+ def run_deterministic(c: Campaign, on_step: Callable[[], None] | None = None) -> None:
151
+ """Offline policy with the same tools and rules: used in tests and replay mode."""
152
+ while c.status == "running" and len(c.shots) < c.max_shots:
153
+ if c.shots:
154
+ dispatch(c, "fit_model", {})
155
+ cands = [x for x in c.suggest_recipes()["candidates"] if x["gate"] == "pass"]
156
+ if not cands:
157
+ dispatch(c, "escalate", {"reason": "no candidate passes the safety gate"})
158
+ break
159
+ # A colourist starts from the model, then corrects from real dips once close.
160
+ near = c.best is not None and c.best.delta_e < CORRECTION_ZONE_DE00
161
+ correction = [x for x in cands if x["method"] == "colourist_correction"]
162
+ first = [x for x in cands if x["method"] == "glimmer"]
163
+ if first:
164
+ pick = first[0]
165
+ else:
166
+ pick = correction[0] if near and correction else min(cands, key=lambda x: x["predicted_delta_e00"])
167
+ dispatch(c, "write_note", {"text": f"Shot {len(c.shots) + 1}: {pick['method']} predicts dE00 {pick['predicted_delta_e00']}."})
168
+ dispatch(c, "run_shot", {"recipe": pick["recipe"], "rationale": pick["why"]})
169
+ if on_step:
170
+ on_step()
171
+ if c.status == "running":
172
+ dispatch(c, "finish", {"summary": "Shot budget spent."})
173
+
174
+
175
+ def best_delta_e(c: Campaign) -> float:
176
+ b = c.best
177
+ return float("inf") if b is None else b.delta_e
178
+
179
+
180
+ __all__ = ["run_with_wonder", "run_deterministic", "dispatch", "best_delta_e", "np"]
src/rangrez/assisted.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assisted rig: a person is the pumps, the phone is the camera.
2
+
3
+ The agent and the safety gate work exactly as with the robot rig. Instead of
4
+ driving pumps, a dip becomes a short set of instructions on the dashboard
5
+ (how many mL of each colour, how long to dip), and the lab waits for a photo
6
+ of the dried strip lying next to the colour card.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import threading
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+
16
+ import numpy as np
17
+ from PIL import Image, ImageOps
18
+
19
+ from .camera import grab
20
+ from .camera import source as camera_source
21
+ from .profiles import PROFILE
22
+ from .rig import DYES, UNIT, Inventory, PumpPlan, Recipe, RigLimits, ShotResult, now
23
+
24
+ STEP_ML = PROFILE.step_ml
25
+ MAX_SIDE_PX = 1920 # SAM 3.1 accepts up to about 4 MP; phones shoot 12+
26
+
27
+
28
+ class Cancelled(RuntimeError):
29
+ pass
30
+
31
+
32
+ def normalise_photo(data: bytes) -> bytes:
33
+ """Any phone photo (JPEG, PNG, iPhone HEIC) -> upright RGB JPEG no larger than MAX_SIDE_PX."""
34
+ from pillow_heif import register_heif_opener
35
+
36
+ register_heif_opener()
37
+ try:
38
+ img = Image.open(io.BytesIO(data))
39
+ img = ImageOps.exif_transpose(img).convert("RGB")
40
+ except Exception as exc:
41
+ raise ValueError("could not read that image; please send a JPEG, PNG or HEIC photo") from exc
42
+ img.thumbnail((MAX_SIDE_PX, MAX_SIDE_PX))
43
+ buf = io.BytesIO()
44
+ img.save(buf, format="JPEG", quality=93)
45
+ return buf.getvalue()
46
+
47
+
48
+ @dataclass
49
+ class AssistedRig:
50
+ limits: RigLimits = field(default_factory=RigLimits)
51
+ inventory: Inventory = field(default_factory=Inventory)
52
+ on_instructions: Callable[[dict], None] = lambda _: None
53
+ timeout_s: float = 45 * 60
54
+ camera: int | str = field(default_factory=camera_source)
55
+
56
+ def __post_init__(self) -> None:
57
+ self._photo: bytes | None = None
58
+ self._ready = threading.Event()
59
+ self._cancelled = False
60
+ self.waiting: dict | None = None
61
+
62
+ # -- what a person can actually measure ------------------------------
63
+ def quantize(self, recipe: Recipe) -> Recipe:
64
+ ml = recipe.as_array() / self.limits.stock_g_per_l * self.limits.bath_ml
65
+ ml = np.round(ml / STEP_ML) * STEP_ML
66
+ conc = ml / self.limits.bath_ml * self.limits.stock_g_per_l
67
+ return Recipe(tuple(float(round(v, 4)) for v in conc), recipe.dip_seconds)
68
+
69
+ def instructions(self, recipe: Recipe, index: int) -> dict:
70
+ ml = recipe.as_array() / self.limits.stock_g_per_l * self.limits.bath_ml
71
+ water = self.limits.bath_ml - ml.sum()
72
+ return {
73
+ "index": index,
74
+ "dyes_ml": {d: round(float(v), 2) for d, v in zip(DYES, ml)},
75
+ "amounts": {d: (round(c) if UNIT == "drops" else round(float(v), 1)) for d, v, c in zip(DYES, ml, recipe.conc)},
76
+ "unit": "drops" if UNIT == "drops" else "mL",
77
+ "water_ml": round(float(water), 1),
78
+ "dip_seconds": int(recipe.dip_seconds),
79
+ "steps": [
80
+ "Rinse the glass and the dropper or syringes.",
81
+ *[self._amount(d, v, c) for d, v, c in zip(DYES, ml, recipe.conc) if v >= STEP_ML / 2],
82
+ (f"Add 4 teaspoons (20 mL) of water and stir." if UNIT == "drops" else f"Add {water:.1f} mL of water and stir for 10 seconds."),
83
+ f"Dip a fresh white cotton strip for {int(recipe.dip_seconds)} seconds.",
84
+ "Squeeze it gently and dry it fully with the hair dryer (about 2 minutes).",
85
+ "Lay it on a sheet of white paper (or next to the colour card) in the same spot and light as before, and send the photo.",
86
+ "If you have the customer's cloth, lay it beside the strip: Rangrez then compares them side by side under the same light.",
87
+ ],
88
+ }
89
+
90
+ @staticmethod
91
+ def _amount(dye: str, ml: float, conc: float) -> str:
92
+ if UNIT == "drops":
93
+ return f"Add {round(conc)} drops of {dye}."
94
+ return f"Add {ml:.1f} mL of {dye}."
95
+
96
+ # -- rig protocol ----------------------------------------------------------
97
+ def run_shot(self, recipe: Recipe) -> ShotResult:
98
+ started = now()
99
+ index = getattr(self, "_count", 0) + 1
100
+ self._count = index
101
+ self.waiting = self.instructions(recipe, index)
102
+ self._photo, self._cancelled = None, False
103
+ self._ready.clear()
104
+ self.on_instructions(self.waiting)
105
+ if not self._ready.wait(self.timeout_s):
106
+ self.waiting = None
107
+ raise Cancelled("no photo arrived in time")
108
+ self.waiting = None
109
+ if self._cancelled or self._photo is None:
110
+ raise Cancelled("campaign stopped")
111
+ ml = recipe.as_array() / self.limits.stock_g_per_l * self.limits.bath_ml
112
+ plan = PumpPlan(tuple(float(v) for v in ml), float(self.limits.bath_ml - ml.sum()))
113
+ return ShotResult(self._photo, plan, started, now(), {"assisted": True})
114
+
115
+ def provide(self, photo: bytes) -> None:
116
+ """Called when the person uploads a photo or presses 'use webcam'."""
117
+ if self.waiting is None:
118
+ raise RuntimeError("the lab is not waiting for a photo right now")
119
+ self._photo = normalise_photo(photo)
120
+ self._ready.set()
121
+
122
+ def cancel(self) -> None:
123
+ self._cancelled = True
124
+ self._ready.set()
125
+
126
+ def capture(self) -> bytes:
127
+ return grab(self.camera)
src/rangrez/camera.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grab one frame from a camera: a webcam index, a phone streaming as an IP
2
+ webcam (e.g. http://192.168.1.7:8080/video), or a video file.
3
+
4
+ RANGREZ_CAMERA=0 # first webcam
5
+ RANGREZ_CAMERA=1 # Continuity Camera on a Mac, often
6
+ RANGREZ_CAMERA=http://192.168.1.7:8080/video # an Android phone running an IP-webcam app
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+
14
+ def source() -> int | str:
15
+ raw = os.environ.get("RANGREZ_CAMERA", "0").strip()
16
+ return int(raw) if raw.isdigit() else raw
17
+
18
+
19
+ def grab(src: int | str | None = None, warmup: int = 10, width: int = 1920, height: int = 1080) -> bytes:
20
+ """A JPEG of the current view. Warm-up frames let auto exposure settle."""
21
+ import cv2
22
+
23
+ src = source() if src is None else src
24
+ cam = cv2.VideoCapture(src)
25
+ try:
26
+ if not cam.isOpened():
27
+ raise RuntimeError(f"could not open camera {src!r}")
28
+ if isinstance(src, int):
29
+ cam.set(cv2.CAP_PROP_FRAME_WIDTH, width)
30
+ cam.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
31
+ frame = None
32
+ for _ in range(warmup + 1):
33
+ ok, f = cam.read()
34
+ if ok:
35
+ frame = f
36
+ if frame is None:
37
+ raise RuntimeError(f"camera {src!r} returned no frame")
38
+ ok, jpeg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 94])
39
+ return jpeg.tobytes()
40
+ finally:
41
+ cam.release()
src/rangrez/campaign.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The colourist's toolbox: deterministic tools the agent calls.
2
+
3
+ A shade-matching campaign is what a dye-house colourist does by hand: an
4
+ initial recipe from the dye model, a lab dip, a measurement, a correction,
5
+ again. Here every step is a tool with a checkable result. The agent decides
6
+ which tool to call and why; it never computes a colour or a volume itself.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from dataclasses import dataclass, field
13
+
14
+ import numpy as np
15
+
16
+ from . import glimmer
17
+ from .colour import delta_e_2000, lab_to_hex
18
+ from .km import DyeModel, fit_gains, solve_recipe
19
+ from .profiles import PROFILE
20
+ from .rig import DYES, UNIT, GateError, Recipe, Rig, check_recipe, execute
21
+ from .vision import Measurement, Segmenter, measure, overlay
22
+
23
+ SIDE_BY_SIDE_MAX_DE00 = 12.0 # a cloth further than this from the target is not the customer's
24
+
25
+
26
+ @dataclass
27
+ class Shot:
28
+ index: int
29
+ recipe: Recipe
30
+ measurement: Measurement
31
+ delta_e: float
32
+ rationale: str
33
+ image_jpeg: bytes
34
+ overlay_jpeg: bytes
35
+ at: float = field(default_factory=time.time)
36
+
37
+
38
+ @dataclass
39
+ class Campaign:
40
+ target_lab: np.ndarray
41
+ rig: Rig
42
+ segmenter: Segmenter
43
+ tolerance: float = 1.5 # CIEDE2000 pass threshold
44
+ max_shots: int = 10
45
+ model: DyeModel = field(default_factory=PROFILE.nominal)
46
+ shots: list[Shot] = field(default_factory=list)
47
+ notebook: list[dict] = field(default_factory=list)
48
+ listeners: list = field(default_factory=list)
49
+ status: str = "running"
50
+ side_readings: list = field(default_factory=list) # the customer's cloth, read beside each strip
51
+
52
+ # -- events ------------------------------------------------------------
53
+ def emit(self, kind: str, **data) -> None:
54
+ event = {"kind": kind, "at": time.time(), **data}
55
+ for fn in self.listeners:
56
+ fn(event)
57
+
58
+ def note(self, text: str, who: str = "agent") -> None:
59
+ entry = {"who": who, "text": text, "at": time.time()}
60
+ self.notebook.append(entry)
61
+ self.emit("note", **entry)
62
+
63
+ # -- state ---------------------------------------------------------------
64
+ @property
65
+ def best(self) -> Shot | None:
66
+ ok = [s for s in self.shots if s.measurement.ok]
67
+ return min(ok, key=lambda s: s.delta_e) if ok else None
68
+
69
+ def state(self) -> dict:
70
+ best = self.best
71
+ return {
72
+ "target_lab": [round(v, 2) for v in self.target_lab],
73
+ "target_hex": lab_to_hex(self.target_lab),
74
+ "tolerance_de00": self.tolerance,
75
+ "shots_used": len(self.shots),
76
+ "shots_left": self.max_shots - len(self.shots),
77
+ "best": None if best is None else self._shot_summary(best),
78
+ "history": [self._shot_summary(s) for s in self.shots],
79
+ "model_gains": dict(zip(DYES, np.round(self.model.gains, 3).tolist())),
80
+ "stock_ml": dict(zip(DYES, np.round(self.rig.inventory.stock_ml, 1).tolist())),
81
+ "status": self.status,
82
+ }
83
+
84
+ @staticmethod
85
+ def _shot_summary(s: Shot) -> dict:
86
+ m = s.measurement
87
+ return {
88
+ "shot": s.index,
89
+ "recipe": dict(zip(DYES, np.round(s.recipe.conc, 3).tolist())),
90
+ "measured_lab": [round(v, 2) for v in m.lab],
91
+ "lab_sd": [round(v, 2) for v in m.lab_sd],
92
+ "delta_e00": round(s.delta_e, 2),
93
+ "flags": m.flags,
94
+ }
95
+
96
+ # -- tools -----------------------------------------------------------------
97
+ def fit_model(self) -> dict:
98
+ """Refit per-dye uptake to every valid shot so far."""
99
+ data = [(s.recipe.as_array(), s.measurement.lab) for s in self.shots if s.measurement.ok]
100
+ before = self.model.gains.copy()
101
+ self.model = fit_gains(self.model, data)
102
+ errs = [float(delta_e_2000(self.model.predict_lab(c), lab)) for c, lab in data]
103
+ out = {
104
+ "gains_before": dict(zip(DYES, np.round(before, 3).tolist())),
105
+ "gains_after": dict(zip(DYES, np.round(self.model.gains, 3).tolist())),
106
+ "model_fit_error_de00": round(float(np.mean(errs)), 2) if errs else None,
107
+ "shots_used": len(data),
108
+ }
109
+ self.emit("model", **out)
110
+ return out
111
+
112
+ def suggest_recipes(self) -> dict:
113
+ """Candidate recipes with predicted CIEDE2000, from two independent methods."""
114
+ cands = []
115
+ conc, de = solve_recipe(self.model, self.target_lab, max_conc=self.rig.limits.max_conc_g_per_l)
116
+ cands.append(("model_inverse", conc, de, "Invert the refitted Kubelka-Munk model."))
117
+ if not self.shots and glimmer.configured():
118
+ g = glimmer.first_guess(self.target_lab, log=getattr(self.segmenter, "log", None))
119
+ if g is not None:
120
+ d = float(delta_e_2000(self.model.predict_lab(g), self.target_lab))
121
+ cands.append(("glimmer", g, d, "Muse Glimmer fine-tuned on this lab's own dips: its first recipes "
122
+ "land closer than the supplier model's (the predicted dE00 shown is the supplier model's opinion)."))
123
+ best = self.best
124
+ if best is not None:
125
+ step = self._correction_step(best)
126
+ if step is not None:
127
+ pred = float(delta_e_2000(best.measurement.lab + self._jac(best.recipe.as_array()) @ (step - best.recipe.as_array()), self.target_lab))
128
+ cands.append(("colourist_correction", step, pred, "Correct the best real dip by its measured error, using the refitted model's sensitivity."))
129
+ out = []
130
+ for name, c, d, why in cands:
131
+ c = np.clip(c, 0, self.rig.limits.max_conc_g_per_l)
132
+ recipe = Recipe(tuple(float(round(v, 3)) for v in c))
133
+ try:
134
+ check_recipe(recipe, self.rig.limits, self.rig.inventory)
135
+ gate = "pass"
136
+ except GateError as exc:
137
+ gate = f"refused: {exc}"
138
+ out.append({
139
+ "method": name,
140
+ "recipe": dict(zip(DYES, recipe.conc)),
141
+ "predicted_delta_e00": round(float(d), 2),
142
+ "gate": gate,
143
+ "why": why,
144
+ })
145
+ return {"candidates": out}
146
+
147
+ def run_shot(self, recipe: dict, dip_seconds: float = 90.0, rationale: str = "") -> dict:
148
+ """Gate, dispense, dip, dry, photograph, measure."""
149
+ conc = tuple(float(recipe.get(d, 0.0)) for d in DYES)
150
+ recipe = Recipe(conc, dip_seconds)
151
+ # A rig that can only measure coarsely (a person with a syringe) reports what it will really dispense.
152
+ quantize = getattr(self.rig, "quantize", None)
153
+ if quantize:
154
+ recipe = quantize(recipe)
155
+ if len(self.shots) >= self.max_shots:
156
+ return {"refused": "shot budget exhausted; escalate or stop"}
157
+ try:
158
+ check_recipe(recipe, self.rig.limits, self.rig.inventory)
159
+ except GateError as exc:
160
+ self.emit("gate", recipe=recipe.describe(), refused=str(exc))
161
+ return {"refused": str(exc)}
162
+ self.emit("dispense", recipe=recipe.describe(), index=len(self.shots) + 1, rationale=rationale)
163
+ result = execute(self.rig, recipe)
164
+ m = measure(result.image_jpeg, self.segmenter, with_target=True, seed=len(self.shots))
165
+ if m.ok and m.target_lab is not None:
166
+ self._side_by_side(m)
167
+ de = float(delta_e_2000(m.lab, self.target_lab)) if m.ok else float("nan")
168
+ shot = Shot(len(self.shots) + 1, recipe, m, de, rationale, result.image_jpeg, overlay(result.image_jpeg, m))
169
+ self.shots.append(shot)
170
+ self.emit("shot", index=shot.index, recipe=recipe.describe(), lab=m.lab.tolist(),
171
+ hex=lab_to_hex(m.lab), delta_e=de, flags=m.flags, rationale=rationale)
172
+ if m.ok and de <= self.tolerance:
173
+ self.status = "matched"
174
+ self.emit("status", status=self.status, shot=shot.index, delta_e=de)
175
+ return self._shot_summary(shot) | {"matched": m.ok and de <= self.tolerance}
176
+
177
+ def _side_by_side(self, m) -> None:
178
+ """The customer's cloth lies beside the strip: compare them under the same light.
179
+
180
+ A target read from a photo taken elsewhere carries that photo's light and camera
181
+ error; the cloth in the dip photo shares the strip's. Re-anchor to the running mean
182
+ of those side-by-side readings, unless the "cloth" is clearly something else.
183
+ """
184
+ seen = np.asarray(m.target_lab, dtype=float)
185
+ if float(delta_e_2000(seen, self.target_lab if not self.side_readings else np.mean(self.side_readings, axis=0))) > SIDE_BY_SIDE_MAX_DE00:
186
+ m.flags.append("WARN: another cloth is in the photo but it is not the target colour; ignored it")
187
+ return
188
+ first = not self.side_readings
189
+ self.side_readings.append(seen)
190
+ before = self.target_lab
191
+ self.target_lab = np.mean(self.side_readings, axis=0)
192
+ m.flags.append("NOTE: compared side by side with the customer's cloth in the same photo")
193
+ if first:
194
+ self.note(f"The customer's cloth is in the photo, so I compare the strip with it under the same light. "
195
+ f"Target re-read as {lab_to_hex(self.target_lab)} (was {lab_to_hex(before)}, "
196
+ f"dE00 {float(delta_e_2000(before, self.target_lab)):.1f} apart).", who="agent")
197
+ for s in self.shots: # earlier dips are judged against the better target too
198
+ s.delta_e = float(delta_e_2000(s.measurement.lab, self.target_lab)) if s.measurement.ok else float("nan")
199
+
200
+ def declare_out_of_gamut(self) -> dict:
201
+ best = self.best
202
+ self.status = "out_of_gamut"
203
+ text = (
204
+ f"Target is outside what {', '.join(DYES)} can reach on cotton within the "
205
+ f"{self.rig.limits.max_conc_g_per_l:.0f} {UNIT} safety cap. Closest achievable: dip {best.index}, "
206
+ f"{lab_to_hex(best.measurement.lab)}, dE00 {best.delta_e:.2f}. A darker or different dye is needed."
207
+ )
208
+ self.note(text, who="agent")
209
+ self.emit("status", status=self.status, reason=text, shot=best.index, delta_e=best.delta_e)
210
+ return {"out_of_gamut": True, "closest_shot": best.index, "delta_e00": round(best.delta_e, 2)}
211
+
212
+ def escalate(self, reason: str) -> dict:
213
+ self.status = "escalated"
214
+ self.note(f"Escalated to the colourist: {reason}", who="agent")
215
+ self.emit("status", status=self.status, reason=reason)
216
+ return {"escalated": True}
217
+
218
+ # -- numerics ----------------------------------------------------------------
219
+ def _jac(self, conc: np.ndarray) -> np.ndarray:
220
+ # Sensitivity from the model refitted to every real dip. Learning it from the dips
221
+ # themselves (Broyden, or ridge regression on nearby dips) chases measurement noise
222
+ # when steps are whole drops: tried, and it matched fewer shades.
223
+ return self._model_jacobian(conc)
224
+
225
+ def _model_jacobian(self, conc: np.ndarray) -> np.ndarray:
226
+ h = 0.05
227
+ base = self.model.predict_lab(conc)
228
+ cols = []
229
+ for i in range(len(conc)):
230
+ c = conc.copy()
231
+ c[i] += h
232
+ cols.append((self.model.predict_lab(c) - base) / h)
233
+ return np.stack(cols, axis=1)
234
+
235
+ def _correction_step(self, best: Shot) -> np.ndarray | None:
236
+ c0 = best.recipe.as_array()
237
+ J = self._jac(c0)
238
+ err = self.target_lab - best.measurement.lab
239
+ # Damped least squares; weight lightness less, as CIEDE2000 does.
240
+ Wt = np.diag([0.6, 1.0, 1.0])
241
+ A = Wt @ J
242
+ lam = 0.05
243
+ dc = np.linalg.solve(A.T @ A + lam * np.eye(len(c0)), A.T @ (Wt @ err))
244
+ return np.clip(c0 + dc, 0, self.rig.limits.max_conc_g_per_l)
src/rangrez/card.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The printed reference card that sits in every frame.
2
+
3
+ A 24-patch ColorChecker-style layout (4 rows x 6 columns). The reference
4
+ values are the published sRGB approximations of the Classic chart; print the
5
+ card in `hardware/colour_card.pdf` and every shot is colour-corrected against it,
6
+ so lighting drift and camera auto-white-balance cancel out.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ from .colour import srgb_to_linear
14
+
15
+ PATCH_NAMES = [
16
+ "dark skin", "light skin", "blue sky", "foliage", "blue flower", "bluish green",
17
+ "orange", "purplish blue", "moderate red", "purple", "yellow green", "orange yellow",
18
+ "blue", "green", "red", "yellow", "magenta", "cyan",
19
+ "white", "neutral 8", "neutral 6.5", "neutral 5", "neutral 3.5", "black",
20
+ ]
21
+
22
+ PATCH_SRGB_8BIT = np.array(
23
+ [
24
+ [115, 82, 68], [194, 150, 130], [98, 122, 157], [87, 108, 67], [133, 128, 177], [103, 189, 170],
25
+ [214, 126, 44], [80, 91, 166], [193, 90, 99], [94, 60, 108], [157, 188, 64], [224, 163, 46],
26
+ [56, 61, 150], [70, 148, 73], [175, 54, 60], [231, 199, 31], [187, 86, 149], [8, 133, 161],
27
+ [243, 243, 242], [200, 200, 200], [160, 160, 160], [122, 122, 121], [85, 85, 85], [52, 52, 52],
28
+ ],
29
+ dtype=float,
30
+ )
31
+
32
+ ROWS, COLS = 4, 6
33
+
34
+
35
+ def reference_linear() -> np.ndarray:
36
+ """(24, 3) linear sRGB reference values of the card patches."""
37
+ return srgb_to_linear(PATCH_SRGB_8BIT / 255.0)
src/rangrez/colour.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Colour science: sRGB <-> CIELAB, CIEDE2000, and camera colour correction.
2
+
3
+ Everything colour-related that has a right answer lives here, so the agent
4
+ never has to reason about it in prose.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ # D65 reference white for the sRGB path (CIE 1931 2 deg), Y normalised to 100.
12
+ D65_WHITE = np.array([95.047, 100.0, 108.883])
13
+
14
+ # Linear sRGB -> XYZ (D65), IEC 61966-2-1.
15
+ _RGB_TO_XYZ = np.array(
16
+ [
17
+ [0.4124564, 0.3575761, 0.1804375],
18
+ [0.2126729, 0.7151522, 0.0721750],
19
+ [0.0193339, 0.1191920, 0.9503041],
20
+ ]
21
+ )
22
+ _XYZ_TO_RGB = np.linalg.inv(_RGB_TO_XYZ)
23
+
24
+
25
+ def srgb_to_linear(rgb: np.ndarray) -> np.ndarray:
26
+ """sRGB in [0, 1] -> linear light in [0, 1]."""
27
+ rgb = np.asarray(rgb, dtype=float)
28
+ return np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
29
+
30
+
31
+ def linear_to_srgb(lin: np.ndarray) -> np.ndarray:
32
+ lin = np.clip(np.asarray(lin, dtype=float), 0.0, 1.0)
33
+ return np.where(lin <= 0.0031308, lin * 12.92, 1.055 * lin ** (1 / 2.4) - 0.055)
34
+
35
+
36
+ def linear_rgb_to_xyz(lin: np.ndarray) -> np.ndarray:
37
+ return np.asarray(lin, dtype=float) @ _RGB_TO_XYZ.T * 100.0
38
+
39
+
40
+ def xyz_to_linear_rgb(xyz: np.ndarray) -> np.ndarray:
41
+ return (np.asarray(xyz, dtype=float) / 100.0) @ _XYZ_TO_RGB.T
42
+
43
+
44
+ def xyz_to_lab(xyz: np.ndarray, white: np.ndarray = D65_WHITE) -> np.ndarray:
45
+ t = np.asarray(xyz, dtype=float) / white
46
+ delta = 6 / 29
47
+ f = np.where(t > delta**3, np.cbrt(t), t / (3 * delta**2) + 4 / 29)
48
+ L = 116 * f[..., 1] - 16
49
+ a = 500 * (f[..., 0] - f[..., 1])
50
+ b = 200 * (f[..., 1] - f[..., 2])
51
+ return np.stack([L, a, b], axis=-1)
52
+
53
+
54
+ def lab_to_xyz(lab: np.ndarray, white: np.ndarray = D65_WHITE) -> np.ndarray:
55
+ lab = np.asarray(lab, dtype=float)
56
+ fy = (lab[..., 0] + 16) / 116
57
+ fx = fy + lab[..., 1] / 500
58
+ fz = fy - lab[..., 2] / 200
59
+ delta = 6 / 29
60
+ f = np.stack([fx, fy, fz], axis=-1)
61
+ t = np.where(f > delta, f**3, 3 * delta**2 * (f - 4 / 29))
62
+ return t * white
63
+
64
+
65
+ def srgb_to_lab(rgb: np.ndarray) -> np.ndarray:
66
+ """sRGB in [0, 1] -> CIELAB (D65)."""
67
+ return xyz_to_lab(linear_rgb_to_xyz(srgb_to_linear(rgb)))
68
+
69
+
70
+ def lab_to_srgb(lab: np.ndarray) -> np.ndarray:
71
+ return linear_to_srgb(xyz_to_linear_rgb(lab_to_xyz(lab)))
72
+
73
+
74
+ def hex_to_lab(hex_colour: str) -> np.ndarray:
75
+ h = hex_colour.lstrip("#")
76
+ rgb = np.array([int(h[i : i + 2], 16) for i in (0, 2, 4)]) / 255.0
77
+ return srgb_to_lab(rgb)
78
+
79
+
80
+ def lab_to_hex(lab: np.ndarray) -> str:
81
+ rgb = np.round(lab_to_srgb(lab) * 255).astype(int)
82
+ return "#" + "".join(f"{int(v):02x}" for v in np.clip(rgb, 0, 255))
83
+
84
+
85
+ def delta_e_2000(lab1: np.ndarray, lab2: np.ndarray) -> np.ndarray:
86
+ """CIEDE2000 colour difference (Sharma, Wu & Dalal 2005), kL = kC = kH = 1."""
87
+ lab1 = np.asarray(lab1, dtype=float)
88
+ lab2 = np.asarray(lab2, dtype=float)
89
+ L1, a1, b1 = lab1[..., 0], lab1[..., 1], lab1[..., 2]
90
+ L2, a2, b2 = lab2[..., 0], lab2[..., 1], lab2[..., 2]
91
+
92
+ C1 = np.hypot(a1, b1)
93
+ C2 = np.hypot(a2, b2)
94
+ C_bar = (C1 + C2) / 2
95
+ G = 0.5 * (1 - np.sqrt(C_bar**7 / (C_bar**7 + 25**7)))
96
+ a1p = (1 + G) * a1
97
+ a2p = (1 + G) * a2
98
+ C1p = np.hypot(a1p, b1)
99
+ C2p = np.hypot(a2p, b2)
100
+ h1p = np.degrees(np.arctan2(b1, a1p)) % 360
101
+ h2p = np.degrees(np.arctan2(b2, a2p)) % 360
102
+
103
+ dLp = L2 - L1
104
+ dCp = C2p - C1p
105
+ dhp = h2p - h1p
106
+ dhp = np.where(dhp > 180, dhp - 360, dhp)
107
+ dhp = np.where(dhp < -180, dhp + 360, dhp)
108
+ dhp = np.where(C1p * C2p == 0, 0.0, dhp)
109
+ dHp = 2 * np.sqrt(C1p * C2p) * np.sin(np.radians(dhp / 2))
110
+
111
+ Lp_bar = (L1 + L2) / 2
112
+ Cp_bar = (C1p + C2p) / 2
113
+ hsum = h1p + h2p
114
+ hp_bar = np.where(
115
+ C1p * C2p == 0,
116
+ hsum,
117
+ np.where(
118
+ np.abs(h1p - h2p) <= 180,
119
+ hsum / 2,
120
+ np.where(hsum < 360, (hsum + 360) / 2, (hsum - 360) / 2),
121
+ ),
122
+ )
123
+ T = (
124
+ 1
125
+ - 0.17 * np.cos(np.radians(hp_bar - 30))
126
+ + 0.24 * np.cos(np.radians(2 * hp_bar))
127
+ + 0.32 * np.cos(np.radians(3 * hp_bar + 6))
128
+ - 0.20 * np.cos(np.radians(4 * hp_bar - 63))
129
+ )
130
+ d_theta = 30 * np.exp(-(((hp_bar - 275) / 25) ** 2))
131
+ R_C = 2 * np.sqrt(Cp_bar**7 / (Cp_bar**7 + 25**7))
132
+ S_L = 1 + 0.015 * (Lp_bar - 50) ** 2 / np.sqrt(20 + (Lp_bar - 50) ** 2)
133
+ S_C = 1 + 0.045 * Cp_bar
134
+ S_H = 1 + 0.015 * Cp_bar * T
135
+ R_T = -np.sin(np.radians(2 * d_theta)) * R_C
136
+ return np.sqrt(
137
+ (dLp / S_L) ** 2
138
+ + (dCp / S_C) ** 2
139
+ + (dHp / S_H) ** 2
140
+ + R_T * (dCp / S_C) * (dHp / S_H)
141
+ )
142
+
143
+
144
+ def fit_colour_correction(measured_lin: np.ndarray, reference_lin: np.ndarray) -> np.ndarray:
145
+ """Least-squares affine map (4x3) from camera linear RGB to reference linear sRGB.
146
+
147
+ measured_lin, reference_lin: (n_patches, 3). Returns M such that
148
+ [rgb, 1] @ M ~= reference.
149
+ """
150
+ X = np.hstack([measured_lin, np.ones((len(measured_lin), 1))])
151
+ M, *_ = np.linalg.lstsq(X, reference_lin, rcond=None)
152
+ return M
153
+
154
+
155
+ def apply_colour_correction(M: np.ndarray, lin: np.ndarray) -> np.ndarray:
156
+ lin = np.asarray(lin, dtype=float)
157
+ X = np.concatenate([lin, np.ones(lin.shape[:-1] + (1,))], axis=-1)
158
+ return X @ M
src/rangrez/esp32_emulator.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A virtual Rangrez rig: the ESP32 firmware's protocol and hard limits on a pseudo serial port,
2
+ with the simulator's hidden dye chemistry and a light-box camera that records video.
3
+
4
+ The limits are read from firmware/rangrez_rig/rangrez_rig.ino itself, so the emulator
5
+ refuses exactly what the board refuses. The real SerialRig driver talks to it over a
6
+ real (pseudo) serial port and grabs frames from its camera video with OpenCV, so the
7
+ whole robot path runs end to end without a single part bought.
8
+
9
+ RANGREZ_RIG=serial RANGREZ_PORT=virtual uv run rangrez-lab
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import tempfile
18
+ import termios
19
+ import threading
20
+ import time
21
+ import tty
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ import cv2
26
+ import numpy as np
27
+
28
+ from .km import reflectance_to_lab
29
+ from .sim import VirtualRig
30
+
31
+ FIRMWARE = Path(__file__).parents[2] / "firmware" / "rangrez_rig" / "rangrez_rig.ino"
32
+
33
+
34
+ def firmware_limits(path: Path = FIRMWARE) -> dict[str, float]:
35
+ """The `const float MAX_*` limits and pump calibration, as compiled into the board."""
36
+ src = path.read_text()
37
+ limits = {k: float(v) for k, v in re.findall(r"const float (MAX_\w+) = ([\d.]+);", src)}
38
+ limits["msPerMl"] = [float(v) for v in re.search(r"msPerMl\[5\] = \{([^}]*)\}", src).group(1).split(",")]
39
+ return limits
40
+
41
+
42
+ @dataclass
43
+ class VirtualESP32:
44
+ """Speaks the firmware's line protocol. time_scale=0 skips the real waiting."""
45
+
46
+ camera_path: str = field(default_factory=lambda: tempfile.mkstemp(suffix=".mp4", prefix="rangrez-cam-")[1])
47
+ scene: str = "light_box"
48
+ card: bool = True
49
+ seed: int = 21
50
+ time_scale: float = 0.0
51
+ pump_rate_error: tuple[float, ...] = (0.97, 1.04, 1.01, 1.0) # real pumps vs their calibration
52
+ pump_cv: float = 0.02
53
+
54
+ def __post_init__(self) -> None:
55
+ self.limits = firmware_limits()
56
+ self.rig = VirtualRig(seed=self.seed, scene=self.scene, card=self.card)
57
+ self.rng = np.random.default_rng(self.seed)
58
+ self.bath_ml = 0.0
59
+ self.dye_ml = np.zeros(3)
60
+ self.estop = False
61
+ self.light = False
62
+ self.log: list[dict] = []
63
+ self._write_video() # the camera shows the empty light box until the first dip
64
+
65
+ # -- e-stop button ----------------------------------------------------------
66
+ def press_estop(self) -> None:
67
+ self.estop = True
68
+
69
+ def _wait(self, seconds: float) -> bool:
70
+ end = time.monotonic() + seconds * self.time_scale
71
+ while time.monotonic() < end:
72
+ if self.estop:
73
+ return False
74
+ time.sleep(0.005)
75
+ return not self.estop
76
+
77
+ # -- protocol ---------------------------------------------------------------
78
+ def handle(self, line: str) -> dict:
79
+ try:
80
+ doc = json.loads(line)
81
+ except json.JSONDecodeError:
82
+ return self._reply("?", "bad json")
83
+ cmd = str(doc.get("cmd", ""))
84
+ self.log.append(doc)
85
+ if self.estop and cmd not in ("reset", "ping"):
86
+ return self._reply(cmd, "e-stop latched; send reset")
87
+ L = self.limits
88
+ if cmd == "ping":
89
+ return {"fw": "rangrez-1", "estop": self.estop, "bath_ml": self.bath_ml}
90
+ if cmd == "reset":
91
+ self.estop = False
92
+ return self._reply(cmd)
93
+ if cmd == "pump":
94
+ pid, ml = int(doc.get("id", -1)), float(doc.get("ml", 0))
95
+ if not 0 <= pid <= 3:
96
+ return self._reply(cmd, "pump id must be 0-3 (use drain for 4)")
97
+ if ml <= 0 or ml > L["MAX_PUMP_ML"]:
98
+ return self._reply(cmd, "ml out of range")
99
+ if self.bath_ml + ml > L["MAX_BATH_ML"]:
100
+ return self._reply(cmd, "cup would overflow")
101
+ ok = self._wait(ml * L["msPerMl"][pid] / 1000)
102
+ delivered = ml * self.pump_rate_error[pid] * self.rng.normal(1, self.pump_cv)
103
+ if pid < 3:
104
+ self.dye_ml[pid] += delivered
105
+ self.bath_ml += ml # worst case, for the overflow check
106
+ return self._reply(cmd) if ok else self._reply(cmd, "e-stop during pump")
107
+ if cmd == "drain":
108
+ s = float(doc.get("seconds", 0))
109
+ if s <= 0 or s > L["MAX_DRAIN_S"]:
110
+ return self._reply(cmd, "drain seconds out of range")
111
+ if not self._wait(s):
112
+ return self._reply(cmd, "e-stop during drain")
113
+ self.bath_ml, self.dye_ml = 0.0, np.zeros(3)
114
+ return self._reply(cmd)
115
+ if cmd == "stir":
116
+ s = float(doc.get("seconds", 0))
117
+ if s <= 0 or s > L["MAX_STIR_S"]:
118
+ return self._reply(cmd, "stir seconds out of range")
119
+ return self._reply(cmd) if self._wait(s) else self._reply(cmd, "e-stop during stir")
120
+ if cmd == "dip":
121
+ s = float(doc.get("seconds", 0))
122
+ if s < 10 or s > L["MAX_DIP_S"]:
123
+ return self._reply(cmd, "dip seconds out of range")
124
+ if self.bath_ml < 10:
125
+ return self._reply(cmd, "cup is nearly empty")
126
+ if not self._wait(s):
127
+ return self._reply(cmd, "e-stop during dip")
128
+ self._dye_segment(s)
129
+ return self._reply(cmd)
130
+ if cmd == "advance":
131
+ mm = float(doc.get("mm", 0))
132
+ if mm <= 0 or mm > L["MAX_ADVANCE_MM"]:
133
+ return self._reply(cmd, "advance mm out of range")
134
+ return self._reply(cmd) if self._wait(mm / 30) else self._reply(cmd, "e-stop")
135
+ if cmd == "dry":
136
+ s = float(doc.get("seconds", 0))
137
+ if s <= 0 or s > L["MAX_DRY_S"]:
138
+ return self._reply(cmd, "dry seconds out of range")
139
+ return self._reply(cmd) if self._wait(s) else self._reply(cmd, "e-stop during dry")
140
+ if cmd == "light":
141
+ self.light = bool(doc.get("on", False))
142
+ if self.light:
143
+ self._write_video()
144
+ return self._reply(cmd)
145
+ if cmd == "calibrate":
146
+ pid, ms = int(doc.get("id", -1)), float(doc.get("ms_per_ml", 0))
147
+ if not 0 <= pid <= 3 or not 100 <= ms <= 5000:
148
+ return self._reply(cmd, "bad calibration")
149
+ L["msPerMl"][pid] = ms
150
+ return self._reply(cmd)
151
+ return self._reply(cmd, "unknown command")
152
+
153
+ def _reply(self, cmd: str, error: str | None = None) -> dict:
154
+ out = {"error": error} if error else {"done": cmd}
155
+ return {**out, "bath_ml": self.bath_ml}
156
+
157
+ # -- the physical world -------------------------------------------------------
158
+ def _dye_segment(self, dip_seconds: float) -> None:
159
+ r = self.rig
160
+ dye_ml = self.dye_ml * r.limits.bath_ml / max(self.bath_ml, 1e-6) # as if in the nominal bath
161
+ conc = dye_ml / r.limits.bath_ml * r.limits.stock_g_per_l
162
+ saturated = conc / (1 + conc / r.saturation_g_per_l)
163
+ time_factor = (1 - np.exp(-dip_seconds / 45)) / (1 - np.exp(-90 / 45))
164
+ r.last_swatch_lab = reflectance_to_lab(r.world.reflectance(saturated * time_factor))
165
+
166
+ def _write_video(self, frames: int = 14) -> None:
167
+ """What the light-box webcam sees: a short clip with a little sensor noise per frame."""
168
+ jpeg = self.rig.render()
169
+ img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
170
+ h, w = img.shape[:2]
171
+ tmp = self.camera_path + ".tmp.mp4"
172
+ out = cv2.VideoWriter(tmp, cv2.VideoWriter_fourcc(*"mp4v"), 10, (w, h))
173
+ for _ in range(frames):
174
+ noisy = np.clip(img.astype(np.int16) + self.rng.normal(0, 1.5, img.shape).astype(np.int16), 0, 255)
175
+ out.write(noisy.astype(np.uint8))
176
+ out.release()
177
+ os.replace(tmp, self.camera_path)
178
+
179
+ @property
180
+ def true_lab(self) -> np.ndarray | None:
181
+ return self.rig.last_swatch_lab
182
+
183
+ # -- serial port -------------------------------------------------------------
184
+ def serve(self) -> str:
185
+ """Open a pseudo serial port, answer on it from a thread, return its device path."""
186
+ master, slave = os.openpty()
187
+ tty.setraw(slave)
188
+ attrs = termios.tcgetattr(slave)
189
+ attrs[3] &= ~termios.ECHO
190
+ termios.tcsetattr(slave, termios.TCSANOW, attrs)
191
+ self._slave_fd = slave # keep it open so the port stays alive between connections
192
+ threading.Thread(target=self._loop, args=(master,), daemon=True).start()
193
+ return os.ttyname(slave)
194
+
195
+ def _loop(self, fd: int) -> None:
196
+ buf = b""
197
+ while True:
198
+ try:
199
+ chunk = os.read(fd, 1024)
200
+ except OSError:
201
+ return
202
+ buf += chunk
203
+ while b"\n" in buf:
204
+ line, buf = buf.split(b"\n", 1)
205
+ if line.strip():
206
+ os.write(fd, (json.dumps(self.handle(line.decode(errors="replace"))) + "\n").encode())
207
+
208
+
209
+ def start(**kw) -> tuple[VirtualESP32, str]:
210
+ """A running virtual rig: (board, serial port path). Point the camera at board.camera_path."""
211
+ board = VirtualESP32(**kw)
212
+ return board, board.serve()
213
+
src/rangrez/glimmer.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Glimmer: Muse Glimmer 30B fine-tuned on this lab's own dips, proposing the first recipe.
2
+
3
+ The supplier's model knows the dyes in general; Glimmer learned this dye lot through this
4
+ camera, from every recipe the lab dipped and the colour SAM 3.1 measured on the cloth
5
+ (eval/glimmer.py). The open LoRA weights run anywhere; on Fireworks set
6
+
7
+ GLIMMER_MODEL=accounts/<account>/models/<model>#accounts/<account>/deployments/<deployment>
8
+
9
+ and the first dip of every campaign gets a "glimmer" candidate.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+
18
+ import numpy as np
19
+
20
+ from .colour import lab_to_hex
21
+ from .profiles import PROFILE
22
+
23
+ INFERENCE_URL = "https://api.fireworks.ai/inference/v1"
24
+ SYSTEM = (
25
+ f"You are Glimmer, the colourist of a small cotton dye lab. Dyes: {', '.join(PROFILE.names)} "
26
+ f"({PROFILE.description}). Given a target colour measured in CIELAB (D65), reply with only the "
27
+ f"recipe for the first dip as JSON with one key per dye, in {PROFILE.unit}, each between 0 and {PROFILE.max_conc:g}."
28
+ )
29
+
30
+
31
+ def prompt(lab) -> str:
32
+ L, a, b = (float(v) for v in lab)
33
+ return f"Target L* {L:.1f}, a* {a:.1f}, b* {b:.1f} ({lab_to_hex(np.array([L, a, b]))}). First dip recipe?"
34
+
35
+
36
+ def answer(conc) -> str:
37
+ return json.dumps({d: round(float(c), 2) for d, c in zip(PROFILE.names, conc)})
38
+
39
+
40
+ def parse(text: str) -> np.ndarray | None:
41
+ try:
42
+ obj = json.loads(text[text.index("{"): text.rindex("}") + 1])
43
+ return np.clip([float(obj.get(d, 0.0)) for d in PROFILE.names], 0, PROFILE.max_conc)
44
+ except (ValueError, json.JSONDecodeError, TypeError, AttributeError):
45
+ return None
46
+
47
+
48
+ def configured() -> bool:
49
+ return bool(os.environ.get("GLIMMER_MODEL") and os.environ.get("FIREWORKS_API_KEY"))
50
+
51
+
52
+ def first_guess(target_lab, log=None, model: str | None = None) -> np.ndarray | None:
53
+ """Glimmer's first recipe for target_lab, or None if it is not configured or answers badly."""
54
+ from openai import OpenAI
55
+
56
+ from .vision import ApiCall
57
+
58
+ model = model or os.environ.get("GLIMMER_MODEL")
59
+ if not model:
60
+ return None
61
+ client = OpenAI(base_url=INFERENCE_URL, api_key=os.environ["FIREWORKS_API_KEY"].strip(), timeout=60, max_retries=1)
62
+ started = time.monotonic()
63
+ try:
64
+ r = client.chat.completions.create(model=model, temperature=0, max_tokens=400, messages=[
65
+ {"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt(target_lab)}])
66
+ conc = parse(r.choices[0].message.content or "")
67
+ except Exception as exc: # the lab works without Glimmer: never fail a campaign over it
68
+ if log is not None:
69
+ log.add(ApiCall("muse-glimmer-30b (tuned)", "first dip recipe", time.monotonic() - started, False, str(exc)[:200]))
70
+ return None
71
+ if log is not None:
72
+ log.add(ApiCall("muse-glimmer-30b (tuned)", "first dip recipe", time.monotonic() - started, conc is not None))
73
+ return conc
src/rangrez/km.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spectral Kubelka-Munk model of reactive dye on cotton.
2
+
3
+ This is the colourist's model: each dye adds absorption (K/S) to the fibre in
4
+ proportion to how much of it fixes, and reflectance follows from total K/S.
5
+ The agent holds one of these as its hypothesis and refits it after every shot;
6
+ the simulator holds a different, hidden one as "reality".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ import numpy as np
14
+ from scipy.optimize import least_squares
15
+
16
+ from .colour import delta_e_2000, xyz_to_lab
17
+
18
+ WAVELENGTHS = np.arange(400, 701, 20) # 16 bands, nm
19
+
20
+ # CIE 1931 2-degree colour matching functions at 20 nm.
21
+ _CMF = np.array(
22
+ [
23
+ [0.0143, 0.0004, 0.0679], [0.1344, 0.0040, 0.6456], [0.3483, 0.0230, 1.7471],
24
+ [0.2908, 0.0600, 1.6692], [0.0956, 0.1390, 0.8130], [0.0049, 0.3230, 0.2720],
25
+ [0.0633, 0.7100, 0.0782], [0.2904, 0.9540, 0.0203], [0.5945, 0.9950, 0.0039],
26
+ [0.9163, 0.8700, 0.0017], [1.0622, 0.6310, 0.0008], [0.8544, 0.3810, 0.0002],
27
+ [0.4479, 0.1750, 0.0000], [0.1649, 0.0610, 0.0000], [0.0468, 0.0170, 0.0000],
28
+ [0.0114, 0.0041, 0.0000],
29
+ ]
30
+ )
31
+
32
+ # CIE D65 relative spectral power at 20 nm.
33
+ _D65 = np.array(
34
+ [82.75, 93.43, 104.86, 117.81, 115.92, 109.35, 104.79, 104.41,
35
+ 100.00, 95.79, 90.01, 87.70, 83.70, 80.21, 78.28, 71.61]
36
+ )
37
+
38
+ _WEIGHTS = _CMF * _D65[:, None]
39
+ _NORM = 100.0 / _WEIGHTS[:, 1].sum()
40
+ SPECTRAL_WHITE = _WEIGHTS.sum(axis=0) * _NORM # XYZ of a perfect white
41
+
42
+
43
+ def reflectance_to_xyz(R: np.ndarray) -> np.ndarray:
44
+ return (np.asarray(R, dtype=float) @ _WEIGHTS) * _NORM
45
+
46
+
47
+ def reflectance_to_lab(R: np.ndarray) -> np.ndarray:
48
+ return xyz_to_lab(reflectance_to_xyz(R), white=SPECTRAL_WHITE)
49
+
50
+
51
+ def ks_to_reflectance(ks: np.ndarray) -> np.ndarray:
52
+ ks = np.maximum(np.asarray(ks, dtype=float), 0.0)
53
+ return 1 + ks - np.sqrt(ks**2 + 2 * ks)
54
+
55
+
56
+ def reflectance_to_ks(R: np.ndarray) -> np.ndarray:
57
+ R = np.clip(np.asarray(R, dtype=float), 1e-4, 1.0)
58
+ return (1 - R) ** 2 / (2 * R)
59
+
60
+
61
+ def absorption_band(peak_nm: float, width_nm: float, strength: float) -> np.ndarray:
62
+ """A Gaussian absorption band, as K/S per g/L of fixed dye."""
63
+ return strength * np.exp(-0.5 * ((WAVELENGTHS - peak_nm) / width_nm) ** 2)
64
+
65
+
66
+ # Bleached cotton: near white, slightly warm.
67
+ COTTON_KS = reflectance_to_ks(np.linspace(0.80, 0.90, len(WAVELENGTHS)))
68
+
69
+
70
+ @dataclass
71
+ class DyeModel:
72
+ """K/S of cotton = substrate + sum(gain_i * c_i * unit_ks_i)."""
73
+
74
+ names: list[str]
75
+ unit_ks: np.ndarray # (n_dyes, n_bands) K/S per g/L in the bath, at gain 1
76
+ substrate_ks: np.ndarray = field(default_factory=lambda: COTTON_KS.copy())
77
+ gains: np.ndarray | None = None # per-dye uptake multipliers, fitted from shots
78
+
79
+ def __post_init__(self) -> None:
80
+ if self.gains is None:
81
+ self.gains = np.ones(len(self.names))
82
+
83
+ def ks(self, conc: np.ndarray) -> np.ndarray:
84
+ conc = np.maximum(np.asarray(conc, dtype=float), 0.0)
85
+ return self.substrate_ks + (conc * self.gains) @ self.unit_ks
86
+
87
+ def reflectance(self, conc: np.ndarray) -> np.ndarray:
88
+ return ks_to_reflectance(self.ks(conc))
89
+
90
+ def predict_lab(self, conc: np.ndarray) -> np.ndarray:
91
+ return reflectance_to_lab(self.reflectance(conc))
92
+
93
+ def copy(self) -> DyeModel:
94
+ return DyeModel(list(self.names), self.unit_ks.copy(), self.substrate_ks.copy(), self.gains.copy())
95
+
96
+
97
+ def nominal_procion_cmy() -> DyeModel:
98
+ """The supplier's nominal reactive set; kept for tests and the simulator."""
99
+ from .profiles import nominal_reactive
100
+
101
+ return nominal_reactive()
102
+
103
+
104
+ def fit_gains(model: DyeModel, shots: list[tuple[np.ndarray, np.ndarray]]) -> DyeModel:
105
+ """Refit per-dye uptake gains to measured (concentration, Lab) shots.
106
+
107
+ Minimises CIEDE2000 between predicted and measured shades; returns a new model.
108
+ """
109
+ if not shots:
110
+ return model.copy()
111
+ concs = np.array([c for c, _ in shots])
112
+ labs = np.array([lab for _, lab in shots])
113
+
114
+ def residual(log_gains: np.ndarray) -> np.ndarray:
115
+ trial = model.copy()
116
+ trial.gains = np.exp(log_gains)
117
+ pred = np.array([trial.predict_lab(c) for c in concs])
118
+ # Mild prior pulling gains towards 1 keeps the fit sane with few shots.
119
+ return np.concatenate([delta_e_2000(pred, labs), 0.2 * log_gains])
120
+
121
+ # Wide bounds: household inks can be far stronger or weaker than any nominal guess.
122
+ result = least_squares(residual, np.log(model.gains), bounds=(np.log(0.05), np.log(20.0)))
123
+ fitted = model.copy()
124
+ fitted.gains = np.exp(result.x)
125
+ return fitted
126
+
127
+
128
+ def solve_recipe(
129
+ model: DyeModel,
130
+ target_lab: np.ndarray,
131
+ max_conc: float = 20.0,
132
+ start: np.ndarray | None = None,
133
+ ) -> tuple[np.ndarray, float]:
134
+ """Concentrations (g/L) that the model predicts will match target_lab.
135
+
136
+ Returns (concentrations, predicted CIEDE2000).
137
+ """
138
+ n = len(model.names)
139
+ starts = [start] if start is not None else []
140
+ starts += [np.full(n, v) for v in (0.5, 2.0, 6.0)]
141
+ best = None
142
+ for x0 in starts:
143
+ x0 = np.clip(np.asarray(x0, dtype=float), 0.0, max_conc)
144
+ r = least_squares(
145
+ lambda c: np.atleast_1d(delta_e_2000(model.predict_lab(c), target_lab)),
146
+ x0,
147
+ bounds=(0.0, max_conc),
148
+ )
149
+ de = float(delta_e_2000(model.predict_lab(r.x), target_lab))
150
+ if best is None or de < best[1]:
151
+ best = (r.x, de)
152
+ return best
src/rangrez/make_card.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render the printable 24-patch colour card used in every frame.
2
+
3
+ uv run rangrez-card # writes hardware/colour_card.png (300 dpi, 112 x 76 mm)
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from PIL import Image, ImageDraw
11
+
12
+ from .card import COLS, PATCH_SRGB_8BIT, ROWS
13
+
14
+ DPI = 300
15
+ PATCH_MM, GAP_MM, BORDER_MM = 16, 2, 3
16
+
17
+
18
+ def render() -> Image.Image:
19
+ mm = DPI / 25.4
20
+ patch, gap, border = round(PATCH_MM * mm), round(GAP_MM * mm), round(BORDER_MM * mm)
21
+ w = 2 * border + COLS * patch + (COLS - 1) * gap
22
+ h = 2 * border + ROWS * patch + (ROWS - 1) * gap
23
+ img = Image.new("RGB", (w, h), (8, 8, 8))
24
+ draw = ImageDraw.Draw(img)
25
+ for i, rgb in enumerate(PATCH_SRGB_8BIT.astype(int)):
26
+ r, c = divmod(i, COLS)
27
+ x, y = border + c * (patch + gap), border + r * (patch + gap)
28
+ draw.rectangle([x, y, x + patch - 1, y + patch - 1], fill=tuple(int(v) for v in rgb))
29
+ return img
30
+
31
+
32
+ def main() -> None:
33
+ out = Path("hardware/colour_card.png")
34
+ out.parent.mkdir(exist_ok=True)
35
+ render().save(out, dpi=(DPI, DPI))
36
+ print(f"wrote {out} — print at 100% scale on matte photo paper")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
src/rangrez/photoreal.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A camera that takes realistic phone photos of the simulator's cloth.
2
+
3
+ Scenes are real-looking photos (made with Muse Image) of undyed white cotton on
4
+ printer paper, under desk lamps, daylight, tube lights, phone flash and more.
5
+ SAM 3.1 masked each scene once (eval/scene_masks.py) and the masks are cached
6
+ in scenes/masks. To photograph a dyed strip, the strip's pixels are recoloured
7
+ to its true colour under the scene's own light, keeping the cloth's real folds,
8
+ weave and shadows. The paper's true whiteness and tint are randomised and NOT
9
+ told to the pipeline, the way real printer paper is never exactly white.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ from dataclasses import dataclass
16
+ from functools import cache, cached_property
17
+ from pathlib import Path
18
+
19
+ import cv2
20
+ import numpy as np
21
+ from PIL import Image
22
+ from scipy.ndimage import gaussian_filter
23
+
24
+ from .colour import lab_to_srgb, linear_to_srgb, srgb_to_linear
25
+ from .vision import CARD_PROMPT, PAPER_PROMPTS, TARGET_PROMPT, Instance
26
+
27
+ SCENE_DIR = Path(__file__).parent / "scenes"
28
+ COTTON_LIN = 0.80 # linear reflectance of undyed cotton, to infer the light when there is no paper
29
+
30
+
31
+ def scene_names(kind: str = "strip") -> list[str]:
32
+ """Scenes with a test strip ("strip"), or with only a garment to read ("target")."""
33
+ masks = SCENE_DIR / "masks"
34
+ strips = sorted(p.name[: -len("_strip.png")] for p in masks.glob("*_strip.png"))
35
+ if kind == "strip":
36
+ return strips
37
+ return sorted(n for n in (p.name[: -len("_target.png")] for p in masks.glob("*_target.png")) if n not in strips)
38
+
39
+
40
+ @cache
41
+ def _card_lin(printed: bool = False) -> np.ndarray:
42
+ from .make_card import render as card_image # the same card people print
43
+
44
+ lin = srgb_to_linear(np.asarray(card_image(), dtype=float) / 255)
45
+ return _home_print(lin) if printed else lin
46
+
47
+
48
+ def _home_print(lin: np.ndarray) -> np.ndarray:
49
+ """What a home inkjet on plain paper makes of the card: the printer can't reach saturated
50
+ colours, blacks print as dark grey, and every patch is a few dE00 off its reference."""
51
+ from .card import COLS, PATCH_SRGB_8BIT, ROWS
52
+ from .colour import lab_to_srgb as to_srgb
53
+ from .colour import srgb_to_lab
54
+
55
+ rng = np.random.default_rng(7)
56
+ out = lin.copy()
57
+ flat = out.reshape(-1, 3)
58
+ for ref in PATCH_SRGB_8BIT:
59
+ lab = srgb_to_lab(ref / 255.0)
60
+ L, a, b = lab
61
+ c = np.hypot(a, b)
62
+ squash = 0.8 if c > 45 else 0.92 # out-of-gamut colours compress
63
+ printed = np.array([max(L, 18.0) * 0.97 + 2.0, a * squash, b * squash]) + rng.normal(0, 1.2, 3)
64
+ sel = np.all(np.abs(flat - srgb_to_linear(ref / 255.0)) < 1e-6, axis=1)
65
+ flat[sel] = srgb_to_linear(np.clip(to_srgb(printed), 0, 1))
66
+ body = np.all(flat < 0.01, axis=1)
67
+ flat[body] = srgb_to_linear(np.full(3, 0.12)) # black body prints as dark grey
68
+ assert ROWS * COLS == len(PATCH_SRGB_8BIT)
69
+ return out
70
+
71
+
72
+ def _mask(name: str, kind: str) -> np.ndarray | None:
73
+ p = SCENE_DIR / "masks" / f"{name}_{kind}.png"
74
+ return np.asarray(Image.open(p)) > 127 if p.exists() else None
75
+
76
+
77
+ def _instance(mask: np.ndarray) -> Instance:
78
+ ys, xs = np.nonzero(mask)
79
+ return Instance(mask, (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1))
80
+
81
+
82
+ @dataclass(frozen=True, eq=False)
83
+ class Scene:
84
+ name: str
85
+ lin: np.ndarray
86
+ strip: np.ndarray | None
87
+ paper: np.ndarray | None
88
+ target: np.ndarray | None
89
+ background: np.ndarray | None # bedsheet or tablecloth SAM also calls "fabric"
90
+
91
+ @staticmethod
92
+ @cache
93
+ def load(name: str) -> Scene:
94
+ rgb = np.asarray(Image.open(SCENE_DIR / f"{name}.jpg").convert("RGB"), dtype=float) / 255
95
+ return Scene(name, srgb_to_linear(rgb), _mask(name, "strip"), _mask(name, "paper"), _mask(name, "target"),
96
+ _mask(name, "background"))
97
+
98
+ @property
99
+ def cloth(self) -> np.ndarray:
100
+ out = np.zeros(self.lin.shape[:2], bool)
101
+ for m in (self.strip, self.target):
102
+ if m is not None:
103
+ out |= m
104
+ return out
105
+
106
+ @property
107
+ def bare(self) -> np.ndarray:
108
+ """Where a card can lie: the paper, or the table when there is no paper."""
109
+ base = self.paper if self.paper is not None else np.ones(self.lin.shape[:2], bool)
110
+ return base & ~self.cloth
111
+
112
+ def light(self) -> np.ndarray:
113
+ """Linear RGB of white under this scene's light, assuming nominal paper / cotton."""
114
+ if self.paper is not None:
115
+ return np.median(self.lin[self.bare], axis=0)
116
+ return np.median(self.lin[self.cloth], axis=0) / COTTON_LIN
117
+
118
+ @cached_property
119
+ def illumination(self) -> np.ndarray:
120
+ """Relative light across the frame (1 = median over the paper), read off the bare paper.
121
+
122
+ This is how the scene was lit: lamp falloff, window light, flash hotspot, shadows.
123
+ Where paper is visible the light is the paper's own (lightly smoothed) brightness.
124
+ Under the cloth it is filled in from the lit paper around it, leaving out cast
125
+ shadows beside the cloth so they do not darken the cloth itself.
126
+ """
127
+ h, w = self.lin.shape[:2]
128
+ if self.paper is None:
129
+ return np.ones((h, w, 3))
130
+ small, bare = self.lin[::4, ::4], self.bare[::4, ::4].astype(float)
131
+ weight = bare
132
+ for _ in range(2):
133
+ num = gaussian_filter(small * weight[..., None], (25, 25, 0))
134
+ den = gaussian_filter(weight, 25)[..., None]
135
+ under = num / np.maximum(den, 1e-9)
136
+ weight = bare * (small.mean(axis=2) > 0.8 * under.mean(axis=2)) # drop shadows, refit
137
+ den = gaussian_filter(bare, 3)
138
+ seen = gaussian_filter(small * bare[..., None], (3, 3, 0)) / np.maximum(den, 1e-9)[..., None]
139
+ trust = np.clip(den / 0.6, 0, 1)[..., None]
140
+ field = trust * seen + (1 - trust) * under
141
+ field /= np.median(field[bare > 0], axis=0)
142
+ return cv2.resize(field.astype(np.float32), (w, h), interpolation=cv2.INTER_LINEAR).astype(float)
143
+
144
+ def card_box(self, overlap: bool = False) -> tuple[int, int, int, int]:
145
+ """Card placement: fully on bare paper, or (overlap=True) partly over the cloth's edge."""
146
+ return _card_box(self.name, overlap)
147
+
148
+
149
+ @cache
150
+ def _card_box(name: str, overlap: bool) -> tuple[int, int, int, int]:
151
+ scene = Scene.load(name)
152
+ bare = scene.bare
153
+ ii = np.pad(bare.astype(np.int32).cumsum(0).cumsum(1), ((1, 0), (1, 0)))
154
+ for width in (0.22, 0.18, 0.15): # a smaller print when the paper margin is narrow
155
+ cw = int(width * scene.lin.shape[1])
156
+ ch = int(cw * _card_lin().shape[0] / _card_lin().shape[1])
157
+ best, spot = -np.inf, (0, 0)
158
+ for y in range(0, bare.shape[0] - ch, 16):
159
+ for x in range(0, bare.shape[1] - cw, 16):
160
+ frac = (ii[y + ch, x + cw] - ii[y, x + cw] - ii[y + ch, x] + ii[y, x]) / (ch * cw)
161
+ score = -abs(frac - 0.7) if overlap else frac
162
+ if score > best:
163
+ best, spot = score, (y, x)
164
+ if overlap or best > 0.97:
165
+ break
166
+ y, x = spot
167
+ return (x, y, x + cw, y + ch)
168
+
169
+
170
+ def render(scene: Scene, strip_lab=None, target_lab=None, card: bool = False, seed: int = 0,
171
+ paper_seed: int | None = None, overlap: bool = False, printed_card: bool = False,
172
+ ) -> tuple[bytes, dict[str, np.ndarray]]:
173
+ """One phone photo and the masks of what is in it. Same paper_seed: same sheet of paper.
174
+
175
+ strip_lab=None leaves the strip undyed. Returns the JPEG and {"strip", "target",
176
+ "paper", "card"} masks (absent keys: not in the photo).
177
+ """
178
+ rng = np.random.default_rng(seed)
179
+ prng = np.random.default_rng(seed if paper_seed is None else paper_seed)
180
+ lin = scene.lin.copy()
181
+ # True paper (or cotton) whiteness, unknown to the pipeline: 0.86-0.96, slightly bluish (optical brighteners).
182
+ tint = prng.uniform(0.86, 0.96) * np.array([0.985, 0.995, 1.02]) * prng.normal(1, 0.01, 3)
183
+ light = scene.light() / tint if scene.paper is not None else scene.light() # true illuminant
184
+ masks: dict[str, np.ndarray] = {}
185
+ for kind, lab in (("strip", strip_lab), ("target", target_lab)):
186
+ mask = getattr(scene, kind)
187
+ if mask is None:
188
+ continue
189
+ masks[kind] = mask
190
+ if lab is not None:
191
+ refl = srgb_to_linear(lab_to_srgb(np.asarray(lab, dtype=float)))
192
+ lit = scene.illumination[mask]
193
+ texture = scene.lin[mask] / lit # folds, weave and shading of the white cloth
194
+ texture /= np.median(texture, axis=0)
195
+ lin[mask] = texture * lit * light * refl
196
+ if scene.paper is not None:
197
+ masks["paper"] = scene.paper
198
+ if scene.background is not None:
199
+ masks["background"] = scene.background
200
+ if card:
201
+ l, t, r, b = scene.card_box(overlap)
202
+ img = Image.fromarray((linear_to_srgb(_card_lin(printed_card)) * 255).astype(np.uint8)).resize((r - l, b - t))
203
+ # The card is lit by the light where it lies, like everything else in the scene.
204
+ card_lin = srgb_to_linear(np.asarray(img, dtype=float) / 255)
205
+ lin[t:b, l:r] = card_lin * light * scene.illumination[t:b, l:r]
206
+ cm = np.zeros(lin.shape[:2], bool)
207
+ cm[t:b, l:r] = True
208
+ masks["card"] = cm
209
+ # Camera: auto exposure and white balance wobble, sensor noise, JPEG.
210
+ lin = lin * rng.uniform(0.9, 1.1) * rng.normal(1, 0.02, 3) + rng.normal(0, 0.004, lin.shape)
211
+ out = (linear_to_srgb(lin) * 255).round().clip(0, 255).astype(np.uint8)
212
+ buf = io.BytesIO()
213
+ Image.fromarray(out).save(buf, format="JPEG", quality=int(rng.integers(82, 95)))
214
+ return buf.getvalue(), masks
215
+
216
+
217
+ class SceneSegmenter:
218
+ """Offline stand-in for hosted SAM 3.1: answers prompts from the scene's cached SAM masks."""
219
+
220
+ def __init__(self, masks: dict[str, np.ndarray]):
221
+ self.masks = masks
222
+
223
+ def segment(self, image: Image.Image, jpeg: bytes, concept: str) -> list[Instance]:
224
+ if concept == CARD_PROMPT:
225
+ kinds = ["card"]
226
+ elif concept in PAPER_PROMPTS:
227
+ kinds = ["paper"]
228
+ elif concept == TARGET_PROMPT:
229
+ kinds = ["target"]
230
+ else: # "fabric", "cloth", "kurta"...: SAM returns every piece of cloth, bedsheet included
231
+ kinds = ["strip", "target", "background"]
232
+ size = (image.height, image.width)
233
+ out = []
234
+ for k in kinds:
235
+ if k in self.masks:
236
+ m = self.masks[k]
237
+ if m.shape != size: # e.g. a video codec trimmed a row
238
+ m = cv2.resize(m.astype(np.uint8), size[::-1], interpolation=cv2.INTER_NEAREST) > 0
239
+ out.append(_instance(m))
240
+ return out
src/rangrez/profiles.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dye sets the lab can run. Choose with RANGREZ_DYES=reactive|food.
2
+
3
+ reactive: cold fibre-reactive dyes on soda-ash cotton, as a dye house uses.
4
+ food: supermarket food colours on plain cotton; a stand-in anyone can buy
5
+ tonight. Recipes are in % of the bath that is food colour.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+
16
+ from .km import DyeModel, absorption_band
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Profile:
21
+ key: str
22
+ names: tuple[str, ...]
23
+ unit: str # unit of a recipe entry, as shown to people and to the agent
24
+ stock_per_l: float # stock concentration in the same unit, so mL = c / stock * bath
25
+ max_conc: float
26
+ description: str
27
+ nominal: Callable[[], DyeModel]
28
+ bottle_ml: float = 250.0 # stock on hand per dye
29
+ min_stock_ml: float = 5.0
30
+ step_ml: float = 0.1 # smallest amount a person can measure in assisted mode
31
+
32
+
33
+ def nominal_reactive() -> DyeModel:
34
+ """Supplier's nominal turquoise, fuchsia, lemon yellow (cold reactive)."""
35
+ unit_ks = np.stack(
36
+ [
37
+ absorption_band(640, 55, 0.55), # turquoise absorbs red
38
+ absorption_band(540, 40, 0.60), # fuchsia absorbs green
39
+ absorption_band(430, 35, 0.50), # lemon yellow absorbs blue
40
+ ]
41
+ )
42
+ return DyeModel(["turquoise", "fuchsia", "lemon yellow"], unit_ks)
43
+
44
+
45
+ def nominal_food() -> DyeModel:
46
+ """Brilliant blue, allura red and tartrazine food colours staining cotton."""
47
+ unit_ks = np.stack(
48
+ [
49
+ absorption_band(630, 45, 0.30), # blue (brilliant blue FCF peaks near 630 nm)
50
+ absorption_band(505, 45, 0.30), # red (allura red peaks near 504 nm)
51
+ absorption_band(427, 35, 0.28), # yellow (tartrazine peaks near 427 nm)
52
+ ]
53
+ )
54
+ return DyeModel(["blue", "red", "yellow"], unit_ks)
55
+
56
+
57
+ def nominal_home() -> DyeModel:
58
+ """Household blue, red and yellow inks, per drop in a 20 mL bath."""
59
+ unit_ks = np.stack(
60
+ [
61
+ absorption_band(620, 55, 0.10),
62
+ absorption_band(515, 50, 0.10),
63
+ absorption_band(430, 40, 0.09),
64
+ ]
65
+ )
66
+ return DyeModel(["blue", "red", "yellow"], unit_ks)
67
+
68
+
69
+ PROFILES = {
70
+ "reactive": Profile(
71
+ "reactive", ("turquoise", "fuchsia", "lemon yellow"), "g/L", 20.0, 10.0,
72
+ "cold fibre-reactive dyes on soda-ash-treated cotton; concentrations are g/L of dye in a 20 mL bath",
73
+ nominal_reactive,
74
+ ),
75
+ "food": Profile(
76
+ "food", ("blue", "red", "yellow"), "%", 100.0, 15.0,
77
+ "liquid food colours on white cotton; amounts are % of the 20 mL bath that is food colour (the rest is water); a person measures them with syringes to 0.1 mL",
78
+ nominal_food, bottle_ml=30.0, min_stock_ml=2.0,
79
+ ),
80
+ # Household inks (sketch-pen ink, food colour, haldi...) counted in drops of about 0.05 mL
81
+ # into a 20 mL (4 teaspoon) bath: c drops -> c * 0.05 mL, so stock = 20 / 0.05 = 400.
82
+ "home": Profile(
83
+ "home", ("blue", "red", "yellow"), "drops", 400.0, 40.0,
84
+ "household colours (sketch-pen ink, food colour) on white cotton; amounts are drops of each colour in 4 teaspoons (20 mL) of water, counted by a person",
85
+ nominal_home, bottle_ml=20.0, min_stock_ml=0.5, step_ml=0.05,
86
+ ),
87
+ }
88
+
89
+ PROFILE = PROFILES[os.environ.get("RANGREZ_DYES", "reactive")]
src/rangrez/py.typed ADDED
File without changes
src/rangrez/recorder.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Every campaign, recorded to disk for review and for the demo video.
2
+
3
+ recordings/2026-11-02_101500_SerialRig/
4
+ meta.json rig, dyes, agent, scene, tolerance
5
+ target.jpg the customer's photo, if the target came from one (+ target.json)
6
+ events.jsonl every event: Meta API calls with timings, Wonder's notes and tool calls,
7
+ dispenses, gate refusals, measurements, status
8
+ dip01_photo.jpg each dip exactly as the camera or phone sent it
9
+ dip01_sam.jpg the same photo with SAM 3.1's card and strip boxes drawn on
10
+ state.json the final campaign state
11
+
12
+ Turn one into a video with `uv run python eval/demo_reel.py recordings/<run>`.
13
+ Set RANGREZ_RECORD=0 to switch it off.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import threading
21
+ import time
22
+ from pathlib import Path
23
+
24
+
25
+ class Recorder:
26
+ def __init__(self, root: str | None = None) -> None:
27
+ self.root = Path(root or os.environ.get("RANGREZ_RECORD_DIR", "recordings"))
28
+ self.enabled = os.environ.get("RANGREZ_RECORD", "1") != "0"
29
+ self.dir: Path | None = None
30
+ self.pending: list[tuple[str, bytes]] = [] # made before the campaign starts (the target photo)
31
+ self.lock = threading.Lock()
32
+
33
+ def stash(self, name: str, data: bytes | str | dict) -> None:
34
+ """Keep something for the next campaign's folder."""
35
+ self.pending.append((name, self._bytes(data)))
36
+
37
+ def begin(self, label: str, meta: dict) -> Path | None:
38
+ if not self.enabled:
39
+ return None
40
+ with self.lock:
41
+ self.dir = self.root / f"{time.strftime('%Y-%m-%d_%H%M%S')}_{label}"
42
+ self.dir.mkdir(parents=True, exist_ok=True)
43
+ (self.dir / "meta.json").write_text(json.dumps(meta, indent=2, default=str))
44
+ for name, data in self.pending:
45
+ (self.dir / name).write_bytes(data)
46
+ self.pending = []
47
+ return self.dir
48
+
49
+ def event(self, event: dict) -> None:
50
+ if self.dir is None:
51
+ return
52
+ with self.lock, open(self.dir / "events.jsonl", "a") as f:
53
+ f.write(json.dumps(event, default=str) + "\n")
54
+
55
+ def save(self, name: str, data: bytes | str | dict) -> None:
56
+ if self.dir is not None:
57
+ (self.dir / name).write_bytes(self._bytes(data))
58
+
59
+ @staticmethod
60
+ def _bytes(data: bytes | str | dict) -> bytes:
61
+ if isinstance(data, bytes):
62
+ return data
63
+ if isinstance(data, dict):
64
+ data = json.dumps(data, indent=2, default=str)
65
+ return data.encode()
src/rangrez/rig.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Recipes, the rig interface, and the deterministic safety gate.
2
+
3
+ Model output never reaches a pump directly. The agent proposes a Recipe; the
4
+ gate checks it against hard limits; only then does the executor turn it into
5
+ pump volumes. The firmware enforces the same limits a second time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Protocol
13
+
14
+ import numpy as np
15
+
16
+ from .profiles import PROFILE
17
+
18
+ DYES = list(PROFILE.names)
19
+ UNIT = PROFILE.unit
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class RigLimits:
24
+ bath_ml: float = 20.0 # liquor per shot
25
+ stock_g_per_l: float = PROFILE.stock_per_l # stock concentration, in UNIT
26
+ max_conc_g_per_l: float = PROFILE.max_conc # per dye, in the bath, in UNIT
27
+ max_dye_fraction: float = 0.9 # dye stock may fill at most this share of the bath
28
+ dip_seconds: tuple[float, float] = (30.0, 300.0)
29
+ min_stock_ml: float = PROFILE.min_stock_ml # refuse to run a bottle dry
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Recipe:
34
+ conc: tuple[float, ...] # g/L in the bath, one per dye in DYES order
35
+ dip_seconds: float = 90.0
36
+
37
+ def as_array(self) -> np.ndarray:
38
+ return np.array(self.conc, dtype=float)
39
+
40
+ def describe(self) -> str:
41
+ parts = [f"{name} {c:.2f} {UNIT}" for name, c in zip(DYES, self.conc)]
42
+ return ", ".join(parts) + f", dip {self.dip_seconds:.0f} s"
43
+
44
+
45
+ @dataclass
46
+ class PumpPlan:
47
+ dye_ml: tuple[float, ...]
48
+ water_ml: float
49
+
50
+
51
+ class GateError(ValueError):
52
+ """A recipe the gate refused. The message says why, for the agent to read."""
53
+
54
+
55
+ @dataclass
56
+ class Inventory:
57
+ stock_ml: list[float] = field(default_factory=lambda: [PROFILE.bottle_ml] * len(PROFILE.names))
58
+ strips: int = 60
59
+
60
+
61
+ def check_recipe(recipe: Recipe, limits: RigLimits, inventory: Inventory) -> PumpPlan:
62
+ """Deterministic gate. Returns the pump plan or raises GateError."""
63
+ conc = recipe.as_array()
64
+ if conc.shape != (len(DYES),):
65
+ raise GateError(f"recipe needs {len(DYES)} concentrations, got {conc.shape[0]}")
66
+ if not np.all(np.isfinite(conc)):
67
+ raise GateError("recipe has a non-finite concentration")
68
+ if np.any(conc < 0):
69
+ raise GateError("concentrations must be >= 0")
70
+ if np.any(conc > limits.max_conc_g_per_l):
71
+ raise GateError(f"each dye is capped at {limits.max_conc_g_per_l} {UNIT}")
72
+ lo, hi = limits.dip_seconds
73
+ if not lo <= recipe.dip_seconds <= hi:
74
+ raise GateError(f"dip time must be between {lo:.0f} and {hi:.0f} s")
75
+ dye_ml = conc / limits.stock_g_per_l * limits.bath_ml
76
+ if dye_ml.sum() > limits.max_dye_fraction * limits.bath_ml:
77
+ raise GateError("total dye stock exceeds the bath volume; lower the concentrations")
78
+ for name, need, have in zip(DYES, dye_ml, inventory.stock_ml):
79
+ if have - need < limits.min_stock_ml:
80
+ raise GateError(f"{name} stock is too low ({have:.0f} mL); refill before continuing")
81
+ if inventory.strips <= 0:
82
+ raise GateError("out of cotton strips")
83
+ return PumpPlan(tuple(float(v) for v in dye_ml), float(limits.bath_ml - dye_ml.sum()))
84
+
85
+
86
+ @dataclass
87
+ class ShotResult:
88
+ image_jpeg: bytes
89
+ plan: PumpPlan
90
+ started_at: float
91
+ finished_at: float
92
+ extra: dict = field(default_factory=dict)
93
+
94
+
95
+ class Rig(Protocol):
96
+ limits: RigLimits
97
+ inventory: Inventory
98
+
99
+ def run_shot(self, recipe: Recipe) -> ShotResult: ...
100
+
101
+ def capture(self) -> bytes: ...
102
+
103
+
104
+ def execute(rig: Rig, recipe: Recipe) -> ShotResult:
105
+ """Gate, then run. The only path from a recipe to the pumps."""
106
+ plan = check_recipe(recipe, rig.limits, rig.inventory)
107
+ result = rig.run_shot(recipe)
108
+ for i, ml in enumerate(plan.dye_ml):
109
+ rig.inventory.stock_ml[i] -= ml
110
+ rig.inventory.strips -= 1
111
+ result.plan = plan
112
+ return result
113
+
114
+
115
+ def now() -> float:
116
+ return time.time()
src/rangrez/scenes/crumpled_strip.jpg ADDED

Git LFS Details

  • SHA256: e9220f21a7d849b2ade5ce8d8a8348f5a061c1ea16bc3d2b3634cac9e0c28aab
  • Pointer size: 131 Bytes
  • Size of remote file: 316 kB
src/rangrez/scenes/daylight_window.jpg ADDED

Git LFS Details

  • SHA256: 1bcff0f6eca085e6019278dc07e11b97d02b853fcbc99ce0b300b1c904b8f293
  • Pointer size: 131 Bytes
  • Size of remote file: 271 kB
src/rangrez/scenes/evening_warm.jpg ADDED

Git LFS Details

  • SHA256: df3eccda4703f09c3a2b5ded6788bb3b0ea44114126c2f55f80d0ce5f8f4cc64
  • Pointer size: 131 Bytes
  • Size of remote file: 277 kB
src/rangrez/scenes/kitchen_tube.jpg ADDED

Git LFS Details

  • SHA256: bf904642930ed5a332a99954441a4fe952a1d58893328f9a625e112737a9e737
  • Pointer size: 131 Bytes
  • Size of remote file: 277 kB
src/rangrez/scenes/kurta_target.jpg ADDED

Git LFS Details

  • SHA256: 16a563926810cce88ab41ca828890b01cc9c46c3a346a351759b814a12d670dd
  • Pointer size: 131 Bytes
  • Size of remote file: 313 kB
src/rangrez/scenes/lamp_desk.jpg ADDED

Git LFS Details

  • SHA256: 7f4107642bce875717ac3cb1c2ed46610a23a4127f5b6ff30f9ec82042f39663
  • Pointer size: 131 Bytes
  • Size of remote file: 254 kB
src/rangrez/scenes/light_box.jpg ADDED

Git LFS Details

  • SHA256: 4bb50ea83d59340d1159de8399c8ce796f45deee4050453d4ad4e40e760eaadb
  • Pointer size: 131 Bytes
  • Size of remote file: 268 kB
src/rangrez/scenes/masks/crumpled_strip_paper.png ADDED
src/rangrez/scenes/masks/crumpled_strip_strip.png ADDED
src/rangrez/scenes/masks/daylight_window_paper.png ADDED
src/rangrez/scenes/masks/daylight_window_strip.png ADDED
src/rangrez/scenes/masks/evening_warm_background.png ADDED
src/rangrez/scenes/masks/evening_warm_paper.png ADDED
src/rangrez/scenes/masks/evening_warm_strip.png ADDED
src/rangrez/scenes/masks/kitchen_tube_paper.png ADDED
src/rangrez/scenes/masks/kitchen_tube_strip.png ADDED
src/rangrez/scenes/masks/kurta_target_background.png ADDED
src/rangrez/scenes/masks/kurta_target_paper.png ADDED
src/rangrez/scenes/masks/kurta_target_target.png ADDED
src/rangrez/scenes/masks/lamp_desk_paper.png ADDED
src/rangrez/scenes/masks/lamp_desk_strip.png ADDED
src/rangrez/scenes/masks/light_box_paper.png ADDED
src/rangrez/scenes/masks/light_box_strip.png ADDED
src/rangrez/scenes/masks/no_paper_strip.png ADDED
src/rangrez/scenes/masks/phone_flash_paper.png ADDED
src/rangrez/scenes/masks/phone_flash_strip.png ADDED
src/rangrez/scenes/masks/shadowy_paper.png ADDED
src/rangrez/scenes/masks/shadowy_strip.png ADDED
src/rangrez/scenes/masks/tilted_phone_paper.png ADDED
src/rangrez/scenes/masks/tilted_phone_strip.png ADDED