pysternblot 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ main()
pysternblot/app.py ADDED
@@ -0,0 +1,23 @@
1
+ # Pystern Blot
2
+ # SPDX-License-Identifier: GPL-3.0-only
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, version 3 of the License.
7
+
8
+ from __future__ import annotations
9
+ from PySide6.QtWidgets import QApplication
10
+ from pathlib import Path
11
+ from .storage import Workspace
12
+ from .ui.main_window import MainWindow
13
+
14
+ def default_workspace() -> Path:
15
+ return Path.home() / ".pysternblot"
16
+
17
+ def run():
18
+ app = QApplication([])
19
+ ws = Workspace(default_workspace())
20
+ ws.ensure()
21
+ win = MainWindow(ws)
22
+ win.show()
23
+ app.exec()
@@ -0,0 +1,249 @@
1
+ # Pystern Blot
2
+ # SPDX-License-Identifier: GPL-3.0-only
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ import numpy as np
11
+ from PIL import Image
12
+ from PySide6.QtGui import QImage, QPixmap
13
+ from PySide6.QtCore import Qt
14
+
15
+
16
+ def load_image_uint16(path: str | Path) -> np.ndarray:
17
+ """
18
+ Load an image as native grayscale uint16.
19
+
20
+ Strict rules:
21
+ - accepts only native 16-bit grayscale images
22
+ - rejects 8-bit, RGB, RGBA, palette, and other modes
23
+ - performs no bit-depth promotion and no color conversion
24
+ """
25
+ path = str(path)
26
+
27
+ with Image.open(path) as im:
28
+ if im.mode not in ("I;16", "I;16L", "I;16B"):
29
+ raise ValueError(
30
+ f"Unsupported image mode {im.mode!r} for {path}. "
31
+ "Only native 16-bit grayscale images are accepted."
32
+ )
33
+
34
+ arr = np.array(im, dtype=np.uint16)
35
+
36
+ if arr.ndim != 2:
37
+ raise ValueError(
38
+ f"Expected a 2D grayscale image for {path}, got shape {arr.shape}."
39
+ )
40
+
41
+ return np.ascontiguousarray(arr)
42
+
43
+
44
+ def apply_levels_uint16(
45
+ img: np.ndarray,
46
+ black: int,
47
+ white: int,
48
+ gamma: float,
49
+ invert: bool,
50
+ ) -> np.ndarray:
51
+ """
52
+ Apply black/white/gamma/invert in 16-bit space.
53
+ Returns uint16.
54
+ """
55
+ if img.dtype != np.uint16:
56
+ raise TypeError(f"Expected uint16 image, got {img.dtype}")
57
+
58
+ black = int(black)
59
+ white = int(white)
60
+ gamma = float(gamma)
61
+
62
+ if white <= black:
63
+ white = black + 1
64
+ if gamma <= 0:
65
+ gamma = 1.0
66
+
67
+ arr = img.astype(np.float32)
68
+ arr = (arr - black) / float(white - black)
69
+ arr = np.clip(arr, 0.0, 1.0)
70
+
71
+ inv_gamma = 1.0 / gamma
72
+ arr = np.power(arr, inv_gamma)
73
+
74
+ if invert:
75
+ arr = 1.0 - arr
76
+
77
+ out = np.round(arr * 65535.0).astype(np.uint16)
78
+ return np.ascontiguousarray(out)
79
+
80
+
81
+ def rotate_uint16(
82
+ img: np.ndarray,
83
+ rotation_deg: float,
84
+ expand: bool = False,
85
+ ) -> np.ndarray:
86
+ """
87
+ Rotate a uint16 grayscale image while preserving high bit depth.
88
+
89
+ Uses Pillow in 32-bit integer mode ("I") for the transform, then clips
90
+ back to uint16. No 8-bit conversion is involved.
91
+
92
+ Parameters
93
+ ----------
94
+ img : np.ndarray
95
+ 2D uint16 grayscale image.
96
+ rotation_deg : float
97
+ Rotation angle in degrees.
98
+ expand : bool
99
+ If True, Pillow expands the output canvas to contain the whole rotated
100
+ image. If False, the output canvas keeps the original width/height and
101
+ overflowing corners are clipped.
102
+ """
103
+ if img.dtype != np.uint16:
104
+ raise TypeError(f"Expected uint16 image, got {img.dtype}")
105
+
106
+ if abs(float(rotation_deg)) < 1e-6:
107
+ return np.ascontiguousarray(img)
108
+
109
+ pil = Image.fromarray(img.astype(np.int32), mode="I")
110
+
111
+ rotated = pil.rotate(
112
+ float(rotation_deg),
113
+ resample=Image.Resampling.BICUBIC,
114
+ expand=bool(expand),
115
+ fillcolor=0,
116
+ )
117
+
118
+ arr = np.array(rotated, dtype=np.int32)
119
+ arr = np.clip(arr, 0, 65535).astype(np.uint16)
120
+
121
+ return np.ascontiguousarray(arr)
122
+
123
+
124
+ def crop_uint16(img: np.ndarray, x: int, y: int, w: int, h: int) -> np.ndarray:
125
+ """
126
+ Crop a uint16 image with clamping.
127
+ """
128
+ ih, iw = img.shape[:2]
129
+
130
+ x = max(0, min(int(x), max(0, iw - 1)))
131
+ y = max(0, min(int(y), max(0, ih - 1)))
132
+ w = max(1, min(int(w), iw - x))
133
+ h = max(1, min(int(h), ih - y))
134
+
135
+ return np.ascontiguousarray(img[y:y + h, x:x + w])
136
+
137
+
138
+ def save_uint16_tiff(img: np.ndarray, path: str | Path) -> None:
139
+ """
140
+ Save uint16 grayscale TIFF.
141
+ """
142
+ if img.dtype != np.uint16:
143
+ raise TypeError(f"Expected uint16 image, got {img.dtype}")
144
+
145
+ h, w = img.shape
146
+ pil = Image.frombuffer("I;16", (w, h), img.tobytes(), "raw", "I;16", 0, 1)
147
+ pil.save(str(path), format="TIFF")
148
+
149
+
150
+ def uint16_to_qimage(img: np.ndarray) -> QImage:
151
+ """
152
+ Wrap a uint16 grayscale NumPy array as QImage.Format_Grayscale16.
153
+ Caller must keep the NumPy array alive while the QImage is in use.
154
+ """
155
+ if img.dtype != np.uint16:
156
+ raise TypeError(f"Expected uint16 image, got {img.dtype}")
157
+ if img.ndim != 2:
158
+ raise ValueError(f"Expected 2D grayscale image, got shape {img.shape}")
159
+
160
+ h, w = img.shape
161
+ bytes_per_line = w * 2
162
+
163
+ qimg = QImage(
164
+ img.data,
165
+ w,
166
+ h,
167
+ bytes_per_line,
168
+ QImage.Format_Grayscale16,
169
+ )
170
+ return qimg
171
+
172
+
173
+ def detect_tiff_channel_encoding(
174
+ path: str | Path,
175
+ ) -> Literal["multipage", "rgb_interleaved", "single"]:
176
+ """
177
+ Inspect a TIFF and return how its channels are encoded.
178
+
179
+ - "multipage" — multiple frames, one channel per frame (LI-COR Odyssey style)
180
+ - "rgb_interleaved" — single-frame RGB/RGBA (some composite exports)
181
+ - "single" — single-frame grayscale (ECL, single-channel NIR)
182
+ """
183
+ with Image.open(str(path)) as im:
184
+ n_frames = getattr(im, "n_frames", 1)
185
+ if n_frames > 1:
186
+ return "multipage"
187
+ if im.mode in ("RGB", "RGBA"):
188
+ return "rgb_interleaved"
189
+ return "single"
190
+
191
+
192
+ def load_multichannel_tiff(path: str | Path) -> list[np.ndarray]:
193
+ """
194
+ Load a TIFF and return one uint16 ndarray per channel.
195
+
196
+ Dispatch is based on detect_tiff_channel_encoding:
197
+ - "multipage" — one array per frame
198
+ - "rgb_interleaved" — R and G bands returned as two arrays (NIR dual-channel)
199
+ - "single" — delegates to load_image_uint16; returns a one-element list
200
+ """
201
+ encoding = detect_tiff_channel_encoding(path)
202
+ path_str = str(path)
203
+
204
+ if encoding == "multipage":
205
+ channels: list[np.ndarray] = []
206
+ with Image.open(path_str) as im:
207
+ for i in range(im.n_frames):
208
+ im.seek(i)
209
+ arr = np.array(im, dtype=np.uint16)
210
+ channels.append(np.ascontiguousarray(arr))
211
+ return channels
212
+
213
+ if encoding == "rgb_interleaved":
214
+ with Image.open(path_str) as im:
215
+ bands = im.split()
216
+ return [
217
+ np.ascontiguousarray(np.array(b, dtype=np.uint16))
218
+ for b in bands[:2]
219
+ ]
220
+
221
+ # "single"
222
+ return [load_image_uint16(path_str)]
223
+
224
+
225
+ def parse_typhoon_channel_id(filename: str) -> str | None:
226
+ """
227
+ Extract a channel identifier from a Typhoon / ImageQuant TIFF filename.
228
+
229
+ Two conventions are recognised:
230
+ - Bracket notation: "20260507-142651-[IRlong].tif" → "IRlong"
231
+ - Wavelength suffix: "scan_700nm_ch1.tif" → "700nm"
232
+
233
+ Returns None if neither pattern is found.
234
+ """
235
+ m = re.search(r'\[([^\]]+)\]', filename)
236
+ if m:
237
+ return m.group(1)
238
+ m = re.search(r'(\d+nm)', filename, re.IGNORECASE)
239
+ if m:
240
+ return m.group(1)
241
+ return None
242
+
243
+
244
+ def uint16_to_qpixmap(img: np.ndarray) -> QPixmap:
245
+ """
246
+ Convert uint16 grayscale NumPy array to QPixmap through Grayscale16 QImage.
247
+ """
248
+ qimg = uint16_to_qimage(img)
249
+ return QPixmap.fromImage(qimg, Qt.NoFormatConversion)
@@ -0,0 +1,312 @@
1
+ # Pystern Blot
2
+ # SPDX-License-Identifier: GPL-3.0-only
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import platform
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from PIL import Image
13
+
14
+ from . import __version__
15
+ from .models import Project
16
+ from .storage import Workspace, sha256_file
17
+
18
+
19
+ def _utc_now() -> str:
20
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
21
+
22
+
23
+ def _asset_info(workspace: Workspace, project: Project, sha256: str) -> dict[str, Any]:
24
+ path = workspace.asset_original_file(sha256)
25
+ asset = project.assets.get(sha256)
26
+
27
+ with Image.open(path) as im:
28
+ mode = im.mode
29
+ width, height = im.size
30
+
31
+ bit_depth = 16 if mode in ("I;16", "I;16L", "I;16B") else None
32
+
33
+ return {
34
+ "sha256": sha256,
35
+ "stored_original_path": str(path),
36
+ "stored_original_sha256_check": sha256_file(str(path)),
37
+ "original_source_path": getattr(asset, "original_source_path", None) if asset else None,
38
+ "filename": path.name,
39
+ "image_mode": mode,
40
+ "bit_depth": bit_depth,
41
+ "width_px": width,
42
+ "height_px": height,
43
+ }
44
+
45
+
46
+ def _marker_set_name(project: Project, marker_set_id: str | None) -> str | None:
47
+ if not marker_set_id:
48
+ return None
49
+ for ms in project.marker_sets:
50
+ if ms.id == marker_set_id:
51
+ return ms.name
52
+ return None
53
+
54
+
55
+ def _blot_record(workspace: Workspace, project: Project, blot) -> dict[str, Any]:
56
+ display = blot.display
57
+ crop = blot.crop
58
+
59
+ record = {
60
+ "blot_id": blot.id,
61
+ "protein_label": {
62
+ "text": blot.protein_label.text,
63
+ "font_size_pt": blot.protein_label.font_size_pt,
64
+ },
65
+ "source_image": _asset_info(workspace, project, blot.asset_sha256),
66
+ "operations": {
67
+ "crop": {
68
+ "x": crop.x,
69
+ "y": crop.y,
70
+ "w": crop.w,
71
+ "h": crop.h,
72
+ "mode": crop.mode,
73
+ "ladder_anchor": crop.ladder_anchor,
74
+ },
75
+ "rotation_deg": display.rotation_deg,
76
+ "levels": {
77
+ "black": display.levels_black,
78
+ "white": display.levels_white,
79
+ "gamma": display.levels_gamma,
80
+ },
81
+ "invert": display.invert,
82
+ "auto_contrast": display.auto_contrast,
83
+ },
84
+ "overlay": {
85
+ "present": blot.overlay_asset_sha256 is not None,
86
+ "asset": (
87
+ _asset_info(workspace, project, blot.overlay_asset_sha256)
88
+ if blot.overlay_asset_sha256
89
+ else None
90
+ ),
91
+ "visible": display.overlay_visible,
92
+ "alpha": display.overlay_alpha,
93
+ },
94
+ "ladder": {
95
+ "lane_index": blot.ladder.lane_index,
96
+ "marker_set_id": blot.ladder.marker_set_id,
97
+ "marker_set_name": _marker_set_name(project, blot.ladder.marker_set_id),
98
+ "show_ticks": blot.ladder.show_ticks,
99
+ "calibration_points": [
100
+ {"y_px": p.y_px, "kda": p.kda}
101
+ for p in blot.ladder.calibration_points
102
+ ],
103
+ "fit": blot.ladder.fit.model_dump() if blot.ladder.fit else None,
104
+ },
105
+ "overlay_ladder": (
106
+ blot.overlay_ladder.model_dump()
107
+ if getattr(blot, "overlay_ladder", None) is not None
108
+ else None
109
+ ),
110
+ }
111
+
112
+ return record
113
+
114
+
115
+ def build_integrity_report(
116
+ project: Project,
117
+ workspace: Workspace,
118
+ project_json_path: str | Path | None = None,
119
+ exported_files: list[str | Path] | None = None,
120
+ ) -> dict[str, Any]:
121
+ exported_files = exported_files or []
122
+
123
+ report = {
124
+ "schema": "pysternblot.integrity_report.v1",
125
+ "created_utc": _utc_now(),
126
+ "pysternblot_version": __version__,
127
+ "system": {
128
+ "python": platform.python_version(),
129
+ "platform": platform.platform(),
130
+ },
131
+ "project": {
132
+ "id": project.project.id,
133
+ "name": project.project.name,
134
+ "created_utc": project.project.created_utc,
135
+ "modified_utc": project.project.modified_utc,
136
+ "app_version": project.project.app_version,
137
+ "license": project.project.license,
138
+ "project_json_path": str(project_json_path) if project_json_path else None,
139
+ "project_json_sha256": (
140
+ sha256_file(str(project_json_path))
141
+ if project_json_path and Path(project_json_path).exists()
142
+ else None
143
+ ),
144
+ },
145
+ "panel": {
146
+ "layout_order": list(project.panel.layout.order),
147
+ "style": project.panel.style.model_dump(),
148
+ "lane_layout": project.panel.lane_layout.model_dump(),
149
+ "legend": project.panel.legend.model_dump(),
150
+ },
151
+ "blots": [
152
+ _blot_record(workspace, project, blot)
153
+ for blot in project.panel.blots
154
+ ],
155
+ "exports": [
156
+ {
157
+ "path": str(path),
158
+ "sha256": sha256_file(str(path)) if Path(path).exists() else None,
159
+ }
160
+ for path in exported_files
161
+ ],
162
+ }
163
+
164
+ return report
165
+
166
+
167
+ def write_integrity_json(report: dict[str, Any], path: str | Path) -> Path:
168
+ path = Path(path)
169
+ path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
170
+ return path
171
+
172
+
173
+ def write_integrity_html(report: dict[str, Any], path: str | Path) -> Path:
174
+ path = Path(path)
175
+
176
+ rows = []
177
+ for blot in report["blots"]:
178
+ src = blot["source_image"]
179
+ ops = blot["operations"]
180
+ overlay = blot["overlay"]
181
+
182
+ rows.append(f"""
183
+ <tr>
184
+ <td>{blot["blot_id"]}</td>
185
+ <td>{blot["protein_label"]["text"]}</td>
186
+ <td><code>{src["sha256"]}</code></td>
187
+ <td>{src["bit_depth"]}</td>
188
+ <td>{src["width_px"]} × {src["height_px"]}</td>
189
+ <td>x={ops["crop"]["x"]}, y={ops["crop"]["y"]}, w={ops["crop"]["w"]}, h={ops["crop"]["h"]}</td>
190
+ <td>{ops["rotation_deg"]}</td>
191
+ <td>{ops["levels"]["black"]}–{ops["levels"]["white"]}, γ={ops["levels"]["gamma"]}</td>
192
+ <td>{overlay["present"]}</td>
193
+ </tr>
194
+ """)
195
+
196
+ operation_rows = []
197
+ for entry in report.get("operation_log", []):
198
+ operation_rows.append(f"""
199
+ <tr>
200
+ <td>{entry.get("timestamp_utc", "")}</td>
201
+ <td>{entry.get("operation", "")}</td>
202
+ <td>{entry.get("target_type", "") or ""}</td>
203
+ <td>{entry.get("target_id", "") or ""}</td>
204
+ <td>{entry.get("field", "") or ""}</td>
205
+ <td><code>{json.dumps(entry.get("old_value", None), ensure_ascii=False)}</code></td>
206
+ <td><code>{json.dumps(entry.get("new_value", None), ensure_ascii=False)}</code></td>
207
+ <td>{entry.get("note", "") or ""}</td>
208
+ </tr>
209
+ """)
210
+
211
+ operation_section = ""
212
+ if operation_rows:
213
+ operation_section = f"""
214
+ <h2>Chronological operation log</h2>
215
+ <table>
216
+ <thead>
217
+ <tr>
218
+ <th>Time UTC</th>
219
+ <th>Operation</th>
220
+ <th>Target type</th>
221
+ <th>Target ID</th>
222
+ <th>Field</th>
223
+ <th>Old value</th>
224
+ <th>New value</th>
225
+ <th>Note</th>
226
+ </tr>
227
+ </thead>
228
+ <tbody>
229
+ {''.join(operation_rows)}
230
+ </tbody>
231
+ </table>
232
+ """
233
+
234
+ html = f"""<!doctype html>
235
+ <html>
236
+ <head>
237
+ <meta charset="utf-8">
238
+ <title>Pystern Blot integrity report</title>
239
+ <style>
240
+ body {{ font-family: Arial, sans-serif; margin: 32px; color: #222; }}
241
+ h1, h2 {{ margin-bottom: 0.3em; }}
242
+ table {{ border-collapse: collapse; width: 100%; font-size: 13px; }}
243
+ th, td {{ border: 1px solid #ccc; padding: 6px; vertical-align: top; }}
244
+ th {{ background: #eee; }}
245
+ code {{ font-size: 11px; word-break: break-all; }}
246
+ .summary {{ background: #f7f7f7; padding: 12px; border: 1px solid #ddd; }}
247
+ </style>
248
+ </head>
249
+ <body>
250
+ <h1>Pystern Blot integrity report</h1>
251
+
252
+ <div class="summary">
253
+ <p><strong>Project:</strong> {report["project"]["name"]}</p>
254
+ <p><strong>Project ID:</strong> {report["project"]["id"]}</p>
255
+ <p><strong>Created UTC:</strong> {report["created_utc"]}</p>
256
+ <p><strong>Pystern Blot version:</strong> {report["pysternblot_version"]}</p>
257
+ <p><strong>Schema:</strong> {report["schema"]}</p>
258
+ </div>
259
+
260
+ <h2>Blot provenance</h2>
261
+ <table>
262
+ <thead>
263
+ <tr>
264
+ <th>Blot</th>
265
+ <th>Protein</th>
266
+ <th>Source SHA256</th>
267
+ <th>Bit depth</th>
268
+ <th>Source size</th>
269
+ <th>Crop</th>
270
+ <th>Rotation</th>
271
+ <th>Levels</th>
272
+ <th>Overlay</th>
273
+ </tr>
274
+ </thead>
275
+ <tbody>
276
+ {''.join(rows)}
277
+ </tbody>
278
+ </table>
279
+
280
+ {operation_section}
281
+
282
+ <h2>Machine-readable report</h2>
283
+ <p>The companion JSON file contains the complete project, panel, legend, ladder, crop, overlay and export provenance{" including the chronological operation log" if operation_rows else ""}.</p>
284
+
285
+ </body>
286
+ </html>
287
+ """
288
+
289
+ path.write_text(html, encoding="utf-8")
290
+ return path
291
+
292
+
293
+ def build_detailed_integrity_report(
294
+ project: Project,
295
+ workspace: Workspace,
296
+ project_json_path: str | Path | None = None,
297
+ exported_files: list[str | Path] | None = None,
298
+ ) -> dict[str, Any]:
299
+ report = build_integrity_report(
300
+ project=project,
301
+ workspace=workspace,
302
+ project_json_path=project_json_path,
303
+ exported_files=exported_files,
304
+ )
305
+
306
+ report["schema"] = "pysternblot.detailed_integrity_report.v1"
307
+ report["operation_log"] = [
308
+ entry.model_dump()
309
+ for entry in getattr(project, "operation_log", [])
310
+ ]
311
+
312
+ return report
pysternblot/main.py ADDED
@@ -0,0 +1,14 @@
1
+ # Pystern Blot
2
+ # SPDX-License-Identifier: GPL-3.0-only
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, version 3 of the License.
7
+
8
+ from .app import run
9
+
10
+ def main():
11
+ run()
12
+
13
+ if __name__ == "__main__":
14
+ main()