Rangrez dashboard
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +13 -0
- Dockerfile +12 -0
- README.md +20 -4
- pyproject.toml +38 -0
- src/rangrez/__init__.py +2 -0
- src/rangrez/agent.py +180 -0
- src/rangrez/assisted.py +127 -0
- src/rangrez/camera.py +41 -0
- src/rangrez/campaign.py +244 -0
- src/rangrez/card.py +37 -0
- src/rangrez/colour.py +158 -0
- src/rangrez/esp32_emulator.py +213 -0
- src/rangrez/glimmer.py +73 -0
- src/rangrez/km.py +152 -0
- src/rangrez/make_card.py +40 -0
- src/rangrez/photoreal.py +240 -0
- src/rangrez/profiles.py +89 -0
- src/rangrez/py.typed +0 -0
- src/rangrez/recorder.py +65 -0
- src/rangrez/rig.py +116 -0
- src/rangrez/scenes/crumpled_strip.jpg +3 -0
- src/rangrez/scenes/daylight_window.jpg +3 -0
- src/rangrez/scenes/evening_warm.jpg +3 -0
- src/rangrez/scenes/kitchen_tube.jpg +3 -0
- src/rangrez/scenes/kurta_target.jpg +3 -0
- src/rangrez/scenes/lamp_desk.jpg +3 -0
- src/rangrez/scenes/light_box.jpg +3 -0
- src/rangrez/scenes/masks/crumpled_strip_paper.png +0 -0
- src/rangrez/scenes/masks/crumpled_strip_strip.png +0 -0
- src/rangrez/scenes/masks/daylight_window_paper.png +0 -0
- src/rangrez/scenes/masks/daylight_window_strip.png +0 -0
- src/rangrez/scenes/masks/evening_warm_background.png +0 -0
- src/rangrez/scenes/masks/evening_warm_paper.png +0 -0
- src/rangrez/scenes/masks/evening_warm_strip.png +0 -0
- src/rangrez/scenes/masks/kitchen_tube_paper.png +0 -0
- src/rangrez/scenes/masks/kitchen_tube_strip.png +0 -0
- src/rangrez/scenes/masks/kurta_target_background.png +0 -0
- src/rangrez/scenes/masks/kurta_target_paper.png +0 -0
- src/rangrez/scenes/masks/kurta_target_target.png +0 -0
- src/rangrez/scenes/masks/lamp_desk_paper.png +0 -0
- src/rangrez/scenes/masks/lamp_desk_strip.png +0 -0
- src/rangrez/scenes/masks/light_box_paper.png +0 -0
- src/rangrez/scenes/masks/light_box_strip.png +0 -0
- src/rangrez/scenes/masks/no_paper_strip.png +0 -0
- src/rangrez/scenes/masks/phone_flash_paper.png +0 -0
- src/rangrez/scenes/masks/phone_flash_strip.png +0 -0
- src/rangrez/scenes/masks/shadowy_paper.png +0 -0
- src/rangrez/scenes/masks/shadowy_strip.png +0 -0
- src/rangrez/scenes/masks/tilted_phone_paper.png +0 -0
- 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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
src/rangrez/scenes/daylight_window.jpg
ADDED
|
Git LFS Details
|
src/rangrez/scenes/evening_warm.jpg
ADDED
|
Git LFS Details
|
src/rangrez/scenes/kitchen_tube.jpg
ADDED
|
Git LFS Details
|
src/rangrez/scenes/kurta_target.jpg
ADDED
|
Git LFS Details
|
src/rangrez/scenes/lamp_desk.jpg
ADDED
|
Git LFS Details
|
src/rangrez/scenes/light_box.jpg
ADDED
|
Git LFS Details
|
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
|