1# -*- coding: utf-8 -*-
2"""
3Created on Thu Apr 13 14:02:27 2023
4
5@author: Bart Steemans. Govers Lab.
6"""
7
8import matplotlib.pyplot as plt
9import numpy as np
10import pandas as pd
11import random
12from pathlib import Path
13from matplotlib.colors import LinearSegmentedColormap, ListedColormap
14from matplotlib.lines import Line2D
15from skimage.color import label2rgb
16from skimage.measure import label
17from . import utilities as u
18import tifffile
19from skimage.segmentation import find_boundaries
20from scipy.spatial import cKDTree
21dpi = 300
22
23import numpy as np
24import matplotlib.pyplot as plt
25
26
27def _tutorial_contrast(image):
28 """Return a display range without changing the measured image."""
29 low, high = np.nanpercentile(image, (1, 99.7))
30 return (low, high if high > low else low + 1)
31
32
33def _tutorial_crop(shape, center, window_px):
34 half = window_px // 2
35 y = int(np.clip(center[0] - half, 0, max(shape[0] - window_px, 0)))
36 x = int(np.clip(center[1] - half, 0, max(shape[1] - window_px, 0)))
37 return slice(y, min(y + window_px, shape[0])), slice(x, min(x + window_px, shape[1]))
38
39
40def pick_representative_cell(image_objects, margin_px=20):
41 """Pick an interior, isolated, median-sized cell with valid geometry.
42
43 The rule is deterministic and uses geometry only, never fluorescence intensity.
44 Returns the image object and cell ID so the same cell can be revisited.
45 """
46 candidates = []
47 for image in image_objects:
48 height, width = image.image.shape
49 for cell in getattr(image, "cells", []):
50 contour = np.asarray(cell.contour)
51 mesh = np.asarray(cell.mesh)
52 if (contour.ndim != 2 or contour.shape[1] != 2 or len(contour) < 6
53 or mesh.ndim != 2 or mesh.shape[1] != 4 or len(mesh) < 4
54 or not np.isfinite(contour).all() or not np.isfinite(mesh).all()):
55 continue
56 low = contour.min(axis=0)
57 high = contour.max(axis=0)
58 if (low[0] < margin_px or low[1] < margin_px
59 or high[0] > height - margin_px or high[1] > width - margin_px):
60 continue
61 box = high - low
62 long_side, short_side = max(box), min(box)
63 if short_side < 5 or long_side / short_side < 1.5:
64 continue
65 center = contour.mean(axis=0)
66 center_distance = np.linalg.norm(center / [height, width] - 0.5)
67 candidates.append((image, cell.cell_id, long_side, center_distance, center))
68 if not candidates:
69 raise ValueError("No interior cells with valid elongated contours and meshes")
70 median_length = np.median([item[2] for item in candidates])
71 typical = [item for item in candidates if abs(item[2] - median_length) <= 0.25 * median_length]
72 neighbors = {}
73 for image in image_objects:
74 group = [item for item in candidates if item[0] is image]
75 if not group:
76 continue
77 all_centers = [np.asarray(cell.contour).mean(axis=0)
78 for cell in image.cells
79 if np.asarray(cell.contour).ndim == 2
80 and np.asarray(cell.contour).shape[1] == 2
81 and len(cell.contour) > 0]
82 if len(all_centers) < 2:
83 for item in group:
84 neighbors[(image.image_name, item[1])] = np.inf
85 continue
86 distances = cKDTree(all_centers).query([item[4] for item in group], k=2)[0]
87 for item, distance in zip(group, distances):
88 neighbors[(image.image_name, item[1])] = distance[1] / item[2]
89 winner = min(
90 typical,
91 key=lambda item: (-neighbors[(item[0].image_name, item[1])],
92 item[3], abs(item[2] - median_length),
93 item[0].image_name, item[1]),
94 )
95 return winner[0], winner[1]
96
97
98def select_example_cell(image_objects, field_id=None, cell_id=None):
99 """Choose an existing field/cell, or a representative cell in the chosen field."""
100 objects = list(image_objects)
101 if field_id is not None:
102 objects = [image for image in objects if image.image_name.rsplit("_", 1)[0] == field_id]
103 if not objects:
104 raise ValueError(f"Field {field_id!r} was not found")
105 if cell_id is None:
106 return pick_representative_cell(objects)
107 if field_id is None:
108 raise ValueError("Set field_id when selecting a cell_id")
109 for image in objects:
110 if any(cell.cell_id == cell_id for cell in image.cells):
111 return image, cell_id
112 raise ValueError(f"Cell {cell_id} was not found in field {field_id}")
113
114
115def _tutorial_channel_color(label):
116 label = label.casefold()
117 if "nucleoid" in label or "dapi" in label:
118 return "#1DBCD3"
119 if "rna" in label or "gfp" in label:
120 return "#2BAE66"
121 if "fm" in label or "membrane" in label:
122 return "#D85755"
123 return "#197E89"
124
125
126def _tutorial_channel_cmap(label):
127 color = _tutorial_channel_color(label)
128 return LinearSegmentedColormap.from_list(
129 f"bactoscoop_{label.replace(' ', '_')}", ["#000000", color]
130 )
131
132
133def plot_field_channels(dataset_dir, field_id, channel_labels, crop_size=650):
134 """Preview the same pixel window across the named TIFF channels."""
135 dataset_dir = Path(dataset_dir)
136 labels = dict(channel_labels)
137 if not labels:
138 raise ValueError("channel_labels is empty")
139 first = tifffile.imread(dataset_dir / f"{field_id}_{next(iter(labels))}.tiff")
140 crop = _tutorial_crop(first.shape, (first.shape[0] // 2, first.shape[1] // 2), crop_size)
141 fig, axes = plt.subplots(1, len(labels), figsize=(3.8 * len(labels), 4))
142 for ax, (channel, label) in zip(np.atleast_1d(axes), labels.items()):
143 image = tifffile.imread(dataset_dir / f"{field_id}_{channel}.tiff")
144 if image.shape != first.shape:
145 raise ValueError(f"{channel} dimensions do not match the first channel")
146 data = image[crop]
147 low, high = _tutorial_contrast(data)
148 cmap = "gray" if channel == next(iter(labels)) else _tutorial_channel_cmap(label)
149 ax.imshow(data, cmap=cmap, vmin=low, vmax=high)
150 ax.set_title(f"{channel} {label.upper()}", loc="left", fontsize=10, fontweight="bold", color="#193348")
151 ax.set_xticks([])
152 ax.set_yticks([])
153 for spine in ax.spines.values():
154 spine.set_visible(False)
155 fig.suptitle(f"CHANNELS / {field_id}", x=0.02, y=0.98, ha="left", fontsize=14, fontweight="bold", color="#193348")
156 fig.tight_layout(rect=(0, 0, 1, 0.98))
157 return fig
158
159
160def plot_segmentation_review(image_objects, crop_size=500, mask_label=None, center=None):
161 """Compare phase and mask outlines in a selectable cell-centered crop."""
162 objects = list(image_objects)
163 if not objects:
164 raise ValueError("image_objects is empty")
165 fig, axes = plt.subplots(len(objects), 2, figsize=(10, 4.5 * len(objects)), squeeze=False)
166 for row, image in enumerate(objects):
167 if image.mask is None:
168 raise ValueError(f"No mask is loaded for {image.image_name}")
169 labels = np.unique(image.mask)
170 labels = labels[labels > 0]
171 if mask_label is not None and mask_label not in labels:
172 raise ValueError(f"Mask label {mask_label} was not found in {image.image_name}")
173 chosen = mask_label
174 if chosen is None and center is None and len(labels):
175 midpoint = np.asarray(image.image.shape) / 2
176 nonzero = np.argwhere(image.mask > 0)
177 closest = np.argmin(np.sum((nonzero - midpoint) ** 2, axis=1))
178 chosen = image.mask[tuple(nonzero[closest])]
179 if chosen is not None:
180 focus = np.argwhere(image.mask == chosen).mean(axis=0)
181 else:
182 focus = center if center is not None else np.asarray(image.image.shape) / 2
183 crop = _tutorial_crop(image.image.shape, focus, crop_size)
184 phase, mask = image.image[crop], image.mask[crop]
185 low, high = _tutorial_contrast(phase)
186 for ax in axes[row]:
187 ax.imshow(phase, cmap="gray", vmin=low, vmax=high)
188 ax.set_xticks([])
189 ax.set_yticks([])
190 for spine in ax.spines.values():
191 spine.set_visible(False)
192 outlines = np.ma.masked_where(~find_boundaries(mask, mode="outer"), np.ones(mask.shape))
193 axes[row, 1].imshow(outlines, cmap="autumn", vmin=0, vmax=1, alpha=0.9, interpolation="nearest")
194 if chosen is not None:
195 selected_outline = find_boundaries(mask == chosen, mode="outer")
196 axes[row, 1].imshow(np.ma.masked_where(~selected_outline, np.ones(mask.shape)),
197 cmap="autumn", vmin=0, vmax=1,
198 interpolation="nearest")
199 field = image.image_name.rsplit("_", 1)[0]
200 axes[row, 0].set_title(f"{field} | phase", loc="left", fontsize=11)
201 axes[row, 1].set_title(f"Mask outlines | selected label {chosen}", loc="left", fontsize=11)
202 fig.suptitle("SEGMENTATION REVIEW", x=0.04, y=0.98, ha="left", fontsize=15, fontweight="bold", color="#193348")
203 fig.tight_layout(rect=(0, 0, 1, 0.91))
204 return fig
205
206
207def plot_cell_gallery(image_object, cell_id, channel="C2", window_px=110,
208 channel_label=None, align_signal=False, show_objects=False):
209 """Show phase and mesh, optionally with a registered signal crop and objects.
210
211 The registered crop uses the same local crop/phase registration called by
212 ``object_detection(align=True)``. Plot it after detection to compare its
213 detected object contours with the phase-cell contour.
214 """
215 cell = next((item for item in image_object.cells if item.cell_id == cell_id), None)
216 if cell is None:
217 raise ValueError(f"Cell {cell_id} was not found")
218 if channel is not None and (not image_object.channels or channel not in image_object.channels):
219 raise ValueError(f"Channel {channel} is not loaded")
220 contour = np.asarray(cell.contour)
221 mesh = np.asarray(cell.mesh)
222 midline = np.asarray(cell.midline)
223 extent = int(np.ceil(np.max(np.ptp(contour, axis=0)))) + 24
224 crop = _tutorial_crop(image_object.image.shape, contour.mean(axis=0), max(window_px, extent))
225 phase = image_object.image[crop]
226 label = channel_label or channel
227 panels = [(phase, "gray", "01 PHASE"), (phase, "gray", "02 CONTOUR + MESH")]
228 signal_origin = (crop[1].start, crop[0].start)
229 if channel is not None:
230 signal = image_object.channels[channel][crop]
231 if align_signal:
232 signal, _, _, row_offset, column_offset = u.crop_image(
233 image_object.channels[channel], cell.contour,
234 phase_img=image_object.image,
235 )
236 signal_origin = (column_offset, row_offset)
237 heading = "ALIGNED " if align_signal else ""
238 panels.append((signal, _tutorial_channel_cmap(label),
239 f"03 {heading}{channel} {label.upper()}"))
240 fig, axes = plt.subplots(1, len(panels), figsize=(3.7 * len(panels), 3.9))
241 for ax, (data, cmap, title) in zip(axes, panels):
242 low, high = _tutorial_contrast(data)
243 ax.imshow(data, cmap=cmap, vmin=low, vmax=high)
244 ax.set_title(title, loc="left", fontsize=10, fontweight="bold", color="#193348")
245 ax.set_xticks([])
246 ax.set_yticks([])
247 for spine in ax.spines.values():
248 spine.set_visible(False)
249 x0, y0 = crop[1].start, crop[0].start
250 axes[1].plot(contour[:, 1] - x0, contour[:, 0] - y0, color="#FFCC66", lw=1.6)
251 if channel is not None:
252 axes[2].plot(contour[:, 1] - signal_origin[0], contour[:, 0] - signal_origin[1], color="#FFCC66", lw=1.6)
253 if show_objects:
254 for detected in cell.object_meshdata.get(channel, {}).get("object_contour", []):
255 detected = np.asarray(detected)
256 if detected.ndim == 2 and detected.shape[1] == 2:
257 axes[2].plot(detected[:, 1] - signal_origin[0],
258 detected[:, 0] - signal_origin[1],
259 color="#4DE6F2", lw=1.2)
260 if mesh.ndim == 2 and mesh.shape[1] == 4:
261 axes[1].plot(mesh[:, 1] - x0, mesh[:, 0] - y0, color="#36D6D0", lw=0.7)
262 axes[1].plot(mesh[:, 3] - x0, mesh[:, 2] - y0, color="#36D6D0", lw=0.7)
263 for segment in mesh[::3]:
264 axes[1].plot([segment[1] - x0, segment[3] - x0], [segment[0] - y0, segment[2] - y0], color="#36D6D0", lw=0.55)
265 if midline.ndim == 2 and midline.shape[1] == 2:
266 axes[1].plot(midline[:, 1] - x0, midline[:, 0] - y0, color="#F26463", lw=1.3)
267 max_scale_px = phase.shape[1] * 0.45
268 scale_um = next(
269 (value for value in (5, 2, 1, 0.5, 0.2, 0.1)
270 if value / image_object.px <= max_scale_px),
271 0.1,
272 )
273 scale_px = max(1, round(scale_um / image_object.px))
274 axes[0].plot([7, 7 + scale_px], [phase.shape[0] - 9] * 2, color="white", lw=3, solid_capstyle="butt")
275 axes[0].text(7, phase.shape[0] - 14, f"{scale_um:g} Β΅m", color="white", fontsize=8)
276 fig.suptitle(f"CELL {cell_id} / {image_object.image_name}", x=0.02, y=0.98, ha="left", fontsize=13, fontweight="bold", color="#193348")
277 fig.tight_layout(rect=(0, 0, 1, 0.93))
278 return fig
279
280def plot_C2_alignment(image_object, cell_id, window_px=110,
281 channel="C2", channel_label=None,
282 show_objects=False):
283 """
284 Compare the raw C2 signal with the phase-aligned C2 signal.
285
286 BEFORE: original fluorescence image, cropped around the cell.
287 AFTER: fluorescence crop returned by u.crop_image(..., phase_img=...).
288
289 Cell contours are overlaid using the correct origin for each crop.
290 Detected object contours can optionally be shown on the aligned panel.
291 """
292
293 cell = next(
294 (item for item in image_object.cells if item.cell_id == cell_id),
295 None,
296 )
297 if cell is None:
298 raise ValueError(f"Cell {cell_id} was not found")
299
300 if not image_object.channels or channel not in image_object.channels:
301 raise ValueError(f"Channel {channel} is not loaded")
302
303 contour = np.asarray(cell.contour)
304 label = channel_label or channel
305
306 # BEFORE: use the original image and the same cropping method
307 # as plot_cell_gallery().
308 extent = int(np.ceil(np.max(np.ptp(contour, axis=0)))) + 24
309
310 crop = _tutorial_crop(
311 image_object.image.shape,
312 contour.mean(axis=0),
313 max(window_px, extent),
314 )
315
316 signal_before = image_object.channels[channel][crop]
317
318 # Crop origin in image coordinates: (column, row).
319 origin_before = (
320 crop[1].start,
321 crop[0].start,
322 )
323
324 # AFTER: apply the same local registration as in plot_cell_gallery().
325 signal_after, _, _, row_offset, column_offset = u.crop_image(
326 image_object.channels[channel],
327 cell.contour,
328 phase_img=image_object.image,
329 )
330
331 # The function returns row offset first, column offset second.
332 # For plotting, x = column and y = row.
333 origin_after = (
334 column_offset,
335 row_offset,
336 )
337
338 panels = [
339 (signal_before, origin_before, f"01 UNALIGNED {channel}"),
340 (signal_after, origin_after, f"02 ALIGNED {channel}"),
341 ]
342
343 fig, axes = plt.subplots(
344 1, 2,
345 figsize=(10, 4.8),
346 )
347
348 # Use identical contrast limits in both panels.
349 combined = np.concatenate([
350 signal_before.ravel(),
351 signal_after.ravel(),
352 ])
353 combined = combined[np.isfinite(combined)]
354
355 if combined.size == 0:
356 raise ValueError("Both signal crops contain no finite values")
357
358 low, high = np.percentile(combined, [1, 99.5])
359
360 if low == high:
361 high = low + 1
362
363 for ax, (signal, origin, title) in zip(axes, panels):
364
365 ax.imshow(
366 signal,
367 cmap=_tutorial_channel_cmap(label),
368 vmin=low,
369 vmax=high,
370 )
371
372 ax.set_title(
373 title,
374 loc="left",
375 fontsize=11,
376 fontweight="bold",
377 color="#193348",
378 )
379
380 ax.set_xticks([])
381 ax.set_yticks([])
382
383 for spine in ax.spines.values():
384 spine.set_visible(False)
385
386 # Cell contours use (row, column).
387 # Matplotlib uses (x=column, y=row).
388 x0, y0 = origin
389
390 ax.plot(
391 contour[:, 1] - x0,
392 contour[:, 0] - y0,
393 color="#FFCC66",
394 lw=1.6,
395 )
396
397 # Show detected objects only on the aligned panel.
398 # These contours are taken directly from the cell object.
399 if show_objects:
400 x0, y0 = origin_after
401
402 for detected in cell.object_meshdata.get(
403 channel, {}
404 ).get("object_contour", []):
405
406 detected = np.asarray(detected)
407
408 if detected.ndim == 2 and detected.shape[1] == 2:
409 axes[1].plot(
410 detected[:, 1] - x0,
411 detected[:, 0] - y0,
412 color="#4DE6F2",
413 lw=1.2,
414 )
415
416 fig.suptitle(
417 f"{channel} ALIGNMENT / CELL {cell_id} / "
418 f"{image_object.image_name}",
419 x=0.02,
420 y=0.98,
421 ha="left",
422 fontsize=13,
423 fontweight="bold",
424 color="#193348",
425 )
426
427 fig.tight_layout(rect=(0, 0, 1, 0.93))
428
429 return fig
430def snapshot_cell_contours(image_objects):
431 """Copy cell IDs and contours before curation changes the collection."""
432 return {
433 image.image_name: {
434 cell.cell_id: np.asarray(cell.contour).copy() for cell in image.cells
435 }
436 for image in image_objects
437 }
438
439
440def plot_curation_review(before_contours, image_objects, crop_size=700):
441 """Show retained cells in green and removed cells in red on phase images.
442
443 ``before_contours`` is made by :func:`snapshot_cell_contours` before
444 curation. Cells are matched by image name and cell ID.
445 """
446 objects = list(image_objects)
447 if not objects:
448 raise ValueError("image_objects is empty")
449 fig, axes = plt.subplots(1, len(objects), figsize=(5.2 * len(objects), 5.2), squeeze=False)
450 for ax, image in zip(axes[0], objects):
451 original = before_contours.get(image.image_name)
452 if original is None:
453 raise ValueError(f"No pre-curation contours for {image.image_name}")
454 kept = {cell.cell_id for cell in image.cells}
455 if not kept.issubset(original):
456 raise ValueError(f"Curated cell IDs do not match the original meshes for {image.image_name}")
457 for cell in image.cells:
458 old_center = np.asarray(original[cell.cell_id]).mean(axis=0)
459 new_center = np.asarray(cell.contour).mean(axis=0)
460 if np.linalg.norm(old_center - new_center) > 1:
461 raise ValueError(f"Curated cell geometry does not match the original mesh for {image.image_name}")
462 crop = _tutorial_crop(
463 image.image.shape,
464 (image.image.shape[0] // 2, image.image.shape[1] // 2),
465 crop_size,
466 )
467 phase = image.image[crop]
468 low, high = _tutorial_contrast(phase)
469 ax.imshow(phase, cmap="gray", vmin=low, vmax=high)
470 x0, y0 = crop[1].start, crop[0].start
471 for cell_id, contour in original.items():
472 contour = np.asarray(contour)
473 margin = 3
474 fully_inside = (
475 (contour[:, 0] >= crop[0].start + margin)
476 & (contour[:, 0] < crop[0].stop - margin)
477 & (contour[:, 1] >= crop[1].start + margin)
478 & (contour[:, 1] < crop[1].stop - margin)
479 )
480 if not fully_inside.all():
481 continue
482 keep = cell_id in kept
483 ax.plot(
484 contour[:, 1] - x0, contour[:, 0] - y0,
485 color="#34D986" if keep else "#FF686B",
486 lw=1.15 if keep else 0.85,
487 alpha=0.95 if keep else 0.82,
488 )
489 field = image.image_name.rsplit("_", 1)[0]
490 ax.set_title(
491 f"{field}\n{len(kept):,} kept Β· {len(original) - len(kept):,} removed",
492 loc="left", fontsize=11, color="#193348",
493 )
494 ax.set_xticks([])
495 ax.set_yticks([])
496 for spine in ax.spines.values():
497 spine.set_visible(False)
498 fig.legend(
499 handles=[
500 Line2D([], [], color="#34D986", lw=2, label="Kept"),
501 Line2D([], [], color="#FF686B", lw=2, label="Removed"),
502 ],
503 loc="lower center", ncol=2, frameon=False, bbox_to_anchor=(0.5, -0.01),
504 )
505 fig.suptitle("CURATION REVIEW", x=0.02, y=1.04, ha="left", fontsize=15, fontweight="bold", color="#193348")
506 fig.tight_layout(rect=(0, 0.035, 1, 1))
507 return fig
508
509
510def plot_signal_gallery(image_object, cell_id, channel_labels=None,
511 window_px=120, show_objects=False):
512 """Show the phase image beside raw channel crops, with optional detections.
513
514 Cell contours are displayed on all panels. Detected object contours
515 are optionally overlaid on the corresponding fluorescence channels.
516
517 Crops share pixel coordinates. This function does not register channels.
518 """
519 labels = dict(channel_labels or {"C4": "RNA", "C5": "DAPI / nucleoid"})
520 if not labels:
521 raise ValueError("channel_labels is empty")
522
523 cell = next(
524 (item for item in image_object.cells if item.cell_id == cell_id),
525 None,
526 )
527 if cell is None:
528 raise ValueError(f"Cell {cell_id} was not found")
529
530 missing = [
531 channel for channel in labels
532 if not image_object.channels or channel not in image_object.channels
533 ]
534 if missing:
535 raise ValueError(f"Channels not loaded: {missing}")
536
537 contour = np.asarray(cell.contour)
538 crop = _tutorial_crop(
539 image_object.image.shape, contour.mean(axis=0), window_px
540 )
541 x0, y0 = crop[1].start, crop[0].start
542
543 # Prepare panels
544 panels = [("C1 PHASE", image_object.image, "gray", None)]
545
546 for channel, label in labels.items():
547 cmap = _tutorial_channel_cmap(label)
548 panels.append((
549 f"{channel} {label.upper()}",
550 image_object.channels[channel],
551 cmap,
552 channel,
553 ))
554
555 fig, axes = plt.subplots(
556 1, len(panels),
557 figsize=(3.7 * len(panels), 3.9),
558 )
559
560 for ax, (title, full_image, cmap, channel) in zip(axes, panels):
561 data = full_image[crop]
562 low, high = _tutorial_contrast(data)
563
564 ax.imshow(data, cmap=cmap, vmin=low, vmax=high)
565 ax.set_title(
566 title, loc="left", fontsize=10,
567 fontweight="bold", color="#193348",
568 )
569 ax.set_xticks([])
570 ax.set_yticks([])
571
572 for spine in ax.spines.values():
573 spine.set_visible(False)
574
575 # Plot cell contour on every panel
576 ax.plot(
577 contour[:, 1] - x0,
578 contour[:, 0] - y0,
579 color="#FFCF70",
580 lw=1.5,
581 )
582
583 # Optionally plot detected objects on fluorescence panels
584 if channel is not None and show_objects:
585 contours = cell.object_meshdata.get(
586 channel, {}
587 ).get("object_contour", [])
588
589 if contours is not None:
590 for item in contours:
591 outline = np.asarray(item)
592
593 if outline.ndim == 2 and outline.shape[1] == 2:
594 ax.plot(
595 outline[:, 1] - x0,
596 outline[:, 0] - y0,
597 color="#4DE6F2",
598 lw=1.2,
599 )
600
601 fig.suptitle(
602 f"CHANNEL EXAMPLE / CELL {cell_id}",
603 x=0.02, y=0.98, ha="left",
604 fontsize=14, fontweight="bold", color="#193348",
605 )
606
607 fig.tight_layout(rect=(0, 0, 1, 0.93))
608 return fig
609
610
611def plot_object_area_vs_cell_area(features, channel_labels):
612 """Compare detected object area with cell area for each named channel."""
613 labels = dict(channel_labels)
614 if not labels:
615 raise ValueError("channel_labels is empty")
616 if "cell_area" not in features:
617 raise ValueError("Missing cell_area")
618 fig, axes = plt.subplots(1, len(labels), figsize=(4.3 * len(labels), 4), squeeze=False)
619 x = np.asarray(features["cell_area"], dtype=float)
620 for ax, (channel, label) in zip(axes[0], labels.items()):
621 column = f"{channel}_cell_total_obj_area"
622 if column not in features:
623 raise ValueError(f"Missing {column}; calculate object features first")
624 y = np.asarray(features[column], dtype=float)
625 valid = np.isfinite(x) & np.isfinite(y)
626 ax.scatter(x[valid], y[valid], s=12, color=_tutorial_channel_color(label), alpha=0.42, linewidths=0)
627 ax.set_title(f"{channel} {label}", loc="left", fontsize=11, fontweight="bold", color="#193348")
628 ax.set_xlabel("Cell area (Β΅mΒ²)")
629 ax.set_ylabel("Detected object area (Β΅mΒ²)")
630 ax.text(0.97, 0.96, f"n = {valid.sum():,}", transform=ax.transAxes, ha="right", va="top", fontsize=9, color="#516676")
631 ax.spines[["top", "right"]].set_visible(False)
632 ax.grid(color="#E7EDF1", lw=0.8)
633 ax.set_axisbelow(True)
634 ax.tick_params(colors="#516676", labelsize=9)
635 fig.suptitle("SUBCELLULAR AREA / CELL AREA", x=0.02, y=1.04, ha="left", fontsize=14, fontweight="bold", color="#193348")
636 fig.tight_layout()
637 return fig
638
639
640def plot_feature_overview(features, signal_column="C4_NC_ratio"):
641 """
642 Exploratory overview of cell morphology, constriction,
643 detected objects, and relative signal localization.
644
645 C2 = outer membrane
646 C3 = inner membrane
647 C4 = RNA
648 C5 = DAPI
649
650 Missing features are indicated in their panels.
651 Nothing is saved.
652 """
653
654 def numeric(column):
655 if column not in features.columns:
656 return None
657 return pd.to_numeric(features[column], errors="coerce").to_numpy()
658
659 def unavailable(ax, *columns):
660 ax.text(
661 0.5, 0.5,
662 "Feature unavailable:\n" + "\n".join(columns),
663 ha="center", va="center",
664 transform=ax.transAxes,
665 fontsize=9, color="gray",
666 )
667 ax.set_axis_off()
668
669 length = numeric("cell_length")
670 width = numeric("cell_width")
671 constriction = numeric("cell_constriction_degree_width_axial")
672
673 fig, axes = plt.subplots(
674 2, 3,
675 figsize=(15, 9),
676 constrained_layout=True,
677 )
678
679 axes = axes.ravel()
680
681 for ax in axes:
682 ax.spines[["top", "right"]].set_visible(False)
683 ax.grid(axis="y", color="#E7EDF1", lw=0.8)
684 ax.set_axisbelow(True)
685 ax.tick_params(labelsize=9)
686
687 # 1. Cell morphology, coloured by constriction
688 ax = axes[0]
689
690 if length is not None and width is not None:
691 valid = np.isfinite(length) & np.isfinite(width)
692
693 if constriction is not None:
694 valid &= np.isfinite(constriction)
695
696 scatter = ax.scatter(
697 length[valid],
698 width[valid],
699 c=constriction[valid],
700 cmap="viridis",
701 s=10,
702 alpha=0.55,
703 linewidths=0,
704 )
705
706 fig.colorbar(
707 scatter,
708 ax=ax,
709 label="Constriction degree",
710 )
711 else:
712 ax.scatter(
713 length[valid],
714 width[valid],
715 color="#197E89",
716 s=10,
717 alpha=0.4,
718 linewidths=0,
719 )
720
721 ax.set(
722 xlabel="Cell length (Β΅m)",
723 ylabel="Cell width (Β΅m)",
724 title="Cell shape and constriction",
725 )
726 else:
727 unavailable(ax, "cell_length", "cell_width")
728
729 # 2. Constriction-degree distribution
730 ax = axes[1]
731
732 if constriction is not None:
733 valid = constriction[np.isfinite(constriction)]
734
735 ax.hist(
736 valid,
737 bins=35,
738 color="#197E89",
739 edgecolor="white",
740 )
741
742 ax.set(
743 xlabel="Constriction degree",
744 ylabel="Cells",
745 title="Cell constriction",
746 )
747 else:
748 unavailable(ax, "cell_constriction_degree_width_axial")
749
750 # 3. Number of detected DAPI objects per cell
751 ax = axes[2]
752 object_number = numeric("C5_object_number")
753
754 if object_number is not None:
755 counts = (
756 pd.Series(object_number)
757 .dropna()
758 .value_counts()
759 .sort_index()
760 )
761
762 ax.bar(
763 counts.index,
764 counts.values,
765 color="#7F3F98",
766 width=0.75,
767 )
768
769 ax.set(
770 xlabel="Detected DAPI objects per cell",
771 ylabel="Cells",
772 title="DAPI object distribution",
773 )
774 else:
775 unavailable(ax, "C5_object_number")
776
777 # 4. Nucleoid-to-cell ratio
778 ax = axes[3]
779 nc_ratio = numeric("C5_NC_ratio")
780
781 if nc_ratio is not None:
782 valid = nc_ratio[np.isfinite(nc_ratio)]
783
784 ax.hist(
785 valid,
786 bins=35,
787 color="#7F3F98",
788 edgecolor="white",
789 )
790
791 ax.set(
792 xlabel="DAPI NC ratio",
793 ylabel="Cells",
794 title="Nucleoid occupancy",
795 )
796 else:
797 unavailable(ax, "C5_NC_ratio")
798
799 # 5. Relative object occupancy versus cell length
800 ax = axes[4]
801 signal = numeric(signal_column)
802
803 if length is not None and signal is not None:
804 valid = np.isfinite(length) & np.isfinite(signal)
805
806 ax.scatter(
807 length[valid],
808 signal[valid],
809 color="#E17953",
810 s=10,
811 alpha=0.4,
812 linewidths=0,
813 )
814
815 ax.set(
816 xlabel="Cell length (Β΅m)",
817 ylabel=signal_column.replace("_", " "),
818 title="Object occupancy versus cell length",
819 )
820 else:
821 unavailable(ax, "cell_length", signal_column)
822
823 # 6. Outer- versus inner-membrane signal constriction
824 ax = axes[5]
825
826 outer = numeric("C2_signal_constriction_degree_mesh")
827 inner = numeric("C3_signal_constriction_degree_mesh")
828
829 if outer is not None and inner is not None:
830 valid = np.isfinite(outer) & np.isfinite(inner)
831
832 ax.scatter(
833 outer[valid],
834 inner[valid],
835 color="#36A5A0",
836 s=10,
837 alpha=0.4,
838 linewidths=0,
839 )
840
841 ax.set(
842 xlabel="Outer membrane constriction degree (C2)",
843 ylabel="Inner membrane constriction degree (C3)",
844 title="Membrane signal localization",
845 )
846 else:
847 unavailable(
848 ax,
849 "C2_signal_constriction_degree_mesh",
850 "C3_signal_constriction_degree_mesh",
851 )
852
853 fig.suptitle(
854 f"CELL FEATURE OVERVIEW / {len(features):,} cells",
855 fontsize=15,
856 fontweight="bold",
857 color="#193348",
858 )
859
860 return fig
861import numpy as np
862import pandas as pd
863import matplotlib.pyplot as plt
864
865
866def plot_feature_histogram(
867 df,
868 feature,
869 bins=40,
870 xlabel=None,
871 color="#197E89",
872 figsize=(7, 4.5),
873):
874 """Plot a feature distribution with mean, median and interquartile range."""
875
876 if feature not in df.columns:
877 raise ValueError(f"Feature not found: {feature}")
878
879 values = pd.to_numeric(df[feature], errors="coerce").to_numpy()
880 values = values[np.isfinite(values)]
881
882 if len(values) == 0:
883 raise ValueError(f"No valid values for {feature}")
884
885 mean = np.mean(values)
886 median = np.median(values)
887 q1, q3 = np.percentile(values, [25, 75])
888
889 fig, ax = plt.subplots(figsize=figsize)
890
891 # Histogram
892 ax.hist(
893 values,
894 bins=bins,
895 color=color,
896 alpha=0.85,
897 edgecolor="white",
898 linewidth=0.6,
899 )
900
901 # Interquartile range
902 ax.axvspan(
903 q1, q3,
904 color=color,
905 alpha=0.12,
906 label=f"IQR: {q1:.2f}β{q3:.2f}",
907 )
908
909 # Mean and median
910 ax.axvline(
911 mean,
912 color="#E17953",
913 linestyle="--",
914 linewidth=1.8,
915 label=f"Mean: {mean:.2f}",
916 )
917
918 ax.axvline(
919 median,
920 color="#193348",
921 linestyle="-",
922 linewidth=1.8,
923 label=f"Median: {median:.2f}",
924 )
925
926 ax.set_xlabel(xlabel or feature.replace("_", " "), fontsize=11)
927 ax.set_ylabel("Number of cells", fontsize=11)
928 ax.set_title(
929 f"{feature.replace('_', ' ')}\nN = {len(values):,} cells",
930 fontsize=13,
931 fontweight="bold",
932 loc="left",
933 )
934
935 ax.legend(frameon=False, fontsize=9)
936
937 ax.spines[["top", "right"]].set_visible(False)
938 ax.grid(axis="y", alpha=0.15)
939 ax.set_axisbelow(True)
940
941 fig.tight_layout()
942 return fig
943
944
945def plot_feature_scatter(
946 df,
947 x_feature,
948 y_feature,
949 color_by=None,
950 xlabel=None,
951 ylabel=None,
952 trendline=True,
953 figsize=(7, 5),
954):
955 """
956 Plot two features against each other.
957
958 Optionally colour points by a third numeric feature.
959 A linear regression line and Pearson correlation can be shown.
960 """
961
962 columns = [x_feature, y_feature]
963
964 if color_by is not None:
965 columns.append(color_by)
966
967 missing = [col for col in columns if col not in df.columns]
968 if missing:
969 raise ValueError(f"Features not found: {missing}")
970
971 # Convert selected features to numeric values
972 data = df[columns].apply(pd.to_numeric, errors="coerce")
973 data = data.replace([np.inf, -np.inf], np.nan).dropna()
974
975 if data.empty:
976 raise ValueError("No valid cells remain for the selected features")
977
978 x = data[x_feature].to_numpy()
979 y = data[y_feature].to_numpy()
980
981 fig, ax = plt.subplots(figsize=figsize)
982
983 # Scatter plot
984 if color_by is None:
985 ax.scatter(
986 x, y,
987 s=13,
988 alpha=0.3,
989 color="#197E89",
990 edgecolors="none",
991 rasterized=True,
992 )
993 else:
994 scatter = ax.scatter(
995 x, y,
996 c=data[color_by],
997 cmap="viridis",
998 s=13,
999 alpha=0.5,
1000 edgecolors="none",
1001 rasterized=True,
1002 )
1003
1004 fig.colorbar(
1005 scatter,
1006 ax=ax,
1007 label=color_by.replace("_", " "),
1008 )
1009
1010 # Linear trend and Pearson correlation
1011 if len(x) >= 2 and np.std(x) > 0 and np.std(y) > 0:
1012
1013 r = np.corrcoef(x, y)[0, 1]
1014
1015 if trendline:
1016 slope, intercept = np.polyfit(x, y, 1)
1017
1018 x_line = np.linspace(x.min(), x.max(), 200)
1019
1020 ax.plot(
1021 x_line,
1022 slope * x_line + intercept,
1023 color="#E17953",
1024 linewidth=2,
1025 linestyle="--",
1026 label=f"Linear fit (r = {r:.2f})",
1027 )
1028
1029 ax.legend(frameon=False)
1030
1031 else:
1032 ax.text(
1033 0.03, 0.97,
1034 f"Pearson r = {r:.2f}",
1035 transform=ax.transAxes,
1036 ha="left",
1037 va="top",
1038 fontsize=10,
1039 )
1040
1041 ax.set_xlabel(xlabel or x_feature.replace("_", " "), fontsize=11)
1042 ax.set_ylabel(ylabel or y_feature.replace("_", " "), fontsize=11)
1043
1044 ax.set_title(
1045 f"{x_feature.replace('_', ' ')} vs. {y_feature.replace('_', ' ')}"
1046 f"\nN = {len(data):,} cells",
1047 fontsize=13,
1048 fontweight="bold",
1049 loc="left",
1050 )
1051
1052 ax.spines[["top", "right"]].set_visible(False)
1053 ax.grid(alpha=0.15)
1054 ax.set_axisbelow(True)
1055
1056 fig.tight_layout()
1057 return fig
1058
1059def plot_contour(
1060 cell_id,
1061 image,
1062 base_channel=None,
1063 verbose=False,
1064 add_scalebar=True,
1065 scalebar_um=1,
1066 um_per_px=0.065,
1067 scalebar_color="white",
1068 scalebar_lw=6,
1069 window_px=None, # <-- NEW: fixed window size (square)
1070 save_svg_path=None,
1071):
1072 """
1073 Plot the contour of a cell with optional scalebar.
1074
1075 Parameters
1076 ----------
1077 window_px : int or None
1078 If provided, creates a fixed square window (in pixels) centered on the cell.
1079 If None, tight crop is used (default behavior).
1080 """
1081
1082 cellobj = next((cell for cell in image.cells if cell.cell_id == cell_id), None)
1083
1084 if cellobj is None:
1085 if verbose:
1086 print(f"Cell with ID {cell_id} not found.")
1087 return
1088
1089 contour = cellobj.contour
1090 full_img = image.image
1091
1092 # ---------------------------
1093 # Fixed window crop
1094 # ---------------------------
1095 if window_px is not None:
1096
1097 # center of cell
1098 cy = int(np.mean(contour[:, 0]))
1099 cx = int(np.mean(contour[:, 1]))
1100
1101 half = window_px // 2
1102
1103 y_min = max(cy - half, 0)
1104 y_max = min(cy + half, full_img.shape[0])
1105 x_min = max(cx - half, 0)
1106 x_max = min(cx + half, full_img.shape[1])
1107
1108 cropped_img = full_img[y_min:y_max, x_min:x_max]
1109
1110 adjusted_contour_x = contour[:, 1] - x_min
1111 adjusted_contour_y = contour[:, 0] - y_min
1112
1113 # ---------------------------
1114 # Default tight crop
1115 # ---------------------------
1116 else:
1117 cropped_img, _, _, x_offset, y_offset = u.crop_image(
1118 image=full_img, contour=contour, mask_to_crop=None, phase_img=None
1119 )
1120
1121 adjusted_contour_x = contour[:, 1] - y_offset
1122 adjusted_contour_y = contour[:, 0] - x_offset
1123
1124 # ---------------------------
1125 # SVG settings (editable)
1126 # ---------------------------
1127 plt.rcParams.update({
1128 "font.family": "Arial",
1129 "svg.fonttype": "none",
1130 })
1131
1132 fig, ax = plt.subplots(figsize=(6, 6))
1133 ax.imshow(cropped_img, cmap="gist_gray")
1134
1135 ax.plot(adjusted_contour_x, adjusted_contour_y, "-", c="w", lw=3)
1136
1137 # ---------------------------
1138 # Scalebar (vector)
1139 # ---------------------------
1140 if add_scalebar:
1141 bar_px = scalebar_um / um_per_px
1142 h, w = cropped_img.shape[:2]
1143
1144 x_start = int(w * 0.05)
1145 y_start = int(h * 0.95)
1146
1147 ax.plot(
1148 [x_start, x_start + bar_px],
1149 [y_start, y_start],
1150 color=scalebar_color,
1151 lw=scalebar_lw,
1152 solid_capstyle="butt",
1153 )
1154
1155 ax.text(
1156 x_start + bar_px / 2,
1157 y_start - h * 0.05,
1158 f"{scalebar_um:.1f} Β΅m",
1159 color=scalebar_color,
1160 ha="center",
1161 va="bottom",
1162 fontsize=12,
1163 fontfamily="Arial",
1164 )
1165
1166 ax.set_title(
1167 f"Cell {cellobj.cell_id} Frame {image.frame}",
1168 fontfamily="Arial"
1169 )
1170
1171 ax.axis("off")
1172 fig.tight_layout(pad=0)
1173
1174 if save_svg_path:
1175 fig.savefig(
1176 save_svg_path,
1177 format="svg",
1178 bbox_inches="tight",
1179 pad_inches=0
1180 )
1181
1182 plt.show()
1183 plt.close(fig)
1184
1185
1186
1187 # plt.plot(midline.T[1], midline.T[0], '-', c='blue', lw=5)
1188 # plt.plot([msh[:,1][::2], msh[:,3][::2]], [msh[:,0][::2], msh[:,2][::2]], '-', c='cyan', lw=2)
1189import os
1190import numpy as np
1191import matplotlib.pyplot as plt
1192
1193def plot_cell_contour_gallery(
1194 cell_id,
1195 image,
1196 channels,
1197 window_px=100,
1198 phase_first=True,
1199 phase_cmap="gist_grey",
1200 channel_cmap="gist_grey",
1201 contour_color="yellow",
1202 contour_lw=1.5,
1203 add_scalebar=True,
1204 scalebar_um=1,
1205 um_per_px=0.065,
1206 scalebar_color="white",
1207 scalebar_lw=4,
1208 title=True,
1209 figsize_per_panel=3.0,
1210 save_path=None,
1211 save_individual_dir=None,
1212 dpi=500,
1213 verbose=False,
1214):
1215 """
1216 Plot a cropped gallery for one cell using:
1217 - image.image for phase contrast
1218 - image.channels[ch] for channel images
1219 - only the cell contour overlay
1220
1221 Parameters
1222 ----------
1223 cell_id : int or str
1224 image : object
1225 Typically ic.image_objects[frame_id]
1226 channels : list of str
1227 Example: ["C1", "C2", "C3", "C4", "C5"]
1228 """
1229
1230 cellobj = next((cell for cell in image.cells if cell.cell_id == cell_id), None)
1231 if cellobj is None:
1232 if verbose:
1233 print(f"Cell with ID {cell_id} not found.")
1234 return None
1235
1236 contour = np.asarray(cellobj.contour) # expected shape: [N, 2] with [y, x]
1237 full_phase = image.image # phase contrast image
1238
1239 if full_phase is None:
1240 if verbose:
1241 print("Phase image not found at image.image")
1242 return None
1243
1244 # fixed square crop centered on the cell
1245 cy = int(np.mean(contour[:, 0]))
1246 cx = int(np.mean(contour[:, 1]))
1247 half = int(window_px // 2)
1248
1249 y_min = max(cy - half, 0)
1250 y_max = min(cy + half, full_phase.shape[0])
1251 x_min = max(cx - half, 0)
1252 x_max = min(cx + half, full_phase.shape[1])
1253
1254 adjusted_contour_x = contour[:, 1] - x_min
1255 adjusted_contour_y = contour[:, 0] - y_min
1256
1257 panels = []
1258
1259 if phase_first:
1260 panels.append(("Phase", full_phase[y_min:y_max, x_min:x_max], phase_cmap))
1261
1262 for ch in channels:
1263 if ch not in image.channels:
1264 if verbose:
1265 print(f"Channel {ch} not found in image.channels, skipping.")
1266 continue
1267 ch_img = image.channels[ch]
1268 cropped = ch_img[y_min:y_max, x_min:x_max]
1269 panels.append((str(ch), cropped, channel_cmap))
1270
1271 if len(panels) == 0:
1272 if verbose:
1273 print("No panels available to plot.")
1274 return None
1275
1276 plt.rcParams.update({
1277 "font.family": "Arial",
1278 "svg.fonttype": "none",
1279 })
1280
1281 n_panels = len(panels)
1282 fig, axes = plt.subplots(
1283 1, n_panels,
1284 figsize=(figsize_per_panel * n_panels, figsize_per_panel),
1285 squeeze=False
1286 )
1287 axes = axes.ravel()
1288
1289 def _add_scalebar(ax, img_shape):
1290 if not add_scalebar:
1291 return
1292 h, w = img_shape[:2]
1293 bar_px = scalebar_um / um_per_px
1294 x_start = int(w * 0.05)
1295 y_start = int(h * 0.93)
1296
1297 ax.plot(
1298 [x_start, x_start + bar_px],
1299 [y_start, y_start],
1300 color=scalebar_color,
1301 lw=scalebar_lw,
1302 solid_capstyle="butt",
1303 )
1304 ax.text(
1305 x_start + bar_px / 2,
1306 y_start - h * 0.04,
1307 f"{scalebar_um:.1f} Β΅m",
1308 color=scalebar_color,
1309 ha="center",
1310 va="bottom",
1311 fontsize=10,
1312 fontfamily="Arial",
1313 )
1314
1315 for ax, (panel_name, cropped_img, cmap) in zip(axes, panels):
1316 ax.imshow(cropped_img, cmap=cmap)
1317 ax.plot(adjusted_contour_x, adjusted_contour_y, color=contour_color, lw=contour_lw)
1318 _add_scalebar(ax, cropped_img.shape)
1319
1320 if title:
1321 ax.set_title(panel_name)
1322
1323 ax.axis("off")
1324
1325 fig.tight_layout(pad=0.05)
1326
1327 if save_path is not None:
1328 os.makedirs(os.path.dirname(save_path), exist_ok=True)
1329 fig.savefig(save_path, bbox_inches="tight", pad_inches=0.02, dpi=dpi)
1330
1331 if save_individual_dir is not None:
1332 os.makedirs(save_individual_dir, exist_ok=True)
1333
1334 for panel_name, cropped_img, cmap in panels:
1335 fig_i, ax_i = plt.subplots(figsize=(figsize_per_panel, figsize_per_panel))
1336 ax_i.imshow(cropped_img, cmap=cmap)
1337 ax_i.plot(adjusted_contour_x, adjusted_contour_y, color=contour_color, lw=contour_lw)
1338 _add_scalebar(ax_i, cropped_img.shape)
1339
1340 if title:
1341 ax_i.set_title(panel_name)
1342
1343 ax_i.axis("off")
1344 fig_i.tight_layout(pad=0.05)
1345
1346 safe_name = "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in str(panel_name))
1347 out_path = os.path.join(save_individual_dir, f"cell_{cell_id}_{safe_name}.png")
1348 fig_i.savefig(out_path, bbox_inches="tight", pad_inches=0.02, dpi=dpi)
1349 plt.close(fig_i)
1350
1351 plt.show()
1352
1353 return {
1354 "cell_id": cell_id,
1355 "crop_bounds": (y_min, y_max, x_min, x_max),
1356 "panel_names": [p[0] for p in panels],
1357 "figure": fig,
1358 }
1359
1360def plot_aligned_contours(cell_id, image, channel, verbose=False):
1361 """
1362 Plot the contour of a cell on an image.
1363
1364 This function takes a cell object and an image object and plots the contour of the specified cell on the image.
1365 It also displays the cell ID in the title of the plot.
1366
1367 Parameters
1368 ----------
1369 cell : Cell
1370 The cell object to be plotted.
1371 image : Image
1372 The image object on which to plot the cell contour.
1373 """
1374 cellobj = next((cell for cell in image.cells if cell.cell_id == cell_id), None)
1375 if cellobj is None:
1376 if verbose:
1377 print(f"Cell with ID {cell_id} not found.")
1378 return
1379 fig = plt.figure(figsize=(12, 12))
1380
1381 contour = cellobj.contour
1382 midline = cellobj.midline
1383 aligned_contour = cellobj.shifted_contour
1384 xmin = np.min(contour.T[1] - 10)
1385 xmax = np.max(contour.T[1] + 10)
1386 ymin = np.min(contour.T[0] - 10)
1387 ymax = np.max(contour.T[0] + 10)
1388 plt.xlim(xmin, xmax)
1389 plt.ylim(ymin, ymax)
1390 plt.imshow(image.channels[channel], cmap="gist_gray")
1391 plt.plot(contour.T[1], contour.T[0], "-", c="w", lw=5)
1392 if aligned_contour is not None:
1393 plt.plot(aligned_contour.T[1], aligned_contour.T[0], "-", c="orange", lw=5)
1394 # plt.plot(midline.T[1], midline.T[0], '-', c='blue', lw=5)
1395
1396 plt.title(f"Cell {cellobj.cell_id} Frame {image.frame}")
1397 plt.show()
1398
1399
1400def plot_svm_controls(frame_cell_pairs, image_objects, message=None):
1401 """
1402 Plot cells based on frame and cell ID pairs from SVM control analysis.
1403
1404 This function takes frame and cell ID pairs, along with a list of image objects, and plots the cells from SVM control analysis.
1405 It allows adding an optional message to the title of the plot.
1406
1407 Parameters
1408 ----------
1409 frame_cell_pairs : list of tuples
1410 A list of (cell_id, frame) pairs for cells to be plotted.
1411 image_objects : list
1412 A list of image objects corresponding to the frames in frame_cell_pairs.
1413 message : str, optional
1414 An optional message to be added to the title of the plot.
1415 """
1416 images_by_frame = {}
1417 for image_obj in image_objects:
1418 if image_obj.frame in images_by_frame:
1419 raise ValueError(f"Duplicate image frame {image_obj.frame}.")
1420 images_by_frame[image_obj.frame] = image_obj
1421
1422 for cell_id, frame in frame_cell_pairs:
1423 image_obj = images_by_frame.get(frame)
1424 if image_obj is None:
1425 raise ValueError(f"No image object has frame {frame}.")
1426 cellobj = next(
1427 (cell for cell in image_obj.cells if cell.cell_id == cell_id), None
1428 )
1429 if cellobj is None:
1430 raise ValueError(f"Cell {cell_id} is absent from frame {frame}.")
1431 contour = cellobj.contour
1432 # ori_contour = image.mesh_dataframe['contour'][cell]
1433 xmin = np.min(contour.T[1] - 10)
1434 xmax = np.max(contour.T[1] + 10)
1435 ymin = np.min(contour.T[0] - 10)
1436 ymax = np.max(contour.T[0] + 10)
1437
1438 plt.figure(figsize=(12, 12))
1439 plt.xlim(xmin, xmax)
1440 plt.ylim(ymin, ymax)
1441 plt.imshow(image_obj.image, cmap="gist_gray")
1442 plt.plot(contour.T[1], contour.T[0], "-", c="w", lw=5)
1443 if message is not None:
1444 plt.title(f"Cell: {cellobj.cell_id} ---Frame: {frame} ---Type: {message}")
1445 plt.show()
1446
1447
1448def plot_mask(image_object, joined=False, dpi=400, alpha=0.4):
1449 """
1450 Plot the mask overlay on an image.
1451
1452 This function takes an image object and optionally a joined mask, and plots the mask overlay on the image.
1453 The alpha parameter controls the transparency of the mask overlay.
1454
1455 Parameters
1456 ----------
1457 image_object : Image object
1458 An image object containing the image and mask data.
1459 joined : bool, optional
1460 Whether to use the joined mask for overlay (default is False).
1461 dpi : int, optional
1462 Dots per inch for the plot (default is 400).
1463 alpha : float, optional
1464 Alpha (transparency) value for the mask overlay (default is 0.4).
1465 """
1466
1467 plt.figure(
1468 figsize=(image_object.image.shape[1] / dpi, image_object.image.shape[0] / dpi),
1469 dpi=dpi,
1470 )
1471 plt.imshow(image_object.image, cmap="gray")
1472
1473 if joined is True:
1474 plt.imshow(
1475 label2rgb(label(image_object.joined_mask, connectivity=1), bg_label=0),
1476 alpha=alpha,
1477 )
1478 else:
1479
1480 plt.imshow(
1481 label2rgb(label(image_object.mask, connectivity=1), bg_label=0), alpha=alpha
1482 )
1483
1484 plt.axis("off") # Turn off the axis
1485 plt.show()
1486
1487
1488def plot_random_contours(image, num_cells_to_plot=4, crop_size=200, scalebar=False):
1489 """
1490 Plot random cell contours within an image.
1491
1492 This function selects and plots random cell contours within an image, showing a specified number of cells.
1493
1494 Parameters
1495 ----------
1496 image : Image object
1497 An image object containing the image and cell contour data.
1498 num_cells_to_plot : int, optional
1499 Number of random cell contours to plot (default is 4).
1500 crop_size : int, optional
1501 Size of the cropped area around each cell (default is 200).
1502 scalebar : bool, optional
1503 Whether to add a scale bar to the plots (default is False).
1504
1505 Returns
1506 -------
1507 None
1508 """
1509
1510 fig, axes = plt.subplots(2, 2, figsize=(10, 10))
1511
1512 plotted_cells = set() # To keep track of which cells have been plotted
1513
1514 for i in range(num_cells_to_plot):
1515 row = i // 2
1516 col = i % 2
1517
1518 # Find a random cell that hasn't been plotted yet
1519 while True:
1520 cell_idx = random.randint(0, len(image.cells) - 1)
1521 if cell_idx not in plotted_cells:
1522 break
1523
1524 cellobj = image.cells[cell_idx]
1525 contour = cellobj.contour
1526 center_x = np.mean(contour.T[1])
1527 center_y = np.mean(contour.T[0])
1528 half_crop_size = crop_size / 2
1529 xmin = center_x - half_crop_size
1530 xmax = center_x + half_crop_size
1531 ymin = center_y - half_crop_size
1532 ymax = center_y + half_crop_size
1533
1534 axes[row, col].set_xlim(xmin, xmax)
1535 axes[row, col].set_ylim(ymin, ymax)
1536 axes[row, col].imshow(image.image, cmap="gist_gray")
1537 axes[row, col].plot(contour.T[1], contour.T[0], "-", c="w", lw=2)
1538 axes[row, col].set_title(f"Cell {cellobj.cell_id}")
1539
1540 plotted_cells.add(cell_idx) # Mark this cell as plotted
1541 if scalebar:
1542 scale_length_um = 1
1543 scale_length_px = scale_length_um / 0.065 # Convert Β΅m to pixels
1544 scale_x = (
1545 xmin + (xmax - xmin) * 0.02
1546 ) # Adjust the X position of the scale bar
1547 scale_y = (
1548 ymin + (ymax - ymin) * 0.02
1549 ) # Adjust the Y position of the scale bar
1550 axes[row, col].plot(
1551 [scale_x, scale_x + scale_length_px],
1552 [scale_y, scale_y],
1553 color="white",
1554 lw=5,
1555 )
1556
1557 # Remove axes for all subplots
1558 for ax in axes.flat:
1559 ax.axis("off")
1560
1561 plt.tight_layout()
1562 plt.show()
1563
1564
1565def plot_random_objects(
1566 image, chann, num_objects_to_plot=4, crop_size=300, scalebar=True
1567):
1568 num_rows = 2 # Number of rows in the grid
1569 num_cols = 2 # Number of columns in the grid
1570
1571 fig, axes = plt.subplots(num_rows, num_cols, figsize=(10, 10))
1572
1573 plotted_objects = set() # To keep track of which objects have been plotted
1574
1575 for i in range(num_objects_to_plot):
1576 # Find a random cell that hasn't been plotted yet
1577 while True:
1578 object_idx = random.randint(0, len(image.cells) - 1)
1579 if object_idx not in plotted_objects:
1580 break
1581
1582 cellobj = image.cells[object_idx]
1583 contour = cellobj.contour
1584
1585 # Calculate cropping boundaries based on the fixed crop size
1586 center_x = np.mean(contour.T[1])
1587 center_y = np.mean(contour.T[0])
1588 half_crop_size = crop_size / 2
1589 xmin = center_x - half_crop_size
1590 xmax = center_x + half_crop_size
1591 ymin = center_y - half_crop_size
1592 ymax = center_y + half_crop_size
1593
1594 row = i // num_cols
1595 col = i % num_cols
1596
1597 axes[row, col].set_xlim(xmin, xmax)
1598 axes[row, col].set_ylim(ymin, ymax)
1599 axes[row, col].imshow(image.channels[chann], cmap="gist_gray")
1600 axes[row, col].plot(contour.T[1], contour.T[0], "-", c="yellow", lw=2)
1601 if cellobj.object_meshdata[chann]["object_contour"] is not None:
1602 for nuc_contour in cellobj.object_meshdata[chann]["object_contour"]:
1603 axes[row, col].plot(
1604 nuc_contour.T[1], nuc_contour.T[0], "-", c="cyan", lw=2
1605 )
1606
1607 axes[row, col].set_title(f"Cell {cellobj.cell_id}")
1608 axes[row, col].axis("off")
1609
1610 # Add scale bar
1611 if scalebar:
1612 scale_length_um = 1
1613 scale_length_px = scale_length_um / 0.065 # Convert Β΅m to pixels
1614 scale_x = (
1615 xmin + 10
1616 ) # Adjust the position of the scale bar (e.g., 10 pixels from the left)
1617 scale_y = (
1618 ymin + 10
1619 ) # Adjust the position of the scale bar (e.g., 10 pixels from the bottom)
1620 axes[row, col].plot(
1621 [scale_x, scale_x + scale_length_px],
1622 [scale_y, scale_y],
1623 color="white",
1624 lw=5,
1625 )
1626
1627 plotted_objects.add(object_idx) # Mark this object as plotted
1628
1629 plt.tight_layout()
1630 plt.show()
1631
1632
1633def plot_random_axial(feature_dataframe, channels, method, num_objects_to_plot=2):
1634 num_plots = 2 # Number of vertical plots
1635 num_objects_per_plot = num_objects_to_plot // num_plots # Objects per plot
1636 fig, axes = plt.subplots(num_plots, 1, figsize=(12, 10)) # Make the plot taller
1637
1638 # Get a list of unique cell IDs
1639 unique_cell_ids = feature_dataframe["cell_id"].unique()
1640
1641 # Randomly select cell IDs to plot
1642 random_cell_ids = random.sample(list(unique_cell_ids), num_objects_to_plot)
1643
1644 for plot_index in range(num_plots):
1645 for i in range(num_objects_per_plot):
1646 cell_id = random_cell_ids[plot_index * num_objects_per_plot + i]
1647
1648 for channel in channels:
1649 axial_intensity_column = f"{channel}_axial_intensity"
1650 cell_frame_condition = feature_dataframe["cell_id"] == cell_id
1651
1652 if feature_dataframe[cell_frame_condition].empty:
1653 continue
1654
1655 axial_intensity = feature_dataframe[axial_intensity_column][
1656 cell_frame_condition
1657 ].iloc[0]
1658
1659 # Normalize the axial intensity curve by dividing by the maximum value
1660 normalized_midline_intensity_data = (
1661 axial_intensity - np.min(axial_intensity)
1662 ) / (np.max(axial_intensity) - np.min(axial_intensity))
1663
1664 color = f"C{channels.index(channel)}" # Assign a unique color to each channel
1665 ax = axes[plot_index]
1666
1667 ax.plot(
1668 normalized_midline_intensity_data,
1669 linestyle="-",
1670 color=color,
1671 label=f"Channel {channel}",
1672 )
1673
1674 # Retrieve the corresponding frame for the cell
1675 frame = feature_dataframe["frame"][cell_frame_condition].iloc[0]
1676
1677 ax = axes[plot_index]
1678 ax.set_title(f"Cell {cell_id}, Frame {frame}")
1679 ax.set_xlabel("Position along Axial")
1680 ax.set_ylabel("Normalized Axial Intensity")
1681 ax.set_ylim(0, 1.2)
1682 ax.legend(frameon=False) # Remove the frame around the legend
1683
1684 plt.tight_layout()
1685 plt.show()
1686
1687
1688def plot_normalized_axial_intensity(
1689 feature_dataframe, channels, selected_frame=None, selected_cell_id=None,
1690 show=True,
1691):
1692 """Plot normalized axial profiles for selected cells."""
1693 profile_columns = [f"{channel}_axial_intensity" for channel in channels]
1694 missing = set(profile_columns) - set(feature_dataframe.columns)
1695 if missing:
1696 raise ValueError(f"Missing axial profile columns: {sorted(missing)}")
1697 mask = np.ones(len(feature_dataframe), dtype=bool)
1698 if selected_frame is not None:
1699 mask &= feature_dataframe["frame"].to_numpy() == selected_frame
1700 if selected_cell_id is not None:
1701 mask &= feature_dataframe["cell_id"].to_numpy() == selected_cell_id
1702 selected = feature_dataframe.loc[mask, ["frame", "cell_id", *profile_columns]]
1703 figures = []
1704 colors = {"C2": "#E3484D", "C3": "#CF42BA", "C4": "#27AA59", "C5": "#3186DE"}
1705 for _, row in selected.drop_duplicates(["frame", "cell_id"]).iterrows():
1706 fig, ax = plt.subplots(figsize=(8, 4))
1707 for channel, column in zip(channels, profile_columns):
1708 values = np.asarray(row[column], dtype=float)
1709 finite = values[np.isfinite(values)]
1710 if not len(finite):
1711 continue
1712 low, high = finite.min(), finite.max()
1713 scaled = (values - low) / (high - low) if high > low else np.zeros_like(values)
1714 ax.plot(np.linspace(0, 1, len(scaled)), scaled, label=channel,
1715 color=colors.get(channel), lw=1.8)
1716 ax.set(xlabel="Relative position along cell", ylabel="Normalized intensity",
1717 ylim=(-0.03, 1.08), title=f"Frame {row['frame']} | Cell {row['cell_id']}")
1718 ax.spines[["top", "right"]].set_visible(False)
1719 ax.grid(color="#E7EDF1", lw=0.8)
1720 ax.legend(frameon=False, ncol=len(channels))
1721 fig.tight_layout()
1722 figures.append(fig)
1723 if show:
1724 plt.show()
1725 return figures
1726
1727
1728def plot_object_mesh(cell_id, image, chann, verbose=False):
1729
1730 # Look up the cell by its ID
1731 cellobj = next((cell for cell in image.cells if cell.cell_id == cell_id), None)
1732
1733 if cellobj is None:
1734 if verbose:
1735 print(f"Cell with ID {cell_id} not found.")
1736 return
1737 fig = plt.figure(figsize=(12, 12))
1738 contour = cellobj.contour
1739 xmin = np.min(contour.T[1] - 10)
1740 xmax = np.max(contour.T[1] + 10)
1741 ymin = np.min(contour.T[0] - 10)
1742 ymax = np.max(contour.T[0] + 10)
1743
1744 plt.xlim(xmin, xmax)
1745 plt.ylim(ymin, ymax)
1746 plt.imshow(image.channels[chann], cmap="gist_gray")
1747 plt.plot(contour.T[1], contour.T[0], "-", c="yellow", lw=2)
1748
1749 if cellobj.object_meshdata[chann]["object_mesh"] is not None:
1750 for cnt, msh, mdl in zip(
1751 cellobj.object_meshdata[chann]["object_contour"],
1752 cellobj.object_meshdata[chann]["object_mesh"],
1753 cellobj.object_meshdata[chann]["object_midline"],
1754 ):
1755 if np.any(msh):
1756 plt.plot(
1757 [msh[:, 1][::2], msh[:, 3][::2]],
1758 [msh[:, 0][::2], msh[:, 2][::2]],
1759 "-",
1760 c="cyan",
1761 lw=2,
1762 )
1763 plt.plot(cnt.T[1], cnt.T[0], "-", c="white", lw=2)
1764
1765 plt.title(f"Cell {cellobj.cell_id} ----- Channel {chann} ----- Frame {image.frame}")
1766 plt.axis("off")
1767 plt.show()
1768
1769
1770import numpy as np
1771import matplotlib.pyplot as plt
1772
1773
1774import numpy as np
1775import matplotlib.pyplot as plt
1776
1777
1778import numpy as np
1779import matplotlib.pyplot as plt
1780
1781
1782def plot_object_contours(
1783 cell_id,
1784 image,
1785 contour_channels,
1786 shift=False,
1787 base_channel=None,
1788 verbose=False,
1789 add_scalebar=True,
1790 scalebar_um=1,
1791 um_per_px=0.065,
1792 scalebar_color="white",
1793 scalebar_lw=8,
1794 window_px=None, # fixed square window size (pixels); if None -> original crop_image behavior
1795 save_svg_path=None,
1796 save_tiff_path=None # SVG only (editable in Illustrator)
1797):
1798 """
1799 Plot the contours of a cell and its objects for multiple channels on a single base channel image.
1800
1801 Behaves like your original version:
1802 - base_channel None -> uses image.image
1803 - shift True -> crop_image(... phase_img=image.image)
1804 - else -> crop_image(... phase_img=None)
1805 - plots cell contour + object contours from cropped_object_contour
1806 - adds scalebar the same way
1807
1808 Added:
1809 - window_px: optional fixed square window around cell center (bypasses crop_image).
1810 - save_svg_path: save editable SVG (scalebar/text/contours editable in Illustrator).
1811 """
1812
1813 cellobj = next((cell for cell in image.cells if cell.cell_id == cell_id), None)
1814 if cellobj is None:
1815 if verbose:
1816 print(f"Cell with ID {cell_id} not found.")
1817 return
1818
1819 contour = cellobj.contour
1820
1821 # Keep SVG text editable
1822 plt.rcParams.update({
1823 "font.family": "Arial",
1824 "svg.fonttype": "none",
1825 })
1826
1827 # ---------------------------------------------------------
1828 # 1) Get cropped_img and cropped_contour exactly like before
1829 # unless window_px is requested.
1830 # ---------------------------------------------------------
1831 if window_px is None:
1832 # Original logic (respects base_channel and shift exactly)
1833 if base_channel is None:
1834 cropped_img, _, cropped_contour, x_offset, y_offset = u.crop_image(
1835 image=image.image,
1836 contour=contour,
1837 mask_to_crop=None,
1838 phase_img=None,
1839 )
1840 elif shift:
1841 cropped_img, _, cropped_contour, x_offset, y_offset = u.crop_image(
1842 image=image.channels[base_channel],
1843 contour=contour,
1844 mask_to_crop=None,
1845 phase_img=image.image,
1846 )
1847 else:
1848 cropped_img, _, cropped_contour, x_offset, y_offset = u.crop_image(
1849 image=image.channels[base_channel],
1850 contour=contour,
1851 mask_to_crop=None,
1852 phase_img=None,
1853 )
1854
1855 # Object contours: use exactly what your original code used
1856 # (already in cropped coords)
1857 cropped_object_contours = {}
1858 for chann in contour_channels:
1859 # keep it robust if key missing
1860 cropped_object_contours[chann] = (
1861 cellobj.object_meshdata.get(chann, {}).get("cropped_object_contour", None)
1862 )
1863
1864 else:
1865 # ---------------------------------------------------------
1866 # Fixed window mode (consistent output size across cells)
1867 # This mode does NOT call crop_image, so we must:
1868 # - choose the same base image selection logic
1869 # - shift the cell contour + object contours into window coords
1870 # ---------------------------------------------------------
1871 if base_channel is None:
1872 base_img = image.image
1873 else:
1874 base_img = image.channels[base_channel]
1875
1876 # center of cell in full image coords
1877 cy = int(np.mean(contour[:, 0]))
1878 cx = int(np.mean(contour[:, 1]))
1879 half = int(window_px // 2)
1880
1881 y_min = max(cy - half, 0)
1882 y_max = min(cy + half, base_img.shape[0])
1883 x_min = max(cx - half, 0)
1884 x_max = min(cx + half, base_img.shape[1])
1885
1886 cropped_img = base_img[y_min:y_max, x_min:x_max]
1887
1888 # Cell contour in window coords
1889 cropped_contour = np.column_stack([contour[:, 0] - y_min, contour[:, 1] - x_min])
1890
1891 # Object contours:
1892 # In many pipelines, cropped_object_contour is in crop_image coords,
1893 # so for window mode we instead try to use the full-res object contour
1894 # if available, otherwise we fall back to cropped_object_contour (unchanged).
1895 cropped_object_contours = {}
1896 for chann in contour_channels:
1897 meshdata = cellobj.object_meshdata.get(chann, {})
1898
1899 # Try common keys for full-image object contours
1900 full_key_candidates = ["object_contour", "object_contours", "raw_object_contour", "raw_object_contours"]
1901 full_contours = None
1902 for k in full_key_candidates:
1903 if k in meshdata and meshdata[k] is not None:
1904 full_contours = meshdata[k]
1905 break
1906
1907 if full_contours is not None:
1908 # Shift full contours into window coords
1909 shifted = []
1910 for obj in full_contours:
1911 obj = np.asarray(obj)
1912 shifted.append(np.column_stack([obj[:, 0] - y_min, obj[:, 1] - x_min]))
1913 cropped_object_contours[chann] = shifted
1914 else:
1915 # Fallback: use cropped_object_contour as-is (may not align in window mode,
1916 # but prevents hard failure if only that exists)
1917 cropped_object_contours[chann] = meshdata.get("cropped_object_contour", None)
1918
1919 # ---------------------------------------------------------
1920 # 2) Plot (same operations as your original version)
1921 # ---------------------------------------------------------
1922 fig, ax = plt.subplots(figsize=(6, 6))
1923 ax.imshow(cropped_img, cmap="gist_grey")
1924 ax.plot(cropped_contour.T[1], cropped_contour.T[0], color="yellow")
1925
1926 for chann in contour_channels:
1927 ccont = cropped_object_contours.get(chann, None)
1928 if ccont is not None:
1929 for obj_contour in ccont:
1930 ax.plot(
1931 obj_contour.T[1],
1932 obj_contour.T[0],
1933 "-",
1934 lw=2,
1935 label=f"Channel {chann}",
1936 )
1937
1938 if contour_channels:
1939 ax.legend(frameon=False)
1940
1941 # ---------------------------------------------------------
1942 # 3) Scalebar (same logic; editable in SVG)
1943 # ---------------------------------------------------------
1944 if add_scalebar:
1945 bar_px = scalebar_um / um_per_px
1946 h, w = cropped_img.shape[:2]
1947
1948 x_start = int(w * 0.05)
1949 y_start = int(h * 0.95)
1950
1951 ax.plot(
1952 [x_start, x_start + bar_px],
1953 [y_start, y_start],
1954 color=scalebar_color,
1955 lw=scalebar_lw,
1956 solid_capstyle="butt",
1957 )
1958 ax.text(
1959 x_start + bar_px / 2,
1960 y_start - h * 0.03,
1961 f"{scalebar_um:.1f} Β΅m",
1962 color=scalebar_color,
1963 ha="center",
1964 va="bottom",
1965 fontsize=20,
1966 fontfamily="Arial",
1967 )
1968
1969 ax.set_title(f"Cell {cellobj.cell_id} - Frame {image.frame}β Base Channel {base_channel}", fontfamily="Arial")
1970 ax.axis("off")
1971 fig.tight_layout(pad=0)
1972 if save_tiff_path:
1973 plt.imsave(
1974 save_tiff_path,
1975 cropped_img,
1976 cmap="gist_grey",
1977 format="tiff"
1978 )
1979 # SVG only (editable)
1980 if save_svg_path:
1981 fig.savefig(save_svg_path, format="svg", bbox_inches="tight", pad_inches=0)
1982
1983 plt.show()
1984
1985
1986
1987def plot_mesh_scientific(cell_id, image, crop_size=300, verbose=False):
1988
1989 cellobj = next((cell for cell in image.cells if cell.cell_id == cell_id), None)
1990
1991 if cellobj is None:
1992 if verbose:
1993 print(f"Cell with ID {cell_id} not found.")
1994 return
1995
1996 fig = plt.figure(figsize=(12, 12))
1997
1998 profile_mesh = cellobj.profile_mesh
1999 # Calculate the center of the contour
2000 center_x = np.mean(cellobj.contour.T[1])
2001 center_y = np.mean(cellobj.contour.T[0])
2002
2003 # Calculate the new cropping boundaries based on the fixed crop size
2004 half_crop_size = crop_size / 2
2005 xmin = center_x - half_crop_size
2006 xmax = center_x + half_crop_size
2007 ymin = center_y - half_crop_size
2008 ymax = center_y + half_crop_size
2009
2010 plt.xlim(xmin, xmax)
2011 plt.ylim(ymin, ymax)
2012 plt.imshow(image.image, cmap="gist_gray")
2013 plt.plot(cellobj.contour.T[1], cellobj.contour.T[0], "-", c="w", lw=3)
2014
2015 plt.plot(
2016 [cellobj.y1[::2], cellobj.y2[::2]],
2017 [cellobj.x1[::2], cellobj.x2[::2]],
2018 c="cyan",
2019 lw=3,
2020 )
2021 plt.plot(cellobj.midline.T[1], cellobj.midline.T[0], "-", c="orange", lw=3)
2022 # Add scale bar
2023 scale_length_um = 1
2024 scale_length_px = scale_length_um / 0.065 # Convert Β΅m to pixels
2025 scale_x = (xmin + xmax) / 2 - scale_length_px / 2 # Center the scale bar
2026 scale_y = ymin + (ymax - ymin) * 0.02 # Adjust the position of the scale bar
2027 plt.plot(
2028 [scale_x, scale_x + scale_length_px], [scale_y, scale_y], color="white", lw=5
2029 )
2030 # plt.scatter(profile_mesh[1][::2].T, profile_mesh[0][::2].T, s = 3, c = 'cyan')
2031 plt.title(
2032 f"Cell number: {cellobj.cell_id} with contour and profiling mesh", fontsize=16
2033 )
2034 plt.show()
2035
2036
2037def plot_width(cell, image, crop_size=70):
2038 cellobj = image.cells[cell]
2039 other_cellobj = image.cells[cell + 44]
2040
2041 fig1, axs1 = plt.subplots(figsize=(12, 12))
2042 profile_mesh = cellobj.profile_mesh
2043
2044 center_x = np.mean(cellobj.contour.T[1])
2045 center_y = np.mean(cellobj.contour.T[0])
2046
2047 # Calculate the new cropping boundaries based on the fixed crop size
2048 half_crop_size = crop_size / 2
2049 xmin = center_x - half_crop_size
2050 xmax = center_x + half_crop_size
2051 ymin = center_y - half_crop_size
2052 ymax = center_y + half_crop_size
2053
2054 axs1.set_position([0.1, 0.1, 0.5, 0.8])
2055 axs1.imshow(image.image, cmap="gist_gray")
2056 axs1.plot(cellobj.contour.T[1], cellobj.contour.T[0], lw=3, c="orange")
2057
2058 axs1.set_xlim(xmin, xmax)
2059 axs1.set_ylim(ymin, ymax)
2060 # Add scale bar
2061 scale_length_um = 1
2062 scale_length_px = scale_length_um / 0.065 # Convert Β΅m to pixels
2063 scale_x = (xmin + xmax) / 2 - scale_length_px / 2 # Center the scale bar
2064 scale_y = ymin + (ymax - ymin) * 0.02 # Adjust the position of the scale bar
2065 axs1.plot(
2066 [scale_x, scale_x + scale_length_px], [scale_y, scale_y], color="white", lw=5
2067 )
2068 axs1.set_xticks([])
2069 axs1.set_yticks([])
2070 axs1.set_title("Cell " + str(cell), fontname="Arial", fontsize=14)
2071
2072 fig2, axs2 = plt.subplots(figsize=(6, 4))
2073
2074 # Plot the cell width for the first cell (red curve)
2075 axs2.plot(cellobj.profiling_data["cell_widthno"], c="orange")
2076
2077 # Plot the cell width for the other cell (green curve)
2078 axs2.plot(other_cellobj.profiling_data["cell_widthno"], c="b")
2079
2080 # Set x-axis ticks and labels
2081 newxticks = [
2082 np.round(
2083 cellobj.contour_features["cell_length"] * (tick / profile_mesh.shape[1]), 1
2084 )
2085 for tick in axs2.get_xticks()
2086 ]
2087 axs2.set_xticks(axs2.get_xticks())
2088 axs2.set_xticklabels(newxticks, fontname="Arial", fontsize=12)
2089
2090 # Set y-axis ticks and label
2091 newyticks = [np.round(tick, 1) for tick in axs2.get_yticks()]
2092 axs2.set_yticks(axs2.get_yticks())
2093 axs2.set_yticklabels(newyticks, fontname="Arial", fontsize=12)
2094
2095 # Set x-axis to start at 0
2096 axs2.set_xlim(left=0)
2097 axs2.set_ylim(bottom=0)
2098 # Set x and y axis labels
2099 axs2.set_xlabel("cell length [Β΅m]", fontname="Arial", fontsize=12)
2100 axs2.set_ylabel("cell width [Β΅m]", fontname="Arial", fontsize=12)
2101
2102 axs2.legend(["overlapping cell", "single cell"], frameon=False)
2103
2104 fig3, axs3 = plt.subplots(figsize=(12, 12))
2105 other_profile_mesh = other_cellobj.profile_mesh
2106
2107 center_x = np.mean(other_cellobj.contour.T[1])
2108 center_y = np.mean(other_cellobj.contour.T[0])
2109
2110 # Calculate the new cropping boundaries based on the fixed crop size
2111 half_crop_size = crop_size / 2
2112 xmin = center_x - half_crop_size
2113 xmax = center_x + half_crop_size
2114 ymin = center_y - half_crop_size
2115 ymax = center_y + half_crop_size
2116
2117 axs3.set_position([0.1, 0.1, 0.5, 0.8])
2118 axs3.imshow(image.image, cmap="gist_gray")
2119 axs3.plot(other_cellobj.contour.T[1], other_cellobj.contour.T[0], lw=3, c="b")
2120
2121 axs3.set_xlim(xmin, xmax)
2122 axs3.set_ylim(ymin, ymax)
2123
2124 axs3.set_xticks([])
2125 axs3.set_yticks([])
2126 axs3.set_title("Other Cell", fontname="Arial", fontsize=14)
2127 scale_length_um = 1
2128 scale_length_px = scale_length_um / 0.065 # Convert Β΅m to pixels
2129 scale_x = (xmin + xmax) / 2 - scale_length_px / 2 # Center the scale bar
2130 scale_y = ymin + (ymax - ymin) * 0.02 # Adjust the position of the scale bar
2131 axs3.plot(
2132 [scale_x, scale_x + scale_length_px], [scale_y, scale_y], color="white", lw=5
2133 )
2134
2135 plt.show()
2136
2137
2138def plot_signal_profile(cell, image):
2139 cellobj = image.cells[cell]
2140 profile_mesh = cellobj.profile_mesh
2141 profiling_mesh = cellobj.profiling_data["phaco_mesh_intensity"]
2142 fig, ax = plt.subplots(2, 1, figsize=(10, 10))
2143 xmin = np.min(profile_mesh[1] - 10)
2144 xmax = np.max(profile_mesh[1] + 10)
2145 ymin = np.min(profile_mesh[0] - 10)
2146 ymax = np.max(profile_mesh[0] + 10)
2147 ax[0].imshow(profiling_mesh, aspect="auto")
2148 ax[0].get_xticks()
2149 xticks = ax[0].get_xticks()
2150 newxticks = [
2151 np.round(
2152 cellobj.contour_features["cell_length"] * (tick / profiling_mesh.shape[1]),
2153 1,
2154 )
2155 for tick in xticks
2156 ]
2157 ax[0].set_xticklabels(newxticks, fontname="Arial", fontsize=12)
2158 ax[0].set_yticks([])
2159 ax[0].set_ylabel("signal\nstraighten image\n", fontname="Arial", fontsize=12)
2160 ax[0].set_xlabel("cell length [Β΅m]", fontname="Arial", fontsize=12)
2161
2162 ax[1].imshow(image, cmap="gist_gray", aspect="auto")
2163 ax[1].plot(cellobj.contour.T[1], cellobj.contour.T[0], "-", c="r")
2164 ax[1].plot(cellobj.midline.T[1], cellobj.midline.T[0], "-", c="y")
2165 ax[1].set_xlim(xmin, xmax)
2166 ax[1].set_ylim(ymin, ymax)
2167 plt.show()
2168
2169from scipy.interpolate import interp1d
2170
2171def plot_demograph_rotated(cell_lengths, normalized_average_mesh_intensity, title='Demograph Plot',
2172 y_label='Normalized Distance From Midcell (Β΅m)', x_label='Cell Length Percentile',
2173 cmap='rainbow', cbar_title='DnaN-msfGFP Normalized Intensity'):
2174 # Filter out NaN values from cell_lengths and corresponding intensities
2175 valid_indices = [i for i, arr in enumerate(normalized_average_mesh_intensity) if arr is not None and not np.isnan(np.nanmean(arr))]
2176 cell_lengths = cell_lengths[valid_indices].reset_index(drop=True)
2177 normalized_average_mesh_intensity = normalized_average_mesh_intensity[valid_indices].reset_index(drop=True)
2178
2179
2180 # Sort the arrays based on cell_lengths
2181 sorted_indices = np.argsort(cell_lengths)
2182 sorted_lengths = cell_lengths[sorted_indices]
2183 sorted_intensities = normalized_average_mesh_intensity[sorted_indices]
2184
2185 # Reset the indices to ensure correct alignment
2186 sorted_lengths = np.array(sorted_lengths)
2187 sorted_intensities = np.array(sorted_intensities)
2188
2189 # Find the maximum length of the arrays
2190 max_length = max(len(arr) for arr in sorted_intensities)
2191
2192 # Interpolate missing values for shorter arrays
2193 interpolated_arrays = []
2194 for array in sorted_intensities:
2195 x = np.arange(len(array)) # x-coordinates for the existing intensity values
2196 f = interp1d(x, array, kind='linear', fill_value='extrapolate') # Interpolation function
2197 interpolated_array = f(np.linspace(0, len(array), max_length)) # Interpolated array
2198 interpolated_arrays.append(interpolated_array)
2199 stacked_demograph = np.vstack(interpolated_arrays)
2200
2201 fig1 = plt.figure(figsize=(10, 8)) # Adjust figure size as needed
2202
2203 ax = plt.subplot(111)
2204 image = ax.imshow(stacked_demograph.T, aspect='auto', cmap=cmap) # Transpose the array
2205 cbar = plt.colorbar(image) # Use the image as the mappable object for the colorbar
2206 cbar.set_label(cbar_title, rotation=90, labelpad=20, fontsize=14) # Set colorbar label
2207 cbar.ax.tick_params(labelsize=14) # Adjust colorbar tick label size
2208
2209 # Calculate y-axis values and middle index
2210 y_axis_values = np.linspace(-1, 1, max_length) # Ensure values are between -1 and 1
2211 middle_index = len(y_axis_values) // 2
2212
2213 # Set y-axis ticks and labels
2214 plt.yticks([-0.5, middle_index, len(y_axis_values)-0.5], [-1, 0, 1])
2215 plt.xticks([0, len(sorted_lengths) * 0.25, len(sorted_lengths) * 0.5, len(sorted_lengths) * 0.75, len(sorted_lengths)],
2216 ['0', '25', '50', '75', '100'])
2217 plt.xlabel(x_label, fontsize=16)
2218 plt.ylabel(y_label, fontsize=16)
2219
2220 plt.title(title, fontsize=18) # Set the plot title
2221
2222 plt.show()
2223
2224
2225def list_field_ids(dataset_dir, phase_channel="C1"):
2226 dataset_dir = Path(dataset_dir)
2227 return sorted(
2228 {
2229 path.stem.rsplit("_", 1)[0]
2230 for path in dataset_dir.glob(f"*_{phase_channel}.tiff")
2231 }
2232 )
2233
2234
2235def plot_multichannel_overview(dataset_dir, field_id, channel_labels, figsize=(14, 3.8)):
2236 """Return aligned display crops for each channel in a field of view."""
2237 fig = plot_field_channels(dataset_dir, field_id, channel_labels)
2238 if figsize is not None:
2239 fig.set_size_inches(*figsize)
2240 fig.tight_layout(rect=(0, 0, 1, 0.98))
2241 return fig
2242
2243
2244def plot_mesh_overlay(image_object, cell_id, crop_size=150, verbose=False):
2245 cellobj = next((cell for cell in image_object.cells if cell.cell_id == cell_id), None)
2246 if cellobj is None:
2247 if verbose:
2248 print(f"Cell with ID {cell_id} not found.")
2249 return
2250
2251 contour = np.asarray(cellobj.contour)
2252 mesh = np.asarray(cellobj.mesh)
2253 midline = np.asarray(cellobj.midline)
2254
2255 center_x = contour[:, 1].mean()
2256 center_y = contour[:, 0].mean()
2257 half_crop = crop_size / 2
2258 xmin = center_x - half_crop
2259 xmax = center_x + half_crop
2260 ymin = center_y - half_crop
2261 ymax = center_y + half_crop
2262
2263 fig, ax = plt.subplots(figsize=(5.5, 5.5))
2264 ax.imshow(image_object.image, cmap="gray")
2265 ax.plot(contour[:, 1], contour[:, 0], color="white", lw=2, label="Cell contour")
2266 if mesh.size:
2267 ax.plot(
2268 [mesh[:, 1][::2], mesh[:, 3][::2]],
2269 [mesh[:, 0][::2], mesh[:, 2][::2]],
2270 color="cyan",
2271 lw=1,
2272 )
2273 ax.plot(midline[:, 1], midline[:, 0], color="magenta", lw=2, label="Midline")
2274 ax.set_xlim(xmin, xmax)
2275 ax.set_ylim(ymin, ymax)
2276 ax.set_title(f"Mesh overlay for cell {cell_id}")
2277 ax.axis("off")
2278 ax.legend(frameon=False, loc="upper right")
2279 plt.tight_layout()
2280 plt.show()
2281
2282
2283def pick_detected_cell(image_object, preferred_channels=("C4", "C5")):
2284 """Choose an interior, isolated cell with a detected object when possible."""
2285 candidates = []
2286 all_centers = []
2287 for cell in image_object.cells:
2288 contour = np.asarray(cell.contour)
2289 if contour.ndim != 2 or contour.shape[1] != 2 or not len(contour):
2290 continue
2291 center = contour.mean(axis=0)
2292 all_centers.append(center)
2293 detections = sum(
2294 len(contours) for channel in preferred_channels
2295 for contours in [cell.object_meshdata.get(channel, {}).get("object_contour")]
2296 if contours is not None
2297 )
2298 if detections:
2299 margin = min(center[0], center[1],
2300 image_object.image.shape[0] - center[0],
2301 image_object.image.shape[1] - center[1])
2302 length = np.ptp(contour, axis=0).max()
2303 candidates.append((cell.cell_id, center, margin, length))
2304 if not candidates:
2305 return pick_representative_cell([image_object])[1]
2306 median_length = np.median([item[3] for item in candidates])
2307 tree = cKDTree(all_centers) if len(all_centers) > 1 else None
2308 def score(item):
2309 distance = tree.query(item[1], k=2)[0][1] if tree is not None else np.inf
2310 return (item[2] >= 60, distance / max(item[3], 1),
2311 -abs(item[3] - median_length), item[2])
2312 return max(candidates, key=score)[0]
2313
2314
2315def plot_morphology_summary(df):
2316 fig, axes = plt.subplots(2, 2, figsize=(13, 10))
2317
2318 axes[0, 0].hist(df["cell_length"].dropna(), bins=30, color="#4c72b0", alpha=0.9)
2319 axes[0, 0].set_title("Cell length distribution")
2320 axes[0, 0].set_xlabel("Length (um)")
2321 axes[0, 0].set_ylabel("Cell count")
2322
2323 axes[0, 1].hist(df["cell_width"].dropna(), bins=30, color="#55a868", alpha=0.9)
2324 axes[0, 1].set_title("Cell width distribution")
2325 axes[0, 1].set_xlabel("Width (um)")
2326 axes[0, 1].set_ylabel("Cell count")
2327
2328 scatter = axes[1, 0].scatter(
2329 df["cell_length"],
2330 df["cell_area"],
2331 c=df["C5_cell_total_obj_area"].fillna(0),
2332 cmap="magma",
2333 s=20,
2334 alpha=0.75,
2335 )
2336 axes[1, 0].set_title("Cell area vs. cell length")
2337 axes[1, 0].set_xlabel("Length (um)")
2338 axes[1, 0].set_ylabel("Area (um^2)")
2339 cbar = plt.colorbar(scatter, ax=axes[1, 0])
2340 cbar.set_label("Nucleoid object area")
2341
2342 image_names = sorted(df["image_name"].dropna().unique())
2343 width_groups = [
2344 df.loc[df["image_name"] == image_name, "cell_width"].dropna()
2345 for image_name in image_names
2346 ]
2347 axes[1, 1].boxplot(width_groups, labels=image_names, patch_artist=True)
2348 axes[1, 1].set_title("Cell width by field of view")
2349 axes[1, 1].set_ylabel("Width (um)")
2350 axes[1, 1].tick_params(axis="x", rotation=30)
2351
2352 plt.tight_layout()
2353 plt.show()
2354
2355
2356def plot_signal_summary(df):
2357 fig, axes = plt.subplots(1, 3, figsize=(16, 4.5))
2358
2359 axes[0].hist(
2360 df["C2_signal_constriction_degree_mesh"].dropna(),
2361 bins=30,
2362 color="#dd8452",
2363 alpha=0.9,
2364 )
2365 axes[0].set_title("Outer-membrane signal localization")
2366 axes[0].set_xlabel("C2 signal constriction degree")
2367 axes[0].set_ylabel("Cell count")
2368
2369 max_objects = int(
2370 np.nanmax([df["C4_object_number"].max(), df["C5_object_number"].max()])
2371 )
2372 bins = np.arange(0.5, max_objects + 1.6, 1)
2373 axes[1].hist(
2374 df["C4_object_number"].dropna(),
2375 bins=bins,
2376 alpha=0.7,
2377 label="RNA objects",
2378 color="#55a868",
2379 )
2380 axes[1].hist(
2381 df["C5_object_number"].dropna(),
2382 bins=bins,
2383 alpha=0.7,
2384 label="Nucleoid objects",
2385 color="#c44e52",
2386 )
2387 axes[1].set_title("Detected object counts per cell")
2388 axes[1].set_xlabel("Objects per cell")
2389 axes[1].set_ylabel("Cell count")
2390 axes[1].legend(frameon=False)
2391
2392 scatter = axes[2].scatter(
2393 df["C4_NC_ratio"],
2394 df["C5_NC_ratio"],
2395 c=df["cell_length"],
2396 cmap="viridis",
2397 s=20,
2398 alpha=0.75,
2399 )
2400 axes[2].set_title("RNA versus nucleoid occupancy")
2401 axes[2].set_xlabel("C4 object area / cell area")
2402 axes[2].set_ylabel("C5 object area / cell area")
2403 cbar = plt.colorbar(scatter, ax=axes[2])
2404 cbar.set_label("Cell length (um)")
2405
2406 plt.tight_layout()
2407 plt.show()
2408
2409
2410def plot_pairwise_metric_heatmap(
2411 df,
2412 metric_suffix,
2413 channel_order=("C2", "C3", "C4", "C5"),
2414):
2415 matching_columns = [column for column in df.columns if column.endswith(metric_suffix)]
2416 if not matching_columns:
2417 print(f"No columns found for metric suffix: {metric_suffix}")
2418 return
2419
2420 matrix = pd.DataFrame(np.nan, index=channel_order, columns=channel_order, dtype=float)
2421 for column in matching_columns:
2422 ch1, ch2, _ = column.split("_", 2)
2423 value = df[column].median(skipna=True)
2424 matrix.loc[ch1, ch2] = value
2425 matrix.loc[ch2, ch1] = value
2426
2427 if "correlation" in metric_suffix:
2428 np.fill_diagonal(matrix.values, 1.0)
2429 vmin, vmax, cmap = -1, 1, "coolwarm"
2430 else:
2431 vmin = np.nanmin(matrix.values)
2432 vmax = np.nanmax(matrix.values)
2433 cmap = "viridis"
2434
2435 fig, ax = plt.subplots(figsize=(6, 5))
2436 image = ax.imshow(matrix.values.astype(float), cmap=cmap, vmin=vmin, vmax=vmax)
2437 ax.set_xticks(range(len(channel_order)))
2438 ax.set_yticks(range(len(channel_order)))
2439 ax.set_xticklabels(channel_order)
2440 ax.set_yticklabels(channel_order)
2441 ax.set_title(metric_suffix.replace("_", " "))
2442
2443 for row in range(len(channel_order)):
2444 for col in range(len(channel_order)):
2445 value = matrix.iloc[row, col]
2446 if not np.isnan(value):
2447 ax.text(col, row, f"{value:.2f}", ha="center", va="center", color="black")
2448
2449 plt.colorbar(image, ax=ax, fraction=0.046, pad=0.04)
2450 plt.tight_layout()
2451 plt.show()