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.
- pysternblot/__init__.py +1 -0
- pysternblot/__main__.py +3 -0
- pysternblot/app.py +23 -0
- pysternblot/image_utils.py +249 -0
- pysternblot/integrity.py +312 -0
- pysternblot/main.py +14 -0
- pysternblot/models.py +258 -0
- pysternblot/render.py +771 -0
- pysternblot/storage.py +733 -0
- pysternblot/ui/crop_rect_item.py +202 -0
- pysternblot/ui/export_mixin.py +451 -0
- pysternblot/ui/legend_tab.py +382 -0
- pysternblot/ui/main_window.py +1741 -0
- pysternblot/ui/marker_set_mixin.py +255 -0
- pysternblot/ui/nir_import_dialog.py +148 -0
- pysternblot/ui/overlay_ladder_mixin.py +491 -0
- pysternblot/ui/project_io_mixin.py +534 -0
- pysternblot/ui/widgets.py +48 -0
- pysternblot/ui/zoomable_graphics_view.py +107 -0
- pysternblot-1.0.0.dist-info/METADATA +151 -0
- pysternblot-1.0.0.dist-info/RECORD +25 -0
- pysternblot-1.0.0.dist-info/WHEEL +5 -0
- pysternblot-1.0.0.dist-info/entry_points.txt +2 -0
- pysternblot-1.0.0.dist-info/licenses/LICENSE +674 -0
- pysternblot-1.0.0.dist-info/top_level.txt +1 -0
pysternblot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pysternblot/__main__.py
ADDED
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)
|
pysternblot/integrity.py
ADDED
|
@@ -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()
|