Spaces:
Sleeping
Sleeping
new color rendering
Browse files
app.py
CHANGED
|
@@ -147,8 +147,8 @@ def _discard(path):
|
|
| 147 |
pass
|
| 148 |
|
| 149 |
|
| 150 |
-
#
|
| 151 |
-
#
|
| 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 |
-
#
|
| 176 |
-
# new depth)
|
| 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 |
-
|
| 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 = (
|
| 189 |
_cache_put(path, cached)
|
| 190 |
-
|
| 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 |
-
|
| 199 |
-
|
| 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
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
])
|
| 12 |
-
M_XYZ2RGB = np.linalg.inv(M_RGB2XYZ)
|
| 13 |
|
| 14 |
-
|
| 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
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|