budzynski commited on
Commit
4247de9
·
1 Parent(s): 7327d7e

Hugging face demo v1

Browse files
Files changed (8) hide show
  1. LICENSE +12 -0
  2. README.md +18 -1
  3. app.py +230 -0
  4. client.py +60 -0
  5. color.py +79 -0
  6. examples/examples.json +5 -0
  7. exif_gps.py +63 -0
  8. requirements.txt +5 -0
LICENSE ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright © 2026 scub.ai. All rights reserved.
2
+
3
+ UWColorKit, the ScubAI name, the underwater color-recovery method (patent-pending),
4
+ and the contents of this repository are proprietary.
5
+
6
+ This Space is published for live demonstration and evaluation only. No license or
7
+ right is granted to use, copy, modify, distribute, publish, sublicense,
8
+ reverse-engineer, or create derivative works from this code, the engine, the API,
9
+ or the brand — in whole or in part — without prior written permission. Using the
10
+ hosted demo grants no rights to the underlying technology.
11
+
12
+ Contact: info@scub.ai
README.md CHANGED
@@ -10,6 +10,23 @@ app_file: app.py
10
  pinned: false
11
  license: other
12
  short_description: Zero-shot underwater white balance
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  pinned: false
11
  license: other
12
  short_description: Zero-shot underwater white balance
13
+ license_name: "Proprietary — all rights reserved"
14
+ license_link: LICENSE
15
  ---
16
 
17
+ # 🤿 UWColorKit — zero-shot underwater white balance
18
+
19
+ The color engine inside **[ScubAI](https://apps.apple.com/us/app/scubai/id6741768984)** — it
20
+ brings back the reds and oranges the water strips away, from GPS + depth alone.
21
+
22
+ 🐠 Shooting underwater on an iPhone? ScubAI does this automatically, every dive → **[Get it on iOS](https://apps.apple.com/us/app/scubai/id6741768984)**
23
+
24
+ - **Zero-shot** — no per-image tuning, no presets, no reference chart.
25
+ - **On-device, offline-capable.**
26
+ - **Patent-pending.**
27
+
28
+ ---
29
+
30
+ © scub.ai — UWColorKit is proprietary and **patent-pending**. Published for live demonstration
31
+ only; **not licensed** for reuse, redistribution, or reverse-engineering. See [`LICENSE`](LICENSE) ·
32
+ [scub.ai](https://scub.ai)
app.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UWColorKit demo: zero-shot underwater color recovery from location + depth."""
2
+
3
+ import json
4
+ import os
5
+
6
+ import gradio as gr
7
+
8
+ import client
9
+ import color
10
+ import exif_gps
11
+
12
+ DEFAULT_LAT, DEFAULT_LNG, DEFAULT_DEPTH = 20.21, -87.43, 12
13
+
14
+ RAW_EXTENSIONS = [
15
+ ".dng", ".cr2", ".cr3", ".arw", ".arq", ".nef", ".nrw", ".raf", ".rw2",
16
+ ".orf", ".pef", ".srw", ".sr2", ".raw", ".rwl", ".dcr", ".kdc", ".3fr",
17
+ ".mef", ".mos", ".iiq", ".erf", ".x3f",
18
+ ]
19
+
20
+ MAP_HTML = (
21
+ '<div id="scubai-map" style="height:360px;border-radius:8px;'
22
+ 'overflow:hidden;margin:6px 0;border:1px solid #ddd;"></div>'
23
+ )
24
+
25
+ LEAFLET_JS = """
26
+ () => {
27
+ const CSS = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
28
+ const JS = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
29
+ function setNumber(id, v) {
30
+ const el = document.getElementById(id);
31
+ const inp = el ? el.querySelector("input") : null;
32
+ if (!inp) return;
33
+ const set = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
34
+ set.call(inp, v);
35
+ inp.dispatchEvent(new Event("input", { bubbles: true }));
36
+ }
37
+ function readNumber(id, fb) {
38
+ const el = document.getElementById(id);
39
+ const inp = el ? el.querySelector("input") : null;
40
+ const v = inp ? parseFloat(inp.value) : NaN;
41
+ return isNaN(v) ? fb : v;
42
+ }
43
+ function initMap() {
44
+ const el = document.getElementById("scubai-map");
45
+ if (!el || !window.L) { setTimeout(initMap, 200); return; }
46
+ if (el.dataset.ready === "1") return;
47
+ el.dataset.ready = "1";
48
+ const map = L.map(el).setView([readNumber("scubai_lat", 20.21), readNumber("scubai_lng", -87.43)], 5);
49
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
50
+ { maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(map);
51
+ const marker = L.marker(map.getCenter(), { draggable: true }).addTo(map);
52
+ function publish(ll) {
53
+ setNumber("scubai_lat", Math.round(ll.lat * 1e6) / 1e6);
54
+ setNumber("scubai_lng", Math.round(ll.lng * 1e6) / 1e6);
55
+ }
56
+ map.on("click", (e) => { marker.setLatLng(e.latlng); publish(e.latlng); });
57
+ marker.on("dragend", () => publish(marker.getLatLng()));
58
+ window.scubaiSetPin = (la, ln) => {
59
+ la = parseFloat(la); ln = parseFloat(ln);
60
+ if (!isNaN(la) && !isNaN(ln)) { marker.setLatLng([la, ln]); map.panTo([la, ln]); }
61
+ };
62
+ setTimeout(() => map.invalidateSize(), 300);
63
+ }
64
+ function ensure() {
65
+ if (!document.querySelector('link[data-scubai]')) {
66
+ const l = document.createElement("link");
67
+ l.rel = "stylesheet"; l.href = CSS; l.setAttribute("data-scubai", "1");
68
+ document.head.appendChild(l);
69
+ }
70
+ if (window.L) return initMap();
71
+ let s = document.querySelector('script[data-scubai]');
72
+ if (!s) {
73
+ s = document.createElement("script");
74
+ s.src = JS; s.async = true; s.setAttribute("data-scubai", "1");
75
+ s.onload = initMap; document.head.appendChild(s);
76
+ } else { s.addEventListener("load", initMap); }
77
+ }
78
+ ensure();
79
+ }
80
+ """
81
+
82
+ MOVE_PIN_JS = "(la, ln) => { if (window.scubaiSetPin) window.scubaiSetPin(la, ln); }"
83
+
84
+ APP_STORE_URL = "https://apps.apple.com/us/app/scubai/id6741768984"
85
+ SITE_URL = "https://scub.ai"
86
+
87
+ HEADER_MD = """
88
+ # 🤿 UWColorKit — zero-shot underwater white balance
89
+ The color engine inside **ScubAI** — it brings back the reds and oranges the water strips away.
90
+ """
91
+
92
+ APP_CTA_MD = (
93
+ f"🐠 Shooting underwater on an iPhone? **[ScubAI]({APP_STORE_URL})** "
94
+ "does this automatically, every dive."
95
+ )
96
+
97
+ FOOTER_MD = (
98
+ "*Your RAW is decoded for this preview and discarded right after.*\n\n"
99
+ f"*UWColorKit · patent-pending underwater color engine · [scub.ai]({SITE_URL})*"
100
+ )
101
+
102
+ EXAMPLES_DIR = os.path.join(os.path.dirname(__file__), "examples")
103
+
104
+
105
+ def load_examples():
106
+ """Read examples/examples.json -> [[file, lat, lng, depth], ...]; skip missing files."""
107
+ manifest = os.path.join(EXAMPLES_DIR, "examples.json")
108
+ if not os.path.exists(manifest):
109
+ return []
110
+ try:
111
+ with open(manifest) as fh:
112
+ entries = json.load(fh)
113
+ except (OSError, ValueError):
114
+ return []
115
+ rows, missing = [], 0
116
+ for e in entries:
117
+ path = os.path.join(EXAMPLES_DIR, e.get("file", ""))
118
+ if os.path.exists(path):
119
+ rows.append([path, e["lat"], e["lng"], e["depth"]])
120
+ else:
121
+ missing += 1
122
+ if missing:
123
+ print(f"[uwcolorkit] {missing} example(s) listed in examples.json have no file yet")
124
+ return rows
125
+
126
+
127
+ def _discard(path):
128
+ try:
129
+ if path and os.path.exists(path):
130
+ os.remove(path)
131
+ except OSError:
132
+ pass
133
+
134
+
135
+ # Decoded image (+ month) per upload, RAM only. Lets the user re-run with a
136
+ # different depth without re-reading the file (which we discard after decoding).
137
+ _DECODE_CACHE = {}
138
+ _DECODE_CACHE_MAX = 6
139
+
140
+
141
+ def _cache_put(path, value):
142
+ _DECODE_CACHE[path] = value
143
+ while len(_DECODE_CACHE) > _DECODE_CACHE_MAX:
144
+ _DECODE_CACHE.pop(next(iter(_DECODE_CACHE)))
145
+
146
+
147
+ def on_upload(path):
148
+ gps = exif_gps.extract_gps(path) if path else None
149
+ if gps:
150
+ return gr.update(value=round(gps[0], 6)), gr.update(value=round(gps[1], 6))
151
+ return gr.update(), gr.update()
152
+
153
+
154
+ def correct(path, lat, lng, depth):
155
+ if not path:
156
+ raise gr.Error("Upload a RAW file first.")
157
+ if lat is None or lng is None:
158
+ raise gr.Error("Set a location on the map.")
159
+
160
+ # Decode once per upload, cache it, then discard the file. Re-runs (e.g. a
161
+ # new depth) reuse the cached image — no second read of a deleted file.
162
+ cached = _DECODE_CACHE.get(path)
163
+ if cached is None:
164
+ if not os.path.exists(path):
165
+ raise gr.Error("Please re-upload the RAW — the previous file was cleared.")
166
+ month = exif_gps.extract_month(path) # read metadata before discarding
167
+ try:
168
+ linear = color.decode_raw(path)
169
+ except Exception as exc:
170
+ raise gr.Error(f"Could not decode RAW ({type(exc).__name__}): {exc}")
171
+ finally:
172
+ _discard(path)
173
+ cached = (linear, month)
174
+ _cache_put(path, cached)
175
+ linear, month = cached # month is None when no EXIF date -> engine uses yearly data
176
+
177
+ try:
178
+ illum = client.fetch_illuminant(lat, lng, depth, month)
179
+ except Exception as exc:
180
+ raise gr.Error(f"Parameter API error: {exc}")
181
+
182
+ print(f"[uwcolorkit] month={month} illuminant={illum.as_tuple()} stub={client.using_stub()}")
183
+ matrix = color.correction_matrix(illum.as_tuple())
184
+ before = color.encode_display(linear)
185
+ after = color.encode_display(color.apply_correction(linear, matrix))
186
+ return (before, after)
187
+
188
+
189
+ def build():
190
+ with gr.Blocks(title="UWColorKit — underwater color engine", theme=gr.themes.Soft()) as demo:
191
+ gr.Markdown(HEADER_MD)
192
+ with gr.Row():
193
+ with gr.Column():
194
+ raw_file = gr.File(
195
+ label="RAW photo",
196
+ file_count="single",
197
+ type="filepath",
198
+ file_types=RAW_EXTENSIONS,
199
+ )
200
+ gr.HTML(MAP_HTML)
201
+ with gr.Row():
202
+ lat = gr.Number(label="Latitude", value=DEFAULT_LAT, elem_id="scubai_lat", precision=6)
203
+ lng = gr.Number(label="Longitude", value=DEFAULT_LNG, elem_id="scubai_lng", precision=6)
204
+ depth = gr.Slider(0, 40, value=DEFAULT_DEPTH, step=1, label="Depth (m)")
205
+ correct_btn = gr.Button("Correct colour", variant="primary")
206
+ examples = load_examples()
207
+ if examples:
208
+ gr.Examples(
209
+ examples=examples,
210
+ inputs=[raw_file, lat, lng, depth],
211
+ label="Or try a sample dive",
212
+ )
213
+ with gr.Column():
214
+ slider = gr.ImageSlider(label="RAW → Corrected", type="numpy", slider_position=50)
215
+
216
+ gr.Markdown(APP_CTA_MD)
217
+ gr.Markdown(FOOTER_MD)
218
+
219
+ raw_file.upload(on_upload, inputs=raw_file, outputs=[lat, lng])
220
+ correct_btn.click(correct, inputs=[raw_file, lat, lng, depth], outputs=slider)
221
+ lat.change(None, [lat, lng], None, js=MOVE_PIN_JS)
222
+ lng.change(None, [lat, lng], None, js=MOVE_PIN_JS)
223
+ demo.load(None, None, None, js=LEAFLET_JS)
224
+ return demo
225
+
226
+
227
+ demo = build()
228
+
229
+ if __name__ == "__main__":
230
+ demo.queue().launch()
client.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parameter API client: sends only {lat, lng, depth_m}, returns illuminant (x, y)."""
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.request
7
+ from dataclasses import dataclass
8
+
9
+ ALLOWED_REQUEST_KEYS = frozenset({"lat", "lng", "depth_m", "month"})
10
+ STUB_ILLUMINANT_XY = (0.25, 0.30) # TODO(owner): replace with the live solver
11
+ ENV_API_URL = "SCUBAI_PARAM_API_URL"
12
+ ENV_API_TOKEN = "SCUBAI_PARAM_API_TOKEN"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Illuminant:
17
+ x: float
18
+ y: float
19
+
20
+ def as_tuple(self):
21
+ return (self.x, self.y)
22
+
23
+
24
+ def using_stub():
25
+ return not os.environ.get(ENV_API_URL)
26
+
27
+
28
+ def _validate(x, y):
29
+ x, y = float(x), float(y)
30
+ if not (0.0 < x < 0.8 and 0.0 < y < 0.9 and (x + y) < 1.0):
31
+ raise ValueError(f"illuminant out of range: x={x}, y={y}")
32
+ return Illuminant(x, y)
33
+
34
+
35
+ def fetch_illuminant(lat, lng, depth_m, month=None, *, timeout=10.0):
36
+ payload = {"lat": float(lat), "lng": float(lng), "depth_m": float(depth_m)}
37
+ if month is not None:
38
+ payload["month"] = int(month) # omitted when EXIF has no date -> engine uses yearly data
39
+ assert set(payload).issubset(ALLOWED_REQUEST_KEYS) # only location/depth/date — never pixels
40
+
41
+ url = os.environ.get(ENV_API_URL)
42
+ if not url:
43
+ return _validate(*STUB_ILLUMINANT_XY)
44
+
45
+ data = json.dumps(payload).encode("utf-8")
46
+ req = urllib.request.Request(
47
+ url, data=data, method="POST", headers={"Content-Type": "application/json"}
48
+ )
49
+ token = os.environ.get(ENV_API_TOKEN)
50
+ if token:
51
+ req.add_header("Authorization", f"Bearer {token}")
52
+ try:
53
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
54
+ body = json.loads(resp.read().decode("utf-8"))
55
+ except urllib.error.URLError as exc:
56
+ raise RuntimeError(f"parameter API request failed: {exc}") from exc
57
+ try:
58
+ return _validate(body["x"], body["y"])
59
+ except (KeyError, TypeError) as exc:
60
+ raise RuntimeError(f"parameter API response missing x/y: {body!r}") from exc
color.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
48
+
49
+
50
+ def _resize_max(img, max_long_edge):
51
+ from PIL import Image
52
+
53
+ h, w = img.shape[:2]
54
+ if max(h, w) <= max_long_edge:
55
+ return img
56
+ scale = max_long_edge / max(h, w)
57
+ new_w, new_h = max(1, round(w * scale)), max(1, round(h * scale))
58
+ chans = [
59
+ np.asarray(Image.fromarray(img[..., c], mode="F").resize((new_w, new_h), Image.BILINEAR))
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)
examples/examples.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [
2
+ { "file": "cozumel-reef-12m.dng", "label": "Cozumel reef", "lat": 20.21, "lng": -87.43, "depth": 12 },
3
+ { "file": "red-sea-wall-18m.dng", "label": "Red Sea wall", "lat": 27.85, "lng": 34.31, "depth": 18 },
4
+ { "file": "mediterranean-6m.dng", "label": "Mediterranean", "lat": 41.53, "lng": 2.45, "depth": 6 }
5
+ ]
exif_gps.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local EXIF GPS extraction to prefill the map pin (no pixels touched)."""
2
+
3
+ from typing import Optional, Tuple
4
+
5
+
6
+ def _to_degrees(tag):
7
+ try:
8
+ d, m, s = (float(p.num) / float(p.den) for p in tag.values)
9
+ return d + m / 60.0 + s / 3600.0
10
+ except (AttributeError, TypeError, ValueError, ZeroDivisionError):
11
+ return None
12
+
13
+
14
+ def extract_gps(path) -> Optional[Tuple[float, float]]:
15
+ try:
16
+ import exifread
17
+ except ImportError:
18
+ return None
19
+ try:
20
+ with open(path, "rb") as fh:
21
+ tags = exifread.process_file(fh, details=False)
22
+ except Exception:
23
+ return None
24
+
25
+ lat = _to_degrees(tags.get("GPS GPSLatitude"))
26
+ lng = _to_degrees(tags.get("GPS GPSLongitude"))
27
+ if lat is None or lng is None:
28
+ return None
29
+ if str(tags.get("GPS GPSLatitudeRef", "N")).strip().upper().startswith("S"):
30
+ lat = -lat
31
+ if str(tags.get("GPS GPSLongitudeRef", "E")).strip().upper().startswith("W"):
32
+ lng = -lng
33
+ if not (-90 <= lat <= 90 and -180 <= lng <= 180):
34
+ return None
35
+ return (lat, lng)
36
+
37
+
38
+ def extract_month(path) -> Optional[int]:
39
+ """Calendar month (1-12) from the EXIF capture date, or None."""
40
+ try:
41
+ import exifread
42
+ except ImportError:
43
+ return None
44
+ try:
45
+ with open(path, "rb") as fh:
46
+ tags = exifread.process_file(fh, details=False)
47
+ except Exception:
48
+ return None
49
+
50
+ for key in ("EXIF DateTimeOriginal", "EXIF DateTimeDigitized", "Image DateTime"):
51
+ raw = str(tags.get(key, "")).strip()
52
+ if not raw:
53
+ continue
54
+ date_part = raw.split()[0] # "YYYY:MM:DD" or "YYYY-MM-DD"
55
+ bits = date_part.replace("-", ":").split(":")
56
+ if len(bits) >= 2:
57
+ try:
58
+ month = int(bits[1])
59
+ except ValueError:
60
+ continue
61
+ if 1 <= month <= 12:
62
+ return month
63
+ return None
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=6.0 # modern UI; needs Python >= 3.10. gr.ImageSlider is built in.
2
+ rawpy>=0.19.0 # libraw RAW decode (manylinux wheels bundle libraw)
3
+ numpy>=1.24
4
+ Pillow>=9.0 # linear-light downsample for the preview
5
+ exifread>=3.0.0 # local EXIF GPS read (no pixels touched)