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 ADDED
@@ -0,0 +1,3 @@
1
+ """Petrilya — AI colony counter for biology labs."""
2
+
3
+ __version__ = "0.0.1"
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])
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
@@ -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()