Rangrez dashboard
Browse files- .gitattributes +8 -0
- README.md +4 -0
- src/rangrez/assisted.py +13 -1
- src/rangrez/campaign.py +25 -3
- src/rangrez/colour.py +16 -0
- src/rangrez/esp32_emulator.py +10 -1
- src/rangrez/glimmer.py +14 -0
- src/rangrez/order.py +126 -0
- src/rangrez/replay.py +66 -0
- src/rangrez/report.py +76 -10
- src/rangrez/samples/kurta.jpg +3 -0
- src/rangrez/samples/kurta.json +22 -0
- src/rangrez/samples/order.txt +1 -0
- src/rangrez/samples/runs/teal-kurta/dip01_photo.jpg +3 -0
- src/rangrez/samples/runs/teal-kurta/dip01_sam.jpg +3 -0
- src/rangrez/samples/runs/teal-kurta/dip02_photo.jpg +3 -0
- src/rangrez/samples/runs/teal-kurta/dip02_sam.jpg +3 -0
- src/rangrez/samples/runs/teal-kurta/events.jsonl +56 -0
- src/rangrez/samples/runs/teal-kurta/meta.json +20 -0
- src/rangrez/samples/runs/teal-kurta/order.json +14 -0
- src/rangrez/samples/runs/teal-kurta/preview.jpg +3 -0
- src/rangrez/samples/runs/teal-kurta/reply.txt +1 -0
- src/rangrez/samples/runs/teal-kurta/report.html +0 -0
- src/rangrez/samples/runs/teal-kurta/state.json +91 -0
- src/rangrez/samples/runs/teal-kurta/target.jpg +3 -0
- src/rangrez/samples/runs/teal-kurta/target.json +22 -0
- src/rangrez/server.py +138 -15
- src/rangrez/static/dyehouse.jpg +3 -0
- src/rangrez/static/index.html +786 -205
- src/rangrez/static/logo.svg +23 -0
- src/rangrez/voice.py +41 -10
.gitattributes
CHANGED
|
@@ -46,3 +46,11 @@ 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 49 |
+
src/rangrez/samples/kurta.jpg filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
src/rangrez/samples/runs/teal-kurta/dip01_photo.jpg filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
src/rangrez/samples/runs/teal-kurta/dip01_sam.jpg filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
src/rangrez/samples/runs/teal-kurta/dip02_photo.jpg filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
src/rangrez/samples/runs/teal-kurta/dip02_sam.jpg filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
src/rangrez/samples/runs/teal-kurta/preview.jpg filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
src/rangrez/samples/runs/teal-kurta/target.jpg filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
src/rangrez/static/dyehouse.jpg filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -25,6 +25,10 @@ and you can read a target colour from your own photo ("match this kurta").
|
|
| 25 |
Rangrez gives you a recipe to dye a matching cloth at home, in drops of blue, red and yellow
|
| 26 |
household colour.
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
One lab, shared by everyone viewing: if a campaign is running, you are watching it.
|
| 29 |
|
| 30 |
Code, hardware, firmware and evaluation: https://github.com/TusharTechs/rangrez
|
|
|
|
| 25 |
Rangrez gives you a recipe to dye a matching cloth at home, in drops of blue, red and yellow
|
| 26 |
household colour.
|
| 27 |
|
| 28 |
+
With the passcode you can also paste the buyer's message (English or Hindi) for **Muse Spark** to
|
| 29 |
+
read into an order, run the lab **hands-free by voice** in English or Hindi (Muse Voice Transcribe),
|
| 30 |
+
and see the customer's garment in the approved shade (**Muse Image**, measured and corrected).
|
| 31 |
+
|
| 32 |
One lab, shared by everyone viewing: if a campaign is running, you are watching it.
|
| 33 |
|
| 34 |
Code, hardware, firmware and evaluation: https://github.com/TusharTechs/rangrez
|
src/rangrez/assisted.py
CHANGED
|
@@ -22,6 +22,15 @@ 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 |
|
|
@@ -77,10 +86,12 @@ class AssistedRig:
|
|
| 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.",
|
|
@@ -90,7 +101,8 @@ class AssistedRig:
|
|
| 90 |
@staticmethod
|
| 91 |
def _amount(dye: str, ml: float, conc: float) -> str:
|
| 92 |
if UNIT == "drops":
|
| 93 |
-
|
|
|
|
| 94 |
return f"Add {ml:.1f} mL of {dye}."
|
| 95 |
|
| 96 |
# -- rig protocol ----------------------------------------------------------
|
|
|
|
| 22 |
from .rig import DYES, UNIT, Inventory, PumpPlan, Recipe, RigLimits, ShotResult, now
|
| 23 |
|
| 24 |
STEP_ML = PROFILE.step_ml
|
| 25 |
+
# A dip is only comparable with the final cloth after the dye has fixed and the loose dye is rinsed out.
|
| 26 |
+
FIX_AND_RINSE = (["Wrap the wet strip in cling film and leave it 10 minutes so the dye fixes, then rinse it in cold "
|
| 27 |
+
"water until the water runs clear, and once in warm soapy water."]
|
| 28 |
+
if PROFILE.key == "reactive" else
|
| 29 |
+
["Rinse the strip once in cold water to take off loose colour (household colours are not wash-fast)."])
|
| 30 |
+
# Shown before every dip: dye stains skin and lingers in kitchenware; reactive dye baths are alkaline.
|
| 31 |
+
SAFETY = ["Wear gloves, and use a glass and dropper kept for dyeing only, never again for food or drink."] + (
|
| 32 |
+
["Soda ash (washing soda) irritates skin and eyes: no splashing, rinse at once if it touches skin."]
|
| 33 |
+
if PROFILE.key == "reactive" else [])
|
| 34 |
MAX_SIDE_PX = 1920 # SAM 3.1 accepts up to about 4 MP; phones shoot 12+
|
| 35 |
|
| 36 |
|
|
|
|
| 86 |
"water_ml": round(float(water), 1),
|
| 87 |
"dip_seconds": int(recipe.dip_seconds),
|
| 88 |
"steps": [
|
| 89 |
+
*SAFETY,
|
| 90 |
"Rinse the glass and the dropper or syringes.",
|
| 91 |
*[self._amount(d, v, c) for d, v, c in zip(DYES, ml, recipe.conc) if v >= STEP_ML / 2],
|
| 92 |
(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."),
|
| 93 |
f"Dip a fresh white cotton strip for {int(recipe.dip_seconds)} seconds.",
|
| 94 |
+
*FIX_AND_RINSE,
|
| 95 |
"Squeeze it gently and dry it fully with the hair dryer (about 2 minutes).",
|
| 96 |
"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.",
|
| 97 |
"If you have the customer's cloth, lay it beside the strip: Rangrez then compares them side by side under the same light.",
|
|
|
|
| 101 |
@staticmethod
|
| 102 |
def _amount(dye: str, ml: float, conc: float) -> str:
|
| 103 |
if UNIT == "drops":
|
| 104 |
+
n = round(conc)
|
| 105 |
+
return f"Add {n} drop{'' if n == 1 else 's'} of {dye}."
|
| 106 |
return f"Add {ml:.1f} mL of {dye}."
|
| 107 |
|
| 108 |
# -- rig protocol ----------------------------------------------------------
|
src/rangrez/campaign.py
CHANGED
|
@@ -192,10 +192,32 @@ class Campaign:
|
|
| 192 |
self.shots.append(shot)
|
| 193 |
self.emit("shot", index=shot.index, recipe=recipe.describe(), lab=m.lab.tolist(),
|
| 194 |
hex=lab_to_hex(m.lab), delta_e=de, flags=m.flags, rationale=rationale)
|
| 195 |
-
|
|
|
|
| 196 |
self.status = "matched"
|
| 197 |
-
self.emit("status", status=self.status, shot=shot.index, delta_e=
|
| 198 |
-
return self._shot_summary(shot) | {"matched":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
def _side_by_side(self, m) -> None:
|
| 201 |
"""The customer's cloth lies beside the strip: compare them under the same light.
|
|
|
|
| 192 |
self.shots.append(shot)
|
| 193 |
self.emit("shot", index=shot.index, recipe=recipe.describe(), lab=m.lab.tolist(),
|
| 194 |
hex=lab_to_hex(m.lab), delta_e=de, flags=m.flags, rationale=rationale)
|
| 195 |
+
matched = m.ok and de <= self.tolerance and self._confirmed(shot)
|
| 196 |
+
if matched:
|
| 197 |
self.status = "matched"
|
| 198 |
+
self.emit("status", status=self.status, shot=shot.index, delta_e=shot.delta_e)
|
| 199 |
+
return self._shot_summary(shot) | {"matched": matched}
|
| 200 |
+
|
| 201 |
+
def _confirmed(self, shot: Shot) -> bool:
|
| 202 |
+
"""No single photo decides a pass: an automated rig photographs the strip again and both must agree."""
|
| 203 |
+
if hasattr(self.rig, "provide") or not hasattr(self.rig, "capture"):
|
| 204 |
+
return True # a person sent this photo; asking for another would only slow them down
|
| 205 |
+
try:
|
| 206 |
+
m2 = measure(self.rig.capture(), self.segmenter, seed=100 + shot.index)
|
| 207 |
+
except RuntimeError as exc:
|
| 208 |
+
self.note(f"Confirmation photo failed ({exc}); not calling it a match on one photo.", who="lab")
|
| 209 |
+
return False
|
| 210 |
+
if not m2.ok:
|
| 211 |
+
self.note("Confirmation photo was rejected; not calling it a match on one photo.", who="lab")
|
| 212 |
+
return False
|
| 213 |
+
de2 = float(delta_e_2000(m2.lab, self.target_lab))
|
| 214 |
+
if de2 > self.tolerance:
|
| 215 |
+
self.note(f"Dip {shot.index} read dE00 {shot.delta_e:.2f}, but a second photo reads {de2:.2f}: "
|
| 216 |
+
"not a match yet.", who="lab")
|
| 217 |
+
return False
|
| 218 |
+
shot.delta_e = (shot.delta_e + de2) / 2
|
| 219 |
+
self.note(f"Confirmed with a second photo: dE00 {shot.delta_e * 2 - de2:.2f} and {de2:.2f}.", who="lab")
|
| 220 |
+
return True
|
| 221 |
|
| 222 |
def _side_by_side(self, m) -> None:
|
| 223 |
"""The customer's cloth lies beside the strip: compare them under the same light.
|
src/rangrez/colour.py
CHANGED
|
@@ -82,6 +82,22 @@ def lab_to_hex(lab: np.ndarray) -> str:
|
|
| 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)
|
|
|
|
| 82 |
return "#" + "".join(f"{int(v):02x}" for v in np.clip(rgb, 0, 255))
|
| 83 |
|
| 84 |
|
| 85 |
+
def delta_e_cmc(reference, sample, l: float = 2.0, c: float = 1.0) -> float:
|
| 86 |
+
"""CMC l:c colour difference of sample from reference (the textile industry's other standard; 2:1 for acceptability)."""
|
| 87 |
+
L1, a1, b1 = (float(v) for v in reference)
|
| 88 |
+
L2, a2, b2 = (float(v) for v in sample)
|
| 89 |
+
C1, C2 = np.hypot(a1, b1), np.hypot(a2, b2)
|
| 90 |
+
dL, dC = L1 - L2, C1 - C2
|
| 91 |
+
dH2 = max((a1 - a2) ** 2 + (b1 - b2) ** 2 - dC**2, 0.0)
|
| 92 |
+
H1 = np.degrees(np.arctan2(b1, a1)) % 360
|
| 93 |
+
F = np.sqrt(C1**4 / (C1**4 + 1900))
|
| 94 |
+
T = 0.56 + abs(0.2 * np.cos(np.radians(H1 + 168))) if 164 <= H1 <= 345 else 0.36 + abs(0.4 * np.cos(np.radians(H1 + 35)))
|
| 95 |
+
SL = 0.511 if L1 < 16 else 0.040975 * L1 / (1 + 0.01765 * L1)
|
| 96 |
+
SC = 0.0638 * C1 / (1 + 0.0131 * C1) + 0.638
|
| 97 |
+
SH = SC * (F * T + 1 - F)
|
| 98 |
+
return float(np.sqrt((dL / (l * SL)) ** 2 + (dC / (c * SC)) ** 2 + dH2 / SH**2))
|
| 99 |
+
|
| 100 |
+
|
| 101 |
def delta_e_2000(lab1: np.ndarray, lab2: np.ndarray) -> np.ndarray:
|
| 102 |
"""CIEDE2000 colour difference (Sharma, Wu & Dalal 2005), kL = kC = kH = 1."""
|
| 103 |
lab1 = np.asarray(lab1, dtype=float)
|
src/rangrez/esp32_emulator.py
CHANGED
|
@@ -58,13 +58,18 @@ class VirtualESP32:
|
|
| 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
|
|
@@ -88,6 +93,8 @@ class VirtualESP32:
|
|
| 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":
|
|
@@ -146,6 +153,8 @@ class VirtualESP32:
|
|
| 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")
|
|
|
|
| 58 |
self.bath_ml = 0.0
|
| 59 |
self.dye_ml = np.zeros(3)
|
| 60 |
self.estop = False
|
| 61 |
+
self.button_pressed = False # the physical e-stop button, separate from the latched flag
|
| 62 |
self.light = False
|
| 63 |
+
self.compiled_ms_per_ml = list(self.limits["msPerMl"])
|
| 64 |
self.log: list[dict] = []
|
| 65 |
self._write_video() # the camera shows the empty light box until the first dip
|
| 66 |
|
| 67 |
# -- e-stop button ----------------------------------------------------------
|
| 68 |
def press_estop(self) -> None:
|
| 69 |
+
self.estop = self.button_pressed = True
|
| 70 |
+
|
| 71 |
+
def release_estop(self) -> None:
|
| 72 |
+
self.button_pressed = False
|
| 73 |
|
| 74 |
def _wait(self, seconds: float) -> bool:
|
| 75 |
end = time.monotonic() + seconds * self.time_scale
|
|
|
|
| 93 |
if cmd == "ping":
|
| 94 |
return {"fw": "rangrez-1", "estop": self.estop, "bath_ml": self.bath_ml}
|
| 95 |
if cmd == "reset":
|
| 96 |
+
if self.button_pressed:
|
| 97 |
+
return self._reply(cmd, "e-stop still pressed: release it first")
|
| 98 |
self.estop = False
|
| 99 |
return self._reply(cmd)
|
| 100 |
if cmd == "pump":
|
|
|
|
| 153 |
pid, ms = int(doc.get("id", -1)), float(doc.get("ms_per_ml", 0))
|
| 154 |
if not 0 <= pid <= 3 or not 100 <= ms <= 5000:
|
| 155 |
return self._reply(cmd, "bad calibration")
|
| 156 |
+
if abs(ms - self.compiled_ms_per_ml[pid]) > 0.20 * self.compiled_ms_per_ml[pid]:
|
| 157 |
+
return self._reply(cmd, "calibration more than 20% from the compiled value: reflash to change it")
|
| 158 |
L["msPerMl"][pid] = ms
|
| 159 |
return self._reply(cmd)
|
| 160 |
return self._reply(cmd, "unknown command")
|
src/rangrez/glimmer.py
CHANGED
|
@@ -34,6 +34,20 @@ def prompt(lab) -> str:
|
|
| 34 |
return f"Target L* {L:.1f}, a* {a:.1f}, b* {b:.1f} ({lab_to_hex(np.array([L, a, b]))}). First dip recipe?"
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def answer(conc) -> str:
|
| 38 |
return json.dumps({d: round(float(c), 2) for d, c in zip(PROFILE.names, conc)})
|
| 39 |
|
|
|
|
| 34 |
return f"Target L* {L:.1f}, a* {a:.1f}, b* {b:.1f} ({lab_to_hex(np.array([L, a, b]))}). First dip recipe?"
|
| 35 |
|
| 36 |
|
| 37 |
+
def notebook_prompt(lab, neighbours) -> str:
|
| 38 |
+
"""Glimmer v2: the target plus the most similar dips this lab has made (recipe -> measured colour)."""
|
| 39 |
+
lines = [prompt(lab).replace(" First dip recipe?", "")]
|
| 40 |
+
if neighbours:
|
| 41 |
+
lines.append("The lab's most similar past dips (recipe -> measured L*a*b*):")
|
| 42 |
+
for conc, nlab in neighbours:
|
| 43 |
+
recipe = ", ".join(f"{d} {float(c):g}" for d, c in zip(PROFILE.names, conc))
|
| 44 |
+
lines.append(f"- {recipe} -> {float(nlab[0]):.1f}, {float(nlab[1]):.1f}, {float(nlab[2]):.1f}")
|
| 45 |
+
else:
|
| 46 |
+
lines.append("The lab has no similar past dips yet.")
|
| 47 |
+
lines.append("First dip recipe?")
|
| 48 |
+
return "\n".join(lines)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
def answer(conc) -> str:
|
| 52 |
return json.dumps({d: round(float(c), 2) for d, c in zip(PROFILE.names, conc)})
|
| 53 |
|
src/rangrez/order.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""From the buyer's message to a production plan.
|
| 2 |
+
|
| 3 |
+
Buyers send orders as chat messages ("need 500 kurtas in this teal, cotton poplin, by Friday,
|
| 4 |
+
buyer very strict on shade"). Muse Spark reads the message into a structured order; the
|
| 5 |
+
numbers that follow are plain arithmetic: how strict the shade must be sets the lab's pass
|
| 6 |
+
tolerance, and the approved dip's recipe scales to the order's fabric, dye bath and chemicals.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
from .profiles import PROFILE
|
| 16 |
+
from .rig import DYES
|
| 17 |
+
|
| 18 |
+
SPARK_MODEL = "muse-spark-1.3"
|
| 19 |
+
TOLERANCE = {"strict": 1.0, "normal": 1.5, "relaxed": 2.0} # CIEDE2000 pass threshold by how picky the buyer is
|
| 20 |
+
KG_PER_PIECE = {"kurta": 0.30, "shirt": 0.25, "t-shirt": 0.18, "saree": 0.55, "dupatta": 0.15, "kurti": 0.25,
|
| 21 |
+
"towel": 0.40, "bedsheet": 0.70, "trouser": 0.45, "dress": 0.35}
|
| 22 |
+
KG_PER_METRE = 0.14 # 110 GSM poplin, 1.1 m wide, plus cutting waste
|
| 23 |
+
LIQUOR_RATIO = 10 # litres of dye bath per kg of cotton
|
| 24 |
+
# Salt and soda ash rise with shade depth (% on weight of fabric): typical reactive-dye practice.
|
| 25 |
+
SALT_SODA_BY_DEPTH = [(0.5, 30, 10), (2.0, 50, 15), (4.0, 70, 20), (float("inf"), 80, 20)]
|
| 26 |
+
|
| 27 |
+
SCHEMA = {
|
| 28 |
+
"type": "object",
|
| 29 |
+
"properties": {
|
| 30 |
+
"garment": {"type": "string", "description": "what is being dyed, singular, e.g. kurta, saree, fabric"},
|
| 31 |
+
"quantity": {"type": "number"},
|
| 32 |
+
"quantity_unit": {"type": "string", "enum": ["pieces", "metres", "kg"]},
|
| 33 |
+
"fabric": {"type": "string"},
|
| 34 |
+
"deadline": {"type": "string", "description": "delivery date as YYYY-MM-DD, or empty if not given"},
|
| 35 |
+
"lab_dip_due": {"type": "string", "description": "when the buyer wants the lab dip, YYYY-MM-DD, or empty"},
|
| 36 |
+
"shade_strictness": {"type": "string", "enum": ["strict", "normal", "relaxed"]},
|
| 37 |
+
"notes": {"type": "string", "description": "anything else that matters for dyeing, short"},
|
| 38 |
+
},
|
| 39 |
+
"required": ["garment", "quantity", "quantity_unit", "fabric", "deadline", "lab_dip_due", "shade_strictness", "notes"],
|
| 40 |
+
"additionalProperties": False,
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def parse_order(message: str, log=None) -> dict:
|
| 45 |
+
"""Muse Spark reads a buyer's chat message into a structured order."""
|
| 46 |
+
from openai import OpenAI
|
| 47 |
+
|
| 48 |
+
from .vision import ApiCall
|
| 49 |
+
|
| 50 |
+
client = OpenAI(base_url="https://api.meta.ai/v1", api_key=os.environ["MODEL_API_KEY"], timeout=90, max_retries=1)
|
| 51 |
+
today = time.strftime("%A %Y-%m-%d")
|
| 52 |
+
started = time.monotonic()
|
| 53 |
+
try:
|
| 54 |
+
r = client.chat.completions.create(
|
| 55 |
+
model=SPARK_MODEL, reasoning_effort="low",
|
| 56 |
+
messages=[{"role": "system", "content": f"You read dye orders for a small cotton dye house. Today is {today}. "
|
| 57 |
+
"Resolve relative dates. Use only what the message says."},
|
| 58 |
+
{"role": "user", "content": message}],
|
| 59 |
+
response_format={"type": "json_schema", "json_schema": {"name": "order", "schema": SCHEMA, "strict": True}})
|
| 60 |
+
order = json.loads(r.choices[0].message.content)
|
| 61 |
+
except Exception as exc:
|
| 62 |
+
if log is not None:
|
| 63 |
+
log.add(ApiCall(SPARK_MODEL, "read the order", time.monotonic() - started, False, str(exc)[:200]))
|
| 64 |
+
raise RuntimeError(f"could not read the order: {exc}") from exc
|
| 65 |
+
if log is not None:
|
| 66 |
+
log.add(ApiCall(SPARK_MODEL, "read the order", time.monotonic() - started, True))
|
| 67 |
+
order["tolerance_de00"] = TOLERANCE.get(order.get("shade_strictness", "normal"), 1.5)
|
| 68 |
+
return order
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def fabric_kg(order: dict) -> float:
|
| 72 |
+
q, unit = float(order.get("quantity") or 0), order.get("quantity_unit", "pieces")
|
| 73 |
+
if unit == "kg":
|
| 74 |
+
return q
|
| 75 |
+
if unit == "metres":
|
| 76 |
+
return q * KG_PER_METRE
|
| 77 |
+
garment = order.get("garment", "").lower().rstrip("s")
|
| 78 |
+
return q * KG_PER_PIECE.get(garment, 0.30)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def production_plan(order: dict, recipe_conc) -> dict | None:
|
| 82 |
+
"""The approved dip's recipe, scaled to the order. Reactive dyes (g/L) only."""
|
| 83 |
+
if PROFILE.unit != "g/L":
|
| 84 |
+
return None
|
| 85 |
+
kg = fabric_kg(order)
|
| 86 |
+
litres = kg * LIQUOR_RATIO
|
| 87 |
+
depth = float(sum(recipe_conc)) * LIQUOR_RATIO / 10 # % on weight of fabric: g/L x L/kg / 10
|
| 88 |
+
salt, soda = next((s, a) for limit, s, a in SALT_SODA_BY_DEPTH if depth <= limit)
|
| 89 |
+
return {
|
| 90 |
+
"fabric_kg": round(kg, 1),
|
| 91 |
+
"bath_litres": round(litres),
|
| 92 |
+
"dye_g": {d: round(float(c) * litres) for d, c in zip(DYES, recipe_conc)},
|
| 93 |
+
"shade_depth_owf": round(depth, 2),
|
| 94 |
+
"salt_kg": round(salt * litres / 1000, 1),
|
| 95 |
+
"soda_ash_kg": round(soda * litres / 1000, 1),
|
| 96 |
+
"assumptions": f"{LIQUOR_RATIO}:1 liquor ratio; {KG_PER_PIECE.get(order.get('garment', '').lower().rstrip('s'), 0.30)} kg "
|
| 97 |
+
f"per {order.get('garment', 'piece')}; a {depth:.1f}% shade, so salt {salt} g/L and soda ash {soda} g/L, "
|
| 98 |
+
"typical for reactive dye at that depth. Indicative until calibrated with your own lab-to-bulk factor.",
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def draft_reply(message: str, facts: dict, log=None) -> str:
|
| 103 |
+
"""The message back to the buyer, in their language, using only the lab's numbers."""
|
| 104 |
+
from openai import OpenAI
|
| 105 |
+
|
| 106 |
+
from .vision import ApiCall
|
| 107 |
+
|
| 108 |
+
client = OpenAI(base_url="https://api.meta.ai/v1", api_key=os.environ["MODEL_API_KEY"], timeout=90, max_retries=1)
|
| 109 |
+
started = time.monotonic()
|
| 110 |
+
try:
|
| 111 |
+
r = client.chat.completions.create(
|
| 112 |
+
model=SPARK_MODEL, reasoning_effort="low",
|
| 113 |
+
messages=[{"role": "system", "content": (
|
| 114 |
+
"You write the dye house's short chat reply to a buyer about their lab dip. Reply in the same language and "
|
| 115 |
+
"script the buyer used (Hindi in Devanagari if they wrote Hindi, Hinglish if they wrote Hinglish). Use only "
|
| 116 |
+
"the facts given; never invent numbers or promises. At most 70 words, warm and plain, no markdown. Say the "
|
| 117 |
+
"lab-dip report is attached; if a preview exists, say a photo of their garment in the shade is attached.")},
|
| 118 |
+
{"role": "user", "content": f"Buyer's message:\n{message}\n\nFacts:\n{json.dumps(facts, ensure_ascii=False)}"}])
|
| 119 |
+
text = (r.choices[0].message.content or "").strip()
|
| 120 |
+
except Exception as exc:
|
| 121 |
+
if log is not None:
|
| 122 |
+
log.add(ApiCall(SPARK_MODEL, "reply to the buyer", time.monotonic() - started, False, str(exc)[:200]))
|
| 123 |
+
raise RuntimeError(f"could not draft the reply: {exc}") from exc
|
| 124 |
+
if log is not None:
|
| 125 |
+
log.add(ApiCall(SPARK_MODEL, "reply to the buyer", time.monotonic() - started, True))
|
| 126 |
+
return text
|
src/rangrez/replay.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Replay a recorded live run on the dashboard, for visitors without the live passcode.
|
| 2 |
+
|
| 3 |
+
Every event in a recording happened for real: each SAM 3.1, Muse Wonder, Muse Spark, Muse Image
|
| 4 |
+
or Muse Voice Transcribe call with its timing, each dip photo and SAM's masks, the notebook, the
|
| 5 |
+
report. A replay sends those events again in order (a little faster), and the dashboard labels
|
| 6 |
+
it as a recording with its date. Recordings shipped with the app live in samples/runs/.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
SAMPLES = Path(__file__).parent / "samples"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def runs() -> list[dict]:
|
| 20 |
+
out = []
|
| 21 |
+
for d in sorted((SAMPLES / "runs").glob("*")):
|
| 22 |
+
meta = json.loads((d / "meta.json").read_text()) if (d / "meta.json").exists() else {}
|
| 23 |
+
out.append({"name": d.name, "title": meta.get("title", d.name), "started": meta.get("started", ""),
|
| 24 |
+
"agent": meta.get("agent", ""), "rig": meta.get("rig", "")})
|
| 25 |
+
return out
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Replay:
|
| 29 |
+
def __init__(self, name: str, publish, speed: float = 3.0, max_gap: float = 2.5) -> None:
|
| 30 |
+
self.dir = SAMPLES / "runs" / Path(name).name # no path tricks: a run name only
|
| 31 |
+
if not (self.dir / "events.jsonl").exists():
|
| 32 |
+
raise FileNotFoundError(name)
|
| 33 |
+
self.meta = json.loads((self.dir / "meta.json").read_text()) if (self.dir / "meta.json").exists() else {}
|
| 34 |
+
self.events = [json.loads(line) for line in (self.dir / "events.jsonl").read_text().splitlines() if line.strip()]
|
| 35 |
+
self.publish, self.speed, self.max_gap = publish, speed, max_gap
|
| 36 |
+
self.stopped = False
|
| 37 |
+
self.thread = threading.Thread(target=self._run, daemon=True)
|
| 38 |
+
|
| 39 |
+
def start(self) -> None:
|
| 40 |
+
self.thread.start()
|
| 41 |
+
|
| 42 |
+
def stop(self) -> None:
|
| 43 |
+
self.stopped = True
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def running(self) -> bool:
|
| 47 |
+
return self.thread.is_alive()
|
| 48 |
+
|
| 49 |
+
def file(self, name: str) -> bytes | None:
|
| 50 |
+
p = self.dir / Path(name).name
|
| 51 |
+
return p.read_bytes() if p.exists() else None
|
| 52 |
+
|
| 53 |
+
def _run(self) -> None:
|
| 54 |
+
recorded = self.meta.get("started", "")
|
| 55 |
+
last = None
|
| 56 |
+
for e in self.events:
|
| 57 |
+
if self.stopped:
|
| 58 |
+
return
|
| 59 |
+
at = e.get("at")
|
| 60 |
+
if last is not None and at is not None:
|
| 61 |
+
time.sleep(min(max(at - last, 0) / self.speed, self.max_gap))
|
| 62 |
+
last = at if at is not None else last
|
| 63 |
+
e = {**e, "at": time.time(), "replay": True}
|
| 64 |
+
if e.get("kind") == "start":
|
| 65 |
+
e["recorded"] = recorded
|
| 66 |
+
self.publish(e)
|
src/rangrez/report.py
CHANGED
|
@@ -15,11 +15,12 @@ import time
|
|
| 15 |
|
| 16 |
import numpy as np
|
| 17 |
|
| 18 |
-
from .colour import delta_e_2000, lab_to_hex
|
| 19 |
from .profiles import PROFILE
|
| 20 |
from .rig import DYES, UNIT
|
| 21 |
|
| 22 |
LIQUOR_RATIO = 10 # litres of dye bath per kg of cotton (1:10), typical for exhaust dyeing
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def difference(target, sample) -> dict:
|
|
@@ -50,17 +51,62 @@ def difference(target, sample) -> dict:
|
|
| 50 |
"words": ", ".join(words) or "no visible difference"}
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
def _img(jpeg: bytes, width: int) -> str:
|
| 54 |
return f'<img src="data:image/jpeg;base64,{base64.b64encode(jpeg).decode()}" width="{width}" alt="">'
|
| 55 |
|
| 56 |
|
| 57 |
def render(campaign, target_photo: bytes | None = None, agent: str = "", api_calls: int | None = None,
|
| 58 |
-
preview: dict | None = None) -> str:
|
| 59 |
c = campaign
|
| 60 |
best = c.best
|
| 61 |
tol = c.tolerance
|
| 62 |
-
passed = best is not None and best.delta_e <= tol
|
| 63 |
diff = difference(c.target_lab, best.measurement.lab) if best else None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
when = time.strftime("%d %b %Y, %H:%M")
|
| 65 |
ref = f"RZ-{c.campaign_id[-8:].upper()}"
|
| 66 |
rows = []
|
|
@@ -74,9 +120,17 @@ def render(campaign, target_photo: bytes | None = None, agent: str = "", api_cal
|
|
| 74 |
if best is not None:
|
| 75 |
bath = best.recipe.as_array() / PROFILE.stock_per_l * 1000 # mL of stock per litre of bath
|
| 76 |
per_kg = bath * LIQUOR_RATIO
|
| 77 |
-
|
|
|
|
| 78 |
for n, v, m in zip(DYES, best.recipe.conc, per_kg))
|
| 79 |
-
verdict = ("PASS" if passed else "NOT YET") if best else "NO DIP"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
flags = sorted({f for s in c.shots for f in s.measurement.flags})
|
| 81 |
notes = "".join(f"<li>{html.escape(f)}</li>" for f in flags) or "<li>No measurement warnings.</li>"
|
| 82 |
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
@@ -103,16 +157,26 @@ img {{ border-radius: 6px; max-width: 100%; height: auto; }} .sign {{ display:fl
|
|
| 103 |
<b>Target</b> {lab_to_hex(c.target_lab)} · L*a*b* {", ".join(f"{v:.1f}" for v in c.target_lab)}
|
| 104 |
{("<div>" + _img(target_photo, 300) + "</div>") if target_photo else ""}</div>
|
| 105 |
<div><div class="chip" style="background:{lab_to_hex(best.measurement.lab) if best else "#fff"}"></div>
|
| 106 |
-
<b>
|
| 107 |
{("· L*a*b* " + ", ".join(f"{v:.1f}" for v in best.measurement.lab)) if best else ""}</div>
|
| 108 |
<div><div class="verdict">{verdict}</div>
|
| 109 |
-
{f'<div>ΔE00 <b>{
|
|
|
|
| 110 |
</div>
|
| 111 |
|
| 112 |
-
{f'''<h2>
|
| 113 |
-
<table><tr><th>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
<p class="meta">Dip {best.recipe.dip_seconds:.0f} s. Stock at {PROFILE.stock_per_l:g} {UNIT if UNIT != "drops" else "drops"} per litre. Scale-up assumes the same liquor ratio, temperature and fixation as the lab dip; confirm with a bulk trial.</p>''' if best else ""}
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
{f"""<h2>Bulk preview</h2>
|
| 117 |
<div class="grid"><div>{_img(preview["jpeg"], 420)}</div>
|
| 118 |
<div class="meta">The customer's photo, recoloured by Muse Image to dip {preview["dip"]}'s shade. An image model is not a colour
|
|
@@ -126,7 +190,9 @@ garment's pixels; it now measures {preview["measured_hex"]}, ΔE00 {preview["de0
|
|
| 126 |
<h2>How it was measured</h2>
|
| 127 |
<ul class="meta">
|
| 128 |
<li>Each dip is dried and photographed on white paper; SAM 3.1 segments the strip and the 24-patch colour card, and a colour-correction matrix turns pixels into CIELAB (D65). Uneven light is evened out using the paper.</li>
|
| 129 |
-
<li>Colour difference is CIEDE2000; ΔL*, ΔC*, ΔH* are CIELAB components (positive: lighter, brighter, counter-clockwise in hue).</li>
|
|
|
|
|
|
|
| 130 |
<li>Campaign run by {html.escape(agent or "Rangrez")}{f", {api_calls} live Meta API calls" if api_calls is not None else ""}; {len(c.shots)} dips; status {html.escape(c.status)}.</li>
|
| 131 |
{notes}
|
| 132 |
</ul>
|
|
|
|
| 15 |
|
| 16 |
import numpy as np
|
| 17 |
|
| 18 |
+
from .colour import delta_e_2000, delta_e_cmc, lab_to_hex
|
| 19 |
from .profiles import PROFILE
|
| 20 |
from .rig import DYES, UNIT
|
| 21 |
|
| 22 |
LIQUOR_RATIO = 10 # litres of dye bath per kg of cotton (1:10), typical for exhaust dyeing
|
| 23 |
+
UNCERTAINTY = {"card": 0.7, "paper": 1.2} # median dE00 error on the 44-photo test bench (eval/accuracy.py)
|
| 24 |
|
| 25 |
|
| 26 |
def difference(target, sample) -> dict:
|
|
|
|
| 51 |
"words": ", ".join(words) or "no visible difference"}
|
| 52 |
|
| 53 |
|
| 54 |
+
def _impact_block(c) -> str:
|
| 55 |
+
"""What this lab dip took, next to how lab dips usually go."""
|
| 56 |
+
if not c.shots:
|
| 57 |
+
return ""
|
| 58 |
+
stock_ml = sum(float(np.sum(s.recipe.as_array())) / PROFILE.stock_per_l * c.rig.limits.bath_ml for s in c.shots)
|
| 59 |
+
water_ml = len(c.shots) * c.rig.limits.bath_ml
|
| 60 |
+
minutes = (c.shots[-1].at - c.shots[0].at) / 60 if len(c.shots) > 1 else 0
|
| 61 |
+
took = f"{len(c.shots)} dip{'s' if len(c.shots) > 1 else ''}" + (f" over {minutes:.0f} minutes" if minutes >= 1 else "")
|
| 62 |
+
return f"""<h2>What it took</h2>
|
| 63 |
+
<div class="grid">
|
| 64 |
+
<div><div class="verdict" style="color:var(--ink)">{took}</div>
|
| 65 |
+
<div class="meta">{stock_ml:.1f} mL of dye stock, about {water_ml / 1000:.2f} L of water and {len(c.shots)} cotton strip{'s' if len(c.shots) > 1 else ''}.</div></div>
|
| 66 |
+
<div class="meta">How lab dips usually go: about 2 to 4 rounds, each several days of mill work plus courier and the buyer's review
|
| 67 |
+
(one retailer allows up to five business days per review). Sources:
|
| 68 |
+
<a href="https://ifactoryapp.com/industries/textile-manufacturing/textile-sample-development-cost-time-management">iFactory sample-development guide</a>,
|
| 69 |
+
<a href="https://www.thekrogerco.com/wp-content/uploads/2017/08/ale_gmsg.pdf">a retailer's supplier guide</a>,
|
| 70 |
+
<a href="https://cottonworks.com/en/topics/sourcing-manufacturing/dyeing/the-lab-dip-process">Cotton Incorporated</a>.</div>
|
| 71 |
+
</div>"""
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _order_block(order: dict, best) -> str:
|
| 75 |
+
from .order import production_plan
|
| 76 |
+
|
| 77 |
+
rows = "".join(f"<tr><td>{html.escape(k)}</td><td>{html.escape(str(v))}</td></tr>" for k, v in (
|
| 78 |
+
("Garment", f"{order.get('quantity'):g} {order.get('quantity_unit')} of {order.get('garment')}"),
|
| 79 |
+
("Fabric", order.get("fabric")), ("Lab dip due", order.get("lab_dip_due") or "not given"),
|
| 80 |
+
("Delivery", order.get("deadline") or "not given"),
|
| 81 |
+
("Shade", f"{order.get('shade_strictness')}: pass at ΔE00 ≤ {order.get('tolerance_de00')}"),
|
| 82 |
+
("Notes", order.get("notes") or "")))
|
| 83 |
+
plan = production_plan(order, best.recipe.conc) if best else None
|
| 84 |
+
plan_html = ""
|
| 85 |
+
if plan:
|
| 86 |
+
dyes = "".join(f"<tr><td>{html.escape(d)}</td><td class='num'>{g:,} g</td></tr>" for d, g in plan["dye_g"].items())
|
| 87 |
+
plan_html = f"""<table><tr><th>For the order</th><th class="num"></th></tr>
|
| 88 |
+
<tr><td>Cotton</td><td class="num">{plan["fabric_kg"]:,} kg</td></tr><tr><td>Dye bath</td><td class="num">{plan["bath_litres"]:,} L</td></tr>
|
| 89 |
+
{dyes}<tr><td>Salt</td><td class="num">{plan["salt_kg"]:,} kg</td></tr><tr><td>Soda ash</td><td class="num">{plan["soda_ash_kg"]:,} kg</td></tr></table>
|
| 90 |
+
<p class="meta">{html.escape(plan["assumptions"])}</p>"""
|
| 91 |
+
return f"""<h2>Order and production plan</h2>
|
| 92 |
+
<p class="meta">Read from the buyer's message by Muse Spark; quantities are plain arithmetic on the approved dip's recipe.</p>
|
| 93 |
+
<div class="grid"><div><table>{rows}</table></div><div>{plan_html}</div></div>"""
|
| 94 |
+
|
| 95 |
+
|
| 96 |
def _img(jpeg: bytes, width: int) -> str:
|
| 97 |
return f'<img src="data:image/jpeg;base64,{base64.b64encode(jpeg).decode()}" width="{width}" alt="">'
|
| 98 |
|
| 99 |
|
| 100 |
def render(campaign, target_photo: bytes | None = None, agent: str = "", api_calls: int | None = None,
|
| 101 |
+
preview: dict | None = None, order: dict | None = None) -> str:
|
| 102 |
c = campaign
|
| 103 |
best = c.best
|
| 104 |
tol = c.tolerance
|
|
|
|
| 105 |
diff = difference(c.target_lab, best.measurement.lab) if best else None
|
| 106 |
+
paper = best is not None and any("white-paper balance" in f for f in best.measurement.flags)
|
| 107 |
+
u = UNCERTAINTY["paper" if paper else "card"]
|
| 108 |
+
passed = best is not None and best.delta_e <= tol
|
| 109 |
+
certain = best is not None and best.delta_e + u <= tol
|
| 110 |
when = time.strftime("%d %b %Y, %H:%M")
|
| 111 |
ref = f"RZ-{c.campaign_id[-8:].upper()}"
|
| 112 |
rows = []
|
|
|
|
| 120 |
if best is not None:
|
| 121 |
bath = best.recipe.as_array() / PROFILE.stock_per_l * 1000 # mL of stock per litre of bath
|
| 122 |
per_kg = bath * LIQUOR_RATIO
|
| 123 |
+
owf = (lambda v: f"<td class='num'>{v * LIQUOR_RATIO / 10:.2f}%</td>") if UNIT == "g/L" else (lambda v: "")
|
| 124 |
+
prod = "".join(f"<tr><td>{html.escape(n)}</td><td class='num'>{v:g} {UNIT}</td>{owf(v)}<td class='num'>{m:.0f} mL</td></tr>"
|
| 125 |
for n, v, m in zip(DYES, best.recipe.conc, per_kg))
|
| 126 |
+
verdict = ("PASS" if certain else "BORDERLINE" if passed else "NOT YET") if best else "NO DIP"
|
| 127 |
+
options = sorted((s for s in c.shots if s.measurement.ok), key=lambda s: s.delta_e)[:3]
|
| 128 |
+
option_rows = "".join(
|
| 129 |
+
f"<tr><td><b>{tag}</b> · dip {s.index}</td><td><span class='sw' style='background:{lab_to_hex(s.measurement.lab)}'></span>"
|
| 130 |
+
f"{lab_to_hex(s.measurement.lab)}</td><td class='num'>{s.delta_e:.2f}</td>"
|
| 131 |
+
f"<td class='num'>{delta_e_cmc(c.target_lab, s.measurement.lab):.2f}</td>"
|
| 132 |
+
f"<td>{' · '.join(f'{v:g}' for v in s.recipe.conc)}</td></tr>"
|
| 133 |
+
for tag, s in zip("ABC", options))
|
| 134 |
flags = sorted({f for s in c.shots for f in s.measurement.flags})
|
| 135 |
notes = "".join(f"<li>{html.escape(f)}</li>" for f in flags) or "<li>No measurement warnings.</li>"
|
| 136 |
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
|
|
| 157 |
<b>Target</b> {lab_to_hex(c.target_lab)} · L*a*b* {", ".join(f"{v:.1f}" for v in c.target_lab)}
|
| 158 |
{("<div>" + _img(target_photo, 300) + "</div>") if target_photo else ""}</div>
|
| 159 |
<div><div class="chip" style="background:{lab_to_hex(best.measurement.lab) if best else "#fff"}"></div>
|
| 160 |
+
<b>Option A: dip {best.index if best else ""}</b> {lab_to_hex(best.measurement.lab) if best else ""}
|
| 161 |
{("· L*a*b* " + ", ".join(f"{v:.1f}" for v in best.measurement.lab)) if best else ""}</div>
|
| 162 |
<div><div class="verdict">{verdict}</div>
|
| 163 |
+
{f'<div>ΔE00 <b>{best.delta_e:.2f}</b> ± {u} against a tolerance of {tol} · CMC(2:1) {delta_e_cmc(c.target_lab, best.measurement.lab):.2f}</div><div>ΔL* {diff["dL"]:+.2f} · ΔC* {diff["dC"]:+.2f} · ΔH* {diff["dH"]:+.2f}</div><div>The dip is <b>{html.escape(diff["words"])}</b> than the target.</div>' if diff else ""}
|
| 164 |
+
<div class="meta">{"Within tolerance even allowing for the camera's measurement error." if certain else f"Within tolerance, but the camera's measurement error (±{u}) could take it over: confirm on a spectrophotometer." if passed else ""}</div></div>
|
| 165 |
</div>
|
| 166 |
|
| 167 |
+
{f'''<h2>Options for the buyer</h2>
|
| 168 |
+
<table><tr><th>Option</th><th>Measured</th><th class="num">ΔE00</th><th class="num">CMC(2:1)</th><th>Recipe ({" · ".join(DYES)}, {UNIT})</th></tr>{option_rows}</table>
|
| 169 |
+
<p class="meta">Dye houses usually send two or three dips; these are the closest. Substrate: {html.escape((order or {}).get("fabric") or "white cotton strip")}.
|
| 170 |
+
Standard: {"the customer's photo" if target_photo else "the target colour given"} ({lab_to_hex(c.target_lab)}).</p>
|
| 171 |
+
|
| 172 |
+
<h2>Recipe</h2>
|
| 173 |
+
<table><tr><th>Dye</th><th class="num">Bath concentration</th>{"<th class='num'>% on weight of fabric (1:" + str(LIQUOR_RATIO) + ")</th>" if UNIT == "g/L" else ""}<th class="num">Stock for 1 kg cotton (1:{LIQUOR_RATIO})</th></tr>{prod}</table>
|
| 174 |
<p class="meta">Dip {best.recipe.dip_seconds:.0f} s. Stock at {PROFILE.stock_per_l:g} {UNIT if UNIT != "drops" else "drops"} per litre. Scale-up assumes the same liquor ratio, temperature and fixation as the lab dip; confirm with a bulk trial.</p>''' if best else ""}
|
| 175 |
|
| 176 |
+
{_impact_block(c)}
|
| 177 |
+
|
| 178 |
+
{_order_block(order, best) if order else ""}
|
| 179 |
+
|
| 180 |
{f"""<h2>Bulk preview</h2>
|
| 181 |
<div class="grid"><div>{_img(preview["jpeg"], 420)}</div>
|
| 182 |
<div class="meta">The customer's photo, recoloured by Muse Image to dip {preview["dip"]}'s shade. An image model is not a colour
|
|
|
|
| 190 |
<h2>How it was measured</h2>
|
| 191 |
<ul class="meta">
|
| 192 |
<li>Each dip is dried and photographed on white paper; SAM 3.1 segments the strip and the 24-patch colour card, and a colour-correction matrix turns pixels into CIELAB (D65). Uneven light is evened out using the paper.</li>
|
| 193 |
+
<li>Colour difference is CIEDE2000 (and CMC 2:1 alongside); ΔL*, ΔC*, ΔH* are CIELAB components (positive: lighter, brighter, counter-clockwise in hue).</li>
|
| 194 |
+
<li>Measurement error: about ±{UNCERTAINTY["card"]} ΔE00 with the printed colour card and ±{UNCERTAINTY["paper"]} with white paper only (median error on Rangrez's 44-photo test bench, simulated photos). A phone camera sees one light: check metamerism in a light booth under D65 and TL84 before bulk.</li>
|
| 195 |
+
<li>Rangrez is a pre-screen and correction tool that gets a dye house to a good lab dip in fewer rounds; final approval stays with the buyer's instrument or light booth.</li>
|
| 196 |
<li>Campaign run by {html.escape(agent or "Rangrez")}{f", {api_calls} live Meta API calls" if api_calls is not None else ""}; {len(c.shots)} dips; status {html.escape(c.status)}.</li>
|
| 197 |
{notes}
|
| 198 |
</ul>
|
src/rangrez/samples/kurta.jpg
ADDED
|
Git LFS Details
|
src/rangrez/samples/kurta.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"lab": [
|
| 3 |
+
57.64,
|
| 4 |
+
-15.84,
|
| 5 |
+
-12.65
|
| 6 |
+
],
|
| 7 |
+
"hex": "#5893a0",
|
| 8 |
+
"calibration": "card",
|
| 9 |
+
"concept": "kurta",
|
| 10 |
+
"home_recipe": {
|
| 11 |
+
"drops": {
|
| 12 |
+
"blue": 22,
|
| 13 |
+
"red": 1,
|
| 14 |
+
"yellow": 8
|
| 15 |
+
},
|
| 16 |
+
"water_ml": 20,
|
| 17 |
+
"teaspoons": 4,
|
| 18 |
+
"predicted_de00": 0.3,
|
| 19 |
+
"reachable": true,
|
| 20 |
+
"closest_hex": "#59939f"
|
| 21 |
+
}
|
| 22 |
+
}
|
src/rangrez/samples/order.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Hi bhaiya, need 500 kurtas in exactly this teal, cotton poplin, delivery by Friday 14th. Buyer is very strict on shade. Can you send the lab dip by tomorrow?
|
src/rangrez/samples/runs/teal-kurta/dip01_photo.jpg
ADDED
|
Git LFS Details
|
src/rangrez/samples/runs/teal-kurta/dip01_sam.jpg
ADDED
|
Git LFS Details
|
src/rangrez/samples/runs/teal-kurta/dip02_photo.jpg
ADDED
|
Git LFS Details
|
src/rangrez/samples/runs/teal-kurta/dip02_sam.jpg
ADDED
|
Git LFS Details
|
src/rangrez/samples/runs/teal-kurta/events.jsonl
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'kurta'", "seconds": 2.67, "ok": true, "detail": "", "at": 1793621762.353266}
|
| 2 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 2.63, "ok": true, "detail": "", "at": 1793621766.479362}
|
| 3 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 2.71, "ok": true, "detail": "", "at": 1793621770.985225}
|
| 4 |
+
{"kind": "api", "model": "muse-spark-1.3", "purpose": "read the order", "seconds": 4.46, "ok": true, "detail": "", "at": 1793621775.833088}
|
| 5 |
+
{"kind": "start", "target_lab": [57.64, -15.84, -12.65], "target_hex": "#5893a0", "tolerance": 1.0, "agent": "muse-wonder-1.0", "rig": "VirtualRig", "memory_dips": 29, "memory_campaigns": 15, "at": 1793621775.928276}
|
| 6 |
+
{"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 2.43, "ok": true, "detail": "2068 tokens", "at": 1793621778.355718}
|
| 7 |
+
{"kind": "thought", "at": 1793621778.3567061, "text": "Target #5893a0 (Lab 57.64, -15.84, -12.65), tolerance dE00 1.0 \u2014 starting with model suggestions for the first dip."}
|
| 8 |
+
{"kind": "tool", "at": 1793621778.3570712, "name": "get_state", "args": {}}
|
| 9 |
+
{"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 2.76, "ok": true, "detail": "1856 tokens", "at": 1793621781.1219652}
|
| 10 |
+
{"kind": "tool", "at": 1793621781.12275, "name": "suggest_recipes", "args": {}}
|
| 11 |
+
{"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 3.94, "ok": true, "detail": "2384 tokens", "at": 1793621785.1281128}
|
| 12 |
+
{"kind": "thought", "at": 1793621785.128912, "text": "No glimmer offered. Lab_memory's remembered dip was dE00 7.6 away \u2014 outside the ~6 threshold \u2014 so I'll trust model_inverse for the far-from-target first dip: turquoise 3.616, fuchsia 1.173, lemon yellow 1.669."}
|
| 13 |
+
{"kind": "tool", "at": 1793621785.129199, "name": "write_note", "args": {"text": "Shot 1: Hypothesis model_inverse gives close start despite lot error. No glimmer; lab_memory source dip dE00 7.6 (>~6) so not used. Changed: first dip turquoise 3.616, fuchsia 1.173, lemon yellow 1.669. Expect near target but residual lot/saturation error, then fit_model."}}
|
| 14 |
+
{"kind": "note", "at": 1793621785.129335, "who": "agent", "text": "Shot 1: Hypothesis model_inverse gives close start despite lot error. No glimmer; lab_memory source dip dE00 7.6 (>~6) so not used. Changed: first dip turquoise 3.616, fuchsia 1.173, lemon yellow 1.669. Expect near target but residual lot/saturation error, then fit_model."}
|
| 15 |
+
{"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 1.91, "ok": true, "detail": "2255 tokens", "at": 1793621787.042033}
|
| 16 |
+
{"kind": "tool", "at": 1793621787.042832, "name": "run_shot", "args": {"recipe": {"turquoise": 3.616, "fuchsia": 1.173, "lemon yellow": 1.669}, "rationale": "First dip: use model_inverse per rule; lab_memory remembered dip was 7.6 dE00 away (>~6 threshold), no glimmer offered."}}
|
| 17 |
+
{"kind": "dispense", "at": 1793621787.0433102, "recipe": "turquoise 3.62 g/L, fuchsia 1.17 g/L, lemon yellow 1.67 g/L, dip 90 s", "index": 1, "rationale": "First dip: use model_inverse per rule; lab_memory remembered dip was 7.6 dE00 away (>~6 threshold), no glimmer offered."}
|
| 18 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 2.02, "ok": true, "detail": "", "at": 1793621789.455463}
|
| 19 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 0.97, "ok": true, "detail": "", "at": 1793621790.687516}
|
| 20 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric'", "seconds": 2.01, "ok": true, "detail": "", "at": 1793621794.4337459}
|
| 21 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric swatch'", "seconds": 2.15, "ok": true, "detail": "", "at": 1793621797.301422}
|
| 22 |
+
{"kind": "shot", "at": 1793621797.9141939, "index": 1, "recipe": "turquoise 3.62 g/L, fuchsia 1.17 g/L, lemon yellow 1.67 g/L, dip 90 s", "lab": [58.823361050996795, -14.364486445012759, -10.185166255851286], "hex": "#63969f", "delta_e": 1.9690501181668465, "flags": ["NOTE: evened out uneven light (4.9x across the paper)"], "rationale": "First dip: use model_inverse per rule; lab_memory remembered dip was 7.6 dE00 away (>~6 threshold), no glimmer offered."}
|
| 23 |
+
{"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 2.68, "ok": true, "detail": "2603 tokens", "at": 1793621800.592704}
|
| 24 |
+
{"kind": "thought", "at": 1793621800.593299, "text": "First dip dE00 1.97 \u2014 measured 58.82, -14.36, -10.19 vs target 57.64, -15.84, -12.65: too light, less blue/green. Refitting model to this real lot before correcting."}
|
| 25 |
+
{"kind": "tool", "at": 1793621800.593459, "name": "fit_model", "args": {}}
|
| 26 |
+
{"kind": "model", "at": 1793621800.6247702, "gains_before": {"turquoise": 1.21, "fuchsia": 0.605, "lemon yellow": 0.916}, "gains_after": {"turquoise": 1.202, "fuchsia": 0.604, "lemon yellow": 0.916}, "model_fit_error_de00": 2.46, "shots_used": 30}
|
| 27 |
+
{"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 1.92, "ok": true, "detail": "2620 tokens", "at": 1793621802.545054}
|
| 28 |
+
{"kind": "tool", "at": 1793621802.545749, "name": "get_state", "args": {}}
|
| 29 |
+
{"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 7.11, "ok": true, "detail": "3090 tokens", "at": 1793621809.6604218}
|
| 30 |
+
{"kind": "tool", "at": 1793621809.661202, "name": "suggest_recipes", "args": {}}
|
| 31 |
+
{"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 3.77, "ok": true, "detail": "3575 tokens", "at": 1793621813.4884312}
|
| 32 |
+
{"kind": "thought", "at": 1793621813.4892669, "text": "Best dip is now dE00 1.97 (<6), so per instructions I'll prefer colourist_correction over model_inverse \u2014 it corrects the real dip's measured error."}
|
| 33 |
+
{"kind": "tool", "at": 1793621813.489579, "name": "write_note", "args": {"text": "Shot 2: Hypothesis colourist correction from real dip captures lot error model misses. Changed: use colourist_correction turquoise 4.296, fuchsia 1.084, lemon yellow 1.632 (up turquoise vs shot1 to darken, add green/blue). Expect dE00 move from 1.97 toward <1.0."}}
|
| 34 |
+
{"kind": "note", "at": 1793621813.489713, "who": "agent", "text": "Shot 2: Hypothesis colourist correction from real dip captures lot error model misses. Changed: use colourist_correction turquoise 4.296, fuchsia 1.084, lemon yellow 1.632 (up turquoise vs shot1 to darken, add green/blue). Expect dE00 move from 1.97 toward <1.0."}
|
| 35 |
+
{"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 1.73, "ok": true, "detail": "3499 tokens", "at": 1793621815.2218208}
|
| 36 |
+
{"kind": "tool", "at": 1793621815.222573, "name": "run_shot", "args": {"recipe": {"turquoise": 4.296, "fuchsia": 1.084, "lemon yellow": 1.632}, "rationale": "Best dip within 6 dE00 (1.97), prefer colourist_correction learned from real dip over model_inverse."}}
|
| 37 |
+
{"kind": "dispense", "at": 1793621815.2230558, "recipe": "turquoise 4.30 g/L, fuchsia 1.08 g/L, lemon yellow 1.63 g/L, dip 90 s", "index": 2, "rationale": "Best dip within 6 dE00 (1.97), prefer colourist_correction learned from real dip over model_inverse."}
|
| 38 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 1.65, "ok": true, "detail": "", "at": 1793621817.065462}
|
| 39 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 2.08, "ok": true, "detail": "", "at": 1793621819.398997}
|
| 40 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric'", "seconds": 0.75, "ok": true, "detail": "", "at": 1793621821.878986}
|
| 41 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric swatch'", "seconds": 1.85, "ok": true, "detail": "", "at": 1793621824.4457579}
|
| 42 |
+
{"kind": "shot", "at": 1793621825.0547872, "index": 2, "recipe": "turquoise 4.30 g/L, fuchsia 1.08 g/L, lemon yellow 1.63 g/L, dip 90 s", "lab": [58.34909827704456, -14.93625994840475, -11.815768969991014], "hex": "#5e95a0", "delta_e": 0.9520448628209843, "flags": ["NOTE: evened out uneven light (4.9x across the paper)"], "rationale": "Best dip within 6 dE00 (1.97), prefer colourist_correction learned from real dip over model_inverse."}
|
| 43 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 1.27, "ok": true, "detail": "", "at": 1793621826.485831}
|
| 44 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 0.88, "ok": true, "detail": "", "at": 1793621827.6305852}
|
| 45 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric'", "seconds": 1.88, "ok": true, "detail": "", "at": 1793621831.265545}
|
| 46 |
+
{"kind": "note", "at": 1793621831.979785, "who": "lab", "text": "Confirmed with a second photo: dE00 0.95 and 0.89."}
|
| 47 |
+
{"kind": "status", "at": 1793621831.9799628, "status": "matched", "shot": 2, "delta_e": 0.9201863208202254}
|
| 48 |
+
{"kind": "api", "model": "muse-image-1.0", "purpose": "bulk preview", "seconds": 20.32, "ok": true, "detail": "", "at": 1793621853.0177689}
|
| 49 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'kurta'", "seconds": 2.46, "ok": true, "detail": "", "at": 1793621855.67038}
|
| 50 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric'", "seconds": 2.05, "ok": true, "detail": "", "at": 1793621857.717909}
|
| 51 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 1.95, "ok": true, "detail": "", "at": 1793621863.6589718}
|
| 52 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 1.3, "ok": true, "detail": "", "at": 1793621866.73721}
|
| 53 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'kurta'", "seconds": 2.42, "ok": true, "detail": "", "at": 1793621869.760772}
|
| 54 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 0.98, "ok": true, "detail": "", "at": 1793621872.9022121}
|
| 55 |
+
{"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 0.97, "ok": true, "detail": "", "at": 1793621875.646933}
|
| 56 |
+
{"kind": "api", "model": "muse-spark-1.3", "purpose": "reply to the buyer", "seconds": 4.37, "ok": true, "detail": "", "at": 1793621880.415}
|
src/rangrez/samples/runs/teal-kurta/meta.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"rig": "VirtualRig",
|
| 3 |
+
"scene": "lamp_desk",
|
| 4 |
+
"dyes": [
|
| 5 |
+
"turquoise",
|
| 6 |
+
"fuchsia",
|
| 7 |
+
"lemon yellow"
|
| 8 |
+
],
|
| 9 |
+
"unit": "g/L",
|
| 10 |
+
"agent": "muse-wonder-1.0",
|
| 11 |
+
"tolerance": 1.0,
|
| 12 |
+
"target_lab": [
|
| 13 |
+
57.64,
|
| 14 |
+
-15.84,
|
| 15 |
+
-12.65
|
| 16 |
+
],
|
| 17 |
+
"target_hex": "#5893a0",
|
| 18 |
+
"started": "2026-11-02 17:46:15",
|
| 19 |
+
"title": "Teal kurta for a strict buyer: live SAM 3.1, Muse Spark, Muse Wonder, Muse Image"
|
| 20 |
+
}
|
src/rangrez/samples/runs/teal-kurta/order.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"message": "Hi bhaiya, need 500 kurtas in exactly this teal, cotton poplin, delivery by Friday 14th. Buyer is very strict on shade. Can you send the lab dip by tomorrow?",
|
| 3 |
+
"order": {
|
| 4 |
+
"deadline": "2026-11-14",
|
| 5 |
+
"fabric": "cotton poplin",
|
| 6 |
+
"garment": "kurta",
|
| 7 |
+
"lab_dip_due": "2026-11-03",
|
| 8 |
+
"notes": "exact teal shade",
|
| 9 |
+
"quantity": 500,
|
| 10 |
+
"quantity_unit": "pieces",
|
| 11 |
+
"shade_strictness": "strict",
|
| 12 |
+
"tolerance_de00": 1.0
|
| 13 |
+
}
|
| 14 |
+
}
|
src/rangrez/samples/runs/teal-kurta/preview.jpg
ADDED
|
Git LFS Details
|
src/rangrez/samples/runs/teal-kurta/reply.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Hi bhaiya, aapke 500 kurtas cotton poplin wale exact teal ke liye lab-dip report attached hai, saath me usi shade me garment ki photo bhi attached hai. Shade approved hai tolerance me, Delta 0.92 hai limit 1.0 ke andar, target se slightly lighter slightly duller hai. Humne 2 dips banaye the.
|
src/rangrez/samples/runs/teal-kurta/report.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/rangrez/samples/runs/teal-kurta/state.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"target_lab": [
|
| 3 |
+
57.64,
|
| 4 |
+
-15.84,
|
| 5 |
+
-12.65
|
| 6 |
+
],
|
| 7 |
+
"target_hex": "#5893a0",
|
| 8 |
+
"tolerance_de00": 1.0,
|
| 9 |
+
"shots_used": 2,
|
| 10 |
+
"shots_left": 8,
|
| 11 |
+
"best": {
|
| 12 |
+
"shot": 2,
|
| 13 |
+
"recipe": {
|
| 14 |
+
"turquoise": 4.296,
|
| 15 |
+
"fuchsia": 1.084,
|
| 16 |
+
"lemon yellow": 1.632
|
| 17 |
+
},
|
| 18 |
+
"measured_lab": [
|
| 19 |
+
58.35,
|
| 20 |
+
-14.94,
|
| 21 |
+
-11.82
|
| 22 |
+
],
|
| 23 |
+
"lab_sd": [
|
| 24 |
+
0.13,
|
| 25 |
+
0.05,
|
| 26 |
+
0.06
|
| 27 |
+
],
|
| 28 |
+
"delta_e00": 0.92,
|
| 29 |
+
"flags": [
|
| 30 |
+
"NOTE: evened out uneven light (4.9x across the paper)"
|
| 31 |
+
]
|
| 32 |
+
},
|
| 33 |
+
"history": [
|
| 34 |
+
{
|
| 35 |
+
"shot": 1,
|
| 36 |
+
"recipe": {
|
| 37 |
+
"turquoise": 3.616,
|
| 38 |
+
"fuchsia": 1.173,
|
| 39 |
+
"lemon yellow": 1.669
|
| 40 |
+
},
|
| 41 |
+
"measured_lab": [
|
| 42 |
+
58.82,
|
| 43 |
+
-14.36,
|
| 44 |
+
-10.19
|
| 45 |
+
],
|
| 46 |
+
"lab_sd": [
|
| 47 |
+
0.18,
|
| 48 |
+
0.05,
|
| 49 |
+
0.07
|
| 50 |
+
],
|
| 51 |
+
"delta_e00": 1.97,
|
| 52 |
+
"flags": [
|
| 53 |
+
"NOTE: evened out uneven light (4.9x across the paper)"
|
| 54 |
+
]
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"shot": 2,
|
| 58 |
+
"recipe": {
|
| 59 |
+
"turquoise": 4.296,
|
| 60 |
+
"fuchsia": 1.084,
|
| 61 |
+
"lemon yellow": 1.632
|
| 62 |
+
},
|
| 63 |
+
"measured_lab": [
|
| 64 |
+
58.35,
|
| 65 |
+
-14.94,
|
| 66 |
+
-11.82
|
| 67 |
+
],
|
| 68 |
+
"lab_sd": [
|
| 69 |
+
0.13,
|
| 70 |
+
0.05,
|
| 71 |
+
0.06
|
| 72 |
+
],
|
| 73 |
+
"delta_e00": 0.92,
|
| 74 |
+
"flags": [
|
| 75 |
+
"NOTE: evened out uneven light (4.9x across the paper)"
|
| 76 |
+
]
|
| 77 |
+
}
|
| 78 |
+
],
|
| 79 |
+
"model_gains": {
|
| 80 |
+
"turquoise": 1.202,
|
| 81 |
+
"fuchsia": 0.604,
|
| 82 |
+
"lemon yellow": 0.916
|
| 83 |
+
},
|
| 84 |
+
"stock_ml": {
|
| 85 |
+
"turquoise": 242.1,
|
| 86 |
+
"fuchsia": 247.7,
|
| 87 |
+
"lemon yellow": 246.7
|
| 88 |
+
},
|
| 89 |
+
"dyer_notes": [],
|
| 90 |
+
"status": "matched"
|
| 91 |
+
}
|
src/rangrez/samples/runs/teal-kurta/target.jpg
ADDED
|
Git LFS Details
|
src/rangrez/samples/runs/teal-kurta/target.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"lab": [
|
| 3 |
+
57.64,
|
| 4 |
+
-15.84,
|
| 5 |
+
-12.65
|
| 6 |
+
],
|
| 7 |
+
"hex": "#5893a0",
|
| 8 |
+
"calibration": "card",
|
| 9 |
+
"concept": "kurta",
|
| 10 |
+
"home_recipe": {
|
| 11 |
+
"drops": {
|
| 12 |
+
"blue": 22,
|
| 13 |
+
"red": 1,
|
| 14 |
+
"yellow": 8
|
| 15 |
+
},
|
| 16 |
+
"water_ml": 20,
|
| 17 |
+
"teaspoons": 4,
|
| 18 |
+
"predicted_de00": 0.3,
|
| 19 |
+
"reachable": true,
|
| 20 |
+
"closest_hex": "#59939f"
|
| 21 |
+
}
|
| 22 |
+
}
|
src/rangrez/server.py
CHANGED
|
@@ -19,6 +19,7 @@ import numpy as np
|
|
| 19 |
from dotenv import load_dotenv
|
| 20 |
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 21 |
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
|
|
|
|
| 22 |
from pydantic import BaseModel
|
| 23 |
|
| 24 |
from .agent import WONDER_MODEL, run_deterministic, run_with_wonder
|
|
@@ -35,6 +36,7 @@ load_dotenv()
|
|
| 35 |
STATIC = Path(__file__).parent / "static"
|
| 36 |
|
| 37 |
app = FastAPI(title="Rangrez")
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
class Lab:
|
|
@@ -52,6 +54,9 @@ class Lab:
|
|
| 52 |
self.target_photo: bytes | None = None # the customer's photo the current target came from
|
| 53 |
self.target_concept = "garment"
|
| 54 |
self.preview: dict | None = None # Muse Image's bulk preview, measured and corrected
|
|
|
|
|
|
|
|
|
|
| 55 |
self.log.listeners.append(lambda call: self.publish({
|
| 56 |
"kind": "api", "model": call.model, "purpose": call.purpose,
|
| 57 |
"seconds": round(call.seconds, 2), "ok": call.ok, "detail": call.detail, "at": time.time(),
|
|
@@ -127,7 +132,8 @@ class Lab:
|
|
| 127 |
c = self.campaign
|
| 128 |
calls = sum(1 for e in self.history if e.get("kind") == "api")
|
| 129 |
agent = next((e.get("agent") for e in self.history if e.get("kind") == "start"), "")
|
| 130 |
-
return render(c, target_photo=self.target_photo, agent=agent, api_calls=calls, preview=self.preview
|
|
|
|
| 131 |
|
| 132 |
def subscribe(self) -> queue.Queue:
|
| 133 |
"""A new page (a reload, the phone joining) first gets the campaign so far."""
|
|
@@ -144,6 +150,9 @@ class Lab:
|
|
| 144 |
live = self.online if live is None else live
|
| 145 |
if self.thread and self.thread.is_alive():
|
| 146 |
raise HTTPException(409, "a campaign is already running")
|
|
|
|
|
|
|
|
|
|
| 147 |
with self.lock: # keep only the API calls made since the last start (e.g. reading the target photo)
|
| 148 |
self.history = [e for e in self.history if e.get("kind") == "api" and e.get("at", 0) >= self.since]
|
| 149 |
self.since = time.time()
|
|
@@ -326,18 +335,19 @@ def assist_webcam() -> dict:
|
|
| 326 |
|
| 327 |
|
| 328 |
@app.post("/api/voice")
|
| 329 |
-
async def voice(audio: UploadFile = File(...), passcode: str = Form("")) -> dict:
|
| 330 |
"""One spoken turn from the hands-free dashboard: transcribe it, then do at most one safe thing."""
|
| 331 |
-
from .voice import intent, transcribe
|
| 332 |
|
| 333 |
if not LAB.live(passcode):
|
| 334 |
raise HTTPException(403, "voice needs the Meta models: set MODEL_API_KEY, or enter the live passcode")
|
|
|
|
| 335 |
wav = await audio.read()
|
| 336 |
try:
|
| 337 |
-
text = await asyncio.to_thread(transcribe, wav, LAB.log)
|
| 338 |
except RuntimeError as exc:
|
| 339 |
raise HTTPException(502, str(exc))
|
| 340 |
-
act = intent(text)
|
| 341 |
c, result = LAB.campaign, ""
|
| 342 |
rig = getattr(c, "rig", None)
|
| 343 |
waiting = getattr(rig, "waiting", None)
|
|
@@ -345,25 +355,65 @@ async def voice(audio: UploadFile = File(...), passcode: str = Form("")) -> dict
|
|
| 345 |
if waiting and hasattr(rig, "capture"):
|
| 346 |
try:
|
| 347 |
rig.provide(await asyncio.to_thread(rig.capture))
|
| 348 |
-
result = "
|
| 349 |
-
except (RuntimeError, ValueError)
|
| 350 |
-
result =
|
| 351 |
else:
|
| 352 |
-
result = "
|
| 353 |
elif act == "stop":
|
| 354 |
stop()
|
| 355 |
-
result = "
|
| 356 |
elif act == "repeat":
|
| 357 |
-
result =
|
| 358 |
elif act == "note" and c is not None:
|
| 359 |
c.note(text, who="dyer")
|
| 360 |
-
result = "
|
| 361 |
return {"transcript": text, "intent": act, "result": result}
|
| 362 |
|
| 363 |
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
|
| 368 |
|
| 369 |
@app.post("/api/preview")
|
|
@@ -388,6 +438,8 @@ async def preview(passcode: str = Form("")) -> dict:
|
|
| 388 |
|
| 389 |
@app.get("/api/preview.jpg")
|
| 390 |
def preview_image() -> Response:
|
|
|
|
|
|
|
| 391 |
if not LAB.preview:
|
| 392 |
raise HTTPException(404)
|
| 393 |
return Response(LAB.preview["jpeg"], media_type="image/jpeg")
|
|
@@ -395,6 +447,8 @@ def preview_image() -> Response:
|
|
| 395 |
|
| 396 |
@app.post("/api/stop")
|
| 397 |
def stop() -> dict:
|
|
|
|
|
|
|
| 398 |
c = LAB.campaign
|
| 399 |
if c is None:
|
| 400 |
return {"ok": True}
|
|
@@ -413,13 +467,82 @@ def state() -> dict:
|
|
| 413 |
|
| 414 |
@app.get("/api/report.html")
|
| 415 |
def report() -> HTMLResponse:
|
|
|
|
|
|
|
| 416 |
if LAB.campaign is None or not LAB.campaign.shots:
|
| 417 |
raise HTTPException(404, "no dips yet")
|
| 418 |
return HTMLResponse(LAB.report())
|
| 419 |
|
| 420 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
@app.get("/api/shot/{index}.jpg")
|
| 422 |
def shot_image(index: int, raw: bool = False) -> Response:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
c = LAB.campaign
|
| 424 |
if not c or not 1 <= index <= len(c.shots):
|
| 425 |
raise HTTPException(404)
|
|
|
|
| 19 |
from dotenv import load_dotenv
|
| 20 |
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 21 |
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
|
| 22 |
+
from fastapi.staticfiles import StaticFiles
|
| 23 |
from pydantic import BaseModel
|
| 24 |
|
| 25 |
from .agent import WONDER_MODEL, run_deterministic, run_with_wonder
|
|
|
|
| 36 |
STATIC = Path(__file__).parent / "static"
|
| 37 |
|
| 38 |
app = FastAPI(title="Rangrez")
|
| 39 |
+
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
| 40 |
|
| 41 |
|
| 42 |
class Lab:
|
|
|
|
| 54 |
self.target_photo: bytes | None = None # the customer's photo the current target came from
|
| 55 |
self.target_concept = "garment"
|
| 56 |
self.preview: dict | None = None # Muse Image's bulk preview, measured and corrected
|
| 57 |
+
self.order: dict | None = None # the buyer's order, read by Muse Spark
|
| 58 |
+
self.order_message = ""
|
| 59 |
+
self.replay = None # a recorded live run being shown again
|
| 60 |
self.log.listeners.append(lambda call: self.publish({
|
| 61 |
"kind": "api", "model": call.model, "purpose": call.purpose,
|
| 62 |
"seconds": round(call.seconds, 2), "ok": call.ok, "detail": call.detail, "at": time.time(),
|
|
|
|
| 132 |
c = self.campaign
|
| 133 |
calls = sum(1 for e in self.history if e.get("kind") == "api")
|
| 134 |
agent = next((e.get("agent") for e in self.history if e.get("kind") == "start"), "")
|
| 135 |
+
return render(c, target_photo=self.target_photo, agent=agent, api_calls=calls, preview=self.preview,
|
| 136 |
+
order=self.order)
|
| 137 |
|
| 138 |
def subscribe(self) -> queue.Queue:
|
| 139 |
"""A new page (a reload, the phone joining) first gets the campaign so far."""
|
|
|
|
| 150 |
live = self.online if live is None else live
|
| 151 |
if self.thread and self.thread.is_alive():
|
| 152 |
raise HTTPException(409, "a campaign is already running")
|
| 153 |
+
if self.replay is not None:
|
| 154 |
+
self.replay.stop()
|
| 155 |
+
self.replay = None
|
| 156 |
with self.lock: # keep only the API calls made since the last start (e.g. reading the target photo)
|
| 157 |
self.history = [e for e in self.history if e.get("kind") == "api" and e.get("at", 0) >= self.since]
|
| 158 |
self.since = time.time()
|
|
|
|
| 335 |
|
| 336 |
|
| 337 |
@app.post("/api/voice")
|
| 338 |
+
async def voice(audio: UploadFile = File(...), passcode: str = Form(""), lang: str = Form("en")) -> dict:
|
| 339 |
"""One spoken turn from the hands-free dashboard: transcribe it, then do at most one safe thing."""
|
| 340 |
+
from .voice import SAY, instructions, intent, transcribe
|
| 341 |
|
| 342 |
if not LAB.live(passcode):
|
| 343 |
raise HTTPException(403, "voice needs the Meta models: set MODEL_API_KEY, or enter the live passcode")
|
| 344 |
+
lang = lang if lang in SAY else "en"
|
| 345 |
wav = await audio.read()
|
| 346 |
try:
|
| 347 |
+
text = await asyncio.to_thread(transcribe, wav, LAB.log, lang)
|
| 348 |
except RuntimeError as exc:
|
| 349 |
raise HTTPException(502, str(exc))
|
| 350 |
+
act, say = intent(text), SAY[lang]
|
| 351 |
c, result = LAB.campaign, ""
|
| 352 |
rig = getattr(c, "rig", None)
|
| 353 |
waiting = getattr(rig, "waiting", None)
|
|
|
|
| 355 |
if waiting and hasattr(rig, "capture"):
|
| 356 |
try:
|
| 357 |
rig.provide(await asyncio.to_thread(rig.capture))
|
| 358 |
+
result = say["photo"]
|
| 359 |
+
except (RuntimeError, ValueError):
|
| 360 |
+
result = say["photo_failed"]
|
| 361 |
else:
|
| 362 |
+
result = say["no_photo"]
|
| 363 |
elif act == "stop":
|
| 364 |
stop()
|
| 365 |
+
result = say["stopped"]
|
| 366 |
elif act == "repeat":
|
| 367 |
+
result = instructions(waiting, lang) if waiting else say["no_dip"]
|
| 368 |
elif act == "note" and c is not None:
|
| 369 |
c.note(text, who="dyer")
|
| 370 |
+
result = say["noted"]
|
| 371 |
return {"transcript": text, "intent": act, "result": result}
|
| 372 |
|
| 373 |
|
| 374 |
+
@app.post("/api/order")
|
| 375 |
+
async def read_order(message: str = Form(...), passcode: str = Form("")) -> dict:
|
| 376 |
+
"""The buyer's chat message, read by Muse Spark: what, how much, by when, how strict the shade."""
|
| 377 |
+
from .order import parse_order
|
| 378 |
+
|
| 379 |
+
if not LAB.live(passcode):
|
| 380 |
+
raise HTTPException(403, "reading an order needs the Meta models: set MODEL_API_KEY, or enter the live passcode")
|
| 381 |
+
try:
|
| 382 |
+
LAB.order = await asyncio.to_thread(parse_order, message[:2000], LAB.log)
|
| 383 |
+
except RuntimeError as exc:
|
| 384 |
+
raise HTTPException(502, str(exc))
|
| 385 |
+
LAB.order_message = message[:2000]
|
| 386 |
+
LAB.rec.stash("order.json", {"message": message[:2000], "order": LAB.order})
|
| 387 |
+
return LAB.order
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
@app.post("/api/reply")
|
| 391 |
+
async def reply(passcode: str = Form("")) -> dict:
|
| 392 |
+
"""Draft the chat reply to the buyer, in their language, from the lab's own numbers."""
|
| 393 |
+
from .order import draft_reply, production_plan
|
| 394 |
+
from .report import difference
|
| 395 |
+
|
| 396 |
+
c = LAB.campaign
|
| 397 |
+
if c is None or c.best is None or not LAB.order_message:
|
| 398 |
+
raise HTTPException(409, "needs the buyer's message and a campaign with at least one dip")
|
| 399 |
+
if not LAB.live(passcode):
|
| 400 |
+
raise HTTPException(403, "drafting needs the Meta models: set MODEL_API_KEY, or enter the live passcode")
|
| 401 |
+
best = c.best
|
| 402 |
+
d = difference(c.target_lab, best.measurement.lab)
|
| 403 |
+
plan = production_plan(LAB.order, best.recipe.conc) if LAB.order else None
|
| 404 |
+
facts = {
|
| 405 |
+
"result": "approved shade, within tolerance" if c.status == "matched" else f"closest dip so far ({c.status})",
|
| 406 |
+
"delta_e00": round(best.delta_e, 2), "tolerance_de00": c.tolerance, "dip_vs_target": d["words"],
|
| 407 |
+
"dips_made": len(c.shots), "order": LAB.order,
|
| 408 |
+
"production_plan": plan and {"cotton_kg": plan["fabric_kg"], "dye_bath_litres": plan["bath_litres"]},
|
| 409 |
+
"preview_photo_attached": bool(LAB.preview),
|
| 410 |
+
}
|
| 411 |
+
try:
|
| 412 |
+
text = await asyncio.to_thread(draft_reply, LAB.order_message, facts, LAB.log)
|
| 413 |
+
except RuntimeError as exc:
|
| 414 |
+
raise HTTPException(502, str(exc))
|
| 415 |
+
LAB.rec.save("reply.txt", text)
|
| 416 |
+
return {"reply": text}
|
| 417 |
|
| 418 |
|
| 419 |
@app.post("/api/preview")
|
|
|
|
| 438 |
|
| 439 |
@app.get("/api/preview.jpg")
|
| 440 |
def preview_image() -> Response:
|
| 441 |
+
if LAB.replay is not None and (data := LAB.replay.file("preview.jpg")):
|
| 442 |
+
return Response(data, media_type="image/jpeg")
|
| 443 |
if not LAB.preview:
|
| 444 |
raise HTTPException(404)
|
| 445 |
return Response(LAB.preview["jpeg"], media_type="image/jpeg")
|
|
|
|
| 447 |
|
| 448 |
@app.post("/api/stop")
|
| 449 |
def stop() -> dict:
|
| 450 |
+
if LAB.replay is not None:
|
| 451 |
+
LAB.replay.stop()
|
| 452 |
c = LAB.campaign
|
| 453 |
if c is None:
|
| 454 |
return {"ok": True}
|
|
|
|
| 467 |
|
| 468 |
@app.get("/api/report.html")
|
| 469 |
def report() -> HTMLResponse:
|
| 470 |
+
if LAB.replay is not None and (page := LAB.replay.file("report.html")):
|
| 471 |
+
return HTMLResponse(page)
|
| 472 |
if LAB.campaign is None or not LAB.campaign.shots:
|
| 473 |
raise HTTPException(404, "no dips yet")
|
| 474 |
return HTMLResponse(LAB.report())
|
| 475 |
|
| 476 |
|
| 477 |
+
@app.get("/api/samples")
|
| 478 |
+
def samples() -> dict:
|
| 479 |
+
"""What a first-time visitor can try without bringing anything: a photo, a buyer's message, recorded runs."""
|
| 480 |
+
from .replay import SAMPLES, runs
|
| 481 |
+
|
| 482 |
+
order = SAMPLES / "order.txt"
|
| 483 |
+
read = SAMPLES / "kurta.json" # how live SAM 3.1 read the sample photo, for visitors without the passcode
|
| 484 |
+
return {"photo": "/api/samples/kurta.jpg", "concept": "kurta",
|
| 485 |
+
"read": json.loads(read.read_text()) if read.exists() else None,
|
| 486 |
+
"order": order.read_text().strip() if order.exists() else "", "runs": runs()}
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
@app.get("/api/samples/kurta.jpg")
|
| 490 |
+
def sample_photo() -> Response:
|
| 491 |
+
from .replay import SAMPLES
|
| 492 |
+
|
| 493 |
+
return Response((SAMPLES / "kurta.jpg").read_bytes(), media_type="image/jpeg")
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
@app.post("/api/replay/{name}")
|
| 497 |
+
def replay(name: str) -> dict:
|
| 498 |
+
"""Show a recorded live run again: every Meta API call in it happened for real."""
|
| 499 |
+
from .replay import Replay
|
| 500 |
+
|
| 501 |
+
if LAB.thread and LAB.thread.is_alive():
|
| 502 |
+
raise HTTPException(409, "a campaign is running: watch it, or stop it first")
|
| 503 |
+
if LAB.replay is not None:
|
| 504 |
+
LAB.replay.stop()
|
| 505 |
+
try:
|
| 506 |
+
r = Replay(name, LAB.publish)
|
| 507 |
+
except FileNotFoundError:
|
| 508 |
+
raise HTTPException(404, "no such recorded run")
|
| 509 |
+
with LAB.lock:
|
| 510 |
+
LAB.history = []
|
| 511 |
+
LAB.campaign = None
|
| 512 |
+
LAB.replay = r
|
| 513 |
+
r.start()
|
| 514 |
+
return {"ok": True, "recorded": r.meta.get("started", "")}
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
@app.get("/api/replay-file/{name}")
|
| 518 |
+
def replay_file(name: str) -> Response:
|
| 519 |
+
"""What else a recorded run produced: the buyer's order and the reply Muse Spark drafted."""
|
| 520 |
+
if LAB.replay is None or name not in ("order.json", "reply.txt", "target.json"):
|
| 521 |
+
raise HTTPException(404)
|
| 522 |
+
data = LAB.replay.file(name)
|
| 523 |
+
if data is None:
|
| 524 |
+
raise HTTPException(404)
|
| 525 |
+
return Response(data, media_type="application/json" if name.endswith(".json") else "text/plain; charset=utf-8")
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
@app.get("/api/memory")
|
| 529 |
+
def memory() -> dict:
|
| 530 |
+
"""How the lab has done with this dye lot, campaign by campaign (the night shift, live)."""
|
| 531 |
+
path = default_path()
|
| 532 |
+
m = LabMemory(path) if path is not None else None
|
| 533 |
+
if m is None:
|
| 534 |
+
return {"dips": 0, "campaigns": []}
|
| 535 |
+
keep = ("status", "dips", "first_de00", "best_de00")
|
| 536 |
+
return {"dips": len(m.dips), "campaigns": [{k: c.get(k) for k in keep} for c in m.campaigns[-60:]]}
|
| 537 |
+
|
| 538 |
+
|
| 539 |
@app.get("/api/shot/{index}.jpg")
|
| 540 |
def shot_image(index: int, raw: bool = False) -> Response:
|
| 541 |
+
if LAB.replay is not None:
|
| 542 |
+
data = LAB.replay.file(f"dip{index:02d}_{'photo' if raw else 'sam'}.jpg")
|
| 543 |
+
if data is None:
|
| 544 |
+
raise HTTPException(404)
|
| 545 |
+
return Response(data, media_type="image/jpeg")
|
| 546 |
c = LAB.campaign
|
| 547 |
if not c or not 1 <= index <= len(c.shots):
|
| 548 |
raise HTTPException(404)
|
src/rangrez/static/dyehouse.jpg
ADDED
|
Git LFS Details
|
src/rangrez/static/index.html
CHANGED
|
@@ -3,260 +3,669 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
-
<title>Rangrez
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
<style>
|
| 8 |
:root {
|
| 9 |
-
--bg: #
|
| 10 |
-
--
|
|
|
|
| 11 |
}
|
| 12 |
* { box-sizing: border-box; }
|
| 13 |
-
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
.
|
| 18 |
-
|
| 19 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
.pill.out_of_gamut { color: var(--warn); border-color: var(--warn); }
|
| 21 |
.pill.escalated, .pill.error { color: var(--bad); border-color: var(--bad); }
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
.
|
| 35 |
-
|
| 36 |
-
.
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
.
|
| 40 |
-
.
|
| 41 |
-
.
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
th { color: var(--mute); font-weight: 500; }
|
| 48 |
-
.
|
| 49 |
-
|
| 50 |
-
.feed { max-height: 280px; overflow: auto; font-size: 12.5px; }
|
| 51 |
.feed div { padding: 6px 0; border-bottom: 1px dashed var(--line); }
|
| 52 |
-
.feed .who { color: var(--
|
| 53 |
.feed .tool { color: var(--cyan); }
|
| 54 |
.feed .gate { color: var(--bad); }
|
| 55 |
.feed .muted { color: var(--mute); }
|
| 56 |
.api div { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
|
| 57 |
-
.api .m { color: var(--cyan); }
|
| 58 |
-
.
|
| 59 |
-
.
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
.
|
| 64 |
-
.
|
| 65 |
-
.
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</style>
|
| 70 |
</head>
|
| 71 |
<body>
|
| 72 |
<header>
|
| 73 |
-
<h1>Rangrez</h1>
|
| 74 |
-
<
|
|
|
|
|
|
|
|
|
|
| 75 |
<span class="spacer"></span>
|
| 76 |
-
<span class="pill" id="agent">agent: …</span>
|
| 77 |
-
<span class="pill" id="rig">rig: …</span>
|
| 78 |
-
<span class="pill" id="memory">lab memory</span>
|
| 79 |
<span class="pill" id="status">idle</span>
|
|
|
|
| 80 |
</header>
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
<main>
|
| 83 |
-
<div class="
|
| 84 |
-
<section>
|
| 85 |
-
<
|
| 86 |
-
<
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
<
|
| 90 |
-
<
|
| 91 |
-
<div
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
| 95 |
<p class="hint" id="photoNote"></p>
|
| 96 |
<p class="hint" id="homeRecipe" hidden></p>
|
| 97 |
-
<
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
<
|
| 102 |
-
<
|
| 103 |
-
<
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
</section>
|
|
|
|
| 112 |
<section>
|
| 113 |
-
<h2>
|
| 114 |
-
<
|
| 115 |
</section>
|
| 116 |
</div>
|
| 117 |
|
| 118 |
-
<div class="
|
| 119 |
<section class="assist" id="assist" hidden>
|
| 120 |
<h2 id="assistTitle">Your turn: dip 1</h2>
|
| 121 |
<div class="amounts" id="amounts"></div>
|
| 122 |
<ol id="assistSteps"></ol>
|
| 123 |
-
<label for="assistPhoto">Photo of the dried strip next to the colour card
|
| 124 |
<input type="file" id="assistPhoto" accept="image/*" capture="environment">
|
| 125 |
-
<div style="
|
| 126 |
-
<div class="row"><button id="sendPhoto">Send photo</button><button class="ghost" id="useWebcam">Use webcam</button></div>
|
| 127 |
<p class="hint" id="assistNote"></p>
|
| 128 |
</section>
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
<
|
| 132 |
-
|
| 133 |
-
<div class="
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
</div>
|
| 136 |
-
<p class="hint"><a id="reportLink" href="/api/report.html" target="_blank" rel="noopener" hidden>Lab-dip report for the buyer ↗</a></p>
|
| 137 |
-
<button class="ghost" id="previewBtn" hidden>Preview the customer's garment in this shade (Muse Image)</button>
|
| 138 |
<p class="hint" id="previewNote"></p>
|
| 139 |
<img id="previewImg" hidden alt="The customer's garment recoloured to the dip's shade">
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
<h2>Camera — SAM 3.1 masks: card (yellow), strip (cyan)</h2>
|
| 143 |
-
<img id="frame" hidden alt="Latest camera frame from the rig with segmentation boxes">
|
| 144 |
</section>
|
| 145 |
</div>
|
| 146 |
|
| 147 |
-
<div class="
|
| 148 |
-
<section>
|
| 149 |
-
<h2>
|
| 150 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
</section>
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
<
|
|
|
|
|
|
|
| 155 |
</section>
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
</section>
|
| 160 |
</div>
|
| 161 |
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
<script>
|
| 164 |
const $ = (id) => document.getElementById(id);
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
fetch("/api/config").then((r) => r.json()).then((c) => {
|
|
|
|
| 179 |
$("agent").textContent = "agent: " + c.agent;
|
| 180 |
-
$("rig").textContent = "rig: " + ({ sim: "simulator", serial: "
|
| 181 |
$("dyeHead").textContent = c.dyes.join(" · ") + " " + c.unit;
|
| 182 |
$("liveBox").hidden = !c.passcode;
|
| 183 |
-
if (c.public_reads_left > 0) $("photoNote").textContent = "Try it: photograph any outfit and get its colour, plus a recipe to dye it at home.";
|
| 184 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
function setStatus(s) {
|
| 187 |
-
const el = $("status");
|
| 188 |
-
el.textContent = s;
|
| 189 |
-
el.className = "pill " + s;
|
| 190 |
$("start").disabled = s === "running" || s === "waiting";
|
| 191 |
}
|
| 192 |
-
|
| 193 |
function feed(id, html) {
|
| 194 |
const box = $(id);
|
| 195 |
if (box.firstElementChild && box.firstElementChild.classList.contains("muted") && box.children.length === 1) box.innerHTML = "";
|
| 196 |
-
const div = document.createElement("div");
|
| 197 |
-
div.innerHTML = html;
|
| 198 |
-
box.prepend(div);
|
| 199 |
}
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
function drawChart() {
|
| 203 |
-
const svg = $("chart"), w = 340, h =
|
| 204 |
const max = Math.max(8, ...shots.map((s) => s.delta_e || 0));
|
| 205 |
const x = (i) => pad + (i / Math.max(1, Math.max(5, shots.length) - 1)) * (w - pad - 10);
|
| 206 |
const y = (v) => h - pad + 4 - (Math.min(v, max) / max) * (h - pad - 14);
|
| 207 |
-
let g = `<line x1="${pad}" y1="${y(tolerance)}" x2="${w - 10}" y2="${y(tolerance)}" stroke="#
|
| 208 |
-
g += `<text x="${w - 12}" y="${y(tolerance) - 5}" fill="#
|
| 209 |
-
g += `<text x="${pad - 6}" y="${y(max) + 4}" fill="#
|
| 210 |
-
g += `<text x="${pad - 6}" y="${y(0) + 4}" fill="#9a968f" font-size="10" text-anchor="end">0</text>`;
|
| 211 |
const pts = shots.map((s, i) => [x(i), y(s.delta_e)]);
|
| 212 |
-
if (pts.length > 1) g += `<polyline fill="none" stroke="
|
| 213 |
shots.forEach((s, i) => {
|
| 214 |
-
g += `<circle cx="${pts[i][0]}" cy="${pts[i][1]}" r="
|
| 215 |
-
g += `<text x="${pts[i][0]}" y="${h - 8}" fill="#
|
| 216 |
});
|
|
|
|
| 217 |
svg.innerHTML = g;
|
| 218 |
}
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
const es = new EventSource("/api/events");
|
| 221 |
es.onmessage = (msg) => {
|
| 222 |
const e = JSON.parse(msg.data);
|
| 223 |
if (e.kind === "replayed") { replaying = false; return; }
|
| 224 |
-
if (e.kind === "reset") {
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
for (const id of ["shots", "notebook", "api"]) $(id).innerHTML = "";
|
| 228 |
-
$("reportLink").hidden = true; $("previewBtn").hidden = true; $("previewImg").hidden = true; $("previewNote").textContent = "";
|
| 229 |
-
$("assist").hidden = true; $("frame").hidden = true; drawChart(); setStatus("idle");
|
| 230 |
-
} else if (e.kind === "start") {
|
| 231 |
$("agent").textContent = "agent: " + e.agent;
|
| 232 |
$("memory").textContent = `lab memory: ${e.memory_dips || 0} dips · ${e.memory_campaigns || 0} campaigns`;
|
| 233 |
-
|
| 234 |
-
$("
|
| 235 |
-
$("
|
| 236 |
-
|
| 237 |
-
$("dipChip").style.background = "transparent"; $("dipLabel").textContent = "no dip yet";
|
| 238 |
-
$("de").textContent = "—"; $("deBox").classList.remove("pass");
|
| 239 |
-
setStatus("running");
|
| 240 |
} else if (e.kind === "assist") {
|
| 241 |
-
if (handsFree && !replaying) say(
|
| 242 |
-
$("assist").hidden = false;
|
| 243 |
-
$("assistTitle").textContent = `Your turn: dip ${e.index}`;
|
| 244 |
$("amounts").innerHTML = Object.entries(e.amounts).map(([d, v]) => `<div class="amount"><b>${v}</b><small>${e.unit} ${esc(d)}</small></div>`).join("");
|
| 245 |
$("assistSteps").innerHTML = e.steps.map((s) => `<li>${esc(s)}</li>`).join("");
|
| 246 |
-
$("assistNote").textContent = "";
|
| 247 |
-
$("assistPhoto").value = "";
|
| 248 |
-
setStatus("waiting");
|
| 249 |
} else if (e.kind === "shot") {
|
| 250 |
$("assist").hidden = true;
|
| 251 |
if ($("status").textContent === "waiting") setStatus("running");
|
| 252 |
-
shots.push(e); drawChart();
|
| 253 |
-
$("dipChip").style.background = e.hex;
|
| 254 |
-
|
| 255 |
-
$("
|
| 256 |
-
$("
|
| 257 |
-
|
| 258 |
-
$("frame").src = `/api/shot/${e.index}.jpg?t=${Date.now()}`; $("frame").hidden = false;
|
| 259 |
-
const rec = e.recipe.match(/[\d.]+(?= (g\/L|%))/g) || [];
|
| 260 |
$("shots").insertAdjacentHTML("beforeend", `<tr><td>${e.index}</td><td><span class="sw" style="background:${e.hex}"></span>${rec.join(" · ")}</td><td>${isFinite(e.delta_e) ? e.delta_e.toFixed(2) : "rejected"}</td></tr>`);
|
| 261 |
if (e.flags && e.flags.length) feed("notebook", `<span class="gate">${esc(e.flags.join("; "))}</span>`);
|
| 262 |
} else if (e.kind === "note") {
|
|
@@ -266,50 +675,112 @@ es.onmessage = (msg) => {
|
|
| 266 |
} else if (e.kind === "tool") {
|
| 267 |
feed("notebook", `<span class="tool">→ ${esc(e.name)}</span> <span class="muted">${esc(JSON.stringify(e.args)).slice(0, 160)}</span>`);
|
| 268 |
} else if (e.kind === "gate") {
|
| 269 |
-
feed("notebook", `<span class="gate">gate refused: ${esc(e.refused)}</span> <span class="muted">${esc(e.recipe)}</span>`);
|
| 270 |
} else if (e.kind === "dispense") {
|
| 271 |
feed("notebook", `<span class="muted">dispensing dip ${e.index}: ${esc(e.recipe)}</span>`);
|
|
|
|
| 272 |
} else if (e.kind === "model") {
|
| 273 |
feed("notebook", `<span class="tool">model refit</span> <span class="muted">uptake ${esc(JSON.stringify(e.gains_after))}, fit error ${e.model_fit_error_de00}</span>`);
|
| 274 |
} else if (e.kind === "status") {
|
| 275 |
setStatus(e.status);
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
if (
|
| 279 |
if (e.reason) feed("notebook", `<span class="gate">${esc(e.status)}: ${esc(e.reason)}</span>`);
|
| 280 |
} else if (e.kind === "api") {
|
|
|
|
| 281 |
feed("api", `<span><span class="m">${esc(e.model)}</span> ${esc(e.purpose)}</span><span class="${e.ok ? "" : "gate"}">${e.seconds}s</span>`);
|
| 282 |
}
|
| 283 |
};
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
-
$("
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
const form = new FormData();
|
| 292 |
-
form.append("photo",
|
| 293 |
-
$("photoNote").textContent = "
|
| 294 |
const r = await fetch("/api/target-from-photo", { method: "POST", body: form });
|
| 295 |
const j = await r.json();
|
| 296 |
-
if (!r.ok) {
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
}
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
| 311 |
};
|
| 312 |
-
|
| 313 |
$("start").onclick = async () => {
|
| 314 |
const r = await fetch("/api/campaign", {
|
| 315 |
method: "POST", headers: { "Content-Type": "application/json" },
|
|
@@ -325,14 +796,33 @@ async function sendAssist(url, body) {
|
|
| 325 |
$("sendPhoto").onclick = () => {
|
| 326 |
const file = $("assistPhoto").files[0];
|
| 327 |
if (!file) { $("assistNote").textContent = "Take or choose a photo first."; return; }
|
| 328 |
-
const form = new FormData(); form.append("photo", file);
|
| 329 |
-
sendAssist("/api/assist/photo", form);
|
| 330 |
};
|
| 331 |
$("useWebcam").onclick = () => sendAssist("/api/assist/webcam");
|
| 332 |
$("stop").onclick = () => fetch("/api/stop", { method: "POST" });
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
$("previewBtn").onclick = async () => {
|
| 337 |
const form = new FormData(); form.append("passcode", $("passcode").value);
|
| 338 |
$("previewNote").textContent = "Muse Image is recolouring the garment; SAM 3.1 will check it…";
|
|
@@ -340,16 +830,25 @@ $("previewBtn").onclick = async () => {
|
|
| 340 |
const j = await r.json();
|
| 341 |
if (!r.ok) { $("previewNote").textContent = j.detail || "Preview failed."; return; }
|
| 342 |
$("previewImg").src = `/api/preview.jpg?t=${Date.now()}`; $("previewImg").hidden = false;
|
| 343 |
-
$("previewNote").textContent = `Dip ${j.dip}'s shade on the customer's garment. Muse Image's first attempt
|
| 344 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
function say(text) {
|
| 346 |
if (!("speechSynthesis" in window)) return;
|
| 347 |
speechSynthesis.cancel();
|
| 348 |
-
|
| 349 |
}
|
| 350 |
-
function wav16k(
|
| 351 |
-
const ratio = rate / 16000, n = Math.floor(
|
| 352 |
-
for (let i = 0; i < n; i++) pcm[i] = Math.max(-1, Math.min(1,
|
| 353 |
const buf = new ArrayBuffer(44 + pcm.length * 2), v = new DataView(buf);
|
| 354 |
const str = (o, t) => [...t].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0)));
|
| 355 |
str(0, "RIFF"); v.setUint32(4, 36 + pcm.length * 2, true); str(8, "WAVEfmt "); v.setUint32(16, 16, true);
|
|
@@ -360,7 +859,7 @@ function wav16k(samples, rate) {
|
|
| 360 |
}
|
| 361 |
async function sendTurn(blob) {
|
| 362 |
const form = new FormData();
|
| 363 |
-
form.append("audio", blob, "turn.wav"); form.append("passcode", $("passcode").value);
|
| 364 |
$("voiceNote").textContent = "Transcribing with Muse Voice Transcribe…";
|
| 365 |
const r = await fetch("/api/voice", { method: "POST", body: form });
|
| 366 |
const j = await r.json();
|
|
@@ -396,11 +895,93 @@ $("handsFree").onclick = async () => {
|
|
| 396 |
$("handsFree").setAttribute("aria-pressed", handsFree);
|
| 397 |
$("handsFree").textContent = `Hands-free voice: ${handsFree ? "listening" : "off"}`;
|
| 398 |
if (handsFree) {
|
| 399 |
-
try { await startListening(); $("voiceNote").textContent = "Listening. Say \"done\", \"repeat\" or \"stop\"."; }
|
| 400 |
catch (err) { handsFree = false; $("handsFree").textContent = "Hands-free voice: off"; $("voiceNote").textContent = "Microphone unavailable: " + err.message; }
|
| 401 |
} else if (mic) { mic.stream.getTracks().forEach((t) => t.stop()); mic.ctx.close(); mic = null; }
|
| 402 |
};
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
</script>
|
| 405 |
</body>
|
| 406 |
</html>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Rangrez · a self-driving dye lab</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Inter:wght@400;500;600;700&family=Tiro+Devanagari+Hindi&display=swap" rel="stylesheet">
|
| 11 |
<style>
|
| 12 |
:root {
|
| 13 |
+
--bg: #f5efe4; --panel: #fffcf6; --panel2: #fbf6ec; --ink: #241f1a; --mute: #7a7166; --line: #e7dccb;
|
| 14 |
+
--dye: #2f5d8a; --ok: #2d7a52; --warn: #b07a12; --bad: #b3432d; --cyan: #1b8aa0; --shadow: 0 1px 2px #3a2a1214, 0 8px 24px #3a2a120f;
|
| 15 |
+
--serif: "Fraunces", Georgia, serif; --sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
| 16 |
}
|
| 17 |
* { box-sizing: border-box; }
|
| 18 |
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 var(--sans); }
|
| 19 |
+
a { color: var(--dye); }
|
| 20 |
+
button { font: inherit; cursor: pointer; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); padding: 9px 14px; font-weight: 600; }
|
| 21 |
+
button.primary { background: var(--ink); color: #fffcf6; border-color: var(--ink); }
|
| 22 |
+
button.dye { background: var(--dye); color: #fff; border-color: transparent; }
|
| 23 |
+
button:disabled { opacity: .45; cursor: default; }
|
| 24 |
+
button.small { padding: 5px 10px; font-size: 12.5px; font-weight: 500; }
|
| 25 |
+
textarea, input[type=text], input[type=number], input[type=password], select { width: 100%; background: #fff; color: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font: inherit; }
|
| 26 |
+
label { display: block; color: var(--mute); font-size: 12px; margin: 10px 0 4px; }
|
| 27 |
+
.hint { color: var(--mute); font-size: 12.5px; margin: 6px 0 0; }
|
| 28 |
+
[hidden] { display: none !important; }
|
| 29 |
+
|
| 30 |
+
/* ---- header ---- */
|
| 31 |
+
header { display: flex; align-items: center; gap: 16px; padding: 14px 24px; flex-wrap: wrap; }
|
| 32 |
+
.brand { display: flex; align-items: baseline; gap: 10px; }
|
| 33 |
+
.brand .hi { font-family: "Tiro Devanagari Hindi", serif; font-size: 22px; color: var(--dye); line-height: 1; transition: color .8s; }
|
| 34 |
+
.brand h1 { font-family: var(--serif); font-size: 26px; margin: 0; letter-spacing: .2px; }
|
| 35 |
+
.brand .sub { color: var(--mute); font-size: 13px; }
|
| 36 |
+
.tabs { display: flex; gap: 4px; background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: 3px; }
|
| 37 |
+
.tabs button { border: 0; background: transparent; padding: 6px 14px; font-weight: 600; color: var(--mute); }
|
| 38 |
+
.tabs button[aria-selected=true] { background: var(--panel); color: var(--ink); box-shadow: var(--shadow); }
|
| 39 |
+
.spacer { flex: 1; }
|
| 40 |
+
.pill { padding: 4px 11px; border-radius: 99px; border: 1px solid var(--line); font-size: 12px; color: var(--mute); background: var(--panel); white-space: nowrap; }
|
| 41 |
+
.pill.running { color: var(--dye); border-color: var(--dye); }
|
| 42 |
+
.pill.waiting { color: var(--cyan); border-color: var(--cyan); }
|
| 43 |
+
.pill.matched { color: #fff; background: var(--ok); border-color: var(--ok); }
|
| 44 |
.pill.out_of_gamut { color: var(--warn); border-color: var(--warn); }
|
| 45 |
.pill.escalated, .pill.error { color: var(--bad); border-color: var(--bad); }
|
| 46 |
+
|
| 47 |
+
/* ---- Meta model chips ---- */
|
| 48 |
+
.labbar { display: flex; align-items: center; gap: 12px; padding: 0 24px 12px; flex-wrap: wrap; }
|
| 49 |
+
.models { display: flex; gap: 8px; flex-wrap: wrap; flex: 1; }
|
| 50 |
+
.facts { color: var(--mute); font-size: 12px; white-space: nowrap; }
|
| 51 |
+
header { border-bottom: 1px solid var(--line); margin-bottom: 12px; background: linear-gradient(180deg, #fffaf1, var(--bg)); }
|
| 52 |
+
#tourBtn { box-shadow: 0 6px 18px #24201b33; }
|
| 53 |
+
.model { display: flex; align-items: center; gap: 7px; padding: 6px 11px; border-radius: 99px; background: var(--panel); border: 1px solid var(--line); font-size: 12.5px; color: var(--mute); transition: all .25s; }
|
| 54 |
+
.model b { color: var(--ink); font-weight: 600; }
|
| 55 |
+
.model i { width: 8px; height: 8px; border-radius: 50%; background: var(--line); transition: background .25s, box-shadow .25s; }
|
| 56 |
+
.model .n { font-variant-numeric: tabular-nums; color: var(--mute); }
|
| 57 |
+
.model.live { border-color: var(--dye); color: var(--ink); }
|
| 58 |
+
.model.live i { background: var(--dye); box-shadow: 0 0 0 4px color-mix(in srgb, var(--dye) 25%, transparent); }
|
| 59 |
+
|
| 60 |
+
.banner { margin: 0 24px 10px; padding: 9px 14px; border-radius: 10px; background: #fff4d6; border: 1px solid #ecd28a; color: #6b4e00; font-size: 13px; }
|
| 61 |
+
|
| 62 |
+
/* ---- the tape: every dip is a segment of cotton tape ---- */
|
| 63 |
+
.tape-wrap { margin: 0 24px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px 10px; box-shadow: var(--shadow); }
|
| 64 |
+
.tape-wrap h2 { margin: 0 0 8px; }
|
| 65 |
+
.tape { display: flex; align-items: flex-end; gap: 2px; min-height: 104px; overflow-x: auto; padding: 4px 2px 6px; }
|
| 66 |
+
.seg { position: relative; flex: 0 0 132px; display: flex; flex-direction: column; justify-content: flex-end; }
|
| 67 |
+
.seg.new { animation: soak 1.4s ease-out, slidein .5s ease-out; }
|
| 68 |
+
.seg .cloth { height: 74px; background-color: var(--c);
|
| 69 |
+
background-image: linear-gradient(180deg, #ffffff30 0, transparent 22%, transparent 70%, #00000024 100%),
|
| 70 |
+
repeating-linear-gradient(45deg, #ffffff12 0 1.5px, transparent 1.5px 4px), repeating-linear-gradient(-45deg, #0000000d 0 1px, transparent 1px 5px);
|
| 71 |
+
box-shadow: 0 6px 14px -6px #3a2a1255, inset 0 0 0 1px #00000014; }
|
| 72 |
+
.seg:first-child .cloth { border-radius: 6px 0 0 6px; }
|
| 73 |
+
.seg small { display: flex; justify-content: space-between; align-items: baseline; font-size: 11.5px; color: var(--mute); padding: 6px 4px 0; font-variant-numeric: tabular-nums; }
|
| 74 |
+
.seg small b { font: 700 16px var(--serif); color: var(--ink); }
|
| 75 |
+
.seg.pass small b { color: var(--ok); }
|
| 76 |
+
.seg.pass small b::after { content: " ✓"; font-size: 13px; }
|
| 77 |
+
.arrow { flex: 0 0 auto; align-self: center; margin: 0 10px 18px; color: var(--mute); font-size: 20px; }
|
| 78 |
+
.seg.target { flex-basis: 150px; }
|
| 79 |
+
.seg.target .cloth { border-radius: 0 10px 10px 0; box-shadow: 0 0 0 3px var(--panel), 0 0 0 5px var(--c), 0 8px 18px -6px #3a2a1266; }
|
| 80 |
+
.seg.empty { flex: 1 1 auto; }
|
| 81 |
+
.seg.empty .cloth { background-color: #fbf8f2; border-radius: 6px 0 0 6px; }
|
| 82 |
+
@keyframes slidein { from { transform: translateX(-24px); opacity: 0; } to { transform: none; opacity: 1; } }
|
| 83 |
+
@keyframes soak { from { filter: saturate(0) brightness(1.4); } to { filter: none; } }
|
| 84 |
+
|
| 85 |
+
/* ---- layout ---- */
|
| 86 |
+
main { display: grid; grid-template-columns: 330px minmax(0, 1fr) 330px; gap: 14px; padding: 0 24px 24px; }
|
| 87 |
+
@media (max-width: 1180px) { main { grid-template-columns: 1fr 1fr; } .col-right { grid-column: span 2; } }
|
| 88 |
+
@media (max-width: 760px) { .model .job, .brand .sub { display: none; } .facts { white-space: normal; } .seg { flex-basis: 96px; } }
|
| 89 |
+
@media (max-width: 760px) { main { grid-template-columns: 1fr; } .col-right { grid-column: auto; } header { padding: 12px 16px; } .models, .tape-wrap, main { padding-left: 16px; padding-right: 16px; } .tape-wrap { margin-left: 16px; margin-right: 16px; } }
|
| 90 |
+
.col { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
| 91 |
+
section { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; box-shadow: var(--shadow); min-width: 0; }
|
| 92 |
+
h2 { margin: 0 0 10px; font: 600 11.5px var(--sans); text-transform: uppercase; letter-spacing: 1.1px; color: var(--mute); }
|
| 93 |
+
h3 { font-family: var(--serif); font-size: 22px; margin: 0 0 8px; letter-spacing: -.2px; }
|
| 94 |
+
.tape-wrap h2 { display: flex; justify-content: space-between; }
|
| 95 |
+
|
| 96 |
+
/* start card */
|
| 97 |
+
.quick { display: grid; gap: 8px; }
|
| 98 |
+
.presets { display: flex; gap: 8px; margin-top: 10px; }
|
| 99 |
+
.presets button { flex: 1; height: 38px; padding: 0; border-radius: 9px; border: 2px solid #fff; box-shadow: 0 0 0 1px var(--line); }
|
| 100 |
+
.presets button[aria-pressed=true] { box-shadow: 0 0 0 2px var(--ink); }
|
| 101 |
+
.pick { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
|
| 102 |
+
.pick input[type=color] { width: 44px; height: 38px; border: 1px solid var(--line); border-radius: 8px; padding: 2px; background: #fff; }
|
| 103 |
+
details { border-top: 1px solid var(--line); margin-top: 14px; padding-top: 10px; }
|
| 104 |
+
summary { cursor: pointer; color: var(--mute); font-weight: 600; font-size: 12.5px; }
|
| 105 |
+
.row { display: flex; gap: 8px; align-items: center; }
|
| 106 |
+
.row > * { flex: 1; }
|
| 107 |
+
|
| 108 |
+
/* hero: camera + gauge */
|
| 109 |
+
.camera { position: relative; border-radius: 12px; overflow: hidden; background: #ece4d6; aspect-ratio: 3/2; display: flex; align-items: center; justify-content: center; }
|
| 110 |
+
.camera img { width: 100%; height: 100%; object-fit: contain; background: #1b1916; }
|
| 111 |
+
.camera .empty { color: var(--mute); text-align: center; padding: 20px; max-width: 360px; }
|
| 112 |
+
.camera .empty b { display: block; font-family: var(--serif); font-size: 28px; color: var(--ink); margin-bottom: 8px; letter-spacing: -.3px; }
|
| 113 |
+
.camera .empty .row > * { flex: 0 0 auto; }
|
| 114 |
+
.caption { font-size: 12.5px; color: var(--mute); margin-top: 8px; }
|
| 115 |
+
.gauge-box { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
| 116 |
+
.gauge { position: relative; width: 190px; height: 190px; }
|
| 117 |
+
.gauge svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
| 118 |
+
.gauge .val { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
| 119 |
+
.gauge .val b { font: 700 46px var(--serif); font-variant-numeric: tabular-nums; line-height: 1; }
|
| 120 |
+
.gauge .val small { color: var(--mute); font-size: 12px; margin-top: 4px; }
|
| 121 |
+
.gauge.pass .val b { color: var(--ok); }
|
| 122 |
+
.gauge.pass { animation: pulse 1.4s ease-out; }
|
| 123 |
+
@keyframes pulse { 0% { transform: scale(1); } 30% { transform: scale(1.05); } 100% { transform: scale(1); } }
|
| 124 |
+
.split { display: grid; grid-template-columns: 1fr 1fr; width: 100%; border-radius: 12px; overflow: hidden; height: 84px; margin-top: 8px; box-shadow: inset 0 0 0 1px #00000014; }
|
| 125 |
+
.split div { display: flex; align-items: flex-end; padding: 6px 8px; font-size: 11.5px; transition: background .8s; }
|
| 126 |
+
.split span { background: #fffcf6e6; padding: 2px 7px; border-radius: 5px; font-variant-numeric: tabular-nums; }
|
| 127 |
+
.verdict { text-align: center; font-size: 13px; color: var(--mute); min-height: 20px; }
|
| 128 |
+
.outcome { border: 2px solid var(--ok); }
|
| 129 |
+
.outcome h3 { color: var(--ok); }
|
| 130 |
+
|
| 131 |
+
/* assist */
|
| 132 |
+
.assist { border: 2px solid var(--dye); }
|
| 133 |
+
.amounts { display: flex; gap: 10px; flex-wrap: wrap; margin: 6px 0 12px; }
|
| 134 |
+
.amount { flex: 1; min-width: 86px; border: 1px solid var(--line); border-radius: 10px; padding: 10px; text-align: center; background: var(--panel2); }
|
| 135 |
+
.amount b { display: block; font: 700 30px var(--serif); font-variant-numeric: tabular-nums; }
|
| 136 |
+
.amount small { color: var(--mute); }
|
| 137 |
+
.assist ol { margin: 0 0 12px; padding-left: 20px; } .assist li { margin: 3px 0; }
|
| 138 |
+
|
| 139 |
+
/* charts, tables, feeds */
|
| 140 |
+
svg.chart { width: 100%; height: 160px; display: block; }
|
| 141 |
+
table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
|
| 142 |
+
td, th { padding: 6px 4px; border-bottom: 1px solid var(--line); text-align: left; }
|
| 143 |
th { color: var(--mute); font-weight: 500; }
|
| 144 |
+
.sw { display: inline-block; width: 14px; height: 14px; border-radius: 3px; vertical-align: -3px; margin-right: 6px; box-shadow: inset 0 0 0 1px #0002; }
|
| 145 |
+
.feed { max-height: 300px; overflow: auto; font-size: 12.5px; }
|
|
|
|
| 146 |
.feed div { padding: 6px 0; border-bottom: 1px dashed var(--line); }
|
| 147 |
+
.feed .who { color: var(--dye); font-weight: 700; margin-right: 6px; }
|
| 148 |
.feed .tool { color: var(--cyan); }
|
| 149 |
.feed .gate { color: var(--bad); }
|
| 150 |
.feed .muted { color: var(--mute); }
|
| 151 |
.api div { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
|
| 152 |
+
.api .m { color: var(--cyan); font-weight: 600; }
|
| 153 |
+
.log summary { font: 600 11.5px var(--sans); text-transform: uppercase; letter-spacing: 1.1px; }
|
| 154 |
+
.log[open] summary { margin-bottom: 10px; }
|
| 155 |
+
#previewImg { width: 100%; border-radius: 10px; margin-top: 8px; }
|
| 156 |
+
|
| 157 |
+
/* ---- motion ---- */
|
| 158 |
+
section, .tape-wrap { animation: rise .6s ease-out both; }
|
| 159 |
+
.col > section:nth-child(2) { animation-delay: .08s; } .col > section:nth-child(3) { animation-delay: .16s; }
|
| 160 |
+
.col-right > section { animation-delay: .12s; }
|
| 161 |
+
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
| 162 |
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
| 163 |
+
|
| 164 |
+
.dyeing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 22px; background: radial-gradient(circle at 50% 60%, #fffaf0, #efe5d3); flex-direction: column; }
|
| 165 |
+
.dyeing svg { width: 220px; height: 220px; overflow: visible; }
|
| 166 |
+
.dyeing .label { font: 600 15px var(--sans); color: var(--ink); }
|
| 167 |
+
.dyeing .label small { display: block; font-weight: 400; color: var(--mute); font-size: 12.5px; text-align: center; margin-top: 2px; }
|
| 168 |
+
.drop { animation: fall 1.1s cubic-bezier(.5, 0, .9, .5) infinite; opacity: 0; }
|
| 169 |
+
@keyframes fall { 0% { transform: translateY(-40px); opacity: 0; } 15% { opacity: 1; } 75% { transform: translateY(72px); opacity: 1; } 76%, 100% { transform: translateY(72px); opacity: 0; } }
|
| 170 |
+
.ripple { transform-origin: 0 128px; animation: ripple 1.1s ease-out infinite; opacity: 0; }
|
| 171 |
+
@keyframes ripple { 0%, 70% { transform: scale(.2); opacity: 0; } 75% { opacity: .7; } 100% { transform: scale(1.6); opacity: 0; } }
|
| 172 |
+
.liquid { transition: fill 2.4s ease; }
|
| 173 |
+
.strip { animation: dip 4.2s ease-in-out infinite; }
|
| 174 |
+
@keyframes dip { 0%, 12% { transform: translateY(-95px); } 38%, 62% { transform: translateY(0); } 88%, 100% { transform: translateY(-95px); } }
|
| 175 |
+
.strip .dyed { transition: fill 1.6s ease .9s; }
|
| 176 |
+
.burst { position: absolute; left: 50%; top: 50%; width: 9px; height: 12px; border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%; pointer-events: none; animation: burst 1.3s ease-out forwards; }
|
| 177 |
+
@keyframes burst { from { transform: translate(-50%, -50%) scale(.4); opacity: 1; } to { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(1) rotate(var(--r)); opacity: 0; } }
|
| 178 |
+
|
| 179 |
+
.hero-logo { display: block; margin: 0 auto 12px; overflow: visible; }
|
| 180 |
+
.hl-drop { transform-origin: 32px 60px; animation: breathe 3.2s ease-in-out infinite; }
|
| 181 |
+
@keyframes breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.05) translateY(-1px); } }
|
| 182 |
+
.hl-tape rect { transform-box: fill-box; transform-origin: left center; animation: walk 4.8s ease-out infinite both; }
|
| 183 |
+
.hl-tape .t2 { animation-delay: .5s; } .hl-tape .t3 { animation-delay: 1s; }
|
| 184 |
+
@keyframes walk { 0% { transform: scaleX(0); opacity: 0; } 12% { transform: scaleX(1); opacity: 1; } 85% { opacity: 1; } 100% { opacity: 0; } }
|
| 185 |
+
.brand img { transition: transform .5s cubic-bezier(.3, 1.6, .5, 1); }
|
| 186 |
+
.brand:hover img { transform: rotate(-12deg) scale(1.08); }
|
| 187 |
+
|
| 188 |
+
/* ---- the case ---- */
|
| 189 |
+
.case-hero { margin: 18px 0 0; border-radius: 18px; overflow: hidden; position: relative; aspect-ratio: 21 / 8; background: #2a2018; }
|
| 190 |
+
.case-hero img { width: 100%; height: 100%; object-fit: cover; animation: pan 26s ease-in-out infinite alternate; transform-origin: 40% 60%; }
|
| 191 |
+
@keyframes pan { from { transform: scale(1.04) translateX(0); } to { transform: scale(1.14) translateX(-3%); } }
|
| 192 |
+
.case-hero figcaption { position: absolute; left: 14px; bottom: 12px; font-size: 12px; color: #fff; background: rgba(20, 14, 8, .55); padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(4px); }
|
| 193 |
+
.case { max-width: 1060px; margin: 0 auto; padding: 4px 24px 60px; }
|
| 194 |
+
.case h2.big { font: 700 34px/1.15 var(--serif); text-transform: none; letter-spacing: 0; color: var(--ink); margin: 18px 0 10px; }
|
| 195 |
+
.case h3 { margin-top: 34px; font-size: 24px; }
|
| 196 |
+
.case p { font-size: 15px; max-width: 760px; }
|
| 197 |
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0; }
|
| 198 |
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow); }
|
| 199 |
+
.card .num { font: 700 30px var(--serif); color: var(--dye); }
|
| 200 |
+
.card .label { color: var(--mute); font-size: 13px; }
|
| 201 |
+
.compare td, .compare th { font-size: 13.5px; padding: 8px 6px; vertical-align: top; }
|
| 202 |
+
.yes { color: var(--ok); font-weight: 700; } .no { color: var(--bad); } .part { color: var(--warn); }
|
| 203 |
+
.limits li { margin: 6px 0; }
|
| 204 |
+
|
| 205 |
+
/* ---- tour ---- */
|
| 206 |
+
.tour-spot { position: fixed; z-index: 50; border-radius: 14px; box-shadow: 0 0 0 9999px #1c140acc; transition: all .35s ease; pointer-events: none; }
|
| 207 |
+
.tour-card { position: fixed; z-index: 51; width: min(380px, calc(100vw - 32px)); background: var(--panel); border-radius: 14px; padding: 18px; box-shadow: 0 20px 60px #0008; transition: top .35s, left .35s; }
|
| 208 |
+
.tour-card h3 { margin: 0 0 6px; }
|
| 209 |
+
.tour-card p { margin: 0 0 14px; color: #3d362e; }
|
| 210 |
+
.tour-card .steps { color: var(--mute); font-size: 12px; }
|
| 211 |
+
.tour-card .row { margin-top: 4px; }
|
| 212 |
+
.tour-card .row > * { flex: 0 0 auto; }
|
| 213 |
</style>
|
| 214 |
</head>
|
| 215 |
<body>
|
| 216 |
<header>
|
| 217 |
+
<div class="brand"><img src="/static/logo.svg" alt="" width="40" height="40" style="align-self:center"><h1>Rangrez</h1><span class="hi" aria-hidden="true">रंगरेज़</span><span class="sub">a self-driving dye lab on Meta models</span></div>
|
| 218 |
+
<div class="tabs" role="tablist">
|
| 219 |
+
<button role="tab" id="tabLab" aria-selected="true">The lab</button>
|
| 220 |
+
<button role="tab" id="tabCase" aria-selected="false">The case</button>
|
| 221 |
+
</div>
|
| 222 |
<span class="spacer"></span>
|
|
|
|
|
|
|
|
|
|
| 223 |
<span class="pill" id="status">idle</span>
|
| 224 |
+
<button class="primary" id="tourBtn">▶ Take the 2-minute tour</button>
|
| 225 |
</header>
|
| 226 |
|
| 227 |
+
<div class="labbar">
|
| 228 |
+
<div class="models" id="models" aria-label="Meta models, lighting up as they are called"></div>
|
| 229 |
+
<div class="facts"><span id="rig">rig: …</span> · <span id="agent">agent: …</span> · <span id="memory">lab memory</span></div>
|
| 230 |
+
</div>
|
| 231 |
+
<div class="banner" id="replayBanner" hidden></div>
|
| 232 |
+
|
| 233 |
+
<div id="labView">
|
| 234 |
+
<div class="tape-wrap" id="tapeWrap">
|
| 235 |
+
<h2><span>The tape: every dip, dyed and measured, ending at the target</span><span id="tapeNote"></span></h2>
|
| 236 |
+
<div class="tape" id="tape"></div>
|
| 237 |
+
</div>
|
| 238 |
+
|
| 239 |
<main>
|
| 240 |
+
<div class="col">
|
| 241 |
+
<section id="startCard">
|
| 242 |
+
<h3>Match a colour</h3>
|
| 243 |
+
<div class="quick">
|
| 244 |
+
<button class="dye" id="sampleBtn">Match this kurta (sample photo)</button>
|
| 245 |
+
<button id="replayBtn">Watch a recorded live run</button>
|
| 246 |
+
</div>
|
| 247 |
+
<div class="presets" id="presets" aria-label="Preset shades"></div>
|
| 248 |
+
<div class="pick">
|
| 249 |
+
<input type="color" id="picker" value="#4b909d" aria-label="Pick a target colour">
|
| 250 |
+
<button class="primary" id="start" style="flex:1">Start the lab</button>
|
| 251 |
+
<button class="small" id="stop">Stop</button>
|
| 252 |
+
</div>
|
| 253 |
<p class="hint" id="photoNote"></p>
|
| 254 |
<p class="hint" id="homeRecipe" hidden></p>
|
| 255 |
+
<details id="advanced">
|
| 256 |
+
<summary>Your own photo, the buyer's message, voice, settings</summary>
|
| 257 |
+
<label for="photo">A photo of the garment to match (with the colour card or white paper in view)</label>
|
| 258 |
+
<input type="file" id="photo" accept="image/*">
|
| 259 |
+
<div class="row" style="margin-top:6px"><input type="text" id="concept" value="kurta" aria-label="Object to match in the photo"><button class="small" id="usePhoto">Read with SAM 3.1</button></div>
|
| 260 |
+
<label for="orderMsg">The buyer's message (English or Hindi)</label>
|
| 261 |
+
<textarea id="orderMsg" rows="3" placeholder="e.g. need 500 kurtas in this teal, cotton poplin, by Friday. Buyer is strict on shade."></textarea>
|
| 262 |
+
<button class="small" id="readOrder" style="margin-top:6px">Read the order with Muse Spark</button>
|
| 263 |
+
<p class="hint" id="orderNote"></p>
|
| 264 |
+
<label for="tol">Pass tolerance, CIEDE2000 (smaller is stricter; 1.0 strict, 1.5 normal, 2.0 relaxed)</label>
|
| 265 |
+
<input type="number" id="tol" value="1.5" step="0.1" min="0.5" max="5">
|
| 266 |
+
<div id="liveBox" hidden>
|
| 267 |
+
<label for="passcode">Live passcode (runs the Meta models live; without it, the offline policy)</label>
|
| 268 |
+
<input type="password" id="passcode" autocomplete="off">
|
| 269 |
+
</div>
|
| 270 |
+
<label>Hands-free (the dyer's gloves are wet)</label>
|
| 271 |
+
<div class="row"><button class="small" id="handsFree" aria-pressed="false">Hands-free voice: off</button>
|
| 272 |
+
<select id="lang" aria-label="Voice language"><option value="en">English</option><option value="hi">हिन्दी</option></select></div>
|
| 273 |
+
<p class="hint" id="voiceNote">Say "done" when the strip is dry, "repeat", or "stop" ("हो गया", "फिर से", "बंद करो"). Anything else goes to the colourist's notebook.</p>
|
| 274 |
+
</details>
|
| 275 |
</section>
|
| 276 |
+
|
| 277 |
<section>
|
| 278 |
+
<h2>Convergence</h2>
|
| 279 |
+
<svg class="chart" id="chart" viewBox="0 0 340 160" role="img" aria-label="Colour difference per dip"></svg>
|
| 280 |
</section>
|
| 281 |
</div>
|
| 282 |
|
| 283 |
+
<div class="col">
|
| 284 |
<section class="assist" id="assist" hidden>
|
| 285 |
<h2 id="assistTitle">Your turn: dip 1</h2>
|
| 286 |
<div class="amounts" id="amounts"></div>
|
| 287 |
<ol id="assistSteps"></ol>
|
| 288 |
+
<label for="assistPhoto">Photo of the dried strip on white paper or next to the colour card</label>
|
| 289 |
<input type="file" id="assistPhoto" accept="image/*" capture="environment">
|
| 290 |
+
<div class="row" style="margin-top:8px"><button class="dye" id="sendPhoto">Send photo</button><button id="useWebcam">Use webcam</button></div>
|
|
|
|
| 291 |
<p class="hint" id="assistNote"></p>
|
| 292 |
</section>
|
| 293 |
+
|
| 294 |
+
<section id="cameraCard">
|
| 295 |
+
<h2>What the camera sees · SAM 3.1 masks: colour card (yellow), strip (cyan)</h2>
|
| 296 |
+
<div class="camera" id="camera">
|
| 297 |
+
<div class="empty" id="cameraEmpty">
|
| 298 |
+
<!-- the logo, alive: the drop breathes and the tape of dips walks towards the shade, again and again -->
|
| 299 |
+
<svg class="hero-logo" viewBox="0 0 64 64" width="96" height="96" aria-hidden="true">
|
| 300 |
+
<defs><linearGradient id="hl-dye" x1="20" y1="4" x2="46" y2="58" gradientUnits="userSpaceOnUse">
|
| 301 |
+
<stop offset="0" stop-color="#12a5bd"/><stop offset="0.5" stop-color="#d9378a"/><stop offset="1" stop-color="#f6b81c"/>
|
| 302 |
+
<animateTransform attributeName="gradientTransform" type="rotate" values="-25 32 36;25 32 36;-25 32 36" dur="7s" repeatCount="indefinite"/></linearGradient>
|
| 303 |
+
<clipPath id="hl-drop"><path d="M32 2C32 2 10 25 10 40a22 22 0 0 0 44 0C54 25 32 2 32 2Z"/></clipPath></defs>
|
| 304 |
+
<g class="hl-drop"><path d="M32 2C32 2 10 25 10 40a22 22 0 0 0 44 0C54 25 32 2 32 2Z" fill="url(#hl-dye)"/>
|
| 305 |
+
<ellipse cx="24" cy="27" rx="4" ry="7" transform="rotate(28 24 27)" fill="#fff" opacity=".22"/></g>
|
| 306 |
+
<g class="hl-tape"><rect class="t1" x="0" y="41" width="12" height="9" rx="2" fill="#c8648f"/><rect class="t2" x="13" y="41" width="9" height="9" fill="#7d6aa6"/>
|
| 307 |
+
<rect class="t3" x="22" y="41" width="42" height="9" rx="2" fill="#2f7f8f"/>
|
| 308 |
+
<path d="M0 43.5h64M0 47.5h64" stroke="#fff" stroke-opacity=".22" stroke-width=".8"/></g>
|
| 309 |
+
</svg>
|
| 310 |
+
<b>Show Rangrez a colour.</b>It picks a recipe, a strip is dyed, a phone photographs it, and it corrects the recipe until the cloth matches.
|
| 311 |
+
<div class="row" style="margin-top:16px;justify-content:center"><button class="dye" onclick="$('sampleBtn').click()">Match a kurta</button><button onclick="$('replayBtn').click()">Watch a live run</button></div></div>
|
| 312 |
+
<img id="frame" hidden alt="The latest dip photo with SAM 3.1's masks">
|
| 313 |
+
<div class="dyeing" id="dyeing" hidden aria-live="polite">
|
| 314 |
+
<svg viewBox="0 0 220 220" aria-hidden="true">
|
| 315 |
+
<g id="drops"></g>
|
| 316 |
+
<!-- the cotton strip, dipped into the bath and lifted out dyed -->
|
| 317 |
+
<g class="strip"><rect x="98" y="62" width="24" height="100" rx="2" fill="#fbf8f1" stroke="#d8ccb8"/>
|
| 318 |
+
<rect class="dyed" id="stripDye" x="98" y="122" width="24" height="40" rx="2" fill="#fbf8f1"/></g>
|
| 319 |
+
<!-- the glass -->
|
| 320 |
+
<path d="M58 110 L66 196 Q68 206 78 206 L142 206 Q152 206 154 196 L162 110" fill="#ffffff" fill-opacity=".55" stroke="#c9bba5" stroke-width="2.5"/>
|
| 321 |
+
<path class="liquid" id="liquid" d="M61 128 L66 196 Q68 204 78 204 L142 204 Q152 204 154 196 L159 128 Z" fill="#eaf3f5" fill-opacity=".9"/>
|
| 322 |
+
<ellipse cx="110" cy="128" rx="49" ry="6" fill="#ffffff" fill-opacity=".35"/>
|
| 323 |
+
</svg>
|
| 324 |
+
<div class="label" id="dyeingLabel">Dyeing…</div>
|
| 325 |
+
</div>
|
| 326 |
+
</div>
|
| 327 |
+
<div class="caption" id="frameCaption"></div>
|
| 328 |
+
</section>
|
| 329 |
+
|
| 330 |
+
<section class="outcome" id="outcome" hidden>
|
| 331 |
+
<h3 id="outcomeTitle">Matched</h3>
|
| 332 |
+
<p class="hint" id="outcomeText"></p>
|
| 333 |
+
<div class="row" style="margin-top:10px; flex-wrap: wrap">
|
| 334 |
+
<a id="reportLink" href="/api/report.html" target="_blank" rel="noopener"><button class="primary" style="width:100%">Open the lab-dip report</button></a>
|
| 335 |
+
<button id="previewBtn" hidden>See the garment in this shade (Muse Image)</button>
|
| 336 |
+
<button id="replyBtn" hidden>Draft the reply to the buyer (Muse Spark)</button>
|
| 337 |
</div>
|
|
|
|
|
|
|
| 338 |
<p class="hint" id="previewNote"></p>
|
| 339 |
<img id="previewImg" hidden alt="The customer's garment recoloured to the dip's shade">
|
| 340 |
+
<textarea id="replyText" rows="5" hidden aria-label="Reply to the buyer" style="margin-top:8px"></textarea>
|
| 341 |
+
<p class="hint" id="replyNote"></p>
|
|
|
|
|
|
|
| 342 |
</section>
|
| 343 |
</div>
|
| 344 |
|
| 345 |
+
<div class="col col-right">
|
| 346 |
+
<section id="gaugeCard">
|
| 347 |
+
<h2>Latest dip against the target</h2>
|
| 348 |
+
<div class="gauge-box">
|
| 349 |
+
<div class="gauge" id="gauge">
|
| 350 |
+
<svg viewBox="0 0 120 120"><circle cx="60" cy="60" r="52" fill="none" stroke="#efe6d8" stroke-width="10"/>
|
| 351 |
+
<circle id="gaugeArc" cx="60" cy="60" r="52" fill="none" stroke="var(--dye)" stroke-width="10" stroke-linecap="round" stroke-dasharray="326.7" stroke-dashoffset="326.7" style="transition: stroke-dashoffset .9s, stroke .4s"/></svg>
|
| 352 |
+
<div class="val"><b id="de">—</b><small id="deNote">ΔE00 (CIEDE2000)</small></div>
|
| 353 |
+
</div>
|
| 354 |
+
<div class="verdict" id="verdict">Below the tolerance is a match.</div>
|
| 355 |
+
<div class="split"><div id="targetChip"><span id="targetLabel">target</span></div><div id="dipChip"><span id="dipLabel">no dip yet</span></div></div>
|
| 356 |
+
</div>
|
| 357 |
</section>
|
| 358 |
+
|
| 359 |
+
<section id="learningCard">
|
| 360 |
+
<h2>The lab learning this dye lot</h2>
|
| 361 |
+
<svg class="chart" id="learning" viewBox="0 0 340 150" role="img" aria-label="Dips per campaign and first-dip error, campaign by campaign"></svg>
|
| 362 |
+
<p class="hint" id="learningNote">Each campaign starts from everything the lab remembers about this dye lot.</p>
|
| 363 |
</section>
|
| 364 |
+
|
| 365 |
+
<section id="logCard">
|
| 366 |
+
<details class="log" id="logDetails">
|
| 367 |
+
<summary>Lab log: Wonder's notebook, dips, Meta API calls</summary>
|
| 368 |
+
<h2>Notebook</h2>
|
| 369 |
+
<div class="feed" id="notebook"><div class="muted">Muse Wonder writes its reasoning here.</div></div>
|
| 370 |
+
<h2 style="margin-top:14px">Dips</h2>
|
| 371 |
+
<table><thead><tr><th>#</th><th>recipe (<span id="dyeHead">dyes</span>)</th><th>ΔE00</th></tr></thead><tbody id="shots"></tbody></table>
|
| 372 |
+
<h2 style="margin-top:14px">Meta API calls</h2>
|
| 373 |
+
<div class="feed api" id="api"><div class="muted">No calls yet.</div></div>
|
| 374 |
+
</details>
|
| 375 |
</section>
|
| 376 |
</div>
|
| 377 |
</main>
|
| 378 |
+
</div>
|
| 379 |
+
|
| 380 |
+
<div id="caseView" class="case" hidden>
|
| 381 |
+
<figure class="case-hero"><img src="/static/dyehouse.jpg" alt="" loading="lazy">
|
| 382 |
+
<figcaption>The world Rangrez is for: a small cotton dye house. An illustration made with Muse Image, not a photo.</figcaption></figure>
|
| 383 |
+
<h2 class="big">A dye house's lab dip, in minutes instead of days, from a phone.</h2>
|
| 384 |
+
<p>Before a garment order is dyed, the dye house dyes a small swatch, a <b>lab dip</b>, and sends it to the buyer for approval. A rejected dip means correct, re-dye, re-send: usually 2 to 4 rounds of several days each. Large mills shorten this with spectrophotometers and colour-matching software. The thousands of small dye houses and job workers in India's textile towns mostly rely on a colourist's eye and repeated trials.</p>
|
| 385 |
+
<p>Rangrez runs the colourist's loop itself: pick a recipe, dye a strip, photograph it, measure it, correct it, until the cloth matches. Six Meta models each do one job in that loop.</p>
|
| 386 |
+
|
| 387 |
+
<h3>Six Meta models, six real jobs</h3>
|
| 388 |
+
<div class="cards">
|
| 389 |
+
<div class="card"><b>SAM 3.1</b><div class="label">A measuring instrument: segments the colour card, the white paper and the dyed strip in every photo, so pixels become calibrated CIELAB.</div></div>
|
| 390 |
+
<div class="card"><b>Muse Wonder</b><div class="label">The colourist: runs each campaign through tools (suggest, dip, refit, escalate) and writes the lab notebook. It never drives a pump directly.</div></div>
|
| 391 |
+
<div class="card"><b>Muse Glimmer 30B</b><div class="label">Open weights, fine-tuned on the lab's own dips to propose the first recipe; v2 reads the lab's notebook. Weights published.</div></div>
|
| 392 |
+
<div class="card"><b>Muse Spark</b><div class="label">Reads the buyer's chat message (English or Hindi) into an order, sets the pass tolerance, and drafts the reply in the buyer's language.</div></div>
|
| 393 |
+
<div class="card"><b>Muse Image</b><div class="label">Shows the customer's garment in the approved shade; SAM measures and corrects it, because an image model is not a colour instrument.</div></div>
|
| 394 |
+
<div class="card"><b>Muse Voice Transcribe</b><div class="label">Hands-free for a dyer with wet gloves: "done", "repeat", "stop", or "हो गया", "फिर से", "बंद करो".</div></div>
|
| 395 |
+
</div>
|
| 396 |
+
|
| 397 |
+
<h3>What it has shown so far</h3>
|
| 398 |
+
<p class="hint">All on the simulator: hidden dye chemistry and real-looking phone photos with known true colours. No real dye on real cloth yet.</p>
|
| 399 |
+
<div class="cards">
|
| 400 |
+
<div class="card"><div class="num">0.66</div><div class="label">median ΔE00 measuring a strip from a phone photo with the printed colour card (live SAM 3.1, 22 photos); 1.17 with white paper only</div></div>
|
| 401 |
+
<div class="card"><div class="num">24 / 24</div><div class="label">shades matched in a median of 2 dips on the robot rig's reactive dyes, every dip photographed in a different room</div></div>
|
| 402 |
+
<div class="card"><div class="num">3.81 → 1.40</div><div class="label">the first dip's error over a 40-campaign night shift, as lab memory learns the dye lot; dips per match 2.35 → 1.65</div></div>
|
| 403 |
+
<div class="card"><div class="num">4.10 → 0.92</div><div class="label">first-dip error, supplier's model vs fine-tuned Glimmer; a Gaussian process on the same dips reaches 0.77, and we say so</div></div>
|
| 404 |
+
<div class="card"><div class="num">8 / 8 vs 5 / 8</div><div class="label">live campaigns matched by Muse Wonder vs a fixed policy with the same tools and safety rules: when a correction overshot, Wonder changed method and recovered</div></div>
|
| 405 |
+
<div class="card"><div class="num">124</div><div class="label">offline tests: colour science, safety gate, firmware emulator, voice, reports, every photo realistic</div></div>
|
| 406 |
+
</div>
|
| 407 |
+
|
| 408 |
+
<h3>What only Rangrez does</h3>
|
| 409 |
+
<table class="compare">
|
| 410 |
+
<thead><tr><th></th><th>Colourist by eye</th><th>Spectrophotometer + matching software</th><th>A general AI chat</th><th>Rangrez</th></tr></thead>
|
| 411 |
+
<tbody>
|
| 412 |
+
<tr><td>Equipment</td><td class="yes">none</td><td class="part">an instrument and software licence, quote-based</td><td class="yes">none</td><td class="yes">a phone and a printed card; a $100 rig to automate</td></tr>
|
| 413 |
+
<tr><td>Measures the dyed cloth</td><td class="no">by eye</td><td class="yes">precisely</td><td class="no">no</td><td class="part">from a phone photo, ±0.7 ΔE00 with the card</td></tr>
|
| 414 |
+
<tr><td>Proposes and corrects the recipe</td><td class="part">by experience</td><td class="yes">yes</td><td class="no">guesses</td><td class="yes">yes, from a model refitted to real dips</td></tr>
|
| 415 |
+
<tr><td>Runs the loop itself (recipe, dip, measure, correct)</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="yes">yes, with a safety gate and a human escalation</td></tr>
|
| 416 |
+
<tr><td>Learns this dye lot across campaigns</td><td class="part">in one head</td><td class="part">some correction features</td><td class="no">no</td><td class="yes">lab memory, plus a fine-tuned open model</td></tr>
|
| 417 |
+
<tr><td>Reads the buyer's WhatsApp order, replies in their language</td><td class="no">no</td><td class="no">no</td><td class="part">if asked</td><td class="yes">Muse Spark, English or Hindi</td></tr>
|
| 418 |
+
<tr><td>Hands-free in Hindi</td><td class="yes">it's a person</td><td class="no">no</td><td class="no">no</td><td class="yes">Muse Voice Transcribe</td></tr>
|
| 419 |
+
<tr><td>Buyer-ready lab-dip report</td><td class="no">no</td><td class="yes">yes</td><td class="no">no</td><td class="yes">ΔE00 and CMC 2:1, options A/B/C, recipe in % owf, production plan</td></tr>
|
| 420 |
+
<tr><td>A home recipe for anyone's outfit</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="yes">photograph it, get drops of household colour</td></tr>
|
| 421 |
+
</tbody>
|
| 422 |
+
</table>
|
| 423 |
+
|
| 424 |
+
<h3>Where it stands, honestly</h3>
|
| 425 |
+
<ul class="limits">
|
| 426 |
+
<li><b>Simulated, not yet dyed for real.</b> The physics, photos and chemistry are simulated. The first real campaign, household colours on a cotton strip with a phone, is the next step.</li>
|
| 427 |
+
<li><b>The robot rig is designed, not built.</b> Firmware, wiring and a virtual ESP32 that runs the real driver are done; the parts are about ₹8,000.</li>
|
| 428 |
+
<li><b>A camera is a pre-screen, not a certificate.</b> ±0.7 ΔE00 with the card is good enough to correct fast, not to certify a strict 1.0 pass; it cannot check metamerism. Final approval stays with the buyer's instrument or light booth.</li>
|
| 429 |
+
<li><b>A lab dip must follow the bulk process.</b> Fixing, rinsing and soaping are in the protocol; transferring a recipe to bulk still needs the dye house's own lab-to-bulk factor.</li>
|
| 430 |
+
<li><b>Household colours are for learning and play.</b> Food colours and inks are not wash-fast; dye houses use reactive dyes.</li>
|
| 431 |
+
<li><b>Glimmer is not magic.</b> On the same data a Gaussian process matches it; Glimmer earns its place by reading the lab's notebook and working with language.</li>
|
| 432 |
+
</ul>
|
| 433 |
+
|
| 434 |
+
<h3>Why it can work</h3>
|
| 435 |
+
<div class="cards">
|
| 436 |
+
<div class="card"><b>Technically</b><div class="label">Runs on a laptop and a phone; the measuring, chemistry and safety are plain tested code, and the Meta models do what they are best at. Every campaign is recorded and replayable.</div></div>
|
| 437 |
+
<div class="card"><b>For the business</b><div class="label">Each avoided round saves days of a buyer's lead time and a courier; the report is the document the buyer already asks for. Per-dip or monthly pricing for small dye houses and sourcing agents.</div></div>
|
| 438 |
+
<div class="card"><b>For the people using it</b><div class="label">Phone first, Hindi, hands-free, WhatsApp-shaped. No new instrument to learn: photograph, dip, send.</div></div>
|
| 439 |
+
<div class="card"><b>The adoption path</b><div class="label">1) assisted mode with a phone, zero capex; 2) the $100 rig for unattended dips; 3) a handheld spectrophotometer in place of the camera for strict shades, same agent and memory.</div></div>
|
| 440 |
+
</div>
|
| 441 |
+
|
| 442 |
+
<h3>What it could become</h3>
|
| 443 |
+
<ul class="limits">
|
| 444 |
+
<li>Real-dye pilots with job-work dye houses in Tiruppur and Erode.</li>
|
| 445 |
+
<li>Spectral camera calibration, and profiles for polyester (disperse dyes) and wool.</li>
|
| 446 |
+
<li>Glimmer reading the dip photo itself, not just numbers.</li>
|
| 447 |
+
<li>Lab memory shared across dye houses that use the same dye lots, without sharing their recipes.</li>
|
| 448 |
+
</ul>
|
| 449 |
+
<p style="margin-top:28px"><button class="primary" id="caseToLab">Back to the lab</button> <a href="https://github.com/TusharTechs/rangrez" target="_blank" rel="noopener">Code, hardware and evaluation on GitHub</a></p>
|
| 450 |
+
</div>
|
| 451 |
+
|
| 452 |
+
<div id="tour" hidden><div class="tour-spot" id="tourSpot"></div><div class="tour-card" id="tourCard" role="dialog" aria-live="polite">
|
| 453 |
+
<div class="steps" id="tourStep"></div><h3 id="tourTitle"></h3><p id="tourText"></p>
|
| 454 |
+
<div class="row"><button class="small" id="tourSkip">Skip</button><span class="spacer"></span><button class="small" id="tourBack">Back</button><button class="primary small" id="tourNext">Next</button></div>
|
| 455 |
+
</div></div>
|
| 456 |
|
| 457 |
<script>
|
| 458 |
const $ = (id) => document.getElementById(id);
|
| 459 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
| 460 |
+
let tolerance = 1.5, shots = [], config = {}, samples = null;
|
| 461 |
+
let handsFree = false, replaying = false, mic = null, targetFromPhoto = false, orderRead = false, liveRun = false;
|
| 462 |
+
|
| 463 |
+
// ---- Meta model chips ------------------------------------------------------------------
|
| 464 |
+
const MODELS = [
|
| 465 |
+
["sam", "SAM 3.1", "measures every photo"], ["wonder", "Muse Wonder", "runs the campaign"],
|
| 466 |
+
["glimmer", "Muse Glimmer", "first-dip recipe (open weights)"], ["spark", "Muse Spark", "reads orders, drafts replies"],
|
| 467 |
+
["image", "Muse Image", "garment preview"], ["voice", "Muse Voice", "hands-free, English and Hindi"],
|
| 468 |
+
];
|
| 469 |
+
const calls = {};
|
| 470 |
+
$("models").innerHTML = MODELS.map(([k, n, job]) => `<span class="model" id="m-${k}" title="${esc(job)}"><i></i><b>${n}</b><span class="job">${esc(job)}</span><span class="n" id="n-${k}"></span></span>`).join("");
|
| 471 |
+
function modelKey(model) {
|
| 472 |
+
const m = model.toLowerCase();
|
| 473 |
+
return m.startsWith("sam") ? "sam" : m.includes("wonder") ? "wonder" : m.includes("glimmer") ? "glimmer" : m.includes("spark") ? "spark" : m.includes("image") ? "image" : m.includes("voice") ? "voice" : null;
|
| 474 |
+
}
|
| 475 |
+
function flash(model) {
|
| 476 |
+
const k = modelKey(model); if (!k) return;
|
| 477 |
+
calls[k] = (calls[k] || 0) + 1; $("n-" + k).textContent = `× ${calls[k]}`;
|
| 478 |
+
const el = $("m-" + k); el.classList.add("live"); clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove("live"), 1400);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// ---- the dyeing animation: drops of each dye fall in, the bath tints, a cotton strip dips ------
|
| 482 |
+
const DYE_HEX = { turquoise: "#13a1b6", fuchsia: "#d23a86", "lemon yellow": "#f4c21c", blue: "#2f5fbf", red: "#d0353d", yellow: "#f2c21b" };
|
| 483 |
+
const DYE_ABS = { turquoise: [1.0, 0.18, 0.08], fuchsia: [0.08, 0.95, 0.35], "lemon yellow": [0.02, 0.06, 0.9], blue: [0.9, 0.45, 0.06], red: [0.05, 0.85, 0.75], yellow: [0.02, 0.08, 0.85] };
|
| 484 |
+
function bathColour(amounts) { // a rough Beer-Lambert mix, for the eye only
|
| 485 |
+
const t = [1, 1, 1], scale = config.unit === "drops" ? 0.06 : 0.25;
|
| 486 |
+
for (const [d, c] of amounts) (DYE_ABS[d] || [0, 0, 0]).forEach((k, i) => { t[i] *= Math.exp(-k * c * scale); });
|
| 487 |
+
return "#" + t.map((v) => Math.round(255 * Math.max(0.04, v)).toString(16).padStart(2, "0")).join("");
|
| 488 |
+
}
|
| 489 |
+
function showDyeing(e) {
|
| 490 |
+
const dyes = (config.dyes || []).map((d) => [d, parseFloat((e.recipe.match(new RegExp(d + "\\s+([\\d.]+)")) || [])[1] || 0)]).filter(([, c]) => c > 0.01);
|
| 491 |
+
const colour = bathColour(dyes);
|
| 492 |
+
const xs = [76, 144, 88]; // either side of the strip
|
| 493 |
+
$("drops").innerHTML = dyes.map(([d], i) => { const delay = `animation-delay:${(i * 0.37).toFixed(2)}s`;
|
| 494 |
+
return `<g transform="translate(${xs[i % 3]} 0)"><path class="drop" style="${delay}" d="M0 40 C-6 50 -6 56 0 58 C6 56 6 50 0 40 Z" fill="${DYE_HEX[d] || "#888"}"/>`
|
| 495 |
+
+ `<ellipse class="ripple" style="${delay}" cx="0" cy="128" rx="12" ry="3" fill="none" stroke="#fff" stroke-width="1.6"/></g>`; }).join("");
|
| 496 |
+
$("liquid").setAttribute("fill", "#eaf3f5");
|
| 497 |
+
$("stripDye").setAttribute("fill", "#fbf8f1");
|
| 498 |
+
requestAnimationFrame(() => setTimeout(() => { $("liquid").setAttribute("fill", colour); $("stripDye").setAttribute("fill", colour); }, 60));
|
| 499 |
+
$("dyeingLabel").innerHTML = `Dyeing dip ${e.index}<small>${esc(e.recipe)}</small>`;
|
| 500 |
+
$("dyeing").hidden = false; $("cameraEmpty").hidden = true; dyeingSince = performance.now();
|
| 501 |
+
}
|
| 502 |
+
let dyeingSince = 0;
|
| 503 |
+
function afterDyeing(fn) { // a sped-up replay still lets the strip finish its dip before the photo
|
| 504 |
+
const wait = replaying ? 0 : Math.max(0, 2800 - (performance.now() - dyeingSince));
|
| 505 |
+
wait ? setTimeout(fn, wait) : fn();
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
// ---- gauge counts down to each new reading; a match bursts into dye drops -------------------
|
| 509 |
+
let shownDe = NaN;
|
| 510 |
+
let tweenId = 0;
|
| 511 |
+
function tweenGauge(to) {
|
| 512 |
+
const id = ++tweenId, from = isFinite(shownDe) ? shownDe : Math.max(to, 12), start = performance.now(), dur = 900;
|
| 513 |
+
const step = (now) => {
|
| 514 |
+
if (id !== tweenId) return;
|
| 515 |
+
const k = Math.min(1, (now - start) / dur), v = from + (to - from) * (1 - Math.pow(1 - k, 3));
|
| 516 |
+
$("de").textContent = v.toFixed(2);
|
| 517 |
+
if (k < 1) requestAnimationFrame(step);
|
| 518 |
+
};
|
| 519 |
+
shownDe = to;
|
| 520 |
+
requestAnimationFrame(step);
|
| 521 |
+
}
|
| 522 |
+
function burst() {
|
| 523 |
+
const box = $("gauge"), colours = ["#13a1b6", "#d23a86", "#f4c21c", getComputedStyle(document.documentElement).getPropertyValue("--dye")];
|
| 524 |
+
for (let i = 0; i < 36; i++) {
|
| 525 |
+
const p = document.createElement("span"), a = (i / 36) * Math.PI * 2, r = 110 + Math.random() * 110;
|
| 526 |
+
p.className = "burst"; p.style.background = colours[i % colours.length];
|
| 527 |
+
p.style.setProperty("--dx", `${Math.cos(a) * r}px`); p.style.setProperty("--dy", `${Math.sin(a) * r}px`); p.style.setProperty("--r", `${Math.random() * 360}deg`);
|
| 528 |
+
box.appendChild(p); setTimeout(() => p.remove(), 1400);
|
| 529 |
+
}
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
// ---- colour theme: the target colour dyes the page --------------------------------------
|
| 533 |
+
function setDye(hex) {
|
| 534 |
+
const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
|
| 535 |
+
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
| 536 |
+
// Keep text readable: very light shades are darkened for accents.
|
| 537 |
+
const k = lum > 0.62 ? 0.62 / lum : 1;
|
| 538 |
+
document.documentElement.style.setProperty("--dye", `rgb(${Math.round(r * k)}, ${Math.round(g * k)}, ${Math.round(b * k)})`);
|
| 539 |
+
$("targetChip").style.background = hex;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
// ---- the tape -----------------------------------------------------------------------------
|
| 543 |
+
let targetHex = null;
|
| 544 |
+
function drawTape(fresh) { // fresh: the dip just measured, which soaks in
|
| 545 |
+
const segs = shots.map((s) => `<div class="seg ${s.delta_e <= tolerance ? "pass" : ""} ${s.index === fresh && !replaying ? "new" : ""}" style="--c:${s.hex}"><div class="cloth"></div><small><span>dip ${s.index}</span><b>${isFinite(s.delta_e) ? s.delta_e.toFixed(2) : "—"}</b></small></div>`);
|
| 546 |
+
if (!shots.length) segs.push(`<div class="seg empty"><div class="cloth"></div><small><span>Undyed cotton: each dip becomes a segment of this tape, measured and corrected until it matches the target</span></small></div>`);
|
| 547 |
+
const target = targetHex ? `<span class="arrow">→</span><div class="seg target" style="--c:${targetHex}"><div class="cloth"></div><small><span>target</span><span>${targetHex}</span></small></div>` : "";
|
| 548 |
+
$("tape").innerHTML = segs.join("") + target;
|
| 549 |
+
$("tape").scrollLeft = 1e6;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
// ---- gauge ----------------------------------------------------------------------------------
|
| 553 |
+
function setGauge(de) {
|
| 554 |
+
const full = 326.7, frac = isFinite(de) ? Math.min(de, 12) / 12 : 0;
|
| 555 |
+
$("gaugeArc").style.strokeDashoffset = String(full * frac);
|
| 556 |
+
$("gaugeArc").style.stroke = isFinite(de) && de <= tolerance ? "var(--ok)" : "var(--dye)";
|
| 557 |
+
if (!isFinite(de)) { $("de").textContent = "—"; shownDe = NaN; }
|
| 558 |
+
else if (replaying) { $("de").textContent = de.toFixed(2); shownDe = de; }
|
| 559 |
+
else if (de !== shownDe) tweenGauge(de);
|
| 560 |
+
$("gauge").classList.toggle("pass", isFinite(de) && de <= tolerance);
|
| 561 |
+
$("verdict").textContent = !isFinite(de) ? "Below the tolerance is a match." : de <= tolerance ? `Within the tolerance of ${tolerance.toFixed(1)}: a match.` : `Tolerance ${tolerance.toFixed(1)}: ${(de - tolerance).toFixed(2)} to go.`;
|
| 562 |
}
|
| 563 |
|
| 564 |
fetch("/api/config").then((r) => r.json()).then((c) => {
|
| 565 |
+
config = c;
|
| 566 |
$("agent").textContent = "agent: " + c.agent;
|
| 567 |
+
$("rig").textContent = "rig: " + ({ sim: "simulator", serial: "robot rig", assisted: "assisted (your hands)" }[c.rig] || c.rig);
|
| 568 |
$("dyeHead").textContent = c.dyes.join(" · ") + " " + c.unit;
|
| 569 |
$("liveBox").hidden = !c.passcode;
|
| 570 |
+
if (c.public_reads_left > 0) $("photoNote").textContent = "Try it: photograph any outfit (under Your own photo) and get its colour, plus a recipe to dye it at home.";
|
| 571 |
});
|
| 572 |
+
fetch("/api/samples").then((r) => r.json()).then((s) => { samples = s; if (s.order) $("orderMsg").placeholder = s.order; if (!s.runs.length) $("replayBtn").hidden = true; }).catch(() => {});
|
| 573 |
+
|
| 574 |
+
// Preset shades a dye house is often asked for.
|
| 575 |
+
const PRESETS = [["#3d7a89", "teal"], ["#b5546f", "rose"], ["#c9a227", "mustard"], ["#4a4f8c", "indigo"], ["#6d7a3c", "olive"]];
|
| 576 |
+
$("presets").innerHTML = PRESETS.map(([h, n]) => `<button style="background:${h}" title="${n}" aria-label="${n}" data-hex="${h}"></button>`).join("");
|
| 577 |
+
$("presets").onclick = (ev) => {
|
| 578 |
+
const h = ev.target.dataset && ev.target.dataset.hex; if (!h) return;
|
| 579 |
+
$("picker").value = h; setDye(h); targetHex = h; drawTape(); targetFromPhoto = false;
|
| 580 |
+
[...$("presets").children].forEach((b) => b.setAttribute("aria-pressed", b.dataset.hex === h));
|
| 581 |
+
};
|
| 582 |
|
| 583 |
function setStatus(s) {
|
| 584 |
+
const el = $("status"); el.textContent = s.replace(/_/g, " "); el.className = "pill " + s;
|
|
|
|
|
|
|
| 585 |
$("start").disabled = s === "running" || s === "waiting";
|
| 586 |
}
|
|
|
|
| 587 |
function feed(id, html) {
|
| 588 |
const box = $(id);
|
| 589 |
if (box.firstElementChild && box.firstElementChild.classList.contains("muted") && box.children.length === 1) box.innerHTML = "";
|
| 590 |
+
const div = document.createElement("div"); div.innerHTML = html; box.prepend(div);
|
|
|
|
|
|
|
| 591 |
}
|
| 592 |
+
|
| 593 |
+
async function drawLearning() {
|
| 594 |
+
const m = await fetch("/api/memory").then((r) => r.json()).catch(() => null);
|
| 595 |
+
if (!m) return;
|
| 596 |
+
const cs = m.campaigns, svg = $("learning"), w = 340, h = 150, pad = 26;
|
| 597 |
+
if (!cs.length) { svg.innerHTML = `<text x="${w / 2}" y="${h / 2}" fill="#7a7166" font-size="11" text-anchor="middle">No campaigns remembered yet: run one</text>`; return; }
|
| 598 |
+
const n = Math.max(cs.length, 2), bw = (w - pad - 8) / n, maxD = Math.max(4, ...cs.map((c) => c.dips)), maxE = 12;
|
| 599 |
+
let g = "";
|
| 600 |
+
cs.forEach((c, i) => {
|
| 601 |
+
const bh = (c.dips / maxD) * (h - pad - 14);
|
| 602 |
+
g += `<rect x="${pad + i * bw + 1}" y="${h - pad - bh}" width="${Math.max(2, bw - 2)}" height="${bh}" rx="2" fill="${c.status === "matched" ? "#2d7a52" : "#b9ae9e"}" opacity="0.45"/>`;
|
| 603 |
+
});
|
| 604 |
+
const pts = cs.map((c, i) => c.first_de00 == null ? null : [pad + i * bw + bw / 2, h - pad - (Math.min(c.first_de00, maxE) / maxE) * (h - pad - 14)]).filter(Boolean);
|
| 605 |
+
if (pts.length > 1) g += `<polyline fill="none" stroke="var(--dye)" stroke-width="2.5" points="${pts.map((p) => p.join(",")).join(" ")}"/>`;
|
| 606 |
+
g += `<text x="${pad}" y="12" fill="#2d7a52" font-size="10">bars: dips to finish</text><text x="${w - 8}" y="12" fill="var(--dye)" font-size="10" text-anchor="end">line: first dip's ΔE00</text>`;
|
| 607 |
+
g += `<text x="${w / 2}" y="${h - 8}" fill="#7a7166" font-size="10" text-anchor="middle">campaign ${cs.length > 1 ? "1 → " + cs.length : "1"}</text>`;
|
| 608 |
+
svg.innerHTML = g;
|
| 609 |
+
$("learningNote").textContent = `${m.dips} dips remembered from ${cs.length} campaigns with this dye lot.`;
|
| 610 |
+
}
|
| 611 |
+
drawLearning();
|
| 612 |
|
| 613 |
function drawChart() {
|
| 614 |
+
const svg = $("chart"), w = 340, h = 160, pad = 28;
|
| 615 |
const max = Math.max(8, ...shots.map((s) => s.delta_e || 0));
|
| 616 |
const x = (i) => pad + (i / Math.max(1, Math.max(5, shots.length) - 1)) * (w - pad - 10);
|
| 617 |
const y = (v) => h - pad + 4 - (Math.min(v, max) / max) * (h - pad - 14);
|
| 618 |
+
let g = `<line x1="${pad}" y1="${y(tolerance)}" x2="${w - 10}" y2="${y(tolerance)}" stroke="#2d7a52" stroke-dasharray="4 4"/>`;
|
| 619 |
+
g += `<text x="${w - 12}" y="${y(tolerance) - 5}" fill="#2d7a52" font-size="10" text-anchor="end">pass ${tolerance}</text>`;
|
| 620 |
+
g += `<text x="${pad - 6}" y="${y(max) + 4}" fill="#7a7166" font-size="10" text-anchor="end">${max.toFixed(0)}</text><text x="${pad - 6}" y="${y(0) + 4}" fill="#7a7166" font-size="10" text-anchor="end">0</text>`;
|
|
|
|
| 621 |
const pts = shots.map((s, i) => [x(i), y(s.delta_e)]);
|
| 622 |
+
if (pts.length > 1) g += `<polyline fill="none" stroke="var(--dye)" stroke-width="2.5" points="${pts.map((p) => p.join(",")).join(" ")}"/>`;
|
| 623 |
shots.forEach((s, i) => {
|
| 624 |
+
g += `<circle cx="${pts[i][0]}" cy="${pts[i][1]}" r="6" fill="${s.hex}" stroke="#fffcf6" stroke-width="2"/>`;
|
| 625 |
+
g += `<text x="${pts[i][0]}" y="${h - 8}" fill="#7a7166" font-size="10" text-anchor="middle">${i + 1}</text>`;
|
| 626 |
});
|
| 627 |
+
if (!shots.length) g += `<text x="${w / 2}" y="${h / 2}" fill="#7a7166" font-size="11" text-anchor="middle">The error falls with each dip</text>`;
|
| 628 |
svg.innerHTML = g;
|
| 629 |
}
|
| 630 |
|
| 631 |
+
function resetView() {
|
| 632 |
+
shots = []; liveRun = false;
|
| 633 |
+
for (const id of ["shots", "notebook", "api"]) $(id).innerHTML = "";
|
| 634 |
+
for (const id of ["outcome", "assist", "frame", "replyText", "previewImg", "previewBtn", "replyBtn", "replayBanner", "dyeing"]) $(id).hidden = true;
|
| 635 |
+
shownDe = NaN;
|
| 636 |
+
$("cameraEmpty").hidden = false; $("frameCaption").textContent = ""; $("previewNote").textContent = ""; $("replyNote").textContent = "";
|
| 637 |
+
Object.keys(calls).forEach((k) => { calls[k] = 0; $("n-" + k).textContent = ""; });
|
| 638 |
+
drawChart(); drawTape(); setGauge(NaN);
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
const es = new EventSource("/api/events");
|
| 642 |
es.onmessage = (msg) => {
|
| 643 |
const e = JSON.parse(msg.data);
|
| 644 |
if (e.kind === "replayed") { replaying = false; return; }
|
| 645 |
+
if (e.kind === "reset") { replaying = true; resetView(); setStatus("idle"); return; }
|
| 646 |
+
if (e.kind === "start") {
|
| 647 |
+
resetView(); liveRun = !e.replay;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 648 |
$("agent").textContent = "agent: " + e.agent;
|
| 649 |
$("memory").textContent = `lab memory: ${e.memory_dips || 0} dips · ${e.memory_campaigns || 0} campaigns`;
|
| 650 |
+
tolerance = e.tolerance; targetHex = e.target_hex; setDye(e.target_hex);
|
| 651 |
+
$("targetLabel").textContent = `target ${e.target_hex}`; $("dipChip").style.background = "transparent"; $("dipLabel").textContent = "no dip yet";
|
| 652 |
+
if (e.replay) { $("replayBanner").hidden = false; $("replayBanner").textContent = `Replaying a recorded live run${e.recorded ? " from " + e.recorded : ""}: every Meta API call shown here happened for real (sped up).`; }
|
| 653 |
+
drawTape(); drawChart(); setGauge(NaN); setStatus("running");
|
|
|
|
|
|
|
|
|
|
| 654 |
} else if (e.kind === "assist") {
|
| 655 |
+
if (handsFree && !replaying) say(instructions(e));
|
| 656 |
+
$("assist").hidden = false; $("assistTitle").textContent = `Your turn: dip ${e.index}`;
|
|
|
|
| 657 |
$("amounts").innerHTML = Object.entries(e.amounts).map(([d, v]) => `<div class="amount"><b>${v}</b><small>${e.unit} ${esc(d)}</small></div>`).join("");
|
| 658 |
$("assistSteps").innerHTML = e.steps.map((s) => `<li>${esc(s)}</li>`).join("");
|
| 659 |
+
$("assistNote").textContent = ""; $("assistPhoto").value = ""; setStatus("waiting");
|
|
|
|
|
|
|
| 660 |
} else if (e.kind === "shot") {
|
| 661 |
$("assist").hidden = true;
|
| 662 |
if ($("status").textContent === "waiting") setStatus("running");
|
| 663 |
+
shots.push(e); drawChart(); drawTape(e.index); setGauge(e.delta_e);
|
| 664 |
+
$("dipChip").style.background = e.hex; $("dipLabel").textContent = `dip ${e.index} · ${e.hex}`;
|
| 665 |
+
const src = `/api/shot/${e.index}.jpg?t=${Date.now()}`;
|
| 666 |
+
afterDyeing(() => { $("frame").src = src; $("frame").hidden = false; $("cameraEmpty").hidden = true; $("dyeing").hidden = true; });
|
| 667 |
+
$("frameCaption").textContent = `Dip ${e.index}: measured ${e.hex}, ΔE00 ${isFinite(e.delta_e) ? e.delta_e.toFixed(2) : "rejected"}.` + (e.flags && e.flags.length ? " " + e.flags.map((f) => f.replace(/^(NOTE|WARN): /, "")).join(" · ") : "");
|
| 668 |
+
const rec = e.recipe.match(/[\d.]+(?= (g\/L|%|drops))/g) || [];
|
|
|
|
|
|
|
| 669 |
$("shots").insertAdjacentHTML("beforeend", `<tr><td>${e.index}</td><td><span class="sw" style="background:${e.hex}"></span>${rec.join(" · ")}</td><td>${isFinite(e.delta_e) ? e.delta_e.toFixed(2) : "rejected"}</td></tr>`);
|
| 670 |
if (e.flags && e.flags.length) feed("notebook", `<span class="gate">${esc(e.flags.join("; "))}</span>`);
|
| 671 |
} else if (e.kind === "note") {
|
|
|
|
| 675 |
} else if (e.kind === "tool") {
|
| 676 |
feed("notebook", `<span class="tool">→ ${esc(e.name)}</span> <span class="muted">${esc(JSON.stringify(e.args)).slice(0, 160)}</span>`);
|
| 677 |
} else if (e.kind === "gate") {
|
| 678 |
+
feed("notebook", `<span class="gate">safety gate refused: ${esc(e.refused)}</span> <span class="muted">${esc(e.recipe)}</span>`);
|
| 679 |
} else if (e.kind === "dispense") {
|
| 680 |
feed("notebook", `<span class="muted">dispensing dip ${e.index}: ${esc(e.recipe)}</span>`);
|
| 681 |
+
if (!replaying) showDyeing(e);
|
| 682 |
} else if (e.kind === "model") {
|
| 683 |
feed("notebook", `<span class="tool">model refit</span> <span class="muted">uptake ${esc(JSON.stringify(e.gains_after))}, fit error ${e.model_fit_error_de00}</span>`);
|
| 684 |
} else if (e.kind === "status") {
|
| 685 |
setStatus(e.status);
|
| 686 |
+
const done = !["running", "waiting"].includes(e.status);
|
| 687 |
+
if (done && !replaying) setTimeout(drawLearning, 500);
|
| 688 |
+
if (done) { $("assist").hidden = true; afterDyeing(() => { $("dyeing").hidden = true; }); showOutcome(e); if (e.status === "matched" && !replaying) setTimeout(burst, 500); }
|
| 689 |
if (e.reason) feed("notebook", `<span class="gate">${esc(e.status)}: ${esc(e.reason)}</span>`);
|
| 690 |
} else if (e.kind === "api") {
|
| 691 |
+
flash(e.model);
|
| 692 |
feed("api", `<span><span class="m">${esc(e.model)}</span> ${esc(e.purpose)}</span><span class="${e.ok ? "" : "gate"}">${e.seconds}s</span>`);
|
| 693 |
}
|
| 694 |
};
|
| 695 |
|
| 696 |
+
function showOutcome(e) {
|
| 697 |
+
if (!shots.length) return;
|
| 698 |
+
// A match is confirmed by a second photo; the status event carries the confirmed reading.
|
| 699 |
+
const confirmed = shots.find((s) => s.index === e.shot);
|
| 700 |
+
if (confirmed && isFinite(e.delta_e)) { confirmed.delta_e = e.delta_e; drawTape(); setGauge(e.delta_e); }
|
| 701 |
+
const best = shots.filter((s) => isFinite(s.delta_e)).sort((a, b) => a.delta_e - b.delta_e)[0];
|
| 702 |
+
const titles = { matched: "Matched on cotton", out_of_gamut: "Out of these dyes' reach", escalated: "Handed to a human", finished: "Budget spent", stopped: "Stopped" };
|
| 703 |
+
$("outcome").hidden = false;
|
| 704 |
+
$("outcome").style.borderColor = e.status === "matched" ? "var(--ok)" : "var(--warn)";
|
| 705 |
+
$("outcomeTitle").textContent = titles[e.status] || e.status;
|
| 706 |
+
$("outcomeTitle").style.color = e.status === "matched" ? "var(--ok)" : "var(--warn)";
|
| 707 |
+
$("outcomeText").textContent = best ? `Best: dip ${best.index}, ΔE00 ${best.delta_e.toFixed(2)} against a tolerance of ${tolerance.toFixed(1)}, after ${shots.length} dip${shots.length > 1 ? "s" : ""}. The report is ready for the buyer.` : "";
|
| 708 |
+
$("previewBtn").hidden = !(liveRun && targetFromPhoto);
|
| 709 |
+
$("replyBtn").hidden = !(liveRun && orderRead);
|
| 710 |
+
if (!liveRun) { // a recorded run: show what else it produced
|
| 711 |
+
fetch("/api/preview.jpg").then((r) => { if (r.ok) { $("previewImg").src = "/api/preview.jpg"; $("previewImg").hidden = false; $("previewNote").textContent = "The customer's garment in the approved shade: Muse Image recoloured it, SAM 3.1 checked it."; } });
|
| 712 |
+
fetch("/api/replay-file/order.json").then((r) => r.ok ? r.json() : null).then((j) => {
|
| 713 |
+
if (!j) return;
|
| 714 |
+
const o = j.order;
|
| 715 |
+
$("orderNote").textContent = `Muse Spark read the buyer's message: ${o.quantity} ${o.quantity_unit} of ${o.garment}, ${o.fabric}, by ${o.deadline}; shade ${o.shade_strictness}, so pass at ΔE00 ≤ ${o.tolerance_de00}.`;
|
| 716 |
+
$("orderMsg").value = j.message;
|
| 717 |
+
$("outcomeText").textContent += ` The buyer's order: ${o.quantity} ${o.garment}s, shade ${o.shade_strictness}.`;
|
| 718 |
+
}).catch(() => {});
|
| 719 |
+
fetch("/api/replay-file/reply.txt").then((r) => r.ok ? r.text() : null).then((t) => {
|
| 720 |
+
if (!t) return;
|
| 721 |
+
$("replyText").value = t; $("replyText").hidden = false;
|
| 722 |
+
$("replyNote").textContent = "The reply Muse Spark drafted, in the buyer's own Hinglish. Rangrez never sends messages itself.";
|
| 723 |
+
}).catch(() => {});
|
| 724 |
+
}
|
| 725 |
+
}
|
| 726 |
|
| 727 |
+
$("picker").addEventListener("input", (ev) => { setDye(ev.target.value); targetHex = ev.target.value; drawTape(); targetFromPhoto = false; });
|
| 728 |
+
|
| 729 |
+
function showHomeRecipe(h) {
|
| 730 |
+
if (!h) return;
|
| 731 |
+
const drops = Object.entries(h.drops).filter(([, n]) => n > 0).map(([d, n]) => `${n} drop${n === 1 ? "" : "s"} ${esc(d)}`).join(" · ");
|
| 732 |
+
$("homeRecipe").hidden = false;
|
| 733 |
+
$("homeRecipe").innerHTML = h.reachable
|
| 734 |
+
? `<b>Dye it at home:</b> ${drops || "plain water"} in ${h.teaspoons} teaspoons of water. A first-dip estimate for household colours; brands vary, so dip a white cotton strip and let Rangrez correct it.`
|
| 735 |
+
: `<b>At home:</b> household blue, red and yellow can't quite reach this shade. Closest: ${drops} <span class="sw" style="background:${h.closest_hex}"></span>${h.closest_hex}.`;
|
| 736 |
+
}
|
| 737 |
+
function showRead(j, note) {
|
| 738 |
+
$("picker").value = j.hex; setDye(j.hex); targetHex = j.hex; drawTape(); targetFromPhoto = true;
|
| 739 |
+
showHomeRecipe(j.home_recipe);
|
| 740 |
+
$("photoNote").textContent = note || {
|
| 741 |
+
card: `SAM 3.1 read the ${j.concept || "garment"} as ${j.hex}, calibrated against the colour card.`,
|
| 742 |
+
paper: `SAM 3.1 read ${j.hex}, balanced against the white paper. Photograph every dip in the same spot and light.`,
|
| 743 |
+
none: `Read ${j.hex}, uncalibrated: put white paper or the colour card in the shot.`,
|
| 744 |
+
}[j.calibration];
|
| 745 |
+
}
|
| 746 |
+
function showPhoto(src, caption) {
|
| 747 |
+
$("frame").src = src; $("frame").hidden = false; $("cameraEmpty").hidden = true; $("frameCaption").textContent = caption;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
async function readPhoto(blob, name) {
|
| 751 |
const form = new FormData();
|
| 752 |
+
form.append("photo", blob, name); form.append("concept", $("concept").value || "fabric"); form.append("passcode", $("passcode").value);
|
| 753 |
+
$("photoNote").textContent = "SAM 3.1 is finding the garment and the colour card…";
|
| 754 |
const r = await fetch("/api/target-from-photo", { method: "POST", body: form });
|
| 755 |
const j = await r.json();
|
| 756 |
+
if (!r.ok) return { error: j.detail || "Could not read the photo." };
|
| 757 |
+
showRead(j); return j;
|
| 758 |
+
}
|
| 759 |
+
$("usePhoto").onclick = async () => {
|
| 760 |
+
const file = $("photo").files[0];
|
| 761 |
+
if (!file) { $("photoNote").textContent = "Choose a photo first."; return; }
|
| 762 |
+
showPhoto(URL.createObjectURL(file), "The customer's photo.");
|
| 763 |
+
const j = await readPhoto(file, file.name);
|
| 764 |
+
if (j.error) $("photoNote").textContent = j.error;
|
| 765 |
+
};
|
| 766 |
+
// The sample: read it live if the Meta models are available, else show how live SAM read it earlier.
|
| 767 |
+
$("sampleBtn").onclick = async () => {
|
| 768 |
+
if (!samples) return;
|
| 769 |
+
$("concept").value = samples.concept;
|
| 770 |
+
showPhoto(samples.photo, "A customer's kurta photographed on white paper with the printed colour card.");
|
| 771 |
+
const live = config.online || $("passcode").value;
|
| 772 |
+
if (live) {
|
| 773 |
+
const blob = await fetch(samples.photo).then((r) => r.blob());
|
| 774 |
+
const j = await readPhoto(blob, "kurta.jpg");
|
| 775 |
+
if (!j.error) return;
|
| 776 |
}
|
| 777 |
+
if (samples.read) showRead(samples.read, `Live SAM 3.1 read this kurta earlier as ${samples.read.hex}, calibrated against the colour card. Press Start the lab.`);
|
| 778 |
+
};
|
| 779 |
+
$("replayBtn").onclick = async () => {
|
| 780 |
+
if (!samples || !samples.runs.length) return;
|
| 781 |
+
const r = await fetch(`/api/replay/${encodeURIComponent(samples.runs[samples.runs.length - 1].name)}`, { method: "POST" });
|
| 782 |
+
if (!r.ok) { const j = await r.json(); $("photoNote").textContent = j.detail || "Could not replay."; }
|
| 783 |
};
|
|
|
|
| 784 |
$("start").onclick = async () => {
|
| 785 |
const r = await fetch("/api/campaign", {
|
| 786 |
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
| 796 |
$("sendPhoto").onclick = () => {
|
| 797 |
const file = $("assistPhoto").files[0];
|
| 798 |
if (!file) { $("assistNote").textContent = "Take or choose a photo first."; return; }
|
| 799 |
+
const form = new FormData(); form.append("photo", file); sendAssist("/api/assist/photo", form);
|
|
|
|
| 800 |
};
|
| 801 |
$("useWebcam").onclick = () => sendAssist("/api/assist/webcam");
|
| 802 |
$("stop").onclick = () => fetch("/api/stop", { method: "POST" });
|
| 803 |
|
| 804 |
+
$("replyBtn").onclick = async () => {
|
| 805 |
+
const form = new FormData(); form.append("passcode", $("passcode").value);
|
| 806 |
+
$("replyNote").textContent = "Muse Spark is drafting the reply in the buyer's language…";
|
| 807 |
+
const r = await fetch("/api/reply", { method: "POST", body: form });
|
| 808 |
+
const j = await r.json();
|
| 809 |
+
if (!r.ok) { $("replyNote").textContent = j.detail || "Could not draft the reply."; return; }
|
| 810 |
+
$("replyText").value = j.reply; $("replyText").hidden = false;
|
| 811 |
+
$("replyNote").textContent = "A draft: check it, then send it with the lab-dip report. Rangrez never sends messages itself.";
|
| 812 |
+
};
|
| 813 |
+
$("readOrder").onclick = async () => {
|
| 814 |
+
const msg = $("orderMsg").value.trim() || (samples && samples.order) || "";
|
| 815 |
+
if (!msg) { $("orderNote").textContent = "Paste the buyer's message first."; return; }
|
| 816 |
+
$("orderMsg").value = msg;
|
| 817 |
+
const form = new FormData(); form.append("message", msg); form.append("passcode", $("passcode").value);
|
| 818 |
+
$("orderNote").textContent = "Muse Spark is reading the order…";
|
| 819 |
+
const r = await fetch("/api/order", { method: "POST", body: form });
|
| 820 |
+
const o = await r.json();
|
| 821 |
+
if (!r.ok) { $("orderNote").textContent = o.detail || "Could not read the order."; return; }
|
| 822 |
+
$("tol").value = o.tolerance_de00; orderRead = true;
|
| 823 |
+
$("orderNote").textContent = `${o.quantity} ${o.quantity_unit} of ${o.garment}, ${o.fabric}` + (o.deadline ? `, by ${o.deadline}` : "") +
|
| 824 |
+
`. Shade ${o.shade_strictness}: pass at ΔE00 ≤ ${o.tolerance_de00}.` + (o.lab_dip_due ? ` Lab dip due ${o.lab_dip_due}.` : "");
|
| 825 |
+
};
|
| 826 |
$("previewBtn").onclick = async () => {
|
| 827 |
const form = new FormData(); form.append("passcode", $("passcode").value);
|
| 828 |
$("previewNote").textContent = "Muse Image is recolouring the garment; SAM 3.1 will check it…";
|
|
|
|
| 830 |
const j = await r.json();
|
| 831 |
if (!r.ok) { $("previewNote").textContent = j.detail || "Preview failed."; return; }
|
| 832 |
$("previewImg").src = `/api/preview.jpg?t=${Date.now()}`; $("previewImg").hidden = false;
|
| 833 |
+
$("previewNote").textContent = `Dip ${j.dip}'s shade on the customer's garment. Muse Image's first attempt measured ΔE00 ${j.raw_de00} from the dip, so Rangrez corrected it: a picture for the eye, the dip is the evidence.`;
|
| 834 |
};
|
| 835 |
+
|
| 836 |
+
// ---- hands-free: Muse Voice Transcribe hears the dyer; the browser reads instructions aloud ----
|
| 837 |
+
const DYE_HI = { blue: "नीला", red: "लाल", yellow: "पीला", turquoise: "फ़िरोज़ी", fuchsia: "रानी", "lemon yellow": "नींबू पीला" };
|
| 838 |
+
function instructions(e) {
|
| 839 |
+
const parts = Object.entries(e.amounts).filter(([, v]) => +v > 0);
|
| 840 |
+
if ($("lang").value === "hi")
|
| 841 |
+
return `डिप ${e.index}। ` + parts.map(([d, v]) => `${DYE_HI[d] || d} ${v} ${e.unit === "drops" ? "बूँद" : "एमएल"}`).join(", ") + `। ${e.dip_seconds} सेकंड डुबोइए। कपड़ा सूखकर कागज़ पर रखें, तो 'हो गया' बोलिए।`;
|
| 842 |
+
return `Dip ${e.index}. ` + parts.map(([d, v]) => `${v} ${e.unit} of ${d}`).join(", ") + `. Dip for ${e.dip_seconds} seconds. Say done when the strip is dry and on the paper.`;
|
| 843 |
+
}
|
| 844 |
function say(text) {
|
| 845 |
if (!("speechSynthesis" in window)) return;
|
| 846 |
speechSynthesis.cancel();
|
| 847 |
+
const u = new SpeechSynthesisUtterance(text); u.lang = $("lang").value === "hi" ? "hi-IN" : "en-IN"; speechSynthesis.speak(u);
|
| 848 |
}
|
| 849 |
+
function wav16k(samplesIn, rate) {
|
| 850 |
+
const ratio = rate / 16000, n = Math.floor(samplesIn.length / ratio), pcm = new Int16Array(n);
|
| 851 |
+
for (let i = 0; i < n; i++) pcm[i] = Math.max(-1, Math.min(1, samplesIn[Math.floor(i * ratio)])) * 0x7fff;
|
| 852 |
const buf = new ArrayBuffer(44 + pcm.length * 2), v = new DataView(buf);
|
| 853 |
const str = (o, t) => [...t].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0)));
|
| 854 |
str(0, "RIFF"); v.setUint32(4, 36 + pcm.length * 2, true); str(8, "WAVEfmt "); v.setUint32(16, 16, true);
|
|
|
|
| 859 |
}
|
| 860 |
async function sendTurn(blob) {
|
| 861 |
const form = new FormData();
|
| 862 |
+
form.append("audio", blob, "turn.wav"); form.append("passcode", $("passcode").value); form.append("lang", $("lang").value);
|
| 863 |
$("voiceNote").textContent = "Transcribing with Muse Voice Transcribe…";
|
| 864 |
const r = await fetch("/api/voice", { method: "POST", body: form });
|
| 865 |
const j = await r.json();
|
|
|
|
| 895 |
$("handsFree").setAttribute("aria-pressed", handsFree);
|
| 896 |
$("handsFree").textContent = `Hands-free voice: ${handsFree ? "listening" : "off"}`;
|
| 897 |
if (handsFree) {
|
| 898 |
+
try { await startListening(); $("voiceNote").textContent = $("lang").value === "hi" ? "सुन रहे हैं। बोलिए: \"हो गया\", \"फिर से\" या \"बंद करो\"।" : "Listening. Say \"done\", \"repeat\" or \"stop\"."; }
|
| 899 |
catch (err) { handsFree = false; $("handsFree").textContent = "Hands-free voice: off"; $("voiceNote").textContent = "Microphone unavailable: " + err.message; }
|
| 900 |
} else if (mic) { mic.stream.getTracks().forEach((t) => t.stop()); mic.ctx.close(); mic = null; }
|
| 901 |
};
|
| 902 |
+
|
| 903 |
+
// ---- tabs ----------------------------------------------------------------------------------
|
| 904 |
+
function showTab(name) {
|
| 905 |
+
$("labView").hidden = name !== "lab"; $("caseView").hidden = name !== "case";
|
| 906 |
+
$("tapeWrap").hidden = name !== "lab";
|
| 907 |
+
$("tabLab").setAttribute("aria-selected", name === "lab"); $("tabCase").setAttribute("aria-selected", name === "case");
|
| 908 |
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
| 909 |
+
}
|
| 910 |
+
$("tabLab").onclick = () => showTab("lab"); $("tabCase").onclick = () => showTab("case"); $("caseToLab").onclick = () => showTab("lab");
|
| 911 |
+
|
| 912 |
+
// ---- the guided tour: shows the whole project, and has the judge run it ---------------------
|
| 913 |
+
const TOUR = [
|
| 914 |
+
{ title: "Welcome to Rangrez", text: "A dye house's lab dip, in minutes instead of days, from a phone. In two minutes you'll run the lab yourself: match a kurta, watch it dye and correct, and get the buyer's report." },
|
| 915 |
+
{ el: "models", title: "Six Meta models, six jobs", text: "SAM 3.1 measures, Muse Wonder runs the lab, Muse Glimmer proposes the first dip, Muse Spark reads orders, Muse Image previews, Muse Voice listens. Each lights up the moment it's called." },
|
| 916 |
+
{ el: "startCard", title: "Start from the customer's garment", text: "A buyer sends a photo of the kurta to match. Rangrez finds the garment and the printed colour card with SAM 3.1 and turns pixels into a calibrated colour.", action: () => $("sampleBtn").click() },
|
| 917 |
+
{ el: "cameraCard", title: "SAM 3.1 as a measuring instrument", text: "The colour card corrects the camera; the white paper evens out uneven light. The kurta's colour is now the target, and the page takes on its shade." },
|
| 918 |
+
{ el: "tapeWrap", title: "Now the lab runs", text: "We'll replay a recorded live run, sped up: every Wonder decision and SAM measurement in it happened for real. Watch each dip become a segment of this cotton tape.", action: () => $("replayBtn").click() },
|
| 919 |
+
{ el: "gaugeCard", title: "Closer with every dip", text: "CIEDE2000 is the colour difference the eye sees. Below the tolerance (green) is a match; on a robot rig a second photo must agree before Rangrez calls it." },
|
| 920 |
+
{ el: "logCard", title: "Muse Wonder, the colourist", text: "Wonder chooses each dip with tools: the dye model, the lab's memory, a correction learned from real dips. It never touches a pump: a safety gate checks every recipe, and it hands over to a human when stuck.", action: () => { $("logDetails").open = true; } },
|
| 921 |
+
{ el: "learningCard", title: "It learns this dye lot", text: "Every dip is remembered. Over a 40-campaign night shift, the first dip's error fell from 3.81 to 1.40 ΔE00. Glimmer, fine-tuned on the lab's records, proposes first dips too." },
|
| 922 |
+
{ el: "outcome", title: "The buyer's lab-dip report", text: "When it matches: a report the buyer already asks for: ΔE00 and CMC 2:1, options A/B/C, the recipe in % on weight of fabric, the production plan, and the garment previewed in the shade.", wait: true },
|
| 923 |
+
{ el: "advanced", title: "Hands-free, in Hindi too", text: "The dyer's gloves are wet with dye: say \"done\" or \"हो गया\", \"repeat\" or \"फिर से\". Paste the buyer's WhatsApp order and Muse Spark reads it and drafts the reply.", action: () => { $("advanced").open = true; } },
|
| 924 |
+
{ el: "tabCase", title: "The case", text: "What only Rangrez does, what it has shown, where it honestly stands (simulated, not yet dyed for real), and how a dye house would adopt it." },
|
| 925 |
+
{ title: "Your turn", text: "Pick a preset shade or your own colour and press Start the lab. Or open “Your own photo” and photograph your outfit: Rangrez gives you a recipe to dye a matching cloth at home." },
|
| 926 |
+
];
|
| 927 |
+
let tourAt = 0;
|
| 928 |
+
function placeTour() {
|
| 929 |
+
const step = TOUR[tourAt], spot = $("tourSpot"), card = $("tourCard");
|
| 930 |
+
$("tourStep").textContent = `${tourAt + 1} of ${TOUR.length}`; $("tourTitle").textContent = step.title; $("tourText").textContent = step.text;
|
| 931 |
+
$("tourBack").disabled = tourAt === 0; $("tourNext").textContent = tourAt === TOUR.length - 1 ? "Start exploring" : "Next";
|
| 932 |
+
const el = step.el && $(step.el);
|
| 933 |
+
if (el && !el.hidden) {
|
| 934 |
+
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
| 935 |
+
followSpot();
|
| 936 |
+
} else {
|
| 937 |
+
Object.assign(spot.style, { left: innerWidth / 2 + "px", top: innerHeight / 2 + "px", width: "0px", height: "0px" });
|
| 938 |
+
card.style.left = (innerWidth - card.offsetWidth) / 2 + "px"; card.style.top = innerHeight * 0.3 + "px";
|
| 939 |
+
}
|
| 940 |
+
}
|
| 941 |
+
// Keep the spotlight on its element while the page scrolls into place (and afterwards).
|
| 942 |
+
function followSpot() {
|
| 943 |
+
const step = TOUR[tourAt], el = step && step.el && $(step.el);
|
| 944 |
+
if ($("tour").hidden || !el || el.hidden) return;
|
| 945 |
+
const r = el.getBoundingClientRect(), pad = 8, spot = $("tourSpot"), card = $("tourCard");
|
| 946 |
+
Object.assign(spot.style, { left: r.left - pad + "px", top: r.top - pad + "px", width: r.width + 2 * pad + "px", height: r.height + 2 * pad + "px" });
|
| 947 |
+
const below = r.bottom + 16 + card.offsetHeight < innerHeight;
|
| 948 |
+
const above = r.top - card.offsetHeight - 16 > 0;
|
| 949 |
+
card.style.left = Math.max(16, Math.min(innerWidth - card.offsetWidth - 16, r.left)) + "px";
|
| 950 |
+
card.style.top = (below ? r.bottom + 16 : above ? r.top - card.offsetHeight - 16 : 16) + "px";
|
| 951 |
+
}
|
| 952 |
+
addEventListener("scroll", followSpot, { passive: true });
|
| 953 |
+
|
| 954 |
+
function tourGo(i) {
|
| 955 |
+
if (i >= TOUR.length) return endTour();
|
| 956 |
+
tourAt = Math.max(0, i);
|
| 957 |
+
const step = TOUR[tourAt];
|
| 958 |
+
if (step.el === "tabCase" || step.el === "models") showTab(step.el === "tabCase" ? "case" : "lab"); else if (step.el) showTab("lab");
|
| 959 |
+
if (step.action) step.action();
|
| 960 |
+
if (step.wait && $(step.el).hidden) { // wait for the replay to finish, then point at the report
|
| 961 |
+
$("tourText").textContent = "The lab is still dyeing… the report appears when it's done.";
|
| 962 |
+
const t = setInterval(() => { if (!$(step.el).hidden || $("tour").hidden) { clearInterval(t); placeTour(); } }, 500);
|
| 963 |
+
}
|
| 964 |
+
placeTour();
|
| 965 |
+
}
|
| 966 |
+
function endTour() { $("tour").hidden = true; showTab("lab"); try { localStorage.setItem("rangrez-tour", "done"); } catch (e) {} }
|
| 967 |
+
$("tourBtn").onclick = () => { $("tour").hidden = false; tourGo(0); };
|
| 968 |
+
$("tourNext").onclick = () => tourGo(tourAt + 1);
|
| 969 |
+
$("tourBack").onclick = () => tourGo(tourAt - 1);
|
| 970 |
+
$("tourSkip").onclick = endTour;
|
| 971 |
+
addEventListener("resize", () => { if (!$("tour").hidden) placeTour(); });
|
| 972 |
+
addEventListener("keydown", (ev) => { if ($("tour").hidden) return; if (ev.key === "Escape") endTour(); if (ev.key === "ArrowRight") tourGo(tourAt + 1); if (ev.key === "ArrowLeft") tourGo(tourAt - 1); });
|
| 973 |
+
// Shareable links: ?replay=1 plays the recorded live run, ?tour=1 opens the tour.
|
| 974 |
+
const params = new URLSearchParams(location.search);
|
| 975 |
+
if (params.has("replay")) {
|
| 976 |
+
const go = () => samples ? ($("sampleBtn").click(), setTimeout(() => $("replayBtn").click(), 500)) : setTimeout(go, 200);
|
| 977 |
+
go();
|
| 978 |
+
} else if (params.has("tour")) {
|
| 979 |
+
setTimeout(() => { $("tour").hidden = false; tourGo(0); }, 600);
|
| 980 |
+
} else {
|
| 981 |
+
try { if (!localStorage.getItem("rangrez-tour")) setTimeout(() => { $("tour").hidden = false; tourGo(0); }, 900); } catch (e) {}
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
setDye($("picker").value); drawChart(); drawTape(); setGauge(NaN);
|
| 985 |
</script>
|
| 986 |
</body>
|
| 987 |
</html>
|
src/rangrez/static/logo.svg
ADDED
|
|
src/rangrez/voice.py
CHANGED
|
@@ -19,22 +19,30 @@ from .profiles import PROFILE
|
|
| 19 |
|
| 20 |
MODEL = "muse-voice-transcribe-1.0"
|
| 21 |
URL = "https://api.meta.ai/v1/asr/transcribe"
|
| 22 |
-
KEYWORDS =
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
INTENTS = [ # first match wins
|
| 26 |
-
("stop", r"\b(stop|cancel|abort)\b"),
|
| 27 |
-
("repeat", r"\b(repeat|again|say that|what was|instructions)\b"),
|
| 28 |
-
("photo", r"\b(done|ready|photo|picture|capture|webcam|shoot|snap|dry now|it'?s dry)\b"
|
|
|
|
| 29 |
]
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
-
def transcribe(wav: bytes, log=None) -> str:
|
| 33 |
-
"""Text of one spoken turn (a 16 kHz mono WAV)."""
|
| 34 |
from .vision import ApiCall
|
| 35 |
|
| 36 |
boundary = uuid.uuid4().hex
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"request\"\r\nContent-Type: application/json\r\n\r\n"
|
| 39 |
f"{request}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"audio\"; filename=\"turn.wav\"\r\n"
|
| 40 |
"Content-Type: audio/wav\r\n\r\n").encode() + wav + f"\r\n--{boundary}--\r\n".encode()
|
|
@@ -62,7 +70,30 @@ def intent(text: str) -> str:
|
|
| 62 |
for name, pattern in INTENTS:
|
| 63 |
if re.search(pattern, t):
|
| 64 |
# "it looks done but patchy" is an observation with a command in it: a long turn is a note.
|
| 65 |
-
if name == "photo" and len(t.split()) >
|
| 66 |
return "note"
|
| 67 |
return name
|
| 68 |
return "note"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
MODEL = "muse-voice-transcribe-1.0"
|
| 21 |
URL = "https://api.meta.ai/v1/asr/transcribe"
|
| 22 |
+
KEYWORDS = {
|
| 23 |
+
"en": ["done", "dip", "photo", "webcam", "repeat", "stop", "drops", "patchy", "darker", "lighter", "Rangrez", *PROFILE.names],
|
| 24 |
+
"hi": [], # tried: Devanagari keywords made Hindi worse ("फोटो लो" heard as "फट लो"); language bias alone is clean
|
| 25 |
+
}
|
| 26 |
|
| 27 |
+
INTENTS = [ # first match wins. English as whole words; Hindi (Devanagari or Hinglish) as phrases.
|
| 28 |
+
("stop", r"\b(stop|cancel|abort|band karo|ruk jao)\b|रुको|रुक जाओ|बंद करो|^बस\b"),
|
| 29 |
+
("repeat", r"\b(repeat|again|say that|what was|instructions|phir se|dobara)\b|फिर से|दोबारा|दुबारा|क्या था"),
|
| 30 |
+
("photo", (r"\b(done|ready|photo|picture|capture|webcam|shoot|snap|dry now|it'?s dry|ho gaya|ho gya)\b"
|
| 31 |
+
r"|हो गया|हो गई|फोटो|फ़ोटो|तस्वीर|खींचो|सूख गया")),
|
| 32 |
]
|
| 33 |
+
LANGUAGES = {"en": ["English"], "hi": ["Hindi", "English"]}
|
| 34 |
|
| 35 |
|
| 36 |
+
def transcribe(wav: bytes, log=None, lang: str = "en") -> str:
|
| 37 |
+
"""Text of one spoken turn (a 16 kHz mono WAV), biased towards the dyer's language."""
|
| 38 |
from .vision import ApiCall
|
| 39 |
|
| 40 |
boundary = uuid.uuid4().hex
|
| 41 |
+
lang = lang if lang in LANGUAGES else "en"
|
| 42 |
+
settings = {"model": MODEL, "audioEncoding": "WAV", "mode": "PUSH_TO_TALK", "languageBias": LANGUAGES[lang]}
|
| 43 |
+
if KEYWORDS[lang]:
|
| 44 |
+
settings["keywords"] = KEYWORDS[lang]
|
| 45 |
+
request = json.dumps(settings)
|
| 46 |
body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"request\"\r\nContent-Type: application/json\r\n\r\n"
|
| 47 |
f"{request}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"audio\"; filename=\"turn.wav\"\r\n"
|
| 48 |
"Content-Type: audio/wav\r\n\r\n").encode() + wav + f"\r\n--{boundary}--\r\n".encode()
|
|
|
|
| 70 |
for name, pattern in INTENTS:
|
| 71 |
if re.search(pattern, t):
|
| 72 |
# "it looks done but patchy" is an observation with a command in it: a long turn is a note.
|
| 73 |
+
if name == "photo" and len(t.split()) > 5:
|
| 74 |
return "note"
|
| 75 |
return name
|
| 76 |
return "note"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# What the lab says back, in the dyer's language.
|
| 80 |
+
SAY = {
|
| 81 |
+
"en": {"photo": "Photo taken. Measuring.", "no_photo": "Nothing to photograph right now.",
|
| 82 |
+
"stopped": "Stopped.", "no_dip": "No dip is waiting.", "noted": "Noted for the colourist.",
|
| 83 |
+
"photo_failed": "Could not take the photo. Send one from your phone."},
|
| 84 |
+
"hi": {"photo": "फोटो ले ली। नाप रहे हैं।", "no_photo": "अभी फोटो लेने को कुछ नहीं है।",
|
| 85 |
+
"stopped": "रोक दिया।", "no_dip": "अभी कोई डिप बाकी नहीं है।", "noted": "रंगसाज़ के लिए लिख लिया।",
|
| 86 |
+
"photo_failed": "फोटो नहीं ले पाए। फ़ोन से फोटो भेजिए।"},
|
| 87 |
+
}
|
| 88 |
+
DYE_HI = {"blue": "नीला", "red": "लाल", "yellow": "पीला", "turquoise": "फ़िरोज़ी", "fuchsia": "रानी", "lemon yellow": "नींबू पीला"}
|
| 89 |
+
UNIT_HI = {"drops": "बूँद", "mL": "एमएल"}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def instructions(w: dict, lang: str = "en") -> str:
|
| 93 |
+
"""One dip's instructions as a sentence to read aloud."""
|
| 94 |
+
amounts = [(d, v) for d, v in w["amounts"].items() if float(v) > 0]
|
| 95 |
+
if lang == "hi":
|
| 96 |
+
parts = ", ".join(f"{DYE_HI.get(d, d)} {v} {UNIT_HI.get(w['unit'], w['unit'])}" for d, v in amounts)
|
| 97 |
+
return f"डिप {w['index']}। {parts}। {w['dip_seconds']} सेकंड डुबोइए। कपड़ा सूखकर कागज़ पर रखें, तो 'हो गया' बोलिए।"
|
| 98 |
+
parts = ", ".join(f"{v} {w['unit']} of {d}" for d, v in amounts)
|
| 99 |
+
return f"Dip {w['index']}. {parts}. Dip for {w['dip_seconds']} seconds. Say done when the strip is dry and on the paper."
|