petrilya 0.0.1__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.
- petrilya/__init__.py +3 -0
- petrilya/cli.py +61 -0
- petrilya/export/__init__.py +0 -0
- petrilya/export/csv_writer.py +34 -0
- petrilya/export/json_manifest.py +69 -0
- petrilya/export/pdf_report.py +153 -0
- petrilya/inference/__init__.py +0 -0
- petrilya/inference/engine.py +28 -0
- petrilya/io/__init__.py +0 -0
- petrilya/metrics/__init__.py +0 -0
- petrilya/metrics/colony.py +38 -0
- petrilya/ui/__init__.py +1 -0
- petrilya/ui/app.py +38 -0
- petrilya/ui/image_view.py +264 -0
- petrilya/ui/main_window.py +573 -0
- petrilya/ui/mock_engine.py +45 -0
- petrilya/ui/worker.py +202 -0
- petrilya-0.0.1.dist-info/METADATA +784 -0
- petrilya-0.0.1.dist-info/RECORD +23 -0
- petrilya-0.0.1.dist-info/WHEEL +5 -0
- petrilya-0.0.1.dist-info/entry_points.txt +3 -0
- petrilya-0.0.1.dist-info/licenses/LICENSE +661 -0
- petrilya-0.0.1.dist-info/top_level.txt +1 -0
petrilya/__init__.py
ADDED
petrilya/cli.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Command-line interface for Petrilya."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import numpy as np
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from petrilya.export.csv_writer import write_csv
|
|
14
|
+
from petrilya.inference.engine import CellposeEngine
|
|
15
|
+
from petrilya.metrics.colony import compute_colony_metrics
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command()
|
|
21
|
+
@click.argument("image_path", type=click.Path(exists=True, path_type=Path))
|
|
22
|
+
@click.option("--output", "-o", type=click.Path(path_type=Path), default=None)
|
|
23
|
+
@click.option("--gpu/--no-gpu", default=False, help="Use CUDA if available.")
|
|
24
|
+
@click.option(
|
|
25
|
+
"--diameter",
|
|
26
|
+
type=float,
|
|
27
|
+
default=None,
|
|
28
|
+
help="Approximate colony diameter in pixels (auto if omitted).",
|
|
29
|
+
)
|
|
30
|
+
def main(
|
|
31
|
+
image_path: Path,
|
|
32
|
+
output: Path | None,
|
|
33
|
+
gpu: bool,
|
|
34
|
+
diameter: float | None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Analyze a petri dish image and export colony metrics."""
|
|
37
|
+
output = output or image_path.with_suffix(".csv")
|
|
38
|
+
|
|
39
|
+
console.print(f"[cyan]Loading[/] {image_path}")
|
|
40
|
+
image = np.array(Image.open(image_path).convert("L"))
|
|
41
|
+
console.print(f" shape={image.shape} dtype={image.dtype}")
|
|
42
|
+
|
|
43
|
+
console.print(f"[cyan]Initializing Cellpose[/] (gpu={gpu})")
|
|
44
|
+
engine = CellposeEngine(use_gpu=gpu)
|
|
45
|
+
|
|
46
|
+
t0 = time.perf_counter()
|
|
47
|
+
masks, diam = engine.segment(image, diameter=diameter)
|
|
48
|
+
elapsed = time.perf_counter() - t0
|
|
49
|
+
|
|
50
|
+
metrics = compute_colony_metrics(masks)
|
|
51
|
+
write_csv(metrics, output)
|
|
52
|
+
|
|
53
|
+
console.print(
|
|
54
|
+
f"[green]OK[/] Found [bold]{len(metrics)}[/] colonies "
|
|
55
|
+
f"(diam~{diam:.1f}px) in [bold]{elapsed:.2f}s[/]"
|
|
56
|
+
)
|
|
57
|
+
console.print(f"[green]OK[/] Saved {output}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""CSV export for colony metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
BASE_HEADERS = [
|
|
10
|
+
"id",
|
|
11
|
+
"area_px",
|
|
12
|
+
"centroid_y",
|
|
13
|
+
"centroid_x",
|
|
14
|
+
"equivalent_diameter_px",
|
|
15
|
+
"eccentricity",
|
|
16
|
+
"solidity",
|
|
17
|
+
]
|
|
18
|
+
PHYSICAL_HEADERS = ["area_um2", "equivalent_diameter_um"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_csv(metrics: list[dict], output_path: Path) -> None:
|
|
22
|
+
"""Write a list of metric dicts to CSV.
|
|
23
|
+
|
|
24
|
+
Auto-detects whether physical-unit columns (um2, um) are present
|
|
25
|
+
and includes them only if so.
|
|
26
|
+
"""
|
|
27
|
+
headers = list(BASE_HEADERS)
|
|
28
|
+
if metrics and "area_um2" in metrics[0]:
|
|
29
|
+
headers.extend(PHYSICAL_HEADERS)
|
|
30
|
+
|
|
31
|
+
with output_path.open("w", newline="", encoding="utf-8") as f:
|
|
32
|
+
writer = csv.DictWriter(f, fieldnames=headers, extrasaction="ignore")
|
|
33
|
+
writer.writeheader()
|
|
34
|
+
writer.writerows(metrics)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Run manifest JSON for reproducibility.
|
|
2
|
+
|
|
3
|
+
Captures every parameter that affected a run so the result can be
|
|
4
|
+
reproduced or audited later (important for scientific publication
|
|
5
|
+
and GMP-relevant workflows).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import platform
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from petrilya import __version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def file_sha256(path: Path, chunk: int = 1 << 20) -> str:
|
|
20
|
+
h = hashlib.sha256()
|
|
21
|
+
with path.open("rb") as f:
|
|
22
|
+
while True:
|
|
23
|
+
buf = f.read(chunk)
|
|
24
|
+
if not buf:
|
|
25
|
+
break
|
|
26
|
+
h.update(buf)
|
|
27
|
+
return h.hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_manifest(
|
|
31
|
+
*,
|
|
32
|
+
image_path: Path,
|
|
33
|
+
masks_shape: tuple[int, int],
|
|
34
|
+
n_objects: int,
|
|
35
|
+
elapsed_seconds: float,
|
|
36
|
+
engine_name: str,
|
|
37
|
+
engine_params: dict,
|
|
38
|
+
scale_um_per_px: float | None = None,
|
|
39
|
+
) -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"schema_version": 1,
|
|
42
|
+
"petrilya_version": __version__,
|
|
43
|
+
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
|
|
44
|
+
"platform": {
|
|
45
|
+
"system": platform.system(),
|
|
46
|
+
"release": platform.release(),
|
|
47
|
+
"python": platform.python_version(),
|
|
48
|
+
},
|
|
49
|
+
"input": {
|
|
50
|
+
"path": str(image_path),
|
|
51
|
+
"filename": image_path.name,
|
|
52
|
+
"sha256": file_sha256(image_path),
|
|
53
|
+
"size_bytes": image_path.stat().st_size,
|
|
54
|
+
},
|
|
55
|
+
"engine": {
|
|
56
|
+
"name": engine_name,
|
|
57
|
+
"params": engine_params,
|
|
58
|
+
},
|
|
59
|
+
"result": {
|
|
60
|
+
"n_objects": n_objects,
|
|
61
|
+
"masks_shape": list(masks_shape),
|
|
62
|
+
"elapsed_seconds": round(elapsed_seconds, 4),
|
|
63
|
+
"scale_um_per_px": scale_um_per_px,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write_manifest(manifest: dict, output_path: Path) -> None:
|
|
69
|
+
output_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Generate a one-page PDF report for a single image analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from reportlab.lib import colors
|
|
12
|
+
from reportlab.lib.pagesizes import A4
|
|
13
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
14
|
+
from reportlab.lib.units import cm
|
|
15
|
+
from reportlab.platypus import (
|
|
16
|
+
Image as RLImage,
|
|
17
|
+
Paragraph,
|
|
18
|
+
SimpleDocTemplate,
|
|
19
|
+
Spacer,
|
|
20
|
+
Table,
|
|
21
|
+
TableStyle,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _composite_preview(image: np.ndarray, masks: np.ndarray) -> Image.Image:
|
|
26
|
+
"""Compose grayscale image + colored mask overlay into a PIL image."""
|
|
27
|
+
if image.ndim == 2:
|
|
28
|
+
rgb = np.stack([image] * 3, axis=-1)
|
|
29
|
+
else:
|
|
30
|
+
rgb = image.copy()
|
|
31
|
+
|
|
32
|
+
overlay = np.zeros_like(rgb)
|
|
33
|
+
rng = np.random.default_rng(0)
|
|
34
|
+
n = int(masks.max()) + 1
|
|
35
|
+
if n > 1:
|
|
36
|
+
palette = rng.integers(60, 230, size=(n, 3), dtype=np.uint8)
|
|
37
|
+
palette[0] = 0
|
|
38
|
+
overlay = palette[masks].astype(np.uint8)
|
|
39
|
+
|
|
40
|
+
blended = (0.6 * rgb + 0.4 * overlay).clip(0, 255).astype(np.uint8)
|
|
41
|
+
blended[masks == 0] = rgb[masks == 0]
|
|
42
|
+
return Image.fromarray(blended)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _histogram_png(
|
|
46
|
+
areas: list[float],
|
|
47
|
+
label: str,
|
|
48
|
+
bins: int = 30,
|
|
49
|
+
) -> io.BytesIO:
|
|
50
|
+
"""Render a histogram of areas as PNG bytes (uses matplotlib lazily)."""
|
|
51
|
+
import matplotlib
|
|
52
|
+
|
|
53
|
+
matplotlib.use("Agg")
|
|
54
|
+
import matplotlib.pyplot as plt
|
|
55
|
+
|
|
56
|
+
fig, ax = plt.subplots(figsize=(6, 3), dpi=110)
|
|
57
|
+
ax.hist(areas, bins=bins, color="#5388c8", edgecolor="white")
|
|
58
|
+
ax.set_xlabel(label)
|
|
59
|
+
ax.set_ylabel("count")
|
|
60
|
+
ax.set_title("Colony size distribution")
|
|
61
|
+
fig.tight_layout()
|
|
62
|
+
|
|
63
|
+
buf = io.BytesIO()
|
|
64
|
+
fig.savefig(buf, format="png")
|
|
65
|
+
plt.close(fig)
|
|
66
|
+
buf.seek(0)
|
|
67
|
+
return buf
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_pdf_report(
|
|
71
|
+
output_path: Path,
|
|
72
|
+
*,
|
|
73
|
+
image: np.ndarray,
|
|
74
|
+
masks: np.ndarray,
|
|
75
|
+
metrics: list[dict],
|
|
76
|
+
image_name: str,
|
|
77
|
+
elapsed_seconds: float,
|
|
78
|
+
engine_name: str,
|
|
79
|
+
scale_um_per_px: float | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Render a one-page PDF report and write it to ``output_path``."""
|
|
82
|
+
styles = getSampleStyleSheet()
|
|
83
|
+
doc = SimpleDocTemplate(
|
|
84
|
+
str(output_path),
|
|
85
|
+
pagesize=A4,
|
|
86
|
+
leftMargin=1.5 * cm,
|
|
87
|
+
rightMargin=1.5 * cm,
|
|
88
|
+
topMargin=1.5 * cm,
|
|
89
|
+
bottomMargin=1.5 * cm,
|
|
90
|
+
title=f"Petrilya report — {image_name}",
|
|
91
|
+
)
|
|
92
|
+
story = []
|
|
93
|
+
|
|
94
|
+
story.append(Paragraph("<b>Petrilya — Colony Analysis Report</b>", styles["Title"]))
|
|
95
|
+
story.append(Spacer(1, 0.3 * cm))
|
|
96
|
+
|
|
97
|
+
meta_rows = [
|
|
98
|
+
["Image", image_name],
|
|
99
|
+
["Generated", datetime.now().strftime("%Y-%m-%d %H:%M:%S")],
|
|
100
|
+
["Engine", engine_name],
|
|
101
|
+
["Inference time", f"{elapsed_seconds:.2f} s"],
|
|
102
|
+
["Colonies found", str(len(metrics))],
|
|
103
|
+
]
|
|
104
|
+
if scale_um_per_px:
|
|
105
|
+
meta_rows.append(["Scale", f"{scale_um_per_px:.4f} um/px"])
|
|
106
|
+
|
|
107
|
+
meta_table = Table(meta_rows, colWidths=[4 * cm, 12 * cm])
|
|
108
|
+
meta_table.setStyle(
|
|
109
|
+
TableStyle(
|
|
110
|
+
[
|
|
111
|
+
("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
|
|
112
|
+
("FONTSIZE", (0, 0), (-1, -1), 9),
|
|
113
|
+
("BACKGROUND", (0, 0), (0, -1), colors.lightgrey),
|
|
114
|
+
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
|
115
|
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
116
|
+
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
|
117
|
+
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
story.append(meta_table)
|
|
122
|
+
story.append(Spacer(1, 0.5 * cm))
|
|
123
|
+
|
|
124
|
+
# preview
|
|
125
|
+
preview = _composite_preview(image, masks)
|
|
126
|
+
preview.thumbnail((1200, 1200))
|
|
127
|
+
preview_buf = io.BytesIO()
|
|
128
|
+
preview.save(preview_buf, format="PNG")
|
|
129
|
+
preview_buf.seek(0)
|
|
130
|
+
story.append(RLImage(preview_buf, width=16 * cm, height=16 * cm * preview.size[1] / preview.size[0]))
|
|
131
|
+
story.append(Spacer(1, 0.4 * cm))
|
|
132
|
+
|
|
133
|
+
# histogram
|
|
134
|
+
if metrics:
|
|
135
|
+
if scale_um_per_px and "area_um2" in metrics[0]:
|
|
136
|
+
areas = [m["area_um2"] for m in metrics]
|
|
137
|
+
label = "Area (um^2)"
|
|
138
|
+
else:
|
|
139
|
+
areas = [m["area_px"] for m in metrics]
|
|
140
|
+
label = "Area (px)"
|
|
141
|
+
hist = _histogram_png(areas, label)
|
|
142
|
+
story.append(RLImage(hist, width=14 * cm, height=7 * cm))
|
|
143
|
+
|
|
144
|
+
story.append(Spacer(1, 0.3 * cm))
|
|
145
|
+
story.append(
|
|
146
|
+
Paragraph(
|
|
147
|
+
"<font size=8 color='grey'>Generated by Petrilya. "
|
|
148
|
+
"Open-source: github.com/petrilya-app/petrilya-core</font>",
|
|
149
|
+
styles["Normal"],
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
doc.build(story)
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Cellpose-based segmentation engine for colony counting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from cellpose import models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CellposeEngine:
|
|
10
|
+
"""Wrapper around Cellpose for colony segmentation."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, model_type: str = "cyto3", use_gpu: bool = False) -> None:
|
|
13
|
+
self.use_gpu = use_gpu
|
|
14
|
+
self.model_type = model_type
|
|
15
|
+
self.model = models.Cellpose(gpu=use_gpu, model_type=model_type)
|
|
16
|
+
|
|
17
|
+
def segment(
|
|
18
|
+
self,
|
|
19
|
+
image: np.ndarray,
|
|
20
|
+
diameter: float | None = None,
|
|
21
|
+
) -> tuple[np.ndarray, float]:
|
|
22
|
+
"""Run segmentation. Returns (masks, estimated_diameter)."""
|
|
23
|
+
masks, _flows, _styles, diams = self.model.eval(
|
|
24
|
+
image,
|
|
25
|
+
diameter=diameter,
|
|
26
|
+
channels=[0, 0],
|
|
27
|
+
)
|
|
28
|
+
return masks, float(diams) if np.isscalar(diams) else float(diams[0])
|
petrilya/io/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Compute per-colony metrics from segmentation masks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from skimage import measure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_colony_metrics(
|
|
10
|
+
masks: np.ndarray,
|
|
11
|
+
scale_um_per_px: float | None = None,
|
|
12
|
+
) -> list[dict]:
|
|
13
|
+
"""Compute area, centroid, equivalent diameter for each colony.
|
|
14
|
+
|
|
15
|
+
If ``scale_um_per_px`` is provided, also report area_um2 and
|
|
16
|
+
equivalent_diameter_um in physical units.
|
|
17
|
+
"""
|
|
18
|
+
if masks.max() == 0:
|
|
19
|
+
return []
|
|
20
|
+
props = measure.regionprops(masks)
|
|
21
|
+
out: list[dict] = []
|
|
22
|
+
for p in props:
|
|
23
|
+
rec = {
|
|
24
|
+
"id": int(p.label),
|
|
25
|
+
"area_px": int(p.area),
|
|
26
|
+
"centroid_y": float(p.centroid[0]),
|
|
27
|
+
"centroid_x": float(p.centroid[1]),
|
|
28
|
+
"equivalent_diameter_px": float(p.equivalent_diameter_area),
|
|
29
|
+
"eccentricity": float(p.eccentricity),
|
|
30
|
+
"solidity": float(p.solidity),
|
|
31
|
+
}
|
|
32
|
+
if scale_um_per_px is not None and scale_um_per_px > 0:
|
|
33
|
+
rec["area_um2"] = float(p.area) * scale_um_per_px**2
|
|
34
|
+
rec["equivalent_diameter_um"] = (
|
|
35
|
+
float(p.equivalent_diameter_area) * scale_um_per_px
|
|
36
|
+
)
|
|
37
|
+
out.append(rec)
|
|
38
|
+
return out
|
petrilya/ui/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Petrilya desktop UI (PySide6)."""
|
petrilya/ui/app.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""UI entry point: ``petrilya-ui`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from PySide6.QtWidgets import QApplication
|
|
8
|
+
|
|
9
|
+
from petrilya.ui.main_window import MainWindow
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
app = QApplication(sys.argv)
|
|
14
|
+
app.setApplicationName("Petrilya")
|
|
15
|
+
app.setStyle("Fusion")
|
|
16
|
+
|
|
17
|
+
# simple dark palette
|
|
18
|
+
from PySide6.QtGui import QColor, QPalette
|
|
19
|
+
|
|
20
|
+
palette = QPalette()
|
|
21
|
+
palette.setColor(QPalette.ColorRole.Window, QColor(45, 45, 45))
|
|
22
|
+
palette.setColor(QPalette.ColorRole.WindowText, QColor(220, 220, 220))
|
|
23
|
+
palette.setColor(QPalette.ColorRole.Base, QColor(30, 30, 30))
|
|
24
|
+
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(45, 45, 45))
|
|
25
|
+
palette.setColor(QPalette.ColorRole.Text, QColor(220, 220, 220))
|
|
26
|
+
palette.setColor(QPalette.ColorRole.Button, QColor(60, 60, 60))
|
|
27
|
+
palette.setColor(QPalette.ColorRole.ButtonText, QColor(220, 220, 220))
|
|
28
|
+
palette.setColor(QPalette.ColorRole.Highlight, QColor(80, 130, 200))
|
|
29
|
+
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255))
|
|
30
|
+
app.setPalette(palette)
|
|
31
|
+
|
|
32
|
+
window = MainWindow()
|
|
33
|
+
window.show()
|
|
34
|
+
sys.exit(app.exec())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|