Skip to content

Commit abbd4a6

Browse files
committed
Added surface viewer lib files
1 parent e878e8e commit abbd4a6

7 files changed

Lines changed: 1084 additions & 0 deletions

File tree

libs/surface_viewer/__init__.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from .io import (
2+
get_roi_name_from_api_url,
3+
load_roi_api,
4+
infer_dataset_base_from_api,
5+
add_json_urls,
6+
fetch_json_items,
7+
build_spectrum_index,
8+
attach_spectra,
9+
get_selection_grid_url,
10+
load_all_cells_from_selection_grid,
11+
)
12+
13+
from .spectra import (
14+
stack_spectra,
15+
stack_spectra_trim,
16+
band_sum,
17+
summarize_band_values,
18+
resolve_band_to_channels,
19+
band_label_text,
20+
print_cli_suggestions,
21+
)
22+
23+
from .calibration import (
24+
load_config_txt,
25+
get_energy_cal_from_dataset,
26+
make_energy_axis,
27+
make_energy_axis_from_length,
28+
channel_to_keV,
29+
keV_to_channel,
30+
maybe_get_calibration,
31+
)
32+
33+
from .peaks import (
34+
baseline_als,
35+
preprocess,
36+
estimate_noise,
37+
detect_peaks,
38+
line_library,
39+
identify_elements,
40+
)
41+
42+
from .plotting import (
43+
get_plot_axis,
44+
get_band_span,
45+
add_energy_top_axis,
46+
plot_cumulative,
47+
plot_overlay,
48+
plot_with_peaks,
49+
plot_identified_elements_confident,
50+
)
51+
52+
from .overlays import (
53+
get_api_auth,
54+
create_overlay,
55+
delete_overlay,
56+
)

libs/surface_viewer/calibration.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from urllib.parse import urljoin
5+
6+
import numpy as np
7+
import requests
8+
9+
from .io import infer_dataset_base_from_api
10+
11+
12+
def load_config_txt(dataset_base: str) -> dict:
13+
"""Parse ``config.txt`` from a dataset folder in the same way as the viewer."""
14+
url = urljoin(dataset_base, "config.txt")
15+
r = requests.get(url, timeout=30)
16+
if not r.ok:
17+
return {}
18+
19+
cfg = {}
20+
for line in r.text.splitlines():
21+
s = re.sub(r"#.*$", "", line).strip()
22+
if not s or "=" not in s:
23+
continue
24+
k, v = s.split("=", 1)
25+
cfg[k.strip().lower()] = v.strip()
26+
return cfg
27+
28+
29+
def get_energy_cal_from_dataset(
30+
api_url: str,
31+
default_eV_per_ch=20.000347,
32+
default_start_eV=-192.768,
33+
):
34+
dataset_base = infer_dataset_base_from_api(api_url)
35+
cfg = load_config_txt(dataset_base)
36+
37+
eV_per_ch = float(cfg.get("eds_ev_per_ch", default_eV_per_ch))
38+
start_eV = float(cfg.get("eds_start_ev", default_start_eV))
39+
40+
n_channels = cfg.get("eds_n_channels", None)
41+
n_channels = int(n_channels) if n_channels and str(n_channels).isdigit() else None
42+
43+
return {
44+
"dataset_base": dataset_base,
45+
"eV_per_ch": eV_per_ch,
46+
"start_eV": start_eV,
47+
"n_channels": n_channels,
48+
"raw_cfg": cfg,
49+
}
50+
51+
52+
def make_energy_axis(cum, cal: dict):
53+
n = len(cum) if cal.get("n_channels") is None else min(len(cum), cal["n_channels"])
54+
return (cal["start_eV"] + np.arange(n) * cal["eV_per_ch"]) / 1000.0
55+
56+
57+
def make_energy_axis_from_length(n, cal: dict):
58+
return (cal["start_eV"] + np.arange(n) * cal["eV_per_ch"]) / 1000.0
59+
60+
61+
def channel_to_keV(ch, cal: dict):
62+
return (cal["start_eV"] + ch * cal["eV_per_ch"]) / 1000.0
63+
64+
65+
def maybe_get_calibration(roi_api_urls, need_calibration=False, allow_defaults=True):
66+
if not need_calibration:
67+
return None
68+
first_url = roi_api_urls[0]
69+
cal = get_energy_cal_from_dataset(first_url)
70+
raw_cfg = cal.get("raw_cfg", {}) or {}
71+
cal["from_config"] = ("eds_ev_per_ch" in raw_cfg and "eds_start_ev" in raw_cfg)
72+
if not cal["from_config"] and not allow_defaults:
73+
return None
74+
return cal
75+
76+
77+
def keV_to_channel(keV, cal: dict):
78+
return int(round((float(keV) * 1000.0 - cal["start_eV"]) / cal["eV_per_ch"]))

libs/surface_viewer/io.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
from __future__ import annotations
2+
3+
from urllib.parse import parse_qs, quote, urljoin, urlparse
4+
5+
import pandas as pd
6+
import requests
7+
from tqdm.auto import tqdm
8+
9+
10+
def get_roi_name_from_api_url(api_url: str) -> str:
11+
"""Extract ROI name from the ROI API URL query parameter ``name``."""
12+
p = urlparse(api_url)
13+
qs = parse_qs(p.query)
14+
roi_name = (qs.get("name") or [None])[0]
15+
if not roi_name:
16+
raise ValueError("Could not find 'name' parameter in the ROI API URL.")
17+
return roi_name
18+
19+
20+
def load_roi_api(api_url: str) -> pd.DataFrame:
21+
"""Load ROI selections from the surface-viewer ROI API into a DataFrame."""
22+
r = requests.get(api_url, timeout=60)
23+
r.raise_for_status()
24+
data = r.json()
25+
selections = data.get("selections", [])
26+
df = pd.DataFrame(selections)
27+
28+
if df.empty:
29+
return df
30+
31+
for c in ["row", "col"]:
32+
if c in df.columns:
33+
df[c] = df[c].astype("int64")
34+
35+
for c in ["srcJson", "basename", "foldername"]:
36+
if c in df.columns:
37+
df[c] = df[c].astype("string")
38+
39+
return df
40+
41+
42+
def infer_dataset_base_from_api(api_url: str) -> str:
43+
"""Infer the dataset base URL ending in ``/`` from an ROI API URL."""
44+
p = urlparse(api_url)
45+
qs = parse_qs(p.query)
46+
dataset = (qs.get("dataset") or [None])[0]
47+
if not dataset:
48+
raise ValueError("Could not find 'dataset' parameter in the API URL.")
49+
50+
root = f"{p.scheme}://{p.netloc}/surface-viewer/data/"
51+
dataset_encoded = quote(dataset, safe="")
52+
return urljoin(root, dataset_encoded + "/")
53+
54+
55+
def add_json_urls(df: pd.DataFrame, api_url: str) -> pd.DataFrame:
56+
"""Add a ``json_url`` column by resolving ``srcJson`` against the dataset base."""
57+
if df.empty:
58+
df = df.copy()
59+
df["json_url"] = pd.Series(dtype="string")
60+
return df
61+
62+
dataset_base = infer_dataset_base_from_api(api_url)
63+
df = df.copy()
64+
df["json_url"] = df["srcJson"].apply(lambda p: urljoin(dataset_base, str(p)))
65+
return df
66+
67+
68+
def _new_session() -> requests.Session:
69+
s = requests.Session()
70+
s.headers.update({"User-Agent": "eds-demo-notebook/0.1"})
71+
return s
72+
73+
74+
def fetch_json_items(url: str, session: requests.Session | None = None) -> list:
75+
"""GET a JSON file and return a list of records from either a list or ``{'items': ...}``."""
76+
session = session or _new_session()
77+
try:
78+
r = session.get(url, timeout=60)
79+
r.raise_for_status()
80+
data = r.json()
81+
if isinstance(data, dict) and isinstance(data.get("items"), list):
82+
return data["items"]
83+
if isinstance(data, list):
84+
return data
85+
return []
86+
except Exception:
87+
return []
88+
89+
90+
def build_spectrum_index(
91+
urls: list[str],
92+
*,
93+
progress: bool = True,
94+
session: requests.Session | None = None,
95+
) -> dict[tuple[str, int, int], list[int]]:
96+
"""Build a lookup ``(json_url, row, col) -> spectrum`` by reading each JSON file once."""
97+
session = session or _new_session()
98+
index: dict[tuple[str, int, int], list[int]] = {}
99+
100+
for url in tqdm(urls, desc="Downloading JSON files", disable=not progress):
101+
items = fetch_json_items(url, session)
102+
for rec in items:
103+
r = rec.get("rownum", rec.get("row"))
104+
c = rec.get("colnum", rec.get("col"))
105+
spec = rec.get("aggregatedspectrum") or rec.get("aggregatedSpectrum") or rec.get("spectrum")
106+
if r is None or c is None or spec is None:
107+
continue
108+
try:
109+
key = (url, int(r), int(c))
110+
index[key] = [int(x) for x in spec]
111+
except Exception:
112+
pass
113+
114+
return index
115+
116+
117+
def attach_spectra(
118+
df: pd.DataFrame,
119+
index: dict[tuple[str, int, int], list[int]],
120+
*,
121+
progress: bool = True,
122+
) -> pd.DataFrame:
123+
"""Add a ``spectrum`` column to a DataFrame using the pre-built spectrum index."""
124+
125+
def pick(row):
126+
return index.get((row["json_url"], int(row["row"]), int(row["col"])), None)
127+
128+
df = df.copy()
129+
if progress:
130+
tqdm.pandas(desc="Indexing spectra")
131+
df["spectrum"] = df.progress_apply(pick, axis=1)
132+
else:
133+
df["spectrum"] = df.apply(pick, axis=1)
134+
return df
135+
136+
137+
def get_selection_grid_url(api_url: str) -> str:
138+
"""Return the first matching selection-grid JSON URL from the dataset overlays folder."""
139+
dataset_base = infer_dataset_base_from_api(api_url)
140+
candidates = [
141+
"overlays/selection-grid.json",
142+
"overlays/selection_grid.json",
143+
"selection-grid.json",
144+
"selection_grid.json",
145+
]
146+
session = _new_session()
147+
for rel in candidates:
148+
url = urljoin(dataset_base, rel)
149+
try:
150+
r = session.get(url, timeout=30)
151+
if r.ok:
152+
return url
153+
except Exception:
154+
pass
155+
raise FileNotFoundError("Could not find selection-grid.json in the dataset overlays folder.")
156+
157+
158+
def load_all_cells_from_selection_grid(api_url: str) -> pd.DataFrame:
159+
"""Load the full cell grid from ``overlays/selection-grid.json`` into a DataFrame."""
160+
grid_url = get_selection_grid_url(api_url)
161+
session = _new_session()
162+
items = fetch_json_items(grid_url, session)
163+
164+
rows = []
165+
for rec in items:
166+
if rec.get("type") != "rect":
167+
continue
168+
r = rec.get("rownum", rec.get("row"))
169+
c = rec.get("colnum", rec.get("col"))
170+
src = rec.get("srcJson")
171+
if r is None or c is None or src is None:
172+
continue
173+
rows.append({
174+
"row": int(r),
175+
"col": int(c),
176+
"srcJson": str(src),
177+
"basename": rec.get("basename"),
178+
"label": rec.get("label"),
179+
"x": rec.get("x"),
180+
"y": rec.get("y"),
181+
"width": rec.get("width"),
182+
"height": rec.get("height"),
183+
})
184+
185+
df = pd.DataFrame(rows)
186+
if df.empty:
187+
return df
188+
for c in ["srcJson", "basename", "label"]:
189+
if c in df.columns:
190+
df[c] = df[c].astype("string")
191+
df = add_json_urls(df, api_url)
192+
df = df.drop_duplicates(subset=["json_url", "row", "col"]).reset_index(drop=True)
193+
return df

0 commit comments

Comments
 (0)