pi-exr-reader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luke Richardson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # pi-exr-reader
2
+
3
+ Pi extension that lets any pi session read `.exr` files — something pi's
4
+ `read` tool can't do.
5
+
6
+ The `exr_read` tool reports:
7
+
8
+ - **Part metadata** — size, data window, storage, line order, compression,
9
+ for every part of a multilayer EXR
10
+ - **Per-channel statistics** — min / max / mean / nonzero fraction, per
11
+ component (`UV_Box.R`, `st.G`, ...)
12
+ - **Exact float pixel values** at a point, full precision
13
+ - **Rect statistics** over an inclusive region
14
+ - **PNG preview** — 8-bit linear-mapped (or viewer-look tone-mapped) image
15
+ attached to the tool result when the active model accepts image input
16
+
17
+ Built for Arnold multi-AOV renders and UV/ST map inspection, but works on
18
+ any EXR: scanline or tiled, any compression, multilayer or single-layer.
19
+
20
+ ## Install
21
+
22
+ ```
23
+ pi install npm:pi-exr-reader
24
+ ```
25
+
26
+ or from a local clone:
27
+
28
+ ```
29
+ pi install /path/to/this/folder
30
+ ```
31
+
32
+ ## Requirements
33
+
34
+ The extension shells out to a Python probe
35
+ (`exr_reader/exr_probe.py`) using OpenEXR 3.x, numpy, and Pillow:
36
+
37
+ ```
38
+ pip install OpenEXR numpy Pillow
39
+ ```
40
+
41
+ The extension probes `python` / `python3` and fails with an install hint
42
+ if the packages are missing.
43
+
44
+ ## Usage
45
+
46
+ Ask your model to inspect an EXR, e.g. *"what are the UV values at pixel
47
+ (540, 540) in render.exr"*. Coordinates are `(x, y)` with origin at the
48
+ top-left of the data window, y increasing down — matching the preview
49
+ PNG orientation.
50
+
51
+ Channel names are case-insensitive; a group name selects all its
52
+ components (`"uv_box"` → `UV_Box.R/.G/.B`).
53
+
54
+ ## Development
55
+
56
+ Source lives in `exr_reader/` (`index.ts` + `exr_probe.py`). No build
57
+ step — pi loads the TypeScript directly. The production global install on
58
+ the author's machine is a copy at `~/.pi/agent/extensions/exr_reader/`;
59
+ re-copy after changes (or `pi -e <this folder>` to test from source).
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env python3
2
+ """exr_probe.py — EXR file reader for the pi `exr_read` tool.
3
+
4
+ Reads an EXR file (any compression, scanline or tiled, multi-part,
5
+ half/float/int channels) via the OpenEXR 3.x Python API and prints a
6
+ single JSON object to stdout. Exit code 0 on success (including
7
+ "ok": false results), non-zero on hard failure.
8
+
9
+ Coordinate convention (matches the preview PNG orientation):
10
+ (x, y) = pixel coordinates with origin at the TOP-LEFT of the part's
11
+ data window, x increasing to the right, y increasing DOWN.
12
+ Array index is [y, x]. Preview PNG top row = array row 0 = y=0.
13
+
14
+ Usage:
15
+ python exr_probe.py FILE.exr [--pixel X Y] [--rect X0 Y0 X1 Y1]
16
+ [--channel NAME] [--preview-out PATH]
17
+ [--max-preview N] [--tone-map MODE] [--no-preview]
18
+
19
+ --tone-map MODE selects the preview mapping per component: "auto" (default),
20
+ "linear", or "tonemap".
21
+ linear = full [min,max] -> full range (any data; UV/ST maps, P/Z AOVs)
22
+ tonemap = viewer look: 1.0 = white, sRGB gamma, clip above (what Nuke/
23
+ Photoshop/AE show for an EXR)
24
+ auto = linear when the data fits ~[0, 1.05] (maps/LDR), tonemap for
25
+ image-like HDR (min ~>= 0, max > 1.05), linear full-range for
26
+ data with a large negative span (P/Z-like). The per-component
27
+ mapping used is reported in the preview JSON.
28
+
29
+ --channel NAME selects which channels get pixel values / rect stats /
30
+ the preview. Matches a channel name case-insensitively (e.g. "R", "U",
31
+ "st"); a GROUP name also selects all its components ("uv" matches
32
+ "uv.R", "uv.G", "uv.B"), and an exact component name ("uv.r") still
33
+ works. May be given multiple times. A filter that matches nothing is
34
+ an error that lists the available channel names. Omitted: stats cover
35
+ every channel; pixel queries report every channel; preview composes
36
+ the first R,G,B channels (or the first up-to-3 channels if no R/G/B
37
+ names exist).
38
+ """
39
+
40
+ import argparse
41
+ import json
42
+ import math
43
+ import os
44
+ import re
45
+ import sys
46
+ import tempfile
47
+
48
+ try:
49
+ import numpy as np
50
+ import OpenEXR
51
+ except ImportError as _e: # surfaced via fail() in main()
52
+ np = None # type: ignore[assignment]
53
+ OpenEXR = None # type: ignore[assignment]
54
+ _IMPORT_ERROR = _e
55
+ else:
56
+ _IMPORT_ERROR = None
57
+
58
+
59
+ def fail(msg, **extra):
60
+ out = {"ok": False, "error": msg}
61
+ out.update(extra)
62
+ print(json.dumps(sanitize(out)))
63
+ sys.exit(0)
64
+
65
+
66
+ def sanitize(o):
67
+ """Replace non-finite floats with strings so the output is strict JSON."""
68
+ if isinstance(o, float):
69
+ if math.isnan(o):
70
+ return "NaN"
71
+ if math.isinf(o):
72
+ return "inf" if o > 0 else "-inf"
73
+ return o
74
+ if isinstance(o, dict):
75
+ return {k: sanitize(v) for k, v in o.items()}
76
+ if isinstance(o, (list, tuple)):
77
+ return [sanitize(v) for v in o]
78
+ return o
79
+
80
+
81
+ def call_or_val(v):
82
+ """The OpenEXR 3.x Part API mixes methods (name/width/height/type/
83
+ compression) and dict properties (channels/header) — normalise both."""
84
+ return v() if callable(v) else v
85
+
86
+
87
+ def preview_default_name(path, selected_args):
88
+ """Stable preview filename: <stem>[_<channel-tag>].png, always .png
89
+ (the probe only ever writes PNG). The channel tag keeps previews of
90
+ different channel selections of the same file from overwriting each
91
+ other (e.g. Box-on-alpha_uv_aovs_UV_Box_preview.png)."""
92
+ stem = os.path.splitext(os.path.basename(path))[0]
93
+ if selected_args:
94
+ tag = "_".join(
95
+ re.sub(r"[^a-z0-9]+", "_", s.lower()).strip("_") for s in selected_args
96
+ )
97
+ if tag:
98
+ stem += "_" + tag
99
+ return stem + "_preview.png"
100
+
101
+
102
+ def comp_name(ch_name, arr, i):
103
+ """Component label: bare channel name for 2-D, 'name.0'/'name.1' for n-D."""
104
+ if arr.ndim == 3:
105
+ return f"{ch_name}.{i}"
106
+ return ch_name
107
+
108
+
109
+ def _auto_mode(lo, hi):
110
+ """auto decision for one component:
111
+ - ~[0, 1.05] data -> linear min->max (maps/LDR: honest full-range view)
112
+ - image-like HDR (min ~>= 0, max > 1.05) -> tonemap (the viewer look)
113
+ - large negative span (P/Z-like) -> linear full-range (show everything)
114
+ """
115
+ if hi <= 1.05 and lo >= -0.01:
116
+ return "linear"
117
+ if lo >= -0.01:
118
+ return "tonemap"
119
+ return "linear"
120
+
121
+
122
+ def _srgb_gamma(y):
123
+ y = np.clip(y, 0.0, 1.0)
124
+ return np.where(y <= 0.0031308, 12.92 * y, 1.055 * np.power(y, 1.0 / 2.4) - 0.055)
125
+
126
+
127
+ def map_component(a, lo, hi, mode):
128
+ """Map one component (finite min lo, max hi) to [0, 65535] as float32.
129
+ linear: full [lo, hi] -> [0, 65535] (any data range)
130
+ tonemap: viewer look — 1.0 = white, sRGB gamma, clip above 1 / below 0.
131
+ This is what EXR viewers (Nuke/Photoshop/AE) show, so a vision
132
+ model sees a display-referred image it was trained on."""
133
+ if mode == "linear":
134
+ a = a - lo
135
+ return a / (hi - lo) * 65535.0
136
+ return _srgb_gamma(a) * 65535.0
137
+
138
+
139
+ def channel_matches(cname, selected):
140
+ """Case-insensitive match of a channel name against the --channel set.
141
+ An exact name match works ("uv", "uv.r"); a GROUP name also selects
142
+ all of a multi-component channel's components ("uv" matches
143
+ "uv.R", "uv.G", "uv.B"). The dot-anchored prefix prevents "uv" from
144
+ matching an unrelated channel named "uvbox"."""
145
+ cl = cname.lower()
146
+ if cl in selected:
147
+ return True
148
+ return any(cl.startswith(s + ".") for s in selected)
149
+
150
+
151
+ def stats_of(a):
152
+ a = a.astype("float32")
153
+ finite = a[np.isfinite(a)]
154
+ if finite.size == 0:
155
+ mn = mx = None
156
+ mean = "NaN"
157
+ else:
158
+ mn, mx = float(finite.min()), float(finite.max())
159
+ mean = float(a.mean()) if a.size else "NaN"
160
+ nz = float(np.count_nonzero(a)) / a.size if a.size else 0.0
161
+ return {"min": mn, "max": mx, "mean": mean, "nonzero_frac": round(nz, 6)}
162
+
163
+
164
+ def main():
165
+ ap = argparse.ArgumentParser()
166
+ ap.add_argument("path")
167
+ ap.add_argument("--pixel", nargs=2, type=int, metavar=("X", "Y"))
168
+ ap.add_argument("--rect", nargs=4, type=int, metavar=("X0", "Y0", "X1", "Y1"))
169
+ ap.add_argument(
170
+ "--channel",
171
+ action="append",
172
+ default=None,
173
+ metavar="NAME",
174
+ help="channel name (case-insensitive); a group name like 'uv' also "
175
+ "selects its components ('uv.R','uv.G','uv.B'); may be repeated",
176
+ )
177
+ ap.add_argument("--preview-out", default=None)
178
+ ap.add_argument("--max-preview", type=int, default=1024)
179
+ ap.add_argument(
180
+ "--tone-map",
181
+ choices=["auto", "linear", "tonemap"],
182
+ default="auto",
183
+ help="preview mapping per component (default auto: linear for ~[0,1] data, "
184
+ "Reinhard+sRGB for HDR/negative data)",
185
+ )
186
+ ap.add_argument("--no-preview", action="store_true")
187
+ args = ap.parse_args()
188
+
189
+ path = os.path.abspath(args.path)
190
+ if not os.path.isfile(path):
191
+ fail(f"file not found: {path}")
192
+
193
+ if OpenEXR is None:
194
+ fail(f"missing python dependency: {_IMPORT_ERROR!r}. Install with: pip install OpenEXR")
195
+
196
+ try:
197
+ exr = OpenEXR.File(path, separate_channels=True)
198
+ except Exception as e:
199
+ fail(f"cannot open EXR: {e!r}")
200
+
201
+ selected = {c.lower() for c in args.channel} if args.channel else None
202
+
203
+ # ---------- parts + per-channel stats ----------
204
+ parts_out = []
205
+ all_channel_names = set()
206
+ for pi, part in enumerate(exr.parts):
207
+ chdict = call_or_val(part.channels)
208
+ all_channel_names.update(chdict.keys())
209
+ if not chdict:
210
+ fail(f"part {pi} has no channels", parts=parts_out)
211
+ dw = call_or_val(part.header).get("dataWindow")
212
+ dw_out = None
213
+ if dw is not None:
214
+ try:
215
+ lo, hi = dw
216
+ dw_out = [[int(lo[0]), int(lo[1])], [int(hi[0]), int(hi[1])]]
217
+ except Exception:
218
+ dw_out = str(dw)
219
+ channels_out = {}
220
+ for cname, ch in chdict.items():
221
+ arr = np.array(ch.pixels)
222
+ if selected is not None and not channel_matches(cname, selected):
223
+ continue
224
+ comps = {}
225
+ n = arr.shape[2] if arr.ndim == 3 else 1
226
+ for i in range(n):
227
+ a = arr[..., i] if arr.ndim == 3 else arr
228
+ comps[comp_name(cname, arr, i)] = {
229
+ "shape": list(a.shape),
230
+ **stats_of(a),
231
+ }
232
+ channels_out[cname] = comps
233
+ parts_out.append({
234
+ "index": pi,
235
+ "name": str(call_or_val(part.name)),
236
+ "width": int(call_or_val(part.width)),
237
+ "height": int(call_or_val(part.height)),
238
+ "dataWindow": dw_out,
239
+ "storage": str(call_or_val(part.type)),
240
+ "lineOrder": str(call_or_val(part.header).get("lineOrder")),
241
+ "compression": str(call_or_val(part.compression)),
242
+ "channels": channels_out,
243
+ })
244
+
245
+ result = {
246
+ "ok": True,
247
+ "path": path,
248
+ "bytes": os.path.getsize(path),
249
+ "numParts": len(parts_out),
250
+ "coordinateConvention": "origin top-left of data window, x right, y down; array index [y, x]",
251
+ "parts": parts_out,
252
+ }
253
+
254
+ # ---------- channel filter: honest no-match error ----------
255
+ # Runs BEFORE pixel/rect/preview: without it, a filter that matches
256
+ # nothing (a typo, or an old call that assumed exact-match semantics)
257
+ # produced empty results and, worse, a misleading "outside data
258
+ # window" error from the pixel path — verified on a real Arnold AOV
259
+ # file 2026-08-25.
260
+ if selected is not None:
261
+ if not any(channel_matches(c, selected) for c in all_channel_names):
262
+ avail = sorted(all_channel_names)
263
+ fail(
264
+ f"no channel matches {sorted(selected)!r} — available channel names: {avail!r} "
265
+ "(a group name like 'uv' selects its components 'uv.R'/'uv.G'/'uv.B'; "
266
+ "an exact component name also works)",
267
+ available=avail,
268
+ )
269
+
270
+ # ---------- pixel query ----------
271
+ if args.pixel:
272
+ x, y = args.pixel
273
+ pixel_out = {"x": x, "y": y, "parts": {}}
274
+ found = False
275
+ for p in parts_out:
276
+ if x < 0 or x >= p["width"] or y < 0 or y >= p["height"]:
277
+ continue
278
+ part = exr.parts[p["index"]]
279
+ chdict = call_or_val(part.channels)
280
+ vals = {}
281
+ for cname, ch in chdict.items():
282
+ if selected is not None and not channel_matches(cname, selected):
283
+ continue
284
+ arr = np.array(ch.pixels)
285
+ if arr.ndim == 3:
286
+ for i in range(arr.shape[2]):
287
+ vals[comp_name(cname, arr, i)] = float(arr[y, x, i])
288
+ else:
289
+ vals[cname] = float(arr[y, x])
290
+ if vals:
291
+ pixel_out["parts"][str(p["index"])] = vals
292
+ found = True
293
+ if not found:
294
+ dw = parts_out[0]["dataWindow"]
295
+ fail(
296
+ f"pixel ({x},{y}) outside data window(s) of all parts",
297
+ part0_dataWindow=dw,
298
+ part0_size=[parts_out[0]["width"], parts_out[0]["height"]],
299
+ )
300
+ result["pixel"] = pixel_out
301
+
302
+ # ---------- rect stats ----------
303
+ if args.rect:
304
+ x0, y0, x1, y1 = args.rect
305
+ if x0 > x1 or y0 > y1:
306
+ fail("invalid rect: x0>x1 or y0>y1")
307
+ p0 = parts_out[0]
308
+ part = exr.parts[0]
309
+ chdict = call_or_val(part.channels)
310
+ rect_out = {"x0": x0, "y0": y0, "x1": x1, "y1": y1, "size": [x1 - x0 + 1, y1 - y0 + 1], "channels": {}}
311
+ if x0 < 0 or y0 < 0 or x1 >= p0["width"] or y1 >= p0["height"]:
312
+ fail(
313
+ f"rect ({x0},{y0})-({x1},{y1}) outside part 0 data window "
314
+ f"(0,0)-({p0['width']-1},{p0['height']-1})"
315
+ )
316
+ for cname, ch in chdict.items():
317
+ if selected is not None and not channel_matches(cname, selected):
318
+ continue
319
+ arr = np.array(ch.pixels)[y0 : y1 + 1, x0 : x1 + 1]
320
+ n = arr.shape[2] if arr.ndim == 3 else 1
321
+ rect_out["channels"][cname] = {}
322
+ for i in range(n):
323
+ a = arr[..., i] if arr.ndim == 3 else arr
324
+ rect_out["channels"][cname][comp_name(cname, arr, i)] = stats_of(a)
325
+ result["rect"] = rect_out
326
+
327
+ # ---------- preview ----------
328
+ if not args.no_preview:
329
+ try:
330
+ from PIL import Image
331
+ except ImportError:
332
+ result["preview"] = {
333
+ "skipped": "PIL not installed (pip install Pillow); no preview produced"
334
+ }
335
+ else:
336
+ p0 = parts_out[0]
337
+ part = exr.parts[0]
338
+ chdict = call_or_val(part.channels)
339
+ if selected is not None:
340
+ names = [c for c in chdict if channel_matches(c, selected)]
341
+ else:
342
+ # Named R/G/B channels first; file order is NOT a safe
343
+ # default (this Arnold file lists A, B, G, R), otherwise
344
+ # fall back to the first up-to-3 channels.
345
+ rgb = [c for c in chdict if c.lower() in ("r", "g", "b")]
346
+ names = rgb if rgb else list(chdict.keys())[:3]
347
+ # R,G,B-aware slot ordering for whatever was picked: a channel
348
+ # whose name (or .suffix) is r/g/b goes to the matching color
349
+ # slot in R,G,B order regardless of file channel order — file
350
+ # order put B in the red slot for a group-selected UV AOV, the
351
+ # same swap as the 2026-08-25 preview bug. Non-RGB channels
352
+ # keep file order (stable). This is what makes a UV AOV preview
353
+ # read U->red, V->green.
354
+ _order = {c: i for i, c in enumerate(chdict.keys())}
355
+
356
+ def _slot_key(c):
357
+ lc = c.lower()
358
+ for i, s in enumerate(("r", "g", "b")):
359
+ if lc == s or lc.endswith("." + s):
360
+ return (0, i, 0)
361
+ return (1, 0, _order[c])
362
+
363
+ names.sort(key=_slot_key)
364
+ if not names:
365
+ result["preview"] = {"skipped": "no channels available for preview"}
366
+ else:
367
+ out_path = (
368
+ os.path.abspath(args.preview_out)
369
+ if args.preview_out
370
+ else os.path.join(os.getcwd(), "images", "previews", preview_default_name(path, args.channel))
371
+ )
372
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
373
+ arrs = {c: np.array(chdict[c].pixels).astype("float32") for c in names}
374
+ w, h = arrs[names[0]].shape[1], arrs[names[0]].shape[0]
375
+ scales = {}
376
+ modes = {}
377
+
378
+ def _mode_for(lo, hi):
379
+ return args.tone_map if args.tone_map != "auto" else _auto_mode(lo, hi)
380
+
381
+ if names[0] in arrs and arrs[names[0]].ndim == 3:
382
+ # one multi-component channel: comp i -> channel i of RGB
383
+ comp_arrays, labels = [], []
384
+ for i in range(min(3, arrs[names[0]].shape[2])):
385
+ a = arrs[names[0]][..., i]
386
+ f = a[np.isfinite(a)]
387
+ lo, hi = (float(f.min()), float(f.max())) if f.size else (0.0, 1.0)
388
+ if hi <= lo:
389
+ hi = lo + 1.0
390
+ m = _mode_for(lo, hi)
391
+ scales[f"{names[0]}.{i}"] = [lo, hi]
392
+ modes[f"{names[0]}.{i}"] = m
393
+ comp_arrays.append(map_component(a, lo, hi, m))
394
+ labels.append(f"{names[0]}.{i}")
395
+ rgb = np.stack(
396
+ [np.clip(comp_arrays[0], 0, 65535)]
397
+ + ([np.clip(comp_arrays[1], 0, 65535)] if len(comp_arrays) > 1 else [np.zeros_like(comp_arrays[0])])
398
+ + ([np.clip(comp_arrays[2], 0, 65535)] if len(comp_arrays) > 2 else [np.zeros_like(comp_arrays[0])]),
399
+ axis=-1,
400
+ ) # keep float in [0, 65535]; 8-bit conversion happens below
401
+ else:
402
+ comp_arrays, labels = [], []
403
+ for c in names[:3]:
404
+ a = arrs[c] if arrs[c].ndim == 2 else arrs[c][..., 0]
405
+ f = a[np.isfinite(a)]
406
+ lo, hi = (float(f.min()), float(f.max())) if f.size else (0.0, 1.0)
407
+ if hi <= lo:
408
+ hi = lo + 1.0
409
+ m = _mode_for(lo, hi)
410
+ scales[c] = [lo, hi]
411
+ modes[c] = m
412
+ comp_arrays.append(map_component(a, lo, hi, m))
413
+ labels.append(c)
414
+ rgb = np.stack(
415
+ comp_arrays[0:1]
416
+ + (comp_arrays[1:2] if len(comp_arrays) > 1 else [np.zeros_like(comp_arrays[0])])
417
+ + (comp_arrays[2:3] if len(comp_arrays) > 2 else [np.zeros_like(comp_arrays[0])]),
418
+ axis=-1,
419
+ ) # keep float in [0, 65535]; 8-bit conversion happens below
420
+ # Convert to 8-bit HERE, explicitly. Pillow cannot encode 16-bit
421
+ # RGB PNGs, and Image.fromarray(uint16_array, "RGB") does NOT
422
+ # downscale — it reinterprets the raw 2-byte values as 8-bit
423
+ # byte pairs (verified 2026-08-25: produced a scrambled image).
424
+ rgb = (rgb / 257.0).round().clip(0, 255).astype("uint8")
425
+ im = Image.fromarray(rgb, mode="RGB")
426
+ max_edge = max(args.max_preview, 2)
427
+ if max(h, w) > max_edge:
428
+ scale = max_edge / float(max(h, w))
429
+ im = im.resize((max(2, int(w * scale)), max(2, int(h * scale))), Image.LANCZOS)
430
+ im.save(out_path, "PNG")
431
+ result["preview"] = {
432
+ "path": out_path,
433
+ "size": [im.width, im.height],
434
+ "bitDepth": "8-bit RGB",
435
+ "slots": {s: labels[i] for i, s in enumerate(("R", "G", "B")) if i < len(labels)},
436
+ "mapping": {k: modes.get(k, "linear") for k in scales},
437
+ "componentScales": {
438
+ k: {"from": v, "to": [0, 255]} for k, v in scales.items()
439
+ },
440
+ "note": (
441
+ "per-component mapping: 'linear' = full [min,max] -> full range (LDR/maps); "
442
+ "'tonemap' = shift+Reinhard+sRGB gamma (HDR/negative data, for viewing). "
443
+ "Structure shown, absolute values NOT — use --pixel/--rect for values. "
444
+ f"(requested --tone-map: {args.tone_map})"
445
+ ),
446
+ }
447
+
448
+ print(json.dumps(sanitize(result)))
449
+
450
+
451
+ if __name__ == "__main__":
452
+ try:
453
+ main()
454
+ except SystemExit:
455
+ raise
456
+ except Exception as e:
457
+ fail(f"unexpected error: {e!r}")
@@ -0,0 +1,284 @@
1
+ /**
2
+ * exr_read — EXR inspection tool for pi.
3
+ *
4
+ * Loads .exr files (Arnold/RenderMan/Blender/USD renders, UV/ST maps,
5
+ * AOVs, multilayer EXRs) and reports them to the LLM as:
6
+ * - part metadata (size, data window, storage, compression, line order)
7
+ * - per-channel statistics (min/max/mean/nonzero fraction, per component)
8
+ * - exact float pixel values at a point
9
+ * - per-channel statistics over a rect
10
+ * - an 8-bit linear-mapped PNG preview (attached to the tool result,
11
+ * so a vision model can see the structure)
12
+ *
13
+ * The heavy lifting is done by exr_reader/exr_probe.py (OpenEXR 3.x
14
+ * Python API + numpy + Pillow). This extension only: locates the script,
15
+ * builds the command, runs it, and packages JSON + preview image.
16
+ *
17
+ * Coordinate convention: (x, y) with origin at the TOP-LEFT of the part's
18
+ * data window, x right, y DOWN — matching how the preview PNG is oriented.
19
+ *
20
+ * Project-local install: <project>/.pi/extensions/exr_read/index.ts
21
+ * (add { "extensions": ["../../<abs path>/exr_reader/index.ts"] } to
22
+ * <project>/.pi/settings.json if you keep the code elsewhere)
23
+ * Global install: copy to ~/.pi/agent/extensions/exr_read/index.ts
24
+ */
25
+
26
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
27
+ import { Type } from "typebox";
28
+ import { existsSync, readFileSync, statSync } from "node:fs";
29
+ import { basename, join, resolve } from "node:path";
30
+ import { execFile, spawnSync } from "node:child_process";
31
+
32
+ const SCRIPT_DIR = __dirname;
33
+ const SCRIPT = join(SCRIPT_DIR, "exr_probe.py");
34
+ const MAX_PREVIEW_PX = 2048; // 1080² originals pass through unresized; cap guards huge renders
35
+ const MAX_TEXT_CHARS = 12000;
36
+
37
+ // Mirrors preview_default_name() in exr_probe.py (kept in sync on purpose):
38
+ // <stem>[_<channel-tag>]_preview.png — the channel tag keeps previews of
39
+ // different channel selections of the same file from overwriting each
40
+ // other. The extension always passes --preview-out explicitly (user value
41
+ // or this default) so it knows the exact path for the mutation queue.
42
+ function previewDefaultName(exrPath: string, channels: string[] | undefined): string {
43
+ let stem = basename(exrPath).replace(/\.[^.]*$/, "");
44
+ if (channels?.length) {
45
+ const tag = channels
46
+ .map((s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""))
47
+ .join("_");
48
+ if (tag) stem += "_" + tag;
49
+ }
50
+ return stem + "_preview.png";
51
+ }
52
+
53
+ function findPython(): string | null {
54
+ const candidates = process.platform === "win32" ? ["python", "python3"] : ["python3", "python"];
55
+ for (const c of candidates) {
56
+ try {
57
+ const r = spawnSync(c, ["-c", "import OpenEXR, numpy; print(OpenEXR.__version__)"], {
58
+ timeout: 15000,
59
+ encoding: "utf8",
60
+ windowsHide: true,
61
+ });
62
+ if (r.status === 0 && (r.stdout ?? "").trim()) return c;
63
+ } catch {
64
+ /* try next */
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ const exrReadSchema = Type.Object({
71
+ path: Type.String({ description: "Path to the .exr file (absolute or relative to the session cwd)." }),
72
+ pixel: Type.Optional(
73
+ Type.Tuple([Type.Number(), Type.Number()], {
74
+ description:
75
+ "Optional: query exact values at pixel (x, y). Origin is TOP-LEFT of the part's data window, x right, y DOWN (preview PNG orientation).",
76
+ })
77
+ ),
78
+ rect: Type.Optional(
79
+ Type.Tuple([Type.Number(), Type.Number(), Type.Number(), Type.Number()], {
80
+ description: "Optional: per-channel stats over rect (x0, y0, x1, y1), inclusive corners.",
81
+ })
82
+ ),
83
+ channel: Type.Optional(
84
+ Type.Array(Type.String(), {
85
+ description:
86
+ 'Optional: restrict values/stats/preview to these channels (case-insensitive). A group name selects all its components ("uv" matches "uv.R","uv.G","uv.B"; "UV_Box" matches "UV_Box.R/.G/.B"); an exact component name also works. Omitted = all channels for stats, RGB-like composition for preview. A filter that matches nothing is an error listing the available channel names.',
87
+ })
88
+ ),
89
+ preview: Type.Optional(
90
+ Type.Boolean({ description: "Optional: set false to skip the PNG preview (default true)." })
91
+ ),
92
+ previewToneMap: Type.Optional(
93
+ Type.Union([Type.Literal("auto"), Type.Literal("linear"), Type.Literal("tonemap")], {
94
+ description:
95
+ "Optional: preview mapping per component (default 'auto'). 'linear' = min->max full-range remap; 'tonemap' = the viewer look (1.0 = white, sRGB gamma, clip above — what Nuke/Photoshop/AE show for an EXR); 'auto' = linear for map/LDR-like data (~[0,1]) and for data with large negatives (P/Z), tonemap for image-like HDR.",
96
+ })
97
+ ),
98
+ previewOut: Type.Optional(
99
+ Type.String({
100
+ description:
101
+ "Optional: where to save the preview PNG. Default: <cwd>/images/previews/<filename>[_<channels>]_preview.png (project-local, stable names, re-runs refresh the same file).",
102
+ })
103
+ ),
104
+ });
105
+
106
+ export type ExrReadInput = {
107
+ path: string;
108
+ pixel?: [number, number];
109
+ rect?: [number, number, number, number];
110
+ channel?: string[];
111
+ preview?: boolean;
112
+ previewToneMap?: "auto" | "linear" | "tonemap";
113
+ previewOut?: string;
114
+ };
115
+
116
+ function previewNote(hasVision: boolean, mapping: Record<string, string> | undefined): string {
117
+ const coord =
118
+ "Coordinates: (x, y) with origin at the TOP-LEFT of the part's data window, x increasing right, y increasing DOWN — the preview PNG is oriented the same way, so a point you see at (px, py) in the preview is queryable as pixel [px, py] (scale by preview.size / part size first if the preview was downscaled).";
119
+ const map = mapping && Object.values(mapping).length
120
+ ? ` Mapping per component (see 'mapping' in JSON): ${JSON.stringify(mapping)} — 'tonemap' components are Reinhard+sRGB, for viewing only.`
121
+ : "";
122
+ return hasVision
123
+ ? `Preview PNG: an 8-bit view of the image structure (LDR/map components are a linear min->max remap, HDR components are tone-mapped${map}) — structure is shown, absolute values are NOT; use pixel/rect for values. ${coord}`
124
+ : `Preview: the current model has no image input, so no preview was attached — the JSON below is the full data (the preview PNG is still written for you). ${coord}`;
125
+ }
126
+
127
+ export default function (pi: any) {
128
+ let pythonCache: string | null | undefined;
129
+
130
+ pi.registerTool({
131
+ name: "exr_read",
132
+ label: "EXR Read",
133
+ description:
134
+ "Read an .exr image file (render passes, UV/ST maps, Arnold AOVs such as UV/P/Z/TextureGrid, multilayer EXR). Returns part metadata + per-channel float stats, exact pixel values at a point, per-channel stats over a rect, and a linear-mapped PNG preview. Use it whenever the user mentions or references an .exr file. AOVs: pass the group name (e.g. \"UV_Box\") as channel; pixel queries report every part of a multilayer file.",
135
+ promptSnippet: "exr_read(path, pixel?, rect?, channel?, preview?) — inspect .exr files: metadata, per-channel stats, exact pixel values, rect stats, PNG preview",
136
+ promptGuidelines: [
137
+ "Use exr_read (not read/bash) for .exr files; it reports full-precision channel values.",
138
+ "exr_read coordinates: origin top-left, y down — same orientation as its preview PNG.",
139
+ "exr_read channel filter: a group name (e.g. 'uv_box') selects its components; a non-matching filter is an error that lists the available names.",
140
+ ],
141
+ parameters: exrReadSchema,
142
+ async execute(
143
+ _toolCallId: string,
144
+ params: ExrReadInput,
145
+ signal: AbortSignal | undefined,
146
+ _onUpdate: unknown,
147
+ ctx: any
148
+ ) {
149
+ if (pythonCache === undefined) pythonCache = findPython();
150
+ const python = pythonCache;
151
+ if (!python) {
152
+ return {
153
+ content: [
154
+ {
155
+ type: "text" as const,
156
+ text: "exr_read unavailable: no python with OpenEXR + numpy found on PATH. Install with: pip install OpenEXR numpy Pillow",
157
+ },
158
+ ],
159
+ isError: true,
160
+ };
161
+ }
162
+
163
+ const script = SCRIPT;
164
+ if (!existsSync(script)) {
165
+ return {
166
+ content: [{ type: "text" as const, text: `exr_read: script missing: ${script}` }],
167
+ isError: true,
168
+ };
169
+ }
170
+
171
+ const absPath = resolve(ctx.cwd, params.path);
172
+ if (!existsSync(absPath)) {
173
+ return {
174
+ content: [{ type: "text" as const, text: `exr_read: file not found: ${absPath}` }],
175
+ isError: true,
176
+ };
177
+ }
178
+ const args: string[] = [script, absPath];
179
+ if (params.pixel) args.push("--pixel", String(params.pixel[0]), String(params.pixel[1]));
180
+ if (params.rect)
181
+ args.push(
182
+ "--rect",
183
+ String(params.rect[0]),
184
+ String(params.rect[1]),
185
+ String(params.rect[2]),
186
+ String(params.rect[3])
187
+ );
188
+ if (params.channel) for (const c of params.channel) args.push("--channel", c);
189
+ const wantPreview = params.preview !== false;
190
+ let previewPath: string | undefined;
191
+ if (!wantPreview) {
192
+ args.push("--no-preview");
193
+ } else {
194
+ previewPath = params.previewOut
195
+ ? resolve(ctx.cwd, params.previewOut)
196
+ : join(ctx.cwd, "images", "previews", previewDefaultName(absPath, params.channel));
197
+ args.push(
198
+ "--preview-out", previewPath,
199
+ "--max-preview", String(MAX_PREVIEW_PX),
200
+ "--tone-map", params.previewToneMap ?? "auto"
201
+ );
202
+ }
203
+
204
+ // The probe writes previewPath. Queue it per the extension docs so
205
+ // parallel exr_read calls on the same file (same default preview
206
+ // path) can't interleave their writes.
207
+ const run = () => new Promise<{ stdout: string; stderr: string; code: number; spawnErr?: string }>((resolveP) => {
208
+ execFile(
209
+ python,
210
+ args,
211
+ {
212
+ cwd: ctx.cwd,
213
+ timeout: 300000,
214
+ maxBuffer: 16 * 1024 * 1024,
215
+ windowsHide: true,
216
+ signal,
217
+ },
218
+ (error, stdout, stderr) => {
219
+ let code = 0;
220
+ let spawnErr: string | undefined;
221
+ if (error) {
222
+ if (typeof (error as any).code === "number") code = (error as any).code;
223
+ else spawnErr = (error as any).code ?? String(error);
224
+ }
225
+ resolveP({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), code, spawnErr });
226
+ }
227
+ );
228
+ });
229
+
230
+ const res = previewPath ? await withFileMutationQueue(previewPath, run) : await run();
231
+
232
+ if (res.spawnErr) {
233
+ return {
234
+ content: [{ type: "text" as const, text: `exr_read: python spawn failed (${res.spawnErr})` }],
235
+ isError: true,
236
+ };
237
+ }
238
+ if (res.code !== 0 && !res.stdout.trim()) {
239
+ return {
240
+ content: [{ type: "text" as const, text: `exr_read failed (exit ${res.code}):\n${res.stderr}` }],
241
+ isError: true,
242
+ };
243
+ }
244
+
245
+ let json: any = null;
246
+ try {
247
+ json = JSON.parse(res.stdout.trim().split("\n").pop() ?? "");
248
+ } catch (e: any) {
249
+ return {
250
+ content: [{ type: "text" as const, text: `exr_read: could not parse probe output: ${e?.message}\n${res.stdout.slice(0, 2000)}` }],
251
+ isError: true,
252
+ };
253
+ }
254
+
255
+ if (!json.ok) {
256
+ return {
257
+ content: [{ type: "text" as const, text: `exr_read: ${json.error}${json.part0_dataWindow ? ` (part 0 data window ${JSON.stringify(json.part0_dataWindow)}, size ${JSON.stringify(json.part0_size)})` : ""}` }],
258
+ isError: true,
259
+ };
260
+ }
261
+
262
+ const model: any = ctx.model;
263
+ const hasVision = Array.isArray(model?.input) && model.input.includes("image");
264
+
265
+ const content: Array<{ type: "text" | "image"; text?: string; data?: string; mimeType?: string }> = [];
266
+ let text = JSON.stringify(json, null, 1);
267
+ if (text.length > MAX_TEXT_CHARS) {
268
+ text =
269
+ text.slice(0, MAX_TEXT_CHARS) +
270
+ `\n…[truncated ${text.length - MAX_TEXT_CHARS} chars — re-query with channel/pixel/rect to narrow]`;
271
+ }
272
+ if (hasVision && json.preview?.path && existsSync(json.preview.path) && statSync(json.preview.path).size > 0) {
273
+ content.push({
274
+ type: "image",
275
+ data: readFileSync(json.preview.path).toString("base64"),
276
+ mimeType: "image/png",
277
+ });
278
+ }
279
+ content.push({ type: "text", text: text + "\n\n" + previewNote(hasVision, json.preview?.mapping) });
280
+
281
+ return { content, details: { previewPath: json.preview?.path } };
282
+ },
283
+ });
284
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "pi-exr-reader",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension: read .exr files — exact full-precision channel stats, pixel values, rect stats, and a PNG preview. Works on Arnold/RenderMan/Blender AOVs, UV/ST maps, and multilayer EXRs.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "exr",
10
+ "openexr",
11
+ "aov",
12
+ "arnold",
13
+ "uv-map",
14
+ "render",
15
+ "pixel-inspection"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Luke Richardson",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/lrich0/pi-exr-reader.git"
22
+ },
23
+ "files": [
24
+ "exr_reader/",
25
+ "README.md"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./exr_reader"
30
+ ]
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "typebox": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@types/node": "^25.9.1",
39
+ "typebox": "*",
40
+ "typescript": "^6.0.3"
41
+ }
42
+ }