budzynski commited on
Commit
568914f
·
1 Parent(s): 9d9e8fb

new color rendering

Browse files
Files changed (2) hide show
  1. app.py +14 -11
  2. color.py +51 -43
app.py CHANGED
@@ -147,8 +147,8 @@ def _discard(path):
147
  pass
148
 
149
 
150
- # Decoded image (+ month) per upload, RAM only. Lets the user re-run with a
151
- # different depth without re-reading the file (which we discard after decoding).
152
  _DECODE_CACHE = {}
153
  _DECODE_CACHE_MAX = 6
154
 
@@ -156,7 +156,11 @@ _DECODE_CACHE_MAX = 6
156
  def _cache_put(path, value):
157
  _DECODE_CACHE[path] = value
158
  while len(_DECODE_CACHE) > _DECODE_CACHE_MAX:
159
- _DECODE_CACHE.pop(next(iter(_DECODE_CACHE)))
 
 
 
 
160
 
161
 
162
  def on_upload(path):
@@ -172,22 +176,22 @@ def correct(path, lat, lng, depth):
172
  if lat is None or lng is None:
173
  raise gr.Error("Set a location on the map.")
174
 
175
- # Decode once per upload, cache it, then discard the file. Re-runs (e.g. a
176
- # new depth) reuse the cached image — no second read of a deleted file.
177
  cached = _DECODE_CACHE.get(path)
178
  if cached is None:
179
  if not os.path.exists(path):
180
  raise gr.Error("Please re-upload the RAW — the previous file was cleared.")
181
  month = exif_gps.extract_month(path) # read metadata before discarding
182
  try:
183
- linear = color.decode_raw(path)
184
  except Exception as exc:
185
  raise gr.Error(f"Could not decode RAW ({type(exc).__name__}): {exc}")
186
  finally:
187
  _discard(path)
188
- cached = (linear, month)
189
  _cache_put(path, cached)
190
- linear, month = cached # month is None when no EXIF date -> engine uses yearly data
191
 
192
  try:
193
  illum = client.fetch_illuminant(lat, lng, depth, month)
@@ -195,9 +199,8 @@ def correct(path, lat, lng, depth):
195
  raise gr.Error(f"Parameter API error: {exc}")
196
 
197
  print(f"[uwcolorkit] month={month} stub={client.using_stub()}")
198
- matrix = color.correction_matrix(illum.as_tuple())
199
- before = color.encode_display(linear)
200
- after = color.encode_display(color.apply_correction(linear, matrix))
201
  return (before, after)
202
 
203
 
 
147
  pass
148
 
149
 
150
+ # Raw object (+ month) per upload, RAM only. Lets the user re-run with a different
151
+ # depth without re-reading the file (which we discard right after opening).
152
  _DECODE_CACHE = {}
153
  _DECODE_CACHE_MAX = 6
154
 
 
156
  def _cache_put(path, value):
157
  _DECODE_CACHE[path] = value
158
  while len(_DECODE_CACHE) > _DECODE_CACHE_MAX:
159
+ old = _DECODE_CACHE.pop(next(iter(_DECODE_CACHE)))
160
+ try:
161
+ old[0].close() # free the evicted RawPy's buffers
162
+ except Exception:
163
+ pass
164
 
165
 
166
  def on_upload(path):
 
176
  if lat is None or lng is None:
177
  raise gr.Error("Set a location on the map.")
178
 
179
+ # imread once per upload, cache the raw object, then discard the file. Re-runs
180
+ # (e.g. a new depth) re-render from the in-memory raw — no second file read.
181
  cached = _DECODE_CACHE.get(path)
182
  if cached is None:
183
  if not os.path.exists(path):
184
  raise gr.Error("Please re-upload the RAW — the previous file was cleared.")
185
  month = exif_gps.extract_month(path) # read metadata before discarding
186
  try:
187
+ raw = color.open_raw(path)
188
  except Exception as exc:
189
  raise gr.Error(f"Could not decode RAW ({type(exc).__name__}): {exc}")
190
  finally:
191
  _discard(path)
192
+ cached = (raw, month)
193
  _cache_put(path, cached)
194
+ raw, month = cached # month is None when no EXIF date -> engine uses yearly data
195
 
196
  try:
197
  illum = client.fetch_illuminant(lat, lng, depth, month)
 
199
  raise gr.Error(f"Parameter API error: {exc}")
200
 
201
  print(f"[uwcolorkit] month={month} stub={client.using_stub()}")
202
+ before = color.render(raw, color.baseline_wb(raw))
203
+ after = color.render(raw, color.wb_from_chromaticity(raw, illum.as_tuple()))
 
204
  return (before, after)
205
 
206
 
color.py CHANGED
@@ -1,47 +1,72 @@
1
- """RAW decode and Bradford chromatic adaptation."""
2
 
3
- import numpy as np
4
-
5
- D65_XY = (0.3127, 0.3290)
 
 
6
 
7
- M_RGB2XYZ = np.array([
8
- [0.4124564, 0.3575761, 0.1804375],
9
- [0.2126729, 0.7151522, 0.0721750],
10
- [0.0193339, 0.1191920, 0.9503041],
11
- ])
12
- M_XYZ2RGB = np.linalg.inv(M_RGB2XYZ)
13
 
14
- M_BRADFORD = np.array([
15
- [0.8951000, 0.2664000, -0.1614000],
16
- [-0.7502000, 1.7135000, 0.0367000],
17
- [0.0389000, -0.0685000, 1.0296000],
18
- ])
19
- M_BRADFORD_INV = np.linalg.inv(M_BRADFORD)
20
 
21
  HALF_SIZE = True
22
  MAX_LONG_EDGE = 2000
23
 
 
 
 
 
24
 
25
  def xy_to_XYZ(x, y, Y=1.0):
26
  return np.array([Y * x / y, Y, Y * (1.0 - x - y) / y])
27
 
28
 
29
- def bradford_cat(src_white_XYZ, dst_white_XYZ):
30
- src = M_BRADFORD @ src_white_XYZ
31
- dst = M_BRADFORD @ dst_white_XYZ
32
- return M_BRADFORD_INV @ np.diag(dst / src) @ M_BRADFORD
 
 
 
 
33
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
- def correction_matrix(illuminant_xy, target_xy=D65_XY):
36
- cat = bradford_cat(xy_to_XYZ(*illuminant_xy), xy_to_XYZ(*target_xy))
37
- return M_XYZ2RGB @ cat @ M_RGB2XYZ
38
 
 
 
 
 
 
 
 
 
39
 
40
- def apply_correction(linear_rgb, matrix):
41
- return linear_rgb @ matrix.T
42
 
 
 
 
 
 
 
 
43
 
44
- def encode_display(linear_rgb):
 
45
  lin = np.clip(linear_rgb, 0.0, 1.0)
46
  srgb = np.where(lin <= 0.0031308, lin * 12.92, 1.055 * np.power(lin, 1 / 2.4) - 0.055)
47
  return (np.clip(srgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
@@ -60,20 +85,3 @@ def _resize_max(img, max_long_edge):
60
  for c in range(img.shape[2])
61
  ]
62
  return np.stack(chans, axis=-1)
63
-
64
-
65
- def decode_raw(path, half_size=HALF_SIZE, max_long_edge=MAX_LONG_EDGE):
66
- import rawpy
67
-
68
- # rawpy.imread (Cython) needs an exact str; Gradio passes a NamedString.
69
- with rawpy.imread(str(path)) as raw:
70
- rgb16 = raw.postprocess(
71
- no_auto_bright=True,
72
- output_bps=16,
73
- use_camera_wb=False,
74
- use_auto_wb=False,
75
- gamma=(1, 1),
76
- output_color=rawpy.ColorSpace.sRGB,
77
- half_size=half_size,
78
- )
79
- return _resize_max(rgb16.astype(np.float32) / 65535.0, max_long_edge)
 
1
+ """RAW decode + camera-space white balance from an illuminant chromaticity.
2
 
3
+ White balance is applied the way the iOS app does it (`CIRAWFilter.neutralChromaticity`):
4
+ as per-channel multipliers in the camera's *native* space, derived from the target
5
+ illuminant through the camera's own XYZ->camera matrix. The earlier Bradford-CAT-in-sRGB
6
+ approach used the wrong operator in the wrong space and produced a desaturated,
7
+ magenta/hazy result; this neutralises the illuminant the way the app does.
8
 
9
+ This is deliberately a *white-balance* preview, not a full ISP: no auto-exposure, tone
10
+ curve, or contrast. 'before' and 'after' share identical decode settings and differ
11
+ only in the WB multipliers, so the comparison shows the correction alone.
12
+ """
 
 
13
 
14
+ import numpy as np
 
 
 
 
 
15
 
16
  HALF_SIZE = True
17
  MAX_LONG_EDGE = 2000
18
 
19
+ # Shared postprocess: linear 16-bit, no auto-exposure, half-res. WB + output colour
20
+ # are supplied per call so before/after differ only by white balance.
21
+ _POST = dict(output_bps=16, gamma=(1, 1), no_auto_bright=True, half_size=HALF_SIZE)
22
+
23
 
24
  def xy_to_XYZ(x, y, Y=1.0):
25
  return np.array([Y * x / y, Y, Y * (1.0 - x - y) / y])
26
 
27
 
28
+ def open_raw(path):
29
+ """imread and return the RawPy object. The caller may delete the file right
30
+ after — the unpacked sensor data stays in RAM, so depth re-runs re-render from
31
+ memory (no second read) and the upload is still discarded for privacy."""
32
+ import rawpy
33
+
34
+ return rawpy.imread(str(path))
35
+
36
 
37
+ def wb_from_chromaticity(raw, illuminant_xy):
38
+ """Raw WB multipliers [R, G, B, G2] that render `illuminant_xy` neutral, derived
39
+ from the camera's XYZ->camera matrix (`raw.rgb_xyz_matrix`). This is the camera-
40
+ space equivalent of CIRAWFilter.neutralChromaticity. Validated: feeding D65 here
41
+ reproduces `raw.daylight_whitebalance` to 4 decimals."""
42
+ M = np.asarray(raw.rgb_xyz_matrix, dtype=float)[:3] # XYZ -> camera
43
+ cam = M @ xy_to_XYZ(*illuminant_xy)
44
+ cam = np.where(np.abs(cam) < 1e-9, 1e-9, cam)
45
+ mul = 1.0 / cam
46
+ mul = mul / mul[1] # normalise green = 1
47
+ return [float(mul[0]), 1.0, float(mul[2]), 1.0]
48
 
 
 
 
49
 
50
+ def baseline_wb(raw):
51
+ """'before' WB — the camera's as-shot balance (the photo as captured), falling
52
+ back to the daylight balance. Green-normalised."""
53
+ cw = np.asarray(raw.camera_whitebalance, dtype=float)
54
+ if cw.size < 3 or cw[1] <= 0 or not np.all(np.isfinite(cw[:3])):
55
+ cw = np.asarray(raw.daylight_whitebalance, dtype=float)
56
+ cw = cw / cw[1]
57
+ return [float(cw[0]), 1.0, float(cw[2]), 1.0]
58
 
 
 
59
 
60
+ def render(raw, wb, max_long_edge=MAX_LONG_EDGE):
61
+ """postprocess `raw` with WB multipliers `wb` -> uint8 sRGB display image."""
62
+ import rawpy
63
+
64
+ rgb16 = raw.postprocess(user_wb=list(wb), output_color=rawpy.ColorSpace.sRGB, **_POST)
65
+ linear = _resize_max(rgb16.astype(np.float32) / 65535.0, max_long_edge)
66
+ return _encode_display(linear)
67
 
68
+
69
+ def _encode_display(linear_rgb):
70
  lin = np.clip(linear_rgb, 0.0, 1.0)
71
  srgb = np.where(lin <= 0.0031308, lin * 12.92, 1.055 * np.power(lin, 1 / 2.4) - 0.055)
72
  return (np.clip(srgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
 
85
  for c in range(img.shape[2])
86
  ]
87
  return np.stack(chans, axis=-1)