diff --git a/controller/imager/mqtt.py b/controller/imager/mqtt.py
index 2d5cbe9c2..40e08c6b0 100644
--- a/controller/imager/mqtt.py
+++ b/controller/imager/mqtt.py
@@ -365,6 +365,17 @@ def run(self) -> None:
f'{{"status":"Image {index + 1}/{self._routine.settings.total_images} '
+ f'saved to {filename}"}}',
)
+ self._mqtt_client.publish(
+ "status/imager",
+ json.dumps(
+ {
+ "type": "progress",
+ "path": filename_path,
+ "current": index + 1,
+ "total": self._routine.settings.total_images,
+ }
+ ),
+ )
def stop(self) -> None:
"""Stop the thread.
diff --git a/docs/BUGFIX_CHANGELOG.md b/docs/BUGFIX_CHANGELOG.md
new file mode 100644
index 000000000..6e77adbb7
--- /dev/null
+++ b/docs/BUGFIX_CHANGELOG.md
@@ -0,0 +1,960 @@
+# Bug Fix Change Log
+
+This document tracks bug fixes made to the PlanktoScope Update Dashboard branch.
+Each fix includes the original code so changes can be reverted if needed.
+
+---
+
+## Fix #1: H.264 Video Stream Corruption (RPi5)
+
+**Date:** 2026-01-23
+**File:** `controller/camera/hardware.py`
+**Lines:** 21, 287-301
+**Issue:** Severe video corruption/pixelization in live stream on Raspberry Pi 5
+
+### Problem Description
+
+The video stream displayed severe H.264 decoding artifacts on RPi5 - blocky, pixelated corruption affecting the entire frame. This has been present since the beginning on RPi5.
+
+**Root Cause:** The RPi5 preview resolution `2028x1520` is NOT 16-pixel aligned
+- 2028 / 16 = 126.75
+- H.264 requires 16-pixel macroblock alignment
+- The code comment on line 17 mentions this: "anything <= 1920x1080 divisible by 16 (required by H.264 macroblock alignment)"
+
+The RPi4 resolution (1440x1080) is properly aligned, which is why it works there.
+
+### Screenshot of Issue
+
+See: `/Users/adam/Documents/Update_dashboard/divx.jpg`
+
+### Part A: Resolution Fix (Line 21)
+
+**Original Code:**
+```python
+preview_size = (1440, 1080) if (get_platform() == Platform.VC4) else (2028, 1520)
+```
+
+**New Code:**
+```python
+preview_size = (1440, 1080) if (get_platform() == Platform.VC4) else (1920, 1440)
+```
+
+**Why 1920x1440:**
+- 1920 / 16 = 120 (aligned!)
+- 1440 / 16 = 90 (aligned!)
+- Maintains 4:3 aspect ratio
+- Well-supported, commonly used resolution
+
+### Part B: Encoder Settings (Lines 287-301)
+
+Also added encoder improvements for network resilience:
+
+**Original Code:**
+```python
+ encoder = encoders.H264Encoder(
+ # picamera2-manual.pdf 7.1.1. H264Encoder
+ # the bitrate (in bits per second) to use. The default value None will cause the encoder to
+ # choose an appropriate bitrate according to the Quality when it starts.
+ # bitrate=None,
+ # picamera2-manual.pdf 7.1.1. H264Encoder
+ # whether to repeat the stream's sequence headers with every Intra frame (I-frame). This can
+ # be sometimes be useful when streaming video over a network, when the client may not receive the start of the
+ # stream where the sequence headers would normally be located.
+ # repeat=False,
+ # picamera2-manual.pdf 7.1.1. H264Encoder
+ # iperiod (default None) - the number of frames from one I-frame to the next. The value None leaves this at the
+ # discretion of the hardware, which defaults to 60 frames.
+ # iperiod=None
+ )
+```
+
+**New Code:**
+```python
+ encoder = encoders.H264Encoder(
+ # FIX: Use baseline profile to disable B-frames (WebRTC doesn't support B-frames)
+ # See: https://github.com/bluenviron/mediamtx/issues/3022
+ # See: https://github.com/raspberrypi/picamera2/issues/785
+ profile="baseline",
+ # picamera2-manual.pdf 7.1.1. H264Encoder
+ # whether to repeat the stream's sequence headers with every Intra frame (I-frame). This can
+ # sometimes be useful when streaming video over a network, when the client may not receive the start of the
+ # stream where the sequence headers would normally be located.
+ repeat=True, # FIX: Repeat headers for network resilience
+ # picamera2-manual.pdf 7.1.1. H264Encoder
+ # iperiod (default None) - the number of frames from one I-frame to the next. The value None leaves this at the
+ # discretion of the hardware, which defaults to 60 frames.
+ iperiod=15, # FIX: I-frame every 15 frames for faster recovery
+ )
+```
+
+### Part C: MediaMTX Buffer Fix
+
+**File:** `os/mediamtx/mediamtx.yml`
+
+Added `writeQueueSize: 1024` to prevent packet drops on RPi5 software encoder.
+
+**Original:** (no writeQueueSize setting)
+
+**New:**
+```yaml
+writeQueueSize: 1024
+```
+
+### Research Notes
+
+- **Root cause:** WebRTC doesn't support H.264 B-frames, causing stream corruption
+- **Solution:** Use `profile="baseline"` which disables B-frames
+- **References:**
+ - [MediaMTX B-frames issue](https://github.com/bluenviron/mediamtx/issues/3022)
+ - [picamera2 baseline profile](https://github.com/raspberrypi/picamera2/issues/785)
+ - [picamera2 RPi5 H264 issues](https://github.com/raspberrypi/picamera2/issues/1135)
+
+**Note:** Explicit bitrate (8 Mbps) and iperiod=1 were tried but made things worse.
+
+### How to Revert
+
+**Part A (Resolution):** Change line 21 back to:
+```python
+preview_size = (1440, 1080) if (get_platform() == Platform.VC4) else (2028, 1520)
+```
+
+**Part B (Encoder):** Remove `profile="baseline"`, `repeat=True`, and `iperiod=15` parameters from H264Encoder().
+
+**Part C (MediaMTX):** Remove `writeQueueSize: 1024` from mediamtx.yml.
+
+### Expected Outcome
+
+- Resolution alignment (16-pixel) eliminates macroblock alignment corruption
+- `profile="baseline"` disables B-frames which WebRTC cannot decode
+- `repeat=True` provides header resilience for network issues
+- `iperiod=15` provides reasonable keyframe frequency
+- `writeQueueSize: 1024` prevents packet drops from buffer overflow
+
+---
+
+## Fix #2: Calibration Settings Not Persisting
+
+**Date:** 2026-01-23
+**File:** `node-red/settings.cjs`
+**Lines:** 341-345
+**Issue:** Calibration settings reset on system restart
+
+### Problem Description
+
+Node-RED context storage uses `localfilesystem` module which flushes to disk every 30 seconds by default. If Node-RED restarts before flush completes, calibration data is lost.
+
+### Original Code (for revert)
+
+```javascript
+ contextStorage: {
+ default: {
+ module:"localfilesystem"
+ },
+ },
+```
+
+### New Code
+
+```javascript
+ contextStorage: {
+ default: {
+ module: "localfilesystem",
+ config: {
+ flushInterval: 5, // FIX: Flush every 5 seconds instead of default 30 to prevent calibration data loss on restart
+ }
+ },
+ },
+```
+
+### How to Revert
+
+Remove the `config` object with `flushInterval` from the contextStorage setting.
+
+### Expected Outcome
+
+- Calibration settings will be saved to disk every 5 seconds instead of 30
+- Much less likely to lose data on restart
+- Trade-off: Slightly more disk I/O (negligible impact)
+
+### Calibration Data Stored
+
+The following global context variables are persisted:
+- `calibration_pixel_size` - pixel size in µm/pixel
+- `calibration_scale_factor`, `calibration_sensor_width`, `calibration_stream_width`
+- `calibration_known_distance`, `calibration_measured_distance`
+- `calibration_markerA_x`, `calibration_markerA_y`, `calibration_markerB_x`, `calibration_markerB_y`
+- `calibration_wbg_red`, `calibration_wbg_blue` - white balance gains
+- `calibration_nb_step` - pump steps per mL
+
+**Status:** Applied and deployed to PlanktoScope (2026-01-24)
+**Deployment:** `scp node-red/settings.cjs` to `/home/pi/PlanktoScope/node-red/settings.cjs`
+**Service restart required:** `sudo systemctl restart nodered`
+
+---
+
+## Enhancement #1: Segmentation Mask Cyan Opacity Effect
+
+**Date:** 2026-01-23
+**Files:**
+- `frontend/src/pages/preview/SegmentationOverlay.jsx`
+- `frontend/src/pages/preview/segmentation/index.jsx`
+- `frontend/src/pages/preview/index.jsx`
+
+**Issue:** Segmentation masks used varying hue colors and 50% opacity, making them less visually appealing
+
+### Problem Description
+
+The segmentation mask overlays used a rainbow of colors based on object index (`hsl(index * 137.5, 70%, 50%)`) with 50% opacity. This was inconsistent and didn't highlight detected objects as clearly.
+
+### Original Code (for revert)
+
+```javascript
+const hue = (index * 137.5) % 360
+const color = `hsl(${hue}, 70%, 50%)`
+// ...
+ctx.globalAlpha = 0.5
+```
+
+### New Code
+
+```javascript
+// Cyan color for segmentation overlay (matching the cool opacity effect Thibaut likes)
+const maskColor = "rgb(0, 220, 220)" // Cyan fill color
+const bboxColor = "rgb(0, 128, 128)" // Darker teal for bounding box
+// ...
+ctx.globalAlpha = 0.35 // Subtle transparency to see underlying image
+```
+
+### How to Revert
+
+Replace the `maskColor` and `bboxColor` constants with the original hue-based color generation, and change `globalAlpha` back to `0.5`.
+
+### Expected Outcome
+
+- Consistent cyan/teal color scheme for all detected objects
+- Subtle 35% opacity allows underlying image details to show through
+- Darker teal bounding box provides clear boundary without being harsh
+- Matches the professional microscopy overlay aesthetic
+
+**Status:** Applied
+
+---
+
+## Enhancement #2: Blur Metric Visualization on Segmentation Page
+
+**Date:** 2026-01-24
+**Files:**
+- `frontend/src/pages/preview/segmentation/index.jsx`
+- `frontend/src/pages/preview/segmentation/styles.module.css`
+
+**Issue:** Need blur visualization (sparkline + heatmap) for focus quality monitoring during live segmentation
+
+### Changes Made
+
+**Part A: Added blur state and visualization to `segmentation/index.jsx`:**
+- Added `blurHistory` state for sparkline (last 60 values)
+- Added `blurGrid` state for heatmap data
+- Added `showHeatmap` toggle state
+- Added `blurStats()` computed function for min/avg/max
+- Added `sparklinePath()` function for SVG rendering
+- Added `drawHeatmap()` function for heatmap canvas
+- Added `toggleHeatmap()` and `clearHeatmap()` helper functions
+- Updated message handler to capture `blur_grid` from MQTT
+- Replaced simple focus indicator with full blur panel UI
+- Added heatmap canvas overlay to image wrapper
+
+**Part B: Added CSS styles to `segmentation/styles.module.css`:**
+- `.blur_panel` - container for blur visualization
+- `.blur_value` - numeric focus value display
+- `.sparkline_container` / `.sparkline` - SVG sparkline styling
+- `.blur_stats` - min/avg/max display
+- `.heatmap_btn` / `.heatmap_active` - toggle button
+- `.heatmap_canvas` - heatmap overlay positioning
+
+### How to Revert
+
+**Part A:** Remove the following from `segmentation/index.jsx`:
+- `BLUR_HISTORY_SIZE` constant
+- `blurHistory`, `blurGrid`, `showHeatmap` state signals
+- `heatmapCanvasRef` ref
+- `blurStats()`, `sparklinePath()`, `drawHeatmap()`, `clearHeatmap()`, `toggleHeatmap()` functions
+- Blur-related message handler updates (setBlurHistory, setBlurGrid)
+- Reset statements in handleToggle (setBlurHistory, setBlurGrid)
+- Replace the `blur_panel` div with original focus indicator:
+```jsx
+
+ Focus
+
+ {focusStatus().icon}
+ {focusStatus().label}
+
+
+```
+- Remove heatmap canvas from image wrapper, restore to:
+```jsx
+
+```
+
+**Part B:** Remove CSS classes from `.blur_panel` through `.heatmap_canvas` from `styles.module.css`
+
+### Expected Outcome
+
+- Real-time sparkline showing blur trend over last 60 frames
+- Numeric blur value with color coding (red < 25, yellow 25-50, green > 50)
+- Min/avg/max statistics displayed compactly
+- Toggleable heatmap overlay showing regional blur, still needs work (red=blurry, green=sharp)
+
+### Note on Previous Issue
+
+An earlier attempt to add this to `preview/index.jsx` caused pump control issues (pump wouldn't stop). The issue was likely caused by using `createEffect` with refs. This implementation avoids that by drawing heatmap directly in `onImageLoad` callback.
+
+**Status:** Applied and deployed to PlanktoScope (2026-01-24)
+**Deployment:** `scp -r frontend/dist/* pi@planktoscope-butter-earth.local:/home/pi/PlanktoScope/frontend/dist/`
+
+---
+
+## Fix #3: Video Stream Not Working Over WiFi Hotspot
+
+**Date:** 2026-01-25
+**File:** `/usr/local/etc/mediamtx.yml` (on device), `os/mediamtx/mediamtx.yml` (in repo)
+**Issue:** Video streams work over ethernet but fail when connected via WiFi hotspot
+
+### Problem Description
+
+When connecting to the PlanktoScope via WiFi hotspot (192.168.4.1), the UI loads correctly but the video stream does not display. The same stream works perfectly when connected via ethernet (10.0.0.160).
+
+**Symptoms:**
+- UI accessible at http://192.168.4.1
+- Video stream never loads (blank/spinning)
+- No errors in browser console (connection just hangs)
+- Stream works fine over ethernet
+
+### Root Cause Analysis
+
+**Finding:** WebRTC ICE candidate gathering fails on hotspot network due to unreachable STUN servers.
+
+The MediaMTX configuration relied on public STUN servers for WebRTC ICE candidate discovery:
+```yaml
+webrtcICEServers2:
+ - url: stun:stun.cloudflare.com:3478
+ - url: stun:stun.services.mozilla.com:3478
+ - url: stun:stun.fbsbx.com:3478
+```
+
+When connected via hotspot:
+1. The hotspot network (192.168.4.0/24) typically has no internet access
+2. WebRTC tries to reach STUN servers to gather ICE candidates
+3. STUN servers are unreachable → ICE gathering times out
+4. WebRTC connection never establishes
+5. Video stream fails
+
+Over ethernet, the RPi has internet access, so STUN works and streaming succeeds.
+
+### Diagnostic Commands Used
+
+```bash
+# Confirmed MediaMTX listening on all interfaces
+ss -tlnp | grep 8889
+# Output: LISTEN 0 4096 *:8889 *:*
+
+# Confirmed HTTP endpoint accessible on hotspot IP
+curl -v http://192.168.4.1:8889/cam/
+# Output: HTTP/1.1 200 OK
+
+# Confirmed both interfaces active
+ip addr show wlan0 # 192.168.4.1/24
+ip addr show eth0 # 10.0.0.160/24
+```
+
+### Original Code (for revert)
+
+```yaml
+# https://github.com/bluenviron/mediamtx/blob/v1.15.6/mediamtx.yml
+
+# https://mediamtx.org/docs/usage/webrtc-specific-features#solving-webrtc-connectivity-issues
+# webrtcAdditionalHosts:
+# [
+# localhost,
+# planktoscope.local,
+# pkscope.local,
+# 192.168.4.1,
+# planktoscope-sponge-care,
+# planktoscope-sponge-care.local,
+# home.pkscope,
+# ]
+
+# ICE servers...
+webrtcICEServers2:
+ - url: stun:stun.cloudflare.com:3478
+ - url: stun:stun.services.mozilla.com:3478
+ - url: stun:stun.fbsbx.com:3478
+```
+
+### New Code
+
+```yaml
+# https://github.com/bluenviron/mediamtx/blob/v1.15.6/mediamtx.yml
+
+# https://mediamtx.org/docs/usage/webrtc-specific-features#solving-webrtc-connectivity-issues
+# FIX: Enable webrtcAdditionalHosts for local network streaming without STUN dependency
+webrtcAdditionalHosts:
+ - localhost
+ - planktoscope.local
+ - pkscope.local
+ - 192.168.4.1
+ - 10.0.0.160
+ - planktoscope-butter-earth
+ - planktoscope-butter-earth.local
+ - home.pkscope
+
+# ICE servers. Needed only when local listeners can't be reached by clients.
+# STUN servers allows to obtain and share the public IP of the server.
+# TURN/TURNS servers forces all traffic through them.
+webrtcICEServers2:
+ - url: stun:stun.cloudflare.com:3478
+ - url: stun:stun.services.mozilla.com:3478
+ - url: stun:stun.fbsbx.com:3478
+```
+
+### How the Fix Works
+
+The `webrtcAdditionalHosts` setting tells MediaMTX to explicitly include these IPs/hostnames as valid ICE candidates. This allows WebRTC clients to:
+1. Receive `192.168.4.1` as a direct host candidate
+2. Connect directly to the local IP without needing STUN
+3. Establish the WebRTC connection even without internet access
+
+### How to Revert
+
+Comment out the `webrtcAdditionalHosts` section:
+```yaml
+# webrtcAdditionalHosts:
+# - localhost
+# - planktoscope.local
+# ...
+```
+
+### Deployment
+
+```bash
+# On device - edit the config
+sudo nano /usr/local/etc/mediamtx.yml
+# Add/uncomment webrtcAdditionalHosts as shown above
+
+# Restart MediaMTX
+sudo systemctl restart mediamtx
+
+# Verify
+sudo systemctl status mediamtx
+```
+
+### Expected Outcome
+
+- Video stream works when connected via WiFi hotspot (192.168.4.1)
+- Video stream continues to work via ethernet (10.0.0.160)
+- No dependency on external STUN servers for local connections
+
+### References
+
+- [MediaMTX WebRTC Connectivity Guide](https://mediamtx.org/docs/usage/webrtc-specific-features#solving-webrtc-connectivity-issues)
+- [MediaMTX webrtcAdditionalHosts documentation](https://github.com/bluenviron/mediamtx/blob/main/mediamtx.yml)
+
+**Status:** Applied (2026-01-25)
+**Deployment:** Edit `/usr/local/etc/mediamtx.yml` on device, then `sudo systemctl restart mediamtx`
+
+---
+
+## Fix #4: White Balance and LED Intensity Not Persisting
+
+**Date:** 2026-01-25
+**Files:**
+- `node-red/projects/dashboard/flows.json`
+- `default-configs/v3.0.hardware.json`
+
+**Issue:** White balance (red/blue gains) and LED intensity calibration settings don't persist across restarts
+
+### Problem Description
+
+When users calibrate white balance through the UI, the settings are applied but lost after a system restart. Similarly, LED intensity settings don't persist.
+
+**Root Cause:**
+
+1. **Missing save mechanism:** The Node-RED function nodes stored values in global context but never wrote them to `hardware.json`
+2. **Missing restore mechanism:** On startup, there was no flow to load calibration values from `hardware.json` into Node-RED global context
+3. **Disconnect:** The camera controller reads from `hardware.json`, but Node-RED only saved to its own context storage
+
+### Changes Made
+
+#### Part A: White Balance Save (Function node `d8f732f8fe251222`)
+
+**Original Code:**
+```javascript
+if (msg.topic) {
+ global.set("calibration_wbg_red", msg.payload.settings.white_balance_gain.red);
+ global.set("calibration_wbg_blue", msg.payload.settings.white_balance_gain.blue);
+}
+return msg;
+```
+
+**New Code:**
+```javascript
+if (msg.topic && msg.payload.settings && msg.payload.settings.white_balance_gain) {
+ var red = msg.payload.settings.white_balance_gain.red;
+ var blue = msg.payload.settings.white_balance_gain.blue;
+
+ // Save to Node-RED context
+ global.set("calibration_wbg_red", red);
+ global.set("calibration_wbg_blue", blue);
+
+ // Update hardware_conf and save to hardware.json
+ // Values divided by 100 (UI sends 199, hardware.json needs 1.99)
+ var hardware_conf = global.get("hardware_conf") || {};
+ hardware_conf.red_gain = red / 100;
+ hardware_conf.blue_gain = blue / 100;
+ global.set("hardware_conf", hardware_conf);
+
+ // Return message to trigger file write
+ return { payload: hardware_conf, _saveToFile: true };
+}
+return null;
+```
+
+Added nodes: `wb_json_stringify` (JSON) → `wb_save_hardware_config` (file write)
+
+#### Part B: LED Intensity Save (Function node `0283e992ff5da0f6`)
+
+**Original Code:**
+```javascript
+if (msg.topic) {
+ global.set("led_intensity", msg.payload.value);
+}
+return msg;
+```
+
+**New Code:**
+```javascript
+if (msg.topic && msg.payload.value !== undefined) {
+ var intensity = msg.payload.value;
+
+ // Save to Node-RED context
+ global.set("led_intensity", intensity);
+
+ // Update hardware_conf and save to hardware.json
+ var hardware_conf = global.get("hardware_conf") || {};
+ hardware_conf.led_intensity = intensity;
+ global.set("hardware_conf", hardware_conf);
+
+ // Return message to trigger file write
+ return { payload: hardware_conf, _saveToFile: true };
+}
+return null;
+```
+
+Added nodes: `led_json_stringify` (JSON) → `led_save_hardware_config` (file write)
+
+#### Part C: Startup Restore Flow (New nodes in Setup tab)
+
+Added a startup flow that runs on Node-RED startup:
+1. `startup_load_calibration_inject` - Inject node (runs once, 1s delay)
+2. `startup_load_hardware_file` - File in node (reads `/home/pi/PlanktoScope/hardware.json`)
+3. `startup_parse_hardware_json` - JSON node (parses file contents)
+4. `startup_restore_calibration` - Function node (restores values to global context)
+
+```javascript
+// Restore function logic:
+// Store the full hardware_conf in global context
+global.set("hardware_conf", msg.payload);
+
+// Convert from hardware.json format (1.99) to UI format (199)
+if (msg.payload.red_gain !== undefined) {
+ global.set("calibration_wbg_red", Math.round(msg.payload.red_gain * 100));
+}
+if (msg.payload.blue_gain !== undefined) {
+ global.set("calibration_wbg_blue", Math.round(msg.payload.blue_gain * 100));
+}
+if (msg.payload.led_intensity !== undefined) {
+ global.set("led_intensity", msg.payload.led_intensity);
+}
+```
+
+#### Part D: Default Config Update
+
+Added `led_intensity` to `default-configs/v3.0.hardware.json`:
+```json
+{
+ ...
+ "led_intensity": 1.0,
+ ...
+}
+```
+
+### Data Flow
+
+**On calibration change:**
+```
+UI → MQTT → Function node → Updates global context
+ → Updates hardware_conf object
+ → JSON stringify → Write to hardware.json
+```
+
+**On startup:**
+```
+Inject (once) → Read hardware.json → Parse JSON → Restore to global context
+ → calibration_wbg_red
+ → calibration_wbg_blue
+ → led_intensity
+ → hardware_conf
+```
+
+**Camera controller startup:**
+```
+Python controller → Reads hardware.json → Gets red_gain, blue_gain → Applies to camera
+```
+
+### Value Conversion
+
+| Location | Red Gain Example | Blue Gain Example |
+|----------|------------------|-------------------|
+| UI/MQTT | 199 | 165 |
+| hardware.json | 1.99 | 1.65 |
+| Camera controller | 1.99 | 1.65 |
+
+Conversion: `hardware.json value = UI value / 100`
+
+### How to Revert
+
+1. Restore original function node code (remove hardware_conf updates and file write logic)
+2. Remove the added JSON and file nodes (wb_json_stringify, wb_save_hardware_config, led_json_stringify, led_save_hardware_config)
+3. Remove the startup flow nodes (startup_load_calibration_inject, startup_load_hardware_file, startup_parse_hardware_json, startup_restore_calibration)
+4. Remove `led_intensity` from default-configs/v3.0.hardware.json
+
+### Deployment
+
+```bash
+# Deploy Node-RED flows (from local machine)
+scp node-red/projects/dashboard/flows.json pi@10.0.0.160:/home/pi/PlanktoScope/node-red/projects/dashboard/
+
+# Restart Node-RED (on device)
+sudo systemctl restart nodered
+
+# Verify startup restore worked (check logs)
+sudo journalctl -u nodered -n 20 --no-pager | grep "Restore calibration"
+
+# Test:
+# 1. Calibrate white balance through UI
+# 2. Check hardware.json was updated: cat /home/pi/PlanktoScope/hardware.json
+# 3. Restart Node-RED: sudo systemctl restart nodered
+# 4. Verify settings persisted in hardware.json and UI
+```
+
+### Expected Outcome
+
+- White balance settings (red/blue gains) persist across restarts
+- LED intensity settings persist across restarts
+- Settings are stored in `/home/pi/PlanktoScope/hardware.json`
+- Camera controller reads correct values on startup
+- UI displays correct values after restart
+
+**Status:** Applied and deployed to PlanktoScope (2026-01-26)
+**Verified:** Startup restore flow logged successful restoration of calibration values
+
+---
+
+## Fix #5: Motion Blur Due to Pump Synchronization Race Condition
+
+**Date:** 2026-01-26
+**File:** `controller/imager/main.py`
+**Lines:** ~438-445
+**Issue:** Intermittent motion blur in captured images (1 in every 2-3 frames blurry)
+
+### Problem Description
+
+During stop-flow acquisition, approximately 1 in every 2-3 captured images showed motion blur, even with a 1-second stabilization delay configured. The blur appeared as horizontal smearing of objects, indicating the sample was still moving during capture.
+
+**Root Cause:** Race condition in MQTT pump synchronization.
+
+When starting a new pump cycle, the sequence was:
+1. Acquire lock
+2. Clear `_done` event
+3. Subscribe to `status/pump` MQTT topic
+4. Publish pump command
+5. Wait for `_done` event
+
+The bug: Step 3 (subscribe) would receive **stale "Done" messages** from the previous pump cycle still in the MQTT queue. This triggered `_done.set()` before the new pump actually finished, causing the wait to return immediately.
+
+**Evidence from logs:**
+```
+23:24:07.366 - Subscribe to status/pump (new cycle)
+23:24:07.366 - Pump Done (STALE message from previous cycle!)
+23:24:07.371 - "The pump has stopped" (processing stale message)
+23:24:07.372 - Pump Started (but done event already set!)
+23:24:08.371 - Capture (while pump still running - should be ~08.87!)
+```
+
+### Original Code (for revert)
+
+In `_receive_messages()` method:
+```python
+ if self._mqtt.msg["payload"]["status"] not in {"Done", "Interrupted"}:
+ loguru.logger.debug(f"Ignoring pump status update: {self._mqtt.msg['payload']}")
+ self._mqtt.read_message()
+ continue
+
+ loguru.logger.debug(f"The pump has stopped: {self._mqtt.msg['payload']}")
+```
+
+### New Code
+
+```python
+ if self._mqtt.msg["payload"]["status"] not in {"Done", "Interrupted"}:
+ loguru.logger.debug(f"Ignoring pump status update: {self._mqtt.msg['payload']}")
+ self._mqtt.read_message()
+ continue
+
+ # FIX: Only process Done if we are actually waiting for it
+ # This prevents stale Done messages from triggering early return
+ if self._done.is_set():
+ loguru.logger.debug(f"Ignoring stale pump Done (not waiting): {self._mqtt.msg['payload']}")
+ self._mqtt.read_message()
+ continue
+
+ loguru.logger.debug(f"The pump has stopped: {self._mqtt.msg['payload']}")
+```
+
+### How the Fix Works
+
+1. When a "Done" message arrives, check if `_done.is_set()`
+2. If `_done` is already set, we're not waiting for a Done - it's a stale message from a previous pump cycle
+3. Stale messages are logged and ignored instead of triggering the done event
+4. Only fresh "Done" messages (when `_done` is cleared) are processed
+
+This ensures that only the "Done" message corresponding to the current pump cycle triggers the wait to complete.
+
+### How to Revert
+
+Remove the `time.sleep(0.15)` and second `self._done.clear()` lines from the `run_discrete()` method in `_PumpClient` class.
+
+### Expected Outcome
+
+- All captured images are sharp (taken after proper stabilization)
+- Consistent timing between captures (~2.5s for 1.5s pump + 1.0s stabilization)
+- No more intermittent motion blur
+
+**Status:** Applied and deployed to PlanktoScope (2026-01-26)
+
+---
+
+## Fix #6: Static Object Detection Not Filtering Debris
+
+**Date:** 2026-01-26
+**File:** `segmenter/planktoscope/segmenter/live.py`
+**Lines:** 101, 292, 621
+**Issue:** Debris stuck on flow cell glass not being filtered during live segmentation
+
+### Problem Description
+
+Objects stuck on the glass (debris) were appearing repeatedly in segmentation results instead of being filtered out. The static object removal feature existed but had two problems:
+
+1. **Disabled by default:** `remove_static` defaulted to `False`
+2. **Detection too strict:** 60px grid with 3-frame threshold missed slowly drifting debris due to detection jitter
+
+### Original Code (for revert)
+
+**Line 101:**
+```python
+self.__static_threshold = 3 # Number of consecutive frames to consider static
+```
+
+**Line 292:**
+```python
+grid_size = 60 # Larger grid tolerates detection variation in big objects
+```
+
+**Line 621:**
+```python
+self.__remove_static = last_message.get("remove_static", False)
+```
+
+### New Code
+
+**Line 101:**
+```python
+self.__static_threshold = 2 # FIX: Reduced from 3 to 2 for faster debris detection
+```
+
+**Line 292:**
+```python
+grid_size = 100 # FIX: Larger grid (was 60) tolerates detection jitter for stuck objects
+```
+
+**Line 621:**
+```python
+self.__remove_static = last_message.get("remove_static", True)
+```
+
+### Rationale for Changes
+
+| Parameter | Before | After | Reason |
+|-----------|--------|-------|--------|
+| `remove_static` default | `False` | `True` | Feature should be on by default since debris is common |
+| `grid_size` | 60px | 100px | Larger grid tolerates detection variation where object center jitters between frames |
+| `static_threshold` | 3 frames | 2 frames | Faster detection - debris stuck for 2 frames is clearly not a moving organism |
+
+### How Static Detection Works
+
+1. Image divided into grid cells (now 100×100 pixels)
+2. Each object's centroid maps to a grid cell: `(int(cx/100), int(cy/100))`
+3. Dictionary tracks consecutive frame counts per cell
+4. Objects in cells with count ≥ 2 are filtered as "static debris"
+5. Counters reset when cell becomes empty
+
+### How to Revert
+
+Restore the original values:
+- Line 101: Change `2` back to `3`
+- Line 292: Change `100` back to `60`
+- Line 621: Change `True` back to `False`
+
+### Expected Outcome
+
+- Debris stuck on glass is filtered after appearing in same position for 2 frames
+- Static removal enabled by default for all live segmentation sessions
+- Larger grid tolerance handles detection variation without losing accuracy
+
+**Status:** Applied and deployed to PlanktoScope (2026-01-26)
+
+---
+
+## Fix #7: Stabilization Time Too Short
+
+**Date:** 2026-01-26
+**File:** `node-red/projects/dashboard/flows.json`
+**Node ID:** `bb2825f419cc6526` (function node "start acquisition")
+**Issue:** Default 0.5 second stabilization insufficient for sample to fully settle
+
+### Problem Description
+
+The stabilization time (delay between pump stop and image capture) was hardcoded to 0.5 seconds. This was often insufficient for all particles to settle after pump motion, contributing to motion blur.
+
+### Original Code (for revert)
+
+```javascript
+msg.payload = {
+ action: "image",
+ pump_direction: "FORWARD",
+ volume: acq_interframe_volume,
+ nb_frame: acq_nb_frame,
+ sleep: 0.5
+};
+```
+
+### New Code
+
+```javascript
+msg.payload = {
+ action: "image",
+ pump_direction: "FORWARD",
+ volume: acq_interframe_volume,
+ nb_frame: acq_nb_frame,
+ sleep: 1.0
+};
+```
+
+### Rationale
+
+- 0.5 seconds: Particles still settling, turbulence from pump motion
+- 1.0 seconds: Sufficient time for most particles to fully settle
+- Trade-off: Slightly longer acquisition time (adds 0.5s per frame)
+
+### How to Revert
+
+In Node-RED flows.json, find node `bb2825f419cc6526` and change `sleep: 1.0` back to `sleep: 0.5`.
+
+### Expected Outcome
+
+- All particles fully settled before capture
+- Reduced motion blur from residual fluid motion
+- Acquisition time increases by ~0.5 seconds per frame
+
+**Status:** Applied and deployed to PlanktoScope (2026-01-26)
+
+---
+
+## Fix #6: Live Segmentation Pixel Size Inconsistency
+
+**Date:** 2026-01-26
+**File:** `segmenter/planktoscope/segmenter/live.py`
+**Lines:** 54, 101, 114-141
+
+**Issue:** Live segmenter used hardcoded pixel size (0.75 µm/pixel) instead of reading from calibration config
+
+### Problem Description
+
+The live segmentation module had the pixel size hardcoded to 0.75 µm/pixel. This caused inconsistencies when users calibrated their PlanktoScope to a different pixel size via the calibration dashboard (`/calibration_pixel_size`). The min ESD filter would use the wrong conversion factor, potentially filtering out objects incorrectly.
+
+**Root Cause:** The `__pixel_size_um` value was set to a constant `0.75` instead of reading from `/home/pi/PlanktoScope/hardware.json` like the rest of the system.
+
+### Original Code (for revert)
+
+```python
+# Line 98
+self.__pixel_size_um = 0.75 # Micrometers per pixel (typical PlanktoScope value)
+```
+
+### New Code
+
+```python
+# Line 54 - Added constant
+HARDWARE_CONFIG_PATH = "/home/pi/PlanktoScope/hardware.json"
+
+# Line 101 - Changed initialization
+self.__pixel_size_um = self._load_pixel_size() # Load from hardware config
+
+# Lines 114-141 - Added method
+def _load_pixel_size(self):
+ """Load pixel size from hardware config file.
+
+ Reads process_pixel_fixed from /home/pi/PlanktoScope/hardware.json.
+ This ensures consistency with the calibration value set in the dashboard.
+
+ Returns:
+ float: Pixel size in micrometers per pixel. Defaults to 0.75 if not found.
+ """
+ default_pixel_size = 0.75
+ try:
+ with open(HARDWARE_CONFIG_PATH, "r") as f:
+ config = json.load(f)
+ pixel_size = config.get("process_pixel_fixed", default_pixel_size)
+ logger.info(f"Loaded pixel size from hardware config: {pixel_size} µm/pixel")
+ return float(pixel_size)
+ except FileNotFoundError:
+ logger.warning(
+ f"Hardware config not found at {HARDWARE_CONFIG_PATH}, "
+ f"using default pixel size: {default_pixel_size} µm/pixel"
+ )
+ return default_pixel_size
+ except (json.JSONDecodeError, ValueError) as e:
+ logger.error(
+ f"Error reading hardware config: {e}, "
+ f"using default pixel size: {default_pixel_size} µm/pixel"
+ )
+ return default_pixel_size
+```
+
+### How to Revert
+
+Replace line 101 with:
+```python
+self.__pixel_size_um = 0.75 # Micrometers per pixel (typical PlanktoScope value)
+```
+
+And remove the `_load_pixel_size` method and `HARDWARE_CONFIG_PATH` constant.
+
+### Expected Outcome
+
+- Live segmentation uses the same pixel size as set in the calibration dashboard
+- Min ESD filter correctly converts micrometers to pixels
+- Consistent object detection between live preview and post-acquisition segmentation
+
+**Status:** Applied (2026-01-26)
+
+---
diff --git a/docs/CHANGELOG_PR_DOCUMENTATION.md b/docs/CHANGELOG_PR_DOCUMENTATION.md
new file mode 100644
index 000000000..20d4d3ef7
--- /dev/null
+++ b/docs/CHANGELOG_PR_DOCUMENTATION.md
@@ -0,0 +1,755 @@
+# PlanktoScope Update Dashboard - Release Documentation
+
+
+**Date:** 2026-01-26 (Updated)
+**Base Repository/Branch:** PlanktoScope Dashboard 2.0 - update-dashboard
+**Author:** Adam Larson
+
+---
+
+
+This release implements two major feature enhancements requested via GitHub issues, along with critical bug fixes identified during deployment testing on Raspberry Pi 5 hardware. The changes enable real-time plankton detection during sample acquisition and provide quantitative focus quality metrics for quality assurance.
+
+### Changes Overview
+
+| Category | Description | Priority | Status |
+|----------|-------------|----------|--------|
+| **PR #1** | Live Segmentation Feature | High | Complete |
+| **PR #2** | Blur/Focus Quality Metric | High | Complete |
+| **Bug Fix #1** | H.264 Video Stream Corruption (RPi5) | High | Complete |
+| **Bug Fix #2** | Calibration Settings Persistence (flushInterval) | High | Complete |
+| **Bug Fix #3** | Video Stream Not Working Over WiFi Hotspot | High | Complete |
+| **Bug Fix #4** | White Balance & LED Intensity Not Persisting | High | Complete |
+| **Bug Fix #5** | Motion Blur from Pump Sync Race Condition | High | Complete |
+| **Bug Fix #6** | Static Object Detection Improvements | Medium | Complete |
+| **Bug Fix #7** | Stabilization Time Increased | Medium | Complete |
+
+---
+
+## PR #1: Live Segmentation Feature
+
+### Problem
+
+Operators had no real-time feedback during sample acquisition. They could only assess sample quality and object detection after the acquisition completed, leading to wasted time on poorly focused or improperly positioned samples.
+
+### Solution Implemented
+
+A real-time segmentation system that processes each captured frame during acquisition, overlays detected objects on a live preview, and provides immediate visual feedback to operators.
+
+### Architecture
+
+```
+┌─────────────────────────┐ MQTT: segmenter/live ┌────────────────────────┐
+│ Frontend (SolidJS) │ ──────────────────────────────> │ LiveSegmenterProcess │
+│ - Visualization Page │ │ (Python/OpenCV) │
+│ - Canvas Overlay │ <────────────────────────────── │ │
+│ - Controls UI │ MQTT: status/segmenter/live │ Subscribes to: │
+└─────────────────────────┘ │ status/imager │
+ └────────────────────────┘
+```
+
+### Files Added
+
+#### 1. `segmenter/planktoscope/segmenter/live.py` (NEW - 450 lines)
+
+**Purpose:** Backend process for real-time frame segmentation.
+
+**Key Implementation Details:**
+
+```python
+class LiveSegmenterProcess(multiprocessing.Process):
+ """
+ Runs as a separate process to avoid blocking the main segmenter.
+ Subscribes to imager capture events and processes each frame.
+ """
+```
+
+**Core Methods:**
+
+| Method | Purpose |
+|--------|---------|
+| `segment_single_frame(img)` | Main entry point - segments one frame, returns objects + blur |
+| `_create_simple_mask(img)` | Binary mask using adaptive threshold + morphological ops |
+| `_load_pixel_size()` | Loads `process_pixel_fixed` from `/home/pi/PlanktoScope/hardware.json` |
+| `_esd_um_to_min_area(esd_um)` | Converts µm filter to pixel area using loaded calibration |
+| `_encode_mask_png(mask)` | Base64 PNG encoding with alpha transparency |
+| `_is_static_object(bbox)` | Grid-based debris detection (100px cells, 2-frame threshold) |
+
+**Pixel Size Calibration:**
+
+The live segmenter reads pixel size from `/home/pi/PlanktoScope/hardware.json` (`process_pixel_fixed` field) to ensure consistency with the calibration dashboard. Falls back to 0.75 µm/pixel if config unavailable.
+
+**Improved Static Object Detection Algorithm:**
+
+The system tracks object positions across frames to identify debris stuck on the flow cell glass:
+
+1. Image divided into 100×100 pixel grid cells
+2. Each object's centroid maps to a grid cell
+3. Dictionary tracks consecutive frame counts per cell
+4. Objects in cells with count ≥2 are filtered as "static"
+5. Counters reset when cell becomes empty
+
+**Rationale for Parameters:**
+- **100px grid size:** Tolerates detection jitter while distinguishing separate objects
+- **2-frame threshold:** Faster debris detection (objects stuck on glass are truly stationary)
+
+**Performance Limits:**
+- Maximum 300 objects per frame (prevents UI lag)
+- Maximum 100 masks encoded (masks are memory-intensive)
+- JPEG quality 80% for reasonable bandwidth
+
+---
+
+#### 2. `segmenter/main.py` (MODIFIED)
+
+**Change:** Added live segmenter process initialization.
+
+```python
+# ADDED: Import and start live segmenter
+import planktoscope.segmenter.live
+
+live_segmenter_thread = planktoscope.segmenter.live.LiveSegmenterProcess(
+ shutdown_event, "/home/pi/data"
+)
+live_segmenter_thread.start()
+```
+
+**Rationale:** Separate process ensures live preview doesn't impact main segmentation performance during post-acquisition batch processing.
+
+---
+
+#### 3. `frontend/src/pages/preview/segmentation/index.jsx` (NEW - 482 lines)
+
+**Purpose:** Live Segmentation Visualization page embedded in Node-RED dashboard.
+
+**State Management:**
+```javascript
+const [liveSegmentEnabled, setLiveSegmentEnabled] = createSignal(false)
+const [overlayMode, setOverlayMode] = createSignal("bbox") // bbox | mask | both
+const [minSizeUm, setMinSizeUm] = createSignal(20) // µm minimum ESD
+const [removeStatic, setRemoveStatic] = createSignal(true) // debris filter
+const [objects, setObjects] = createSignal([]) // detected objects
+```
+
+**Overlay Rendering:**
+```javascript
+function drawOverlays() {
+ // Scale from original image coordinates to display coordinates
+ const scaleX = displayWidth / imageRef.naturalWidth
+ const scaleY = displayHeight / imageRef.naturalHeight
+
+ objects().forEach((obj) => {
+ const [x, y, w, h] = obj.bbox
+ // Draw cyan bounding box and/or semi-transparent mask
+ })
+}
+```
+
+**Design Decision - Cyan Color Scheme:**
+- Bounding box: `rgb(0, 128, 128)` (teal)
+- Mask fill: `rgb(0, 220, 220)` (cyan) at 35% opacity
+- Rationale: High contrast against biological samples, professional microscopy aesthetic Thibaut likes
+
+---
+
+#### 4. `frontend/src/pages/preview/segmentation/styles.module.css` (NEW - 352 lines)
+
+CSS module providing dark theme styling consistent with Node-RED dashboard.
+
+---
+
+#### 5. `lib/scope.js` (MODIFIED)
+
+**Added Functions:**
+```javascript
+export async function startLiveSegmentation(config) {
+ // config: { overlay: "bbox", min_esd_um: 20, remove_static: true }
+ await request("segmenter/live", { action: "start", ...config })
+}
+
+export async function stopLiveSegmentation() {
+ await request("segmenter/live", { action: "stop" })
+}
+```
+
+---
+
+### MQTT Interface
+
+| Topic | Direction | Payload |
+|-------|-----------|---------|
+| `segmenter/live` | Frontend → Backend | `{"action": "start", "overlay": "bbox", "min_esd_um": 20, "remove_static": true}` |
+| `segmenter/live` | Frontend → Backend | `{"action": "stop"}` |
+| `status/segmenter/live` | Backend → Frontend | `{"status": "Enabled", "overlay": "bbox"}` |
+| `status/segmenter/live` | Backend → Frontend | `{"objects": [...], "frame_blur": 45.2, "image": "base64...", "image_width": 4056, "image_height": 3040}` |
+
+---
+
+## PR #2: Blur/Focus Quality Metric
+
+### Problem Statement
+
+Operators needed quantitative feedback on image focus quality to:
+1. Assess sample positioning in real-time
+2. Identify focus drift during long acquisitions
+3. Filter poor-quality images in post-processing
+
+### Solution Implemented
+
+A Laplacian variance-based blur metric with real-time visualization including sparkline trending and optional spatial heatmap overlay.
+
+### Technical Background
+
+**Laplacian Variance Method:**
+
+The Laplacian operator detects edges by computing the second derivative of image intensity. Sharp images have strong edges (high variance), while blurry images have weak edges (low variance).
+
+```python
+def calculate_blur(img):
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img
+ return cv2.Laplacian(gray, cv2.CV_64F).var()
+```
+
+**Calibration for PlanktoScope Optics:**
+
+Empirical testing with the PlanktoScope optical system established these thresholds:
+
+| Blur Value | Quality | Visual Indicator |
+|------------|---------|------------------|
+| < 25 | Poor | Red |
+| 25 - 50 | Acceptable | Yellow |
+| > 50 | Good | Green |
+
+**Rationale:** These thresholds were calibrated against manual focus assessment by trained operators using the specific optical configuration (IMX477 sensor, 25mm/12mm lens configuration).
+
+### Files Modified
+
+#### 1. `segmenter/planktoscope/segmenter/operations.py` (MODIFIED)
+
+**Added Functions:**
+
+```python
+def calculate_blur(img):
+ """Calculate blur metric using Laplacian variance.
+
+ Args:
+ img: BGR or grayscale image
+ Returns:
+ float: Higher = sharper, Lower = blurrier
+ """
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img
+ return cv2.Laplacian(gray, cv2.CV_64F).var()
+
+
+def calculate_regional_blur(img, grid_rows=4, grid_cols=4):
+ """Calculate blur for each region of the image.
+
+ Returns 4x4 grid of blur values for spatial focus assessment.
+ Useful for identifying tilt or uneven focus plane.
+ """
+ # Divides image into grid, computes Laplacian variance per cell
+ # Returns: list[list[float]] shape [grid_rows][grid_cols]
+```
+
+**Regional Blur Rationale:**
+
+A 4×4 grid provides sufficient spatial resolution to detect:
+- Sample tilt (gradient across image)
+- Partial occlusion
+- Debris on optical path
+- Non-uniform illumination effects
+
+---
+
+#### 2. `segmenter/planktoscope/segmenter/live.py` (MODIFIED)
+
+**Integration:**
+
+```python
+# In segment_single_frame():
+frame_blur = planktoscope.segmenter.operations.calculate_blur(img)
+blur_grid = planktoscope.segmenter.operations.calculate_regional_blur(img, 4, 4)
+
+return {
+ "objects": objects,
+ "frame_blur": float(frame_blur),
+ "blur_grid": blur_grid, # 4x4 regional heatmap data
+ # ...
+}
+```
+
+---
+
+#### 3. `frontend/src/pages/preview/segmentation/index.jsx` (MODIFIED)
+
+**Added State:**
+```javascript
+const [blurHistory, setBlurHistory] = createSignal([]) // Last 60 values
+const [blurGrid, setBlurGrid] = createSignal(null) // 4x4 regional data
+const [showHeatmap, setShowHeatmap] = createSignal(false)
+```
+
+**Sparkline Implementation:**
+```javascript
+const sparklinePath = () => {
+ const history = blurHistory()
+ if (history.length < 2) return ""
+
+ // SVG path generation for 140x40 sparkline
+ const points = history.map((value, index) => {
+ const x = padding + (index / (BLUR_HISTORY_SIZE - 1)) * (width - 2 * padding)
+ const y = height - padding - ((value - min) / range) * (height - 2 * padding)
+ return `${x},${y}`
+ })
+ return `M ${points.join(" L ")}`
+}
+```
+
+**Heatmap Rendering:**
+```javascript
+function drawHeatmap() {
+ // Color mapping: red (blurry) → yellow → green (sharp)
+ const hue = normalized * 120 // 0=red, 60=yellow, 120=green
+ ctx.fillStyle = `hsla(${hue}, 80%, 50%, 0.35)`
+}
+```
+
+**UI Layout:**
+
+The blur visualization appears as a floating overlay panel in the bottom-right corner of the segmentation preview, containing:
+- Current blur value (large, color-coded)
+- Sparkline (last 60 frames)
+- Min/Avg/Max statistics
+- Heatmap toggle button
+
+---
+
+### EcoTaxa Integration
+
+Per-object blur is exported to the TSV file for post-acquisition quality filtering:
+
+```
+object_blur_laplacian 45.23
+```
+
+This enables researchers to filter the dataset by focus quality during analysis.
+
+---
+
+## Bug Fix: H.264 Video Stream Corruption (RPi5)
+
+### Problem Statement
+
+Severe video corruption manifested as blocky pixelization artifacts on Raspberry Pi 5 hardware. The live preview stream was unustable, displaying what appeared to be DivX-era compression artifacts.
+
+### Root Cause Analysis
+
+**Finding 1: Resolution Misalignment**
+
+The original RPi5 preview resolution `2028×1520` violates H.264 macroblock alignment requirements:
+- H.264 requires dimensions divisible by 16 (macroblock size)
+- 2028 ÷ 16 = 126.75 ❌
+- 1520 ÷ 16 = 95 ✓
+
+The RPi4 resolution `1440×1080` was properly aligned
+
+**Finding 2: B-Frame Incompatibility**
+
+Research identified that WebRTC (used by MediaMTX for browser streaming) does not support H.264 B-frames:
+- Default H.264 profile uses B-frames for compression efficiency
+- WebRTC decoders cannot process B-frames correctly
+- Results in frame reordering artifacts
+
+**References:**
+- https://github.com/bluenviron/mediamtx/issues/3022
+- https://github.com/raspberrypi/picamera2/issues/785
+
+### Changes Applied
+
+#### 1. `controller/camera/hardware.py`
+
+**Resolution Fix (Line 21):**
+
+| Before | After |
+|--------|-------|
+| `(2028, 1520)` | `(1920, 1440)` |
+
+**Rationale for 1920×1440:**
+- 1920 ÷ 16 = 120 ✓
+- 1440 ÷ 16 = 90 ✓
+- Maintains 4:3 aspect ratio
+- Standard resolution with broad decoder support
+
+**Encoder Configuration (Lines 287-301):**
+
+```python
+# BEFORE
+encoder = encoders.H264Encoder()
+
+# AFTER
+encoder = encoders.H264Encoder(
+ profile="baseline", # Disables B-frames
+ repeat=True, # Repeat SPS/PPS headers
+ iperiod=15, # I-frame every 15 frames
+)
+```
+
+**Parameter Rationale:**
+
+| Parameter | Value | Purpose |
+|-----------|-------|---------|
+| `profile="baseline"` | Baseline H.264 | Disables B-frames for WebRTC compatibility |
+| `repeat=True` | Enable | Repeat sequence headers with each I-frame for network resilience |
+| `iperiod=15` | 15 frames | Balance between compression and error recovery |
+
+---
+
+#### 2. `os/mediamtx/mediamtx.yml`
+
+**Added:**
+```yaml
+writeQueueSize: 1024
+```
+
+**Rationale:** The RPi5 software encoder can produce data faster than the default buffer allows, causing packet drops. Increased buffer prevents overflow.
+
+---
+
+### Outcome
+
+**Status: Partial Success**
+
+The changes significantly reduced but did not completely eliminate stream artifacts. Remaining issues are likely related to:
+- RPi5 software encoder limitations (no hardware H.264)
+- Network latency variations
+- Browser decoder variations
+
+**Recommendation:** Consider hardware-accelerated encoding solutions or alternative streaming protocols (MJPEG) for production deployment.
+
+---
+
+## Bug Fix: Calibration Settings Persistence
+
+### Problem Statement
+
+Calibration settings (pixel size, white balance gains, pump steps/mL) were lost on system restart, requiring operators to recalibrate after each power cycle.
+
+### Root Cause
+
+Node-RED's `localfilesystem` context storage module flushes to disk every 30 seconds by default. If the system restarts within this window, unsaved calibration data is lost.
+
+### Solution
+
+#### `node-red/settings.cjs` (Lines 341-345)
+
+```javascript
+// BEFORE
+contextStorage: {
+ default: {
+ module: "localfilesystem"
+ },
+},
+
+// AFTER
+contextStorage: {
+ default: {
+ module: "localfilesystem",
+ config: {
+ flushInterval: 5, // Flush every 5 seconds
+ }
+ },
+},
+```
+
+### Calibration Data Protected
+
+| Variable | Description |
+|----------|-------------|
+| `calibration_pixel_size` | µm/pixel from ruler calibration |
+| `calibration_scale_factor` | Sensor to stream scale ratio |
+| `calibration_wbg_red` | White balance red gain |
+| `calibration_wbg_blue` | White balance blue gain |
+| `calibration_nb_step` | Pump steps per mL |
+| `calibration_markerA_*` | Calibration marker positions |
+| `calibration_markerB_*` | Calibration marker positions |
+
+### Trade-offs
+
+| Consideration | Impact |
+|---------------|--------|
+| Increased disk I/O | Negligible (writes small JSON every 5s) |
+| SD card wear | Minimal (< 1KB per write) |
+| Data loss window | Reduced from 30s to 5s |
+
+---
+
+## Bug Fix #3: Video Stream Not Working Over WiFi Hotspot
+
+### Problem Statement
+
+Video streams work over ethernet but fail when connecting via the PlanktoScope's WiFi hotspot. The UI loads correctly but the video stream shows a spinning wheel indefinitely.
+
+### Root Cause
+
+WebRTC ICE candidate gathering fails on the hotspot network because:
+1. MediaMTX relied on public STUN servers (Cloudflare, Mozilla, Meta) for ICE candidate discovery
+2. The hotspot network (192.168.4.0/24) typically has no internet access
+3. STUN servers are unreachable → ICE gathering times out → WebRTC connection fails
+
+### Solution
+
+Enabled `webrtcAdditionalHosts` in MediaMTX configuration to explicitly advertise local IPs as valid ICE candidates, bypassing the need for STUN:
+
+```yaml
+webrtcAdditionalHosts:
+ - localhost
+ - planktoscope.local
+ - pkscope.local
+ - 192.168.4.1
+ - 10.0.0.160
+ - planktoscope-butter-earth
+ - planktoscope-butter-earth.local
+ - home.pkscope
+```
+
+### Files Modified
+
+| File | Change |
+|------|--------|
+| `os/mediamtx/mediamtx.yml` | Added `webrtcAdditionalHosts` configuration |
+| `/usr/local/etc/mediamtx.yml` (on device) | Same configuration applied |
+
+---
+
+## Bug Fix #4: White Balance and LED Intensity Not Persisting
+
+### Problem Statement
+
+White balance (red/blue gains) and LED intensity calibration settings were lost after system restart, even though the flushInterval fix (Bug Fix #2) was applied.
+
+### Root Cause
+
+The flushInterval fix only addressed Node-RED context storage. However:
+1. **No save mechanism:** Function nodes stored values in Node-RED context but never wrote to `hardware.json`
+2. **No restore mechanism:** On startup, no flow loaded values from `hardware.json` into Node-RED context
+3. **Disconnect:** Camera controller reads from `hardware.json`, but Node-RED only saved to its own context
+
+### Solution
+
+Implemented a complete persistence pipeline:
+
+1. **Save on change:** Modified function nodes to write calibration values to `hardware.json` when changed
+2. **Restore on startup:** Added startup flow to load values from `hardware.json` into Node-RED context
+3. **Value conversion:** Handle the conversion between UI format (199) and hardware.json format (1.99)
+
+### Files Modified
+
+| File | Change |
+|------|--------|
+| `node-red/projects/dashboard/flows.json` | Modified WB/LED function nodes, added save nodes, added startup restore flow |
+| `default-configs/v3.0.hardware.json` | Added `led_intensity` field |
+
+### Data Flow
+
+```
+On Change: UI → MQTT → Function → Update global context → Write hardware.json
+On Startup: Inject → Read hardware.json → Parse → Restore global context
+```
+
+---
+
+## Deployment Summary
+
+### Files Added (New)
+
+| File | Lines | Purpose |
+|------|-------|---------|
+| `segmenter/planktoscope/segmenter/live.py` | ~450 | Live segmentation backend |
+| `frontend/src/pages/preview/segmentation/index.jsx` | ~482 | Visualization page |
+| `frontend/src/pages/preview/segmentation/styles.module.css` | ~352 | Styling |
+| `frontend/src/pages/preview/SegmentationOverlay.jsx` | ~368 | Overlay component |
+| `frontend/src/pages/preview/SegmentationOverlay.module.css` | ~102 | Overlay styling |
+
+### Files Modified
+
+| File | Changes |
+|------|---------|
+| `segmenter/main.py` | Added live segmenter process start |
+| `segmenter/planktoscope/segmenter/operations.py` | Added `calculate_blur()`, `calculate_regional_blur()` |
+| `segmenter/planktoscope/segmenter/__init__.py` | Added `segment_single_frame()` helper |
+| `lib/scope.js` | Added `startLiveSegmentation()`, `stopLiveSegmentation()` |
+| `controller/camera/hardware.py` | Resolution fix, encoder parameters |
+| `os/mediamtx/mediamtx.yml` | Added `writeQueueSize: 1024`, added `webrtcAdditionalHosts` |
+| `node-red/settings.cjs` | Added `flushInterval: 5` |
+| `node-red/projects/dashboard/flows.json` | Added WB/LED persistence, startup restore flow |
+| `default-configs/v3.0.hardware.json` | Added `led_intensity` field |
+
+### Deployment Commands
+
+```bash
+# Frontend deployment
+cd frontend && npx vite build
+scp -r dist/* pi@planktoscope.local:/home/pi/PlanktoScope/frontend/dist/
+
+# Backend deployment
+scp segmenter/planktoscope/segmenter/live.py pi@planktoscope.local:/home/pi/PlanktoScope/segmenter/planktoscope/segmenter/
+scp segmenter/planktoscope/segmenter/operations.py pi@planktoscope.local:/home/pi/PlanktoScope/segmenter/planktoscope/segmenter/
+
+# Node-RED settings
+scp node-red/settings.cjs pi@planktoscope.local:/home/pi/PlanktoScope/node-red/
+sudo systemctl restart nodered
+
+# Restart segmenter
+sudo systemctl restart segmenter
+```
+
+---
+
+## Testing Checklist
+
+### Live Segmentation
+- [ ] Enable live segmentation on Visualization page
+- [ ] Start acquisition and verify objects appear with overlays
+- [ ] Test overlay modes: bbox, mask, both
+- [ ] Adjust minimum size filter, verify small objects filtered
+- [ ] Enable "Remove Static", verify debris filtered after 3 frames
+- [ ] Verify object count updates in real-time
+
+### Blur Metric
+- [ ] Verify focus value displayed during acquisition
+- [ ] Confirm color coding: red (<25), yellow (25-50), green (>50)
+- [ ] Verify sparkline shows trending history
+- [ ] Toggle heatmap overlay, verify regional display
+- [ ] Check EcoTaxa TSV contains `object_blur_laplacian` column
+
+### Video Stream (RPi5)
+- [ ] Verify preview stream displays without severe corruption
+- [ ] Test stream recovery after network interruption
+
+### Video Stream Over WiFi Hotspot
+- [ ] Connect to PlanktoScope WiFi hotspot
+- [ ] Access UI at http://192.168.4.1
+- [ ] Verify video stream loads and displays
+- [ ] Test stream while disconnected from ethernet (hotspot only)
+
+### Calibration Persistence (Node-RED Context)
+- [ ] Perform calibration (pixel size or white balance)
+- [ ] Restart Node-RED service
+- [ ] Verify calibration values persisted
+
+### White Balance & LED Intensity Persistence (hardware.json)
+- [ ] Calibrate white balance through UI
+- [ ] Verify `hardware.json` contains updated `red_gain` and `blue_gain`
+- [ ] Adjust LED intensity
+- [ ] Verify `hardware.json` contains updated `led_intensity`
+- [ ] Restart Node-RED service
+- [ ] Verify UI shows correct values after restart
+- [ ] Restart entire system (reboot)
+- [ ] Verify camera applies correct white balance on startup
+
+---
+
+## Known Limitations
+
+1. **Video Stream (RPi5):** Some residual artifacts may occur due to software encoder limitations
+2. **Live Segmentation Performance:** Maximum 300 objects/frame to prevent UI lag
+3. **Blur Thresholds:** Calibrated for PlanktoScope optics; may need adjustment for different configurations
+4. **WiFi Hotspot mDNS:** When connecting via hotspot, use IP address (192.168.4.1) rather than `.local` hostnames for most reliable connectivity
+5. **MediaMTX Restart:** After restarting MediaMTX, the imager service may need to be restarted to re-establish RTSP publishing
+
+---
+
+## Bug Fix #5: Motion Blur from Pump Synchronization Race Condition
+
+### Problem Statement
+
+During stop-flow acquisition, approximately 1 in every 2-3 captured images showed motion blur, even with adequate stabilization delay. Images were captured while the pump was still running.
+
+### Root Cause
+
+Race condition in MQTT pump synchronization: When starting a new pump cycle, stale "Done" messages from the previous cycle would trigger `_done.set()` before the new pump completed, causing capture to happen prematurely.
+
+### Solution
+
+Modified `_receive_messages()` in `controller/imager/main.py` to check if we're actually waiting for a "Done" message before processing it:
+
+```python
+# FIX: Only process Done if we are actually waiting for it
+if self._done.is_set():
+ loguru.logger.debug(f"Ignoring stale pump Done (not waiting)")
+ self._mqtt.read_message()
+ continue
+```
+
+### Outcome
+
+All captures now occur after proper stabilization. No more intermittent motion blur.
+
+---
+
+## Bug Fix #6: Static Object Detection Improvements
+
+### Problem Statement
+
+Debris stuck on flow cell glass was not being filtered during live segmentation despite the static object removal feature existing.
+
+### Root Cause
+
+1. `remove_static` defaulted to `False` (disabled)
+2. 60px grid was too small - detection jitter caused objects to shift between grid cells
+3. 3-frame threshold was too strict for quickly identifying debris
+
+### Solution
+
+| Parameter | Before | After |
+|-----------|--------|-------|
+| `remove_static` default | `False` | `True` |
+| `grid_size` | 60px | 100px |
+| `static_threshold` | 3 frames | 2 frames |
+
+### Outcome
+
+Debris on glass is now filtered after appearing in the same position for 2 consecutive frames.
+
+---
+
+## Bug Fix #7: Stabilization Time Increased
+
+### Problem Statement
+
+Default 0.5 second stabilization was insufficient for particles to fully settle after pump motion.
+
+### Solution
+
+Changed `sleep` parameter in Node-RED flows from `0.5` to `1.0` seconds.
+
+### Outcome
+
+Adequate settling time for most samples. Adds ~0.5s per frame to acquisition time.
+
+---
+
+## Appendix: Blur Metric Scientific Basis
+
+The Laplacian variance method is a well-established focus measure in computer vision literature:
+
+**Method:** Computes the variance of the Laplacian (second derivative) of image intensity.
+
+**Mathematical Basis:**
+```
+Blur = Var(∇²I)
+```
+
+Where `∇²I` is the Laplacian of image `I`.
+
+**Properties:**
+- Higher variance indicates more edges (sharper focus)
+- Lower variance indicates fewer edges (blur)
+- Computationally efficient (single convolution)
+- Robust to image content variations
+
+**Reference:** Pech-Pacheco, J.L., et al. "Diatom autofocusing in brightfield microscopy: a comparative study." ICPR 2000.
+
+---
+
+
diff --git a/docs/LIVE_SEGMENTATION_CHANGES.md b/docs/LIVE_SEGMENTATION_CHANGES.md
new file mode 100644
index 000000000..fd32fefd4
--- /dev/null
+++ b/docs/LIVE_SEGMENTATION_CHANGES.md
@@ -0,0 +1,322 @@
+# Live Segmentation and Blur Metric Features - Code Changes
+
+This document describes the code changes made to the Dashboard 2.0 repository to implement real-time segmentation with preview during acquisition. In addition, a blur metric is now calculated per object.
+
+## Overview
+
+The Live Segmentation feature provides real-time object detection and visualization during image acquisition. When enabled, each captured frame is segmented and the results are overlaid on a separate preview window, allowing users to see detected plankton objects in real-time. A blur metric is also captured per object, allowing live feedback as well as post acquisition sorting through the .tsv file.
+
+### Key Features
+- **Real-time object detection** during acquisition
+- **Configurable overlay modes**: Bounding boxes, masks, or both
+- **Minimum size filter** (in micrometers) to ignore small objects
+- **Static object removal** to filter out debris stuck on the flow cell glass
+- **Focus/blur indicator** to assess image quality in real-time
+
+---
+
+## Architecture
+
+```
+┌─────────────────────┐ MQTT: segmenter/live ┌──────────────────────┐
+│ Frontend │ ────────────────────────────> │ Live Segmenter │
+│ (SolidJS) │ │ (Python Process) │
+│ │ <──────────────────────────── │ │
+│ Canvas Overlay │ MQTT: status/segmenter/live │ Listens to imager │
+└─────────────────────┘ └──────────────────────┘
+ │
+ │ MQTT: status/imager
+ ▼
+ ┌──────────────────────┐
+ │ Imager Process │
+ │ (captures images) │
+ └──────────────────────┘
+```
+
+---
+
+## File Changes
+
+### Backend (Python)
+
+#### 1. `segmenter/main.py`
+**Change**: Added initialization of the Live Segmenter process.
+
+```python
+import planktoscope.segmenter.live
+
+# Starts the live segmenter process for real-time preview overlays
+live_segmenter_thread = planktoscope.segmenter.live.LiveSegmenterProcess(
+ shutdown_event, "/home/pi/data"
+)
+live_segmenter_thread.start()
+```
+
+The live segmenter runs as a separate multiprocessing.Process alongside the main segmenter.
+
+---
+
+#### 2. `segmenter/planktoscope/segmenter/live.py` (NEW FILE)
+**Purpose**: Handles real-time segmentation during acquisition.
+
+**Key Components**:
+
+##### Class: `LiveSegmenterProcess`
+A multiprocessing.Process that:
+1. Listens for control commands on `segmenter/live` MQTT topic
+2. Subscribes to `status/imager` to receive capture events
+3. Segments each captured frame and publishes results
+
+##### Key Methods:
+
+**`_load_pixel_size()`**
+Loads the pixel size calibration from the hardware config file to ensure consistency with the dashboard calibration settings:
+```python
+def _load_pixel_size(self):
+ """Load pixel size from /home/pi/PlanktoScope/hardware.json.
+ Returns process_pixel_fixed value, or 0.75 as fallback."""
+ try:
+ with open("/home/pi/PlanktoScope/hardware.json", "r") as f:
+ config = json.load(f)
+ return float(config.get("process_pixel_fixed", 0.75))
+ except (FileNotFoundError, json.JSONDecodeError, ValueError):
+ return 0.75 # Default fallback
+```
+
+**`_esd_um_to_min_area(esd_um)`**
+Converts minimum object size from micrometers (ESD) to pixel area:
+```python
+def _esd_um_to_min_area(self, esd_um):
+ esd_pixels = esd_um / self.__pixel_size_um # Loaded from hardware.json
+ area = math.pi * (esd_pixels / 2) ** 2
+ return int(area)
+```
+
+**`_create_simple_mask(img)`**
+Creates a binary mask using the same pipeline as the main segmenter:
+```python
+def _create_simple_mask(self, img):
+ mask = planktoscope.segmenter.operations.simple_threshold(img)
+ mask = planktoscope.segmenter.operations.erode(mask)
+ mask = planktoscope.segmenter.operations.dilate(mask)
+ return mask
+```
+
+**Static Object Detection** (`_get_bbox_key`, `_update_static_tracker`, `_is_static_object`)
+
+Uses grid-based tracking to identify objects that remain in the same position across multiple frames:
+
+```python
+def _get_bbox_key(self, bbox):
+ """Get grid cell for object center (60px grid)"""
+ cx = bbox[0] + bbox[2] / 2
+ cy = bbox[1] + bbox[3] / 2
+ grid_size = 60
+ return (int(cx / grid_size), int(cy / grid_size))
+
+def _is_static_object(self, bbox):
+ """Returns True if object has been in same position for 3+ frames"""
+ key = self._get_bbox_key(bbox)
+ count = self.__static_tracker.get(key, 0)
+ return count >= self.__static_threshold # threshold = 3
+```
+
+**Logic**: Objects must appear in the same 60px grid cell for 3 consecutive frames to be considered static (debris stuck on glass) and filtered out.
+
+**`_encode_mask_png(mask)`**
+Encodes binary masks as base64 PNG with alpha transparency:
+```python
+def _encode_mask_png(self, mask):
+ rgba = np.zeros((height, width, 4), dtype=np.uint8)
+ rgba[mask, :3] = 255 # White RGB for object pixels
+ rgba[mask, 3] = 255 # Full opacity
+ # Background = (0,0,0,0) = transparent
+ img = PIL.Image.fromarray(rgba, mode="RGBA")
+ # ... encode as base64 PNG
+```
+
+**`segment_single_frame(img)`**
+Main segmentation entry point. Returns:
+```python
+{
+ "objects": [{"bbox": [x, y, w, h], "mask": "base64..."}],
+ "frame_blur": float,
+ "object_count": int,
+ "image_width": int,
+ "image_height": int
+}
+```
+
+##### MQTT Interface:
+
+| Topic | Direction | Payload |
+|-------|-----------|---------|
+| `segmenter/live` | Frontend → Backend | `{"action": "start/stop", "overlay": "bbox/mask/both", "min_esd_um": 20, "remove_static": true}` |
+| `status/segmenter/live` | Backend → Frontend | `{"objects": [...], "frame_blur": float, "image": "base64_jpeg"}` |
+
+---
+
+#### 3. `segmenter/planktoscope/segmenter/operations.py`
+**Change**: Added blur calculation function.
+
+```python
+def calculate_blur(img):
+ """Calculate blur metric using Laplacian variance.
+
+ Higher values = sharper image, lower values = more blur.
+
+ Args:
+ img (cv2 img): Image to calculate blur for (BGR or grayscale)
+
+ Returns:
+ float: Laplacian variance (blur metric)
+ """
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img
+ return cv2.Laplacian(gray, cv2.CV_64F).var()
+```
+
+**Calibration for PlanktoScope optics**:
+- Poor focus: < 25
+- OK focus: 25-50
+- Good focus: > 50
+
+---
+
+#### 4. `segmenter/planktoscope/segmenter/__init__.py`
+**Change**: Added helper function for live segmentation.
+
+```python
+def segment_single_frame(img, min_area=100):
+ """Segment a single frame and return object data without file I/O.
+
+ This is a helper function for live segmentation that returns object
+ metadata without saving to disk.
+ """
+ # ... segmentation pipeline identical to main segmenter
+```
+
+Also integrated per-object blur calculation into the EcoTaxa export metadata.
+
+---
+
+### Frontend (SolidJS)
+
+#### 5. `frontend/src/pages/preview/segmentation/index.jsx` (NEW FILE)
+**Purpose**: Live Segmentation Preview page with controls and overlay.
+
+**State Management**:
+```javascript
+const [liveSegmentEnabled, setLiveSegmentEnabled] = createSignal(false)
+const [overlayMode, setOverlayMode] = createSignal("bbox")
+const [minSizeUm, setMinSizeUm] = createSignal(20)
+const [removeStatic, setRemoveStatic] = createSignal(true)
+const [frameBlur, setFrameBlur] = createSignal(0)
+const [objectCount, setObjectCount] = createSignal(0)
+const [capturedImage, setCapturedImage] = createSignal(null)
+const [objects, setObjects] = createSignal([])
+```
+
+**Overlay Drawing**:
+```javascript
+function drawOverlays() {
+ // Scale coordinates from original image to display size
+ const scaleX = displayWidth / imageRef.naturalWidth
+ const scaleY = displayHeight / imageRef.naturalHeight
+
+ objects().forEach((obj, index) => {
+ const [x, y, w, h] = obj.bbox
+ const scaledX = x * scaleX
+ // ... draw bounding boxes and/or masks
+ })
+}
+```
+
+**Focus Indicator**:
+```javascript
+const focusStatus = () => {
+ const blur = frameBlur()
+ if (blur < 25) return { label: "Poor", icon: "✗", color: "#ef4444" }
+ if (blur < 50) return { label: "OK", icon: "~", color: "#eab308" }
+ return { label: "Good", icon: "✓", color: "#22c55e" }
+}
+```
+
+---
+
+#### 6. `frontend/src/pages/preview/segmentation/styles.module.css` (NEW FILE)
+CSS styles for the segmentation preview page including:
+- Dark theme toolbar
+- Toggle switch for enabling/disabling
+- Mode selector dropdown
+- Number input for minimum size
+- Stats display (object count, focus status)
+
+---
+
+#### 7. `lib/scope.js`
+**Change**: Added live segmentation control functions.
+
+```javascript
+export async function startLiveSegmentation(config) {
+ await request("segmenter/live", { action: "start", ...config })
+}
+
+export async function stopLiveSegmentation() {
+ await request("segmenter/live", { action: "stop" })
+}
+```
+
+---
+
+## Static Object Removal Algorithm
+
+The static removal feature filters out debris stuck on the flow cell glass by tracking object positions across frames.
+
+### How It Works:
+
+1. **Grid-Based Tracking**: The image is divided into 60px grid cells
+2. **Position Hashing**: Each object's center point maps to a grid cell
+3. **Frame Counting**: A dictionary tracks how many consecutive frames each grid cell has contained an object
+4. **Filtering**: Objects in cells with count >= 3 are considered static and filtered out
+5. **Automatic Cleanup**: When a position has no object in a frame, its counter is removed
+
+### Why 60px Grid?
+- **Too small** (e.g., 30px): Large objects may shift between cells due to slight detection variation
+- **Too large** (e.g., 100px): Multiple separate small objects might be grouped together
+- **60px**: Good balance for typical PlanktoScope object sizes
+
+### Why 3-Frame Threshold?
+- Ensures objects are truly static, not just slow-moving
+- Avoids false positives from coincidental position overlap
+- Quick enough to filter debris within first few frames of acquisition
+
+---
+
+## Performance Considerations
+
+1. **Object Limit**: Maximum 300 objects per frame to prevent UI lag
+2. **Mask Limit**: Maximum 100 masks encoded (masks are expensive)
+3. **Image Encoding**: JPEG quality 80% for reasonable file size
+4. **Polling Rate**: 50ms between MQTT message checks
+
+---
+
+## Testing
+
+1. Start an acquisition with "Live Segmentation" enabled
+2. Verify objects appear with colored overlays
+3. Test overlay modes: bbox, mask, both
+4. Adjust minimum size and verify small objects are filtered
+5. Enable "Remove Static" and verify debris is filtered after 3 frames
+6. Check focus indicator accuracy against actual image blur
+
+---
+
+## Dependencies
+
+No new dependencies required. Uses existing:
+- OpenCV (cv2)
+- NumPy
+- PIL/Pillow
+- scikit-image (skimage.measure)
+- MQTT (paho-mqtt via planktoscope.mqtt)
diff --git a/docs/LIVE_SEGMENTATION_FEATURE.md b/docs/LIVE_SEGMENTATION_FEATURE.md
new file mode 100644
index 000000000..d276fb361
--- /dev/null
+++ b/docs/LIVE_SEGMENTATION_FEATURE.md
@@ -0,0 +1,146 @@
+# Live Segmentation with Real-time Preview and Blur Metrics
+
+## Summary
+
+This PR introduces **real-time segmentation preview** during image acquisition, allowing operators to see detected plankton objects as they are captured. It also adds **per-object blur metrics** for quality assessment and includes critical bug fixes for acquisition timing and calibration consistency.
+
+---
+
+## New Features
+
+### Real-time Segmentation Preview
+
+Enable live object detection directly from the Acquisition page. As images are captured, detected objects are immediately highlighted with configurable overlays.
+
+
+*The Live Segmentation Preview panel appears below the acquisition controls. Toggle it on to enable real-time detection.*
+
+---
+
+### Overlay Mode Selection
+
+Choose how detected objects are visualized during acquisition:
+
+| Mode | Description |
+|------|-------------|
+| **Bounding Boxes** | Cyan rectangles around each detected object |
+| **Masks** | Semi-transparent overlay showing exact object boundaries |
+| **Both** | Combined bounding boxes and masks |
+
+
+*Select between Bounding Boxes, Masks, or Both to customize the visualization.*
+
+---
+
+### Configurable Detection Parameters
+
+Fine-tune detection directly from the toolbar:
+
+
+
+| Control | Description |
+|---------|-------------|
+| **Min Size (um)** | Filter out objects smaller than this diameter (ESD) |
+| **Remove Static** | Automatically filter debris stuck on the flow cell glass |
+| **Objects** | Live count of detected objects in current frame |
+| **Focus** | Real-time focus quality indicator |
+
+---
+
+### Real-time Focus Quality Indicator
+
+Monitor image sharpness during acquisition with the live blur metric display. The system uses Laplacian variance to quantify focus quality:
+
+#### Good Focus (Score > 50)
+
+*Sharp image with clear object boundaries. Focus score of 41 indicates acceptable quality.*
+
+#### Poor Focus (Score < 25)
+
+*Blurry image requiring focus adjustment. Focus score of 3 indicates poor quality.*
+
+| Score Range | Status | Indicator |
+|-------------|--------|-----------|
+| > 50 | Good | Green |
+| 25-50 | OK | Yellow |
+| < 25 | Poor | Red |
+
+**Note:** Per-object blur metrics are also saved to the EcoTaxa `.tsv` file as `object_blur_laplacian`, enabling post-acquisition quality sorting.
+
+---
+
+## Bug Fixes
+
+### Fix #1: Pump Timing Synchronization
+
+**Problem:** The pump was running continuously instead of following the proper acquisition sequence (pump → wait 0.5s → capture → repeat), causing motion blur.
+
+**Root Cause:** The MQTT progress message (`{"type": "progress", ...}`) that signals image capture completion was removed during a refactor, breaking the synchronization between the imager and live segmenter.
+
+**Solution:** Restored the progress message in `controller/imager/mqtt.py`.
+
+---
+
+### Fix #2: Pixel Size Calibration Consistency
+
+**Problem:** Live segmentation used a hardcoded pixel size (0.75 µm/pixel) instead of reading from the calibration dashboard, causing inconsistent object size filtering.
+
+**Solution:** Live segmenter now reads `process_pixel_fixed` from `/home/pi/PlanktoScope/hardware.json` to match the calibration set in the dashboard.
+
+---
+
+## Technical Details
+
+### Files Changed
+
+| File | Change |
+|------|--------|
+| `controller/imager/mqtt.py` | Restored progress MQTT message |
+| `segmenter/planktoscope/segmenter/live.py` | Load pixel size from hardware config |
+| `LIVE_SEGMENTATION_CHANGES.md` | Updated documentation |
+| `BUGFIX_CHANGELOG.md` | Added fix documentation |
+| `CHANGELOG_PR_DOCUMENTATION.md` | Updated feature documentation |
+
+### MQTT Topics
+
+| Topic | Direction | Purpose |
+|-------|-----------|---------|
+| `segmenter/live` | Frontend → Backend | Start/stop live segmentation |
+| `status/segmenter/live` | Backend → Frontend | Object detection results |
+| `status/imager` | Imager → Segmenter | Image capture progress |
+
+---
+
+## Testing Checklist
+
+- [ ] Enable live segmentation on Acquisition page
+- [ ] Start acquisition and verify objects appear with overlays
+- [ ] Test all overlay modes: bounding boxes, masks, both
+- [ ] Adjust minimum size filter, verify small objects filtered
+- [ ] Enable "Remove Static" and verify debris is filtered
+- [ ] Check focus indicator accuracy against actual image blur
+- [ ] Verify pixel size matches calibration dashboard setting
+- [ ] Confirm proper pump timing (no continuous pumping)
+
+---
+
+## Deployment
+
+```bash
+# Frontend (after npm run build)
+scp -r frontend/dist/* pi@planktoscope.local:/home/pi/PlanktoScope/frontend/dist/
+
+# Backend
+scp segmenter/planktoscope/segmenter/live.py pi@planktoscope.local:/home/pi/PlanktoScope/segmenter/planktoscope/segmenter/
+scp controller/imager/mqtt.py pi@planktoscope.local:/home/pi/PlanktoScope/controller/imager/
+
+# Restart services
+ssh pi@planktoscope.local "sudo systemctl restart segmenter imager"
+```
+
+---
+
+## Related Issues
+
+- Fixes continuous pump running during acquisition
+- Fixes inconsistent object filtering between live and batch segmentation
diff --git a/docs/images/OK.jpg b/docs/images/OK.jpg
new file mode 100644
index 000000000..b3536f5ee
Binary files /dev/null and b/docs/images/OK.jpg differ
diff --git a/docs/images/Preview.jpg b/docs/images/Preview.jpg
new file mode 100644
index 000000000..a934ace27
Binary files /dev/null and b/docs/images/Preview.jpg differ
diff --git a/docs/images/Toggle..jpg b/docs/images/Toggle..jpg
new file mode 100644
index 000000000..581745f78
Binary files /dev/null and b/docs/images/Toggle..jpg differ
diff --git a/docs/images/choice.jpg b/docs/images/choice.jpg
new file mode 100644
index 000000000..77825e808
Binary files /dev/null and b/docs/images/choice.jpg differ
diff --git a/docs/images/good.jpg b/docs/images/good.jpg
new file mode 100644
index 000000000..b6105d487
Binary files /dev/null and b/docs/images/good.jpg differ
diff --git a/docs/images/poor.jpg b/docs/images/poor.jpg
new file mode 100644
index 000000000..dd4ce5188
Binary files /dev/null and b/docs/images/poor.jpg differ
diff --git a/node-red/projects/dashboard/flows.json b/node-red/projects/dashboard/flows.json
index 4d36a872a..96b803d74 100644
--- a/node-red/projects/dashboard/flows.json
+++ b/node-red/projects/dashboard/flows.json
@@ -1,8 +1,16 @@
[
+ {
+ "id": "07f3b717f2a2c8c7",
+ "type": "tab",
+ "label": "Setup",
+ "disabled": false,
+ "info": "",
+ "env": []
+ },
{
"id": "1b667c6443413ced",
"type": "tab",
- "label": "Home dashboard2",
+ "label": "Home",
"disabled": false,
"info": "",
"env": []
@@ -47,14 +55,6 @@
"info": "",
"env": []
},
- {
- "id": "8555b76c53e789e0",
- "type": "tab",
- "label": "[TEST] EcoTaxa",
- "disabled": false,
- "info": "",
- "env": []
- },
{
"id": "8018bd5586fd4054",
"type": "tab",
@@ -95,6 +95,14 @@
"info": "",
"env": []
},
+ {
+ "id": "8555b76c53e789e0",
+ "type": "tab",
+ "label": "[TEST] EcoTaxa",
+ "disabled": false,
+ "info": "",
+ "env": []
+ },
{
"id": "a02961610bc3982a",
"type": "tab",
@@ -670,16 +678,16 @@
"colors": {
"surface": "#ffffff",
"primary": "#1976d2",
- "bgPage": "#dedede",
+ "bgPage": "#eef3ff",
"groupBg": "#ffffff",
- "groupOutline": "#9c9c9c"
+ "groupOutline": "#c6d1dc"
},
"sizes": {
"density": "default",
- "pagePadding": "10px",
- "groupGap": "10px",
- "groupBorderRadius": "4px",
- "widgetGap": "10px"
+ "pagePadding": "1.5rem",
+ "groupGap": "1.5rem",
+ "groupBorderRadius": "8px",
+ "widgetGap": "0px"
}
},
{
@@ -705,7 +713,7 @@
{
"name": "Small Desktop",
"px": "768",
- "cols": "9"
+ "cols": "6"
},
{
"name": "Desktop",
@@ -713,7 +721,7 @@
"cols": "12"
}
],
- "order": 3,
+ "order": 4,
"className": "",
"visible": "true",
"disabled": "false"
@@ -847,7 +855,7 @@
"cols": "12"
}
],
- "order": 5,
+ "order": 6,
"className": "",
"visible": "true",
"disabled": "false"
@@ -855,12 +863,12 @@
{
"id": "bfd4acb7b243514f",
"type": "ui-group",
- "name": "Table",
+ "name": "List of Acquisitions",
"page": "7a4e042a60b734a6",
"width": "12",
"height": 1,
"order": 3,
- "showTitle": false,
+ "showTitle": true,
"className": "",
"visible": "true",
"disabled": "false",
@@ -897,7 +905,7 @@
"cols": "12"
}
],
- "order": 9,
+ "order": 10,
"className": "",
"visible": "false",
"disabled": "false"
@@ -947,7 +955,7 @@
"cols": "12"
}
],
- "order": 10,
+ "order": 11,
"className": "",
"visible": "false",
"disabled": "false"
@@ -997,7 +1005,7 @@
"cols": "12"
}
],
- "order": 11,
+ "order": 12,
"className": "",
"visible": "false",
"disabled": "false"
@@ -1047,7 +1055,7 @@
"cols": "12"
}
],
- "order": 8,
+ "order": 9,
"className": "",
"visible": "false",
"disabled": "false"
@@ -1161,7 +1169,7 @@
"cols": "12"
}
],
- "order": 2,
+ "order": 3,
"className": "",
"visible": "true",
"disabled": "false"
@@ -1225,7 +1233,7 @@
"cols": "12"
}
],
- "order": 4,
+ "order": 5,
"className": "",
"visible": "true",
"disabled": "false"
@@ -1276,12 +1284,12 @@
{
"name": "Tablet",
"px": "576",
- "cols": "6"
+ "cols": "3"
},
{
"name": "Small Desktop",
"px": "768",
- "cols": "9"
+ "cols": "12"
},
{
"name": "Desktop",
@@ -1289,7 +1297,7 @@
"cols": "12"
}
],
- "order": 1,
+ "order": 2,
"className": "",
"visible": "true",
"disabled": "false"
@@ -1405,17 +1413,17 @@
{
"name": "Default",
"px": "0",
- "cols": "3"
+ "cols": "4"
},
{
"name": "Tablet",
"px": "576",
- "cols": "6"
+ "cols": "4"
},
{
"name": "Small Desktop",
"px": "768",
- "cols": "9"
+ "cols": "12"
},
{
"name": "Desktop",
@@ -1423,10 +1431,10 @@
"cols": "12"
}
],
- "order": 6,
+ "order": 8,
"className": "",
- "visible": true,
- "disabled": false
+ "visible": "true",
+ "disabled": "false"
},
{
"id": "b570f76ef526af45",
@@ -1449,7 +1457,7 @@
"page": "d129fac8e7742d5b",
"width": "12",
"height": 1,
- "order": 3,
+ "order": 15,
"showTitle": false,
"className": "",
"visible": "true",
@@ -1464,7 +1472,7 @@
"width": "12",
"height": 1,
"order": 2,
- "showTitle": false,
+ "showTitle": true,
"className": "",
"visible": "true",
"disabled": "false",
@@ -1473,150 +1481,480 @@
{
"id": "fa6393a7d7e3b7d7",
"type": "ui-group",
- "name": "Table",
+ "name": "List of Segmentation",
"page": "d129fac8e7742d5b",
"width": "12",
"height": 1,
"order": 2,
- "showTitle": false,
+ "showTitle": true,
"className": "",
"visible": "true",
"disabled": "false",
"groupType": "default"
},
{
- "id": "82099021.9ceb08",
- "type": "file",
- "z": "4ed26b8b.253504",
- "name": "",
- "filename": "/home/pi/PlanktoScope/hardware.json",
- "appendNewline": true,
- "createDir": true,
- "overwriteFile": "true",
- "encoding": "none",
- "x": 660,
- "y": 40,
- "wires": [
- []
- ]
+ "id": "34112984a39c35bd",
+ "type": "ui-page",
+ "name": "Setup",
+ "ui": "e6ae26617c24c3ea",
+ "path": "/setup",
+ "icon": "cog",
+ "layout": "grid",
+ "theme": "f7770f0b818c3a67",
+ "breakpoints": [
+ {
+ "name": "Default",
+ "px": "0",
+ "cols": "3"
+ },
+ {
+ "name": "Tablet",
+ "px": "576",
+ "cols": "6"
+ },
+ {
+ "name": "Small Desktop",
+ "px": "768",
+ "cols": "9"
+ },
+ {
+ "name": "Desktop",
+ "px": "1024",
+ "cols": "12"
+ }
+ ],
+ "order": 1,
+ "className": "",
+ "visible": "true",
+ "disabled": "false"
},
{
- "id": "bb0a8725.a1849",
- "type": "json",
- "z": "4ed26b8b.253504",
- "name": "Create JSON",
- "property": "payload",
- "action": "str",
- "pretty": true,
- "x": 490,
- "y": 40,
- "wires": [
- [
- "82099021.9ceb08"
- ]
- ]
+ "id": "de680effc3e27451",
+ "type": "ui-group",
+ "name": "body",
+ "page": "34112984a39c35bd",
+ "width": "12",
+ "height": 1,
+ "order": 1,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
},
{
- "id": "53d163be.47cf24",
- "type": "function",
- "z": "4ed26b8b.253504",
- "name": "Update and retrieve hardware_conf",
- "func": "// change global\nhardware_conf = global.get(\"hardware_conf\");\n\nif (msg.topic == \"process_pixel_fixed\" && msg.payload == 0){\n delete hardware_conf[msg.topic]\n delete msg.topic\n}\n\nif (msg.topic !== null && msg.topic !== undefined){\n hardware_conf[msg.topic] = msg.payload;\n global.set(\"hardware_conf\", hardware_conf);\n}\n\nreturn {\"payload\": hardware_conf};",
- "outputs": 1,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 240,
- "y": 40,
- "wires": [
- [
- "bb0a8725.a1849"
- ]
- ]
+ "id": "c5651e1a3e56f3f5",
+ "type": "ui-group",
+ "name": "Explorer",
+ "page": "d129fac8e7742d5b",
+ "width": "12",
+ "height": 1,
+ "order": 13,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
},
{
- "id": "0f16258953fae292",
- "type": "file in",
- "z": "b7861ce703215a01",
- "name": "",
- "filename": "/home/pi/PlanktoScope/hardware.json",
- "filenameType": "str",
- "format": "utf8",
- "chunk": false,
- "sendError": false,
- "encoding": "none",
- "allProps": false,
- "x": 250,
- "y": 40,
- "wires": [
- [
- "81c516291ab19acd"
- ]
- ],
- "info": "# PlanktoScope Help\nThis Node will read the content of the file named **config.txt** containing all the input placeholders.\n"
+ "id": "0c1537ce71affc2b",
+ "type": "ui-group",
+ "name": "Gallery",
+ "page": "d129fac8e7742d5b",
+ "width": "12",
+ "height": 1,
+ "order": 14,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
},
{
- "id": "81c516291ab19acd",
- "type": "json",
- "z": "b7861ce703215a01",
- "name": "Parse JSON",
- "property": "payload",
- "action": "",
- "pretty": false,
- "x": 510,
- "y": 40,
- "wires": [
- [
- "d0fbcd200cd09981"
- ]
- ]
+ "id": "36931a9722892790",
+ "type": "ui-group",
+ "name": "Software Version",
+ "page": "632260133d581caa",
+ "width": "3",
+ "height": 1,
+ "order": 6,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
},
{
- "id": "d0fbcd200cd09981",
- "type": "change",
- "z": "b7861ce703215a01",
- "name": "",
- "rules": [
- {
- "t": "set",
- "p": "hardware_conf",
- "pt": "global",
- "to": "payload",
- "tot": "msg"
- }
- ],
- "action": "",
- "property": "",
- "from": "",
- "to": "",
- "reg": false,
- "x": 730,
- "y": 40,
- "wires": [
- []
- ]
+ "id": "2aa235120084abe4",
+ "type": "ui-group",
+ "name": "Images Acquired",
+ "page": "632260133d581caa",
+ "width": "3",
+ "height": 1,
+ "order": 7,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
},
{
- "id": "730b2780ac215a52",
- "type": "file in",
- "z": "63c85b96537b7355",
- "name": "",
- "filename": "/home/pi/PlanktoScope/config.json",
- "filenameType": "str",
- "format": "utf8",
- "chunk": false,
- "sendError": false,
- "encoding": "none",
- "x": 560,
- "y": 60,
- "wires": [
- [
- "e0b7238a0c5d4ed0"
- ]
- ],
- "info": "# PlanktoScope Help\nThis Node will read the content of the file named **config.txt** containing all the input placeholders.\n"
- },
+ "id": "df7c60bd8b265e48",
+ "type": "ui-group",
+ "name": "Objects Segmented",
+ "page": "632260133d581caa",
+ "width": "3",
+ "height": 1,
+ "order": 8,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "dc304678d9a6b53b",
+ "type": "ui-group",
+ "name": "Storage",
+ "page": "632260133d581caa",
+ "width": "3",
+ "height": 1,
+ "order": 9,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "8a113b6c8e1eadb7",
+ "type": "ui-group",
+ "name": "Learn the basic",
+ "page": "632260133d581caa",
+ "width": "12",
+ "height": 1,
+ "order": 5,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "97e552a2d05b0800",
+ "type": "ui-group",
+ "name": "Lanch the preview",
+ "page": "632260133d581caa",
+ "width": "4",
+ "height": 1,
+ "order": 2,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "82480e386ed6f8bd",
+ "type": "ui-group",
+ "name": "Explore your data",
+ "page": "632260133d581caa",
+ "width": "4",
+ "height": 1,
+ "order": 3,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "9d3e8bdd535f0e0d",
+ "type": "ui-group",
+ "name": "Run the Calibration",
+ "page": "632260133d581caa",
+ "width": "4",
+ "height": 1,
+ "order": 4,
+ "showTitle": false,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "53490f9c39e2065d",
+ "type": "ui-group",
+ "name": "Informations",
+ "page": "d129fac8e7742d5b",
+ "width": "12",
+ "height": 1,
+ "order": 3,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "90b33e458dd29d04",
+ "type": "ui-group",
+ "name": "Heat Map",
+ "page": "d129fac8e7742d5b",
+ "width": 6,
+ "height": 1,
+ "order": 4,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "47800358cf7cee25",
+ "type": "ui-group",
+ "name": "ESD Histogram",
+ "page": "d129fac8e7742d5b",
+ "width": 6,
+ "height": 1,
+ "order": 5,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "1d9c6927a0e0a71b",
+ "type": "ui-group",
+ "name": "Timeline",
+ "page": "d129fac8e7742d5b",
+ "width": "12",
+ "height": 1,
+ "order": 6,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "03ad1c8f517e8769",
+ "type": "ui-group",
+ "name": "Colorspace",
+ "page": "d129fac8e7742d5b",
+ "width": "4",
+ "height": 1,
+ "order": 7,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "0be15f8190e6fd43",
+ "type": "ui-group",
+ "name": "Aspect",
+ "page": "d129fac8e7742d5b",
+ "width": "4",
+ "height": 1,
+ "order": 8,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "1edc962c44888abc",
+ "type": "ui-group",
+ "name": "Greenness",
+ "page": "d129fac8e7742d5b",
+ "width": "4",
+ "height": 1,
+ "order": 9,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "67c63cc92c23c4b1",
+ "type": "ui-group",
+ "name": "Complexity",
+ "page": "d129fac8e7742d5b",
+ "width": "4",
+ "height": 1,
+ "order": 10,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "95a152a68b7ba779",
+ "type": "ui-group",
+ "name": "Texture",
+ "page": "d129fac8e7742d5b",
+ "width": "4",
+ "height": 1,
+ "order": 11,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "1baa943e505febf1",
+ "type": "ui-group",
+ "name": "Solidity",
+ "page": "d129fac8e7742d5b",
+ "width": "4",
+ "height": 1,
+ "order": 12,
+ "showTitle": true,
+ "className": "",
+ "visible": "true",
+ "disabled": "false",
+ "groupType": "default"
+ },
+ {
+ "id": "82099021.9ceb08",
+ "type": "file",
+ "z": "4ed26b8b.253504",
+ "name": "",
+ "filename": "/home/pi/PlanktoScope/hardware.json",
+ "appendNewline": true,
+ "createDir": true,
+ "overwriteFile": "true",
+ "encoding": "none",
+ "x": 660,
+ "y": 40,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "bb0a8725.a1849",
+ "type": "json",
+ "z": "4ed26b8b.253504",
+ "name": "Create JSON",
+ "property": "payload",
+ "action": "str",
+ "pretty": true,
+ "x": 490,
+ "y": 40,
+ "wires": [
+ [
+ "82099021.9ceb08"
+ ]
+ ]
+ },
+ {
+ "id": "53d163be.47cf24",
+ "type": "function",
+ "z": "4ed26b8b.253504",
+ "name": "Update and retrieve hardware_conf",
+ "func": "// change global\nhardware_conf = global.get(\"hardware_conf\");\n\nif (msg.topic == \"process_pixel_fixed\" && msg.payload == 0){\n delete hardware_conf[msg.topic]\n delete msg.topic\n}\n\nif (msg.topic !== null && msg.topic !== undefined){\n hardware_conf[msg.topic] = msg.payload;\n global.set(\"hardware_conf\", hardware_conf);\n}\n\nreturn {\"payload\": hardware_conf};",
+ "outputs": 1,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 240,
+ "y": 40,
+ "wires": [
+ [
+ "bb0a8725.a1849"
+ ]
+ ]
+ },
+ {
+ "id": "0f16258953fae292",
+ "type": "file in",
+ "z": "b7861ce703215a01",
+ "name": "",
+ "filename": "/home/pi/PlanktoScope/hardware.json",
+ "filenameType": "str",
+ "format": "utf8",
+ "chunk": false,
+ "sendError": false,
+ "encoding": "none",
+ "allProps": false,
+ "x": 250,
+ "y": 40,
+ "wires": [
+ [
+ "81c516291ab19acd"
+ ]
+ ],
+ "info": "# PlanktoScope Help\nThis Node will read the content of the file named **config.txt** containing all the input placeholders.\n"
+ },
+ {
+ "id": "81c516291ab19acd",
+ "type": "json",
+ "z": "b7861ce703215a01",
+ "name": "Parse JSON",
+ "property": "payload",
+ "action": "",
+ "pretty": false,
+ "x": 510,
+ "y": 40,
+ "wires": [
+ [
+ "d0fbcd200cd09981"
+ ]
+ ]
+ },
+ {
+ "id": "d0fbcd200cd09981",
+ "type": "change",
+ "z": "b7861ce703215a01",
+ "name": "",
+ "rules": [
+ {
+ "t": "set",
+ "p": "hardware_conf",
+ "pt": "global",
+ "to": "payload",
+ "tot": "msg"
+ }
+ ],
+ "action": "",
+ "property": "",
+ "from": "",
+ "to": "",
+ "reg": false,
+ "x": 730,
+ "y": 40,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "730b2780ac215a52",
+ "type": "file in",
+ "z": "63c85b96537b7355",
+ "name": "",
+ "filename": "/home/pi/PlanktoScope/config.json",
+ "filenameType": "str",
+ "format": "utf8",
+ "chunk": false,
+ "sendError": false,
+ "encoding": "none",
+ "x": 560,
+ "y": 60,
+ "wires": [
+ [
+ "e0b7238a0c5d4ed0"
+ ]
+ ],
+ "info": "# PlanktoScope Help\nThis Node will read the content of the file named **config.txt** containing all the input placeholders.\n"
+ },
{
"id": "e0b7238a0c5d4ed0",
"type": "json",
@@ -1680,66 +2018,193 @@
"x": 910,
"y": 60,
"wires": [
- []
+ []
+ ]
+ },
+ {
+ "id": "24ea99bb02eeffa2",
+ "type": "inject",
+ "z": "63c85b96537b7355",
+ "name": "Load config",
+ "props": [
+ {
+ "p": "topic",
+ "vt": "str"
+ }
+ ],
+ "repeat": "",
+ "crontab": "",
+ "once": true,
+ "onceDelay": 0.1,
+ "topic": "",
+ "payloadType": "str",
+ "x": 230,
+ "y": 60,
+ "wires": [
+ [
+ "730b2780ac215a52"
+ ]
+ ]
+ },
+ {
+ "id": "5248e5e225d854d1",
+ "type": "function",
+ "z": "63c85b96537b7355",
+ "name": "get config payload",
+ "func": "keys = global.get(\"config_keys\")\n\nvar payload = {}\n\nkeys.forEach(function(item, index, array) {\n payload[item] = global.get(item);\n})\n\nreturn {\"payload\": payload};",
+ "outputs": 1,
+ "timeout": "",
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 410,
+ "y": 160,
+ "wires": [
+ [
+ "31ae9b857627673c"
+ ]
+ ]
+ },
+ {
+ "id": "97f8a94e71055782",
+ "type": "ui_ui_control",
+ "z": "63c85b96537b7355",
+ "name": "Connect Event",
+ "events": "connect",
+ "x": 220,
+ "y": 100,
+ "wires": [
+ [
+ "730b2780ac215a52"
+ ]
+ ]
+ },
+ {
+ "id": "7e7d02f3ea356eff",
+ "type": "switch",
+ "z": "07f3b717f2a2c8c7",
+ "name": "msg.payload.page.path === \"/home\"",
+ "property": "payload.page.path",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "eq",
+ "v": "/home",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 1,
+ "x": 310,
+ "y": 40,
+ "wires": [
+ [
+ "d55333906aeb7517"
+ ]
+ ]
+ },
+ {
+ "id": "b1f5b8b5f26121e6",
+ "type": "ui-event",
+ "z": "07f3b717f2a2c8c7",
+ "ui": "e6ae26617c24c3ea",
+ "name": "UI Event",
+ "x": 80,
+ "y": 40,
+ "wires": [
+ [
+ "7e7d02f3ea356eff"
+ ]
+ ]
+ },
+ {
+ "id": "d55333906aeb7517",
+ "type": "function",
+ "z": "07f3b717f2a2c8c7",
+ "name": "Get Global Variables",
+ "func": "const keys = global.keys(); // Get all global variable keys\nmsg.payload = {}; // Initialize the payload object\n\nkeys.forEach(key => {\n // Ignore keys that start with \"$\"\n if (!key.startsWith('$')) {\n msg.payload[key] = global.get(key);\n }\n});\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 120,
+ "y": 140,
+ "wires": [
+ [
+ "87b0cc67cfa07120"
+ ]
+ ]
+ },
+ {
+ "id": "87b0cc67cfa07120",
+ "type": "ui-template",
+ "z": "07f3b717f2a2c8c7",
+ "group": "de680effc3e27451",
+ "page": "",
+ "ui": "",
+ "name": "body",
+ "order": 1,
+ "width": "0",
+ "height": "0",
+ "head": "",
+ "format": "\n \n \n \n \n \n \n \n \n Welcome to the PlanktoScope GUI\n \n \n\n \n Enter your information
\n\n \n \n \n \n \n \n\n \n \n \n \n \n \n \n\n \n \n \n \n mdi-content-save \n Save Configuration\n \n \n \n \n \n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 370,
+ "y": 140,
+ "wires": [
+ [
+ "0470c8bd5e2507dc"
+ ]
]
},
{
- "id": "24ea99bb02eeffa2",
- "type": "inject",
- "z": "63c85b96537b7355",
- "name": "Load config",
- "props": [
- {
- "p": "topic",
- "vt": "str"
- }
- ],
- "repeat": "",
- "crontab": "",
- "once": true,
- "onceDelay": 0.1,
- "topic": "",
- "payloadType": "str",
- "x": 230,
- "y": 60,
+ "id": "c8caad8136e6c5e3",
+ "type": "ui-template",
+ "z": "07f3b717f2a2c8c7",
+ "group": "",
+ "page": "34112984a39c35bd",
+ "ui": "",
+ "name": "CSS (All Pages)",
+ "order": 0,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": ".v-toolbar__content {\n display:none;\n}\n\n.v-main{\n --v-layout-top: 0px !important;\n\n} \n.v-card {\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;\n}",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "page:style",
+ "className": "",
+ "x": 920,
+ "y": 40,
"wires": [
- [
- "730b2780ac215a52"
- ]
+ []
]
},
{
- "id": "5248e5e225d854d1",
+ "id": "0470c8bd5e2507dc",
"type": "function",
- "z": "63c85b96537b7355",
- "name": "get config payload",
- "func": "keys = global.get(\"config_keys\")\n\nvar payload = {}\n\nkeys.forEach(function(item, index, array) {\n payload[item] = global.get(item);\n})\n\nreturn {\"payload\": payload};",
+ "z": "07f3b717f2a2c8c7",
+ "name": "set general settings",
+ "func": "if (msg.topic) {\n global.set(\"countries\", msg.payload.countries);\n global.set(\"timezone\", msg.payload.timezone);\n}\nreturn msg;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
- "x": 410,
- "y": 160,
- "wires": [
- [
- "31ae9b857627673c"
- ]
- ]
- },
- {
- "id": "97f8a94e71055782",
- "type": "ui_ui_control",
- "z": "63c85b96537b7355",
- "name": "Connect Event",
- "events": "connect",
- "x": 220,
- "y": 100,
+ "x": 590,
+ "y": 140,
"wires": [
- [
- "730b2780ac215a52"
- ]
+ []
]
},
{
@@ -1754,14 +2219,14 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n \n \n \n\n \n\n \n\n \n \n \n \n \n \n \n \n \n\n \n \n\n \n PlanktoScope \n \n\n Open and Affordable Quantitative Imaging Platform.
\n\n \n\n \n\n \n\n\n \n \n \n mdi-slack \n Ask the community\n \n \n mdi-bug \n Report a Bug\n \n\n \n mdi-github \n GitHub Repo\n \n\n \n mdi-account-group \n Contributors\n \n \n \n\n \n\n \n \n \n\n © {{ new Date().getFullYear() }} PlanktoScope GUI — made by\n \n FairScope\n \n \n\n \n \n \n \n\n\n",
+ "format": "\n \n \n \n \n \n\n \n\n \n\n \n \n \n \n \n \n \n \n \n\n \n \n\n \n PlanktoScope \n \n\n Open and Affordable Quantitative Imaging Platform.
\n\n \n\n \n\n \n\n\n \n \n \n mdi-slack \n Ask the community\n \n \n mdi-bug \n Report a Bug\n \n\n \n mdi-github \n GitHub Repo\n \n\n \n mdi-account-group \n Contributors\n \n \n \n\n \n\n \n \n \n\n © {{ new Date().getFullYear() }} PlanktoScope GUI — made by\n \n FairScope\n \n \n\n \n \n \n \n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "widget:ui",
"className": "",
"x": 510,
- "y": 440,
+ "y": 700,
"wires": [
[]
]
@@ -1783,7 +2248,7 @@
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 570,
+ "x": 310,
"y": 40,
"wires": [
[
@@ -1793,31 +2258,6 @@
]
]
},
- {
- "id": "e74e4d04e4a79458",
- "type": "switch",
- "z": "1b667c6443413ced",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "$pageview",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 1,
- "x": 280,
- "y": 40,
- "wires": [
- [
- "67b871e8f9c64c2b"
- ]
- ]
- },
{
"id": "7c16cbd0b2c40ea4",
"type": "ui-event",
@@ -1828,7 +2268,7 @@
"y": 40,
"wires": [
[
- "e74e4d04e4a79458"
+ "67b871e8f9c64c2b"
]
]
},
@@ -1844,7 +2284,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": " .v-btn--variant-outlined.v-btn--active {\n background-color: #1976d2 !important;\n color: white !important;\n }\n\n\n.v-row+.v-row {\n margin-top: 0px !important;\n}\n\n.v-row {\n margin: 0px !important;\n}",
+ "format": ".v-btn--variant-outlined.v-btn--active {\n background-color: #1976d2 !important;\n color: white !important;\n}\n\n\n.v-field__field {\n background: #eef3ff;\n border-radius: 4px;\n}\n\n.v-btn-group .v-btn, .v-field__overlay {\n background: #eef3ff;\n}\n \n.v-row {\n margin: 0;\n}",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -1922,170 +2362,371 @@
"id": "90ba8dbcce201a83",
"type": "delay",
"z": "1b667c6443413ced",
- "name": "",
- "pauseType": "delay",
- "timeout": "1",
- "timeoutUnits": "seconds",
- "rate": "1",
- "nbRateUnits": "1",
- "rateUnits": "second",
- "randomFirst": "1",
- "randomLast": "5",
- "randomUnits": "seconds",
- "drop": false,
- "allowrate": false,
- "outputs": 1,
- "x": 180,
- "y": 140,
+ "name": "",
+ "pauseType": "delay",
+ "timeout": "1",
+ "timeoutUnits": "seconds",
+ "rate": "1",
+ "nbRateUnits": "1",
+ "rateUnits": "second",
+ "randomFirst": "1",
+ "randomLast": "5",
+ "randomUnits": "seconds",
+ "drop": false,
+ "allowrate": false,
+ "outputs": 1,
+ "x": 140,
+ "y": 180,
+ "wires": [
+ [
+ "74f8dad75cdd3c16"
+ ]
+ ]
+ },
+ {
+ "id": "74f8dad75cdd3c16",
+ "type": "function",
+ "z": "1b667c6443413ced",
+ "name": "Get Global Variables",
+ "func": "const keys = global.keys(); // Get all global variable keys\nmsg.payload = {}; // Initialize the payload object\n\nkeys.forEach(key => {\n // Ignore keys that start with \"$\"\n if (!key.startsWith('$')) {\n msg.payload[key] = global.get(key);\n }\n});\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 320,
+ "y": 180,
+ "wires": [
+ [
+ "04386808e694e97e",
+ "e68b686e94d78c9d",
+ "e431a9253416f9eb",
+ "b50dce0a86c2668c",
+ "07c889f3fa18b15b"
+ ]
+ ]
+ },
+ {
+ "id": "8efc52e6ee9206f6",
+ "type": "poweroff",
+ "z": "1b667c6443413ced",
+ "name": "",
+ "x": 1160,
+ "y": 240,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "39c7e4b018ce16cb",
+ "type": "reboot",
+ "z": "1b667c6443413ced",
+ "name": "",
+ "x": 1160,
+ "y": 200,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "1db1c2e3e19e85ff",
+ "type": "switch",
+ "z": "1b667c6443413ced",
+ "name": "",
+ "property": "payload",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "eq",
+ "v": "reboot",
+ "vt": "str"
+ },
+ {
+ "t": "eq",
+ "v": "shutdown",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 2,
+ "x": 970,
+ "y": 200,
+ "wires": [
+ [
+ "39c7e4b018ce16cb"
+ ],
+ [
+ "8efc52e6ee9206f6"
+ ]
+ ]
+ },
+ {
+ "id": "cd5cd3a0277b8dba",
+ "type": "ui-template",
+ "z": "1b667c6443413ced",
+ "group": "5d39a98563150f22",
+ "page": "",
+ "ui": "",
+ "name": "empty-state",
+ "order": 1,
+ "width": "0",
+ "height": "0",
+ "head": "",
+ "format": "\n \n \n \n \n \n \n \n \n \n \n \n \n \n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 530,
+ "y": 280,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "04386808e694e97e",
+ "type": "ui-template",
+ "z": "1b667c6443413ced",
+ "group": "",
+ "page": "",
+ "ui": "e6ae26617c24c3ea",
+ "name": "toolbar",
+ "order": 2,
+ "width": "12",
+ "height": "6",
+ "head": "",
+ "format": "\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "widget:ui",
+ "className": "",
+ "x": 800,
+ "y": 180,
+ "wires": [
+ [
+ "1db1c2e3e19e85ff"
+ ]
+ ]
+ },
+ {
+ "id": "e68b686e94d78c9d",
+ "type": "ui-template",
+ "z": "1b667c6443413ced",
+ "group": "36931a9722892790",
+ "page": "",
+ "ui": "",
+ "name": "Software Version",
+ "order": 1,
+ "width": "0",
+ "height": "0",
+ "head": "",
+ "format": "\n \n \n mdi-github \n Software Version \n \n\n \n {{ msg.payload.software_version }}\n \n \n ",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 550,
+ "y": 480,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "e431a9253416f9eb",
+ "type": "ui-template",
+ "z": "1b667c6443413ced",
+ "group": "2aa235120084abe4",
+ "page": "",
+ "ui": "",
+ "name": "Images Acquired",
+ "order": 1,
+ "width": "0",
+ "height": "0",
+ "head": "",
+ "format": "\n \n \n \n mdi-image-multiple-outline\n \n \n Images Acquired\n \n \n\n \n {{ Number(msg.payload.image_acquired).toLocaleString('fr-FR') }}\n \n \n ",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 550,
+ "y": 520,
"wires": [
- [
- "74f8dad75cdd3c16"
- ]
+ []
]
},
{
- "id": "74f8dad75cdd3c16",
- "type": "function",
+ "id": "b50dce0a86c2668c",
+ "type": "ui-template",
"z": "1b667c6443413ced",
- "name": "Get Global Variables",
- "func": "const keys = global.keys(); // Get all global variable keys\nmsg.payload = {}; // Initialize the payload object\n\nkeys.forEach(key => {\n // Ignore keys that start with \"$\"\n if (!key.startsWith('$')) {\n msg.payload[key] = global.get(key);\n }\n});\n\nreturn msg;\n",
- "outputs": 1,
- "timeout": 0,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 200,
- "y": 280,
+ "group": "df7c60bd8b265e48",
+ "page": "",
+ "ui": "",
+ "name": "Objects Segmented",
+ "order": 1,
+ "width": "0",
+ "height": "0",
+ "head": "",
+ "format": "\n \n \n \n mdi-crop\n \n \n Objects Segmented\n \n \n\n \n {{ Number(msg.payload.object_segmented).toLocaleString('fr-FR') }}\n \n \n ",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 560,
+ "y": 560,
"wires": [
- [
- "52902327e2363f8b",
- "cd5cd3a0277b8dba",
- "0c0eb2a907745142"
- ]
+ []
]
},
{
- "id": "52902327e2363f8b",
+ "id": "07c889f3fa18b15b",
"type": "ui-template",
"z": "1b667c6443413ced",
- "group": "",
+ "group": "dc304678d9a6b53b",
"page": "",
- "ui": "e6ae26617c24c3ea",
- "name": "toolbar",
- "order": 2,
- "width": "12",
- "height": "6",
+ "ui": "",
+ "name": "Storage",
+ "order": 1,
+ "width": "0",
+ "height": "0",
"head": "",
- "format": "\n\n\n\n\n\n",
+ "format": "\n \n \n \n mdi-sd \n Storage \n \n\n \n \n 90\n ? 'error'\n : msg.payload.storage_percent_used > 70\n ? 'warning'\n : 'success'\n \" />\n \n\n \n \n {{ msg.payload.storage_percent_used }}%\n \n \n ",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
- "templateScope": "widget:ui",
+ "templateScope": "local",
"className": "",
- "x": 820,
- "y": 200,
+ "x": 520,
+ "y": 600,
"wires": [
- [
- "1db1c2e3e19e85ff"
- ]
+ []
]
},
{
- "id": "8efc52e6ee9206f6",
- "type": "poweroff",
+ "id": "ae546211d7d4413c",
+ "type": "ui-template",
"z": "1b667c6443413ced",
- "name": "",
- "x": 1160,
- "y": 240,
+ "group": "8a113b6c8e1eadb7",
+ "page": "",
+ "ui": "",
+ "name": "Learn the basic",
+ "order": 1,
+ "width": "0",
+ "height": "0",
+ "head": "",
+ "format": "\n \n mdi-book-open-variant \n Learn the Basics\n \n \n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 540,
+ "y": 440,
"wires": [
[]
]
},
{
- "id": "39c7e4b018ce16cb",
- "type": "reboot",
+ "id": "6440d3bb6107d79d",
+ "type": "ui-template",
"z": "1b667c6443413ced",
- "name": "",
- "x": 1160,
- "y": 200,
+ "group": "97e552a2d05b0800",
+ "page": "",
+ "ui": "",
+ "name": "Lanch the preview",
+ "order": 1,
+ "width": "0",
+ "height": "0",
+ "head": "",
+ "format": "\n \n mdi-eye \n Launch the Preview \n \n \n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 550,
+ "y": 320,
"wires": [
[]
]
},
{
- "id": "1db1c2e3e19e85ff",
- "type": "switch",
+ "id": "19ee8d1758dd9c06",
+ "type": "ui-template",
"z": "1b667c6443413ced",
- "name": "",
- "property": "payload",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "reboot",
- "vt": "str"
- },
- {
- "t": "eq",
- "v": "shutdown",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 2,
- "x": 970,
- "y": 200,
+ "group": "82480e386ed6f8bd",
+ "page": "",
+ "ui": "",
+ "name": "Explore your data",
+ "order": 1,
+ "width": "0",
+ "height": "0",
+ "head": "",
+ "format": "\n \n mdi-chart-scatter-plot-hexbin \n Explore your data \n \n \n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 550,
+ "y": 360,
"wires": [
- [
- "39c7e4b018ce16cb"
- ],
- [
- "8efc52e6ee9206f6"
- ]
+ []
]
},
{
- "id": "cd5cd3a0277b8dba",
+ "id": "0152d19f6346a058",
"type": "ui-template",
"z": "1b667c6443413ced",
- "group": "5d39a98563150f22",
+ "group": "9d3e8bdd535f0e0d",
"page": "",
"ui": "",
- "name": "body",
+ "name": "Run the Calibration",
"order": 1,
"width": "0",
"height": "0",
"head": "",
- "format": "\n \n\n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n mdi-eye \n Launch the Preview \n \n \n\n \n \n \n mdi-chart-scatter-plot-hexbin \n Explore your data \n \n \n \n \n \n mdi-tune \n Run the Calibration \n \n \n \n\n \n \n \n \n \n mdi-book-open-variant \n Learn the Basics\n \n \n\n \n\n \n \n \n\n \n \n \n \n mdi-github \n Software Version \n
\n \n {{ msg.payload.software_version }}\n
\n \n \n\n \n \n \n \n mdi-image-multiple-outline \n Images Acquired \n
\n \n {{ msg.payload.image_acquired }}\n
\n \n \n\n \n \n \n \n mdi-crop \n Objects Segmented \n
\n \n {{ msg.payload.object_segmented }}\n
\n \n \n\n \n \n \n\n mdi-sd \n Storage \n\n 90\n ? 'error'\n : msg.payload.storage_percent_used > 70\n ? 'warning'\n : 'success'\n \"\n > \n\n \n {{ msg.payload.storage_percent_used }}%\n
\n\n \n\n \n \n\n \n \n",
+ "format": "\n \n mdi-tune \n Run the Calibration \n \n \n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 510,
- "y": 180,
+ "x": 550,
+ "y": 400,
"wires": [
[]
]
},
{
- "id": "0c0eb2a907745142",
- "type": "debug",
+ "id": "683fec6ef9b340f7",
+ "type": "ui-template",
"z": "1b667c6443413ced",
- "name": "debug 15",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 520,
- "y": 280,
- "wires": []
+ "group": "",
+ "page": "632260133d581caa",
+ "ui": "",
+ "name": "CSS (This page)",
+ "order": 0,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": ".v-card-text{\n padding : 0 !important;\n}\n\n\n \n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "page:style",
+ "className": "",
+ "x": 1300,
+ "y": 80,
+ "wires": [
+ []
+ ]
},
{
"id": "a57a165cd0ce511b",
@@ -2096,10 +2737,10 @@
"ui": "",
"name": "Streaming",
"order": 1,
- "width": "6",
- "height": "18",
+ "width": "0",
+ "height": "0",
"head": "",
- "format": "\n \n
\n\n\n",
+ "format": "\n \n \n\n \n \n \n
\n \n \n \n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2127,8 +2768,7 @@
"y": 140,
"wires": [
[
- "f514a138d38d3c61",
- "4f5f150680be40da"
+ "f514a138d38d3c61"
]
]
},
@@ -2144,7 +2784,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n \n \n \n mdi-chevron-left \n Home\n \n \n\n \n \n \n Metadata\n mdi-chevron-right \n \n \n \n \n \n",
+ "format": "\n \n \n \n \n \n mdi-chevron-left \n Home\n \n \n\n \n \n Preview\n \n \n\n \n \n Metadata\n mdi-chevron-right \n \n \n \n \n \n ",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2156,31 +2796,6 @@
[]
]
},
- {
- "id": "8d5eadf60a2fd54b",
- "type": "switch",
- "z": "ab58b3fd0e6bcd77",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "$pageview",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 1,
- "x": 280,
- "y": 40,
- "wires": [
- [
- "6074fc31218f9a74"
- ]
- ]
- },
{
"id": "da4fbaab826b0d62",
"type": "ui-event",
@@ -2191,7 +2806,7 @@
"y": 40,
"wires": [
[
- "8d5eadf60a2fd54b"
+ "6074fc31218f9a74"
]
]
},
@@ -2212,7 +2827,7 @@
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 580,
+ "x": 320,
"y": 40,
"wires": [
[
@@ -2339,18 +2954,18 @@
"group": "39cbd2658f16d608",
"page": "",
"ui": "",
- "name": "Settings",
+ "name": "body",
"order": 1,
"width": "0",
"height": "0",
"head": "",
- "format": "\n \n \n \n \n LED Control\n \n \n \n \n \n \n Off \n On \n \n \n \n \n \n\n \n\n \n Focus Control\n \n \n \n\n \n \n Focus Distance\n
\n\n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n {{ value }}\n \n \n \n \n\n \n \n \n \n Up \n Stop \n Down \n \n \n \n\n \n \n\n \n \n \n Pump Control\n \n \n \n\n \n \n Pump Flowrate\n
\n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n {{ value }}\n \n \n \n \n\n \n \n Pump Volume\n
\n\n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n {{ value }}\n \n \n \n \n\n \n \n \n \n Backward \n Stop \n Forward \n \n \n \n \n \n\n \n \n \n mdi-tune \n Calibration\n \n \n Modify your camera settings through the\n \n calibration page\n .\n Adjust optical parameters to ensure accurate imaging.\n \n \n\n \n \n \n {{ infoContent.title }} \n \n Unit: {{ infoContent.unit }}
\n Description: {{ infoContent.description }}
\n \n \n \n Close \n \n \n \n \n \n\n\n\n",
+ "format": "\n \n \n \n \n\n \n \n \n \n Off \n On \n \n \n \n \n \n\n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n {{ value }}\n \n \n \n \n\n \n \n \n \n Up \n Stop \n Down \n \n \n \n\n \n \n\n \n \n \n\n \n\n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n {{ value }}\n \n \n \n \n\n \n \n\n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n {{ value }}\n \n \n \n \n\n \n \n \n \n Backward \n Stop \n Forward \n \n \n \n \n \n\n \n \n \n \n \n mdi-tune \n \n\n \n Calibration\n \n\n \n Modify camera settings and optical parameters for accurate imaging.\n \n\n \n mdi-chevron-right \n \n \n \n\n \n \n \n {{ infoContent.title }} \n \n Unit: {{ infoContent.unit }}
\n Description: {{ infoContent.description }}
\n \n \n \n Close \n \n \n \n \n \n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 380,
+ "x": 370,
"y": 140,
"wires": [
[
@@ -2410,7 +3025,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n \n \n \n mdi-chevron-left \n Home\n \n \n\n \n \n \n Metadata\n mdi-chevron-right \n \n \n \n \n \n",
+ "format": "\n \n \n \n \n \n mdi-chevron-left \n Home\n \n \n\n \n\n \n\n \n \n Metadata\n mdi-chevron-right \n \n \n \n \n \n ",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2460,22 +3075,6 @@
]
]
},
- {
- "id": "4f5f150680be40da",
- "type": "debug",
- "z": "ab58b3fd0e6bcd77",
- "name": "debug 12",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "false",
- "statusVal": "",
- "statusType": "auto",
- "x": 380,
- "y": 340,
- "wires": []
- },
{
"id": "349d0f7644f26a62",
"type": "ui-template",
@@ -2488,7 +3087,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Preview\n \n \n\n \n \n \n Acquisition\n mdi-chevron-right \n \n \n \n \n \n",
+ "format": "\n \n \n \n \n \n mdi-chevron-left \n Preview\n \n \n\n \n \n Metadata\n \n \n\n \n \n Acquisition\n mdi-chevron-right \n \n \n \n \n \n ",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2512,7 +3111,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n \n Sampling Gear\n \n \n mdi-information\n \n
\n\n \n \n \n Horizontal Net\n \n \n Vertical Net\n \n \n Niskin bottle\n \n \n Lab culture\n \n \n Demo / Test\n \n \n
\n\n \n \n\n \n \n \n {{ hintTitle }} \n {{ hintText }} \n \n \n Close \n \n \n \n \n\n\n\n",
+ "format": "\n \n \n sendUpdate()\">\n \n \n mdi-information\n \n \n \n \n\n \n sendUpdate()\">\n \n \n mdi-information\n \n \n \n \n\n \n sendUpdate()\">\n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n Sampling Gear\n \n \n mdi-information\n \n
\n\n \n sendUpdate()\">\n \n Horizontal Net\n \n \n Vertical Net\n \n \n Niskin bottle\n \n \n Lab culture\n \n \n Demo / Test\n \n \n
\n \n \n \n \n \n {{ hintTitle }} \n {{ hintText }} \n \n \n Close \n \n \n \n \n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2522,7 +3121,8 @@
"y": 140,
"wires": [
[
- "13f96cf49ae6d6a6"
+ "13f96cf49ae6d6a6",
+ "1185f4705f9643a3"
]
]
},
@@ -2538,7 +3138,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n \n Date and Time \n \n\n \n \n \n \n\n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n Latitude \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n Longitude \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n\n \n \n + \n − \n
\n \n \n\n \n \n \n {{ hintTitle }} \n {{ hintText }} \n \n \n Got it \n \n \n \n \n \n\n\n",
+ "format": "\n \n \n \n Date and Time \n \n\n \n \n \n \n\n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n Latitude \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n Longitude \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n\n \n \n + \n − \n
\n \n \n\n \n \n \n {{ hintTitle }} \n {{ hintText }} \n \n \n Got it \n \n \n \n \n \n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2564,7 +3164,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n \n \n End Date and Time \n \n\n \n \n \n \n\n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n Latitude (End) \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n Longitude (End) \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n\n \n \n + \n − \n
\n \n \n\n \n \n \n {{ hintTitleEnd }} \n {{ hintTextEnd }} \n \n \n Got it \n \n \n \n \n \n\n\n\n\n",
+ "format": "\n \n \n End Date and Time \n \n\n \n \n \n \n\n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n Latitude (End) \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n Longitude (End) \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n\n \n + \n − \n
\n \n \n\n \n \n {{ hintTitleEnd }} \n {{ hintTextEnd }} \n \n \n Got it \n \n \n \n \n \n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2590,7 +3190,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n\n \n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n {{ hintTitle }} \n {{ hintText }} \n \n \n Got it \n \n \n \n \n \n\n\n\n\n",
+ "format": "\n \n \n\n \n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n {{ hintTitle }} \n {{ hintText }} \n \n \n Got it \n \n \n \n \n \n\n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2616,7 +3216,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n {{ hintTitle }} \n {{ hintText }} \n \n \n Got it \n \n \n \n \n \n\n\n\n\n",
+ "format": "\n \n \n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n {{ hintTitle }} \n {{ hintText }} \n \n \n Got it \n \n \n \n \n \n\n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2642,7 +3242,7 @@
"width": 0,
"height": 0,
"head": "",
- "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Preview\n \n \n\n \n \n \n Acquisition\n mdi-chevron-right \n \n \n \n \n \n",
+ "format": "\n \n \n \n \n \n mdi-chevron-left \n Preview\n \n \n\n \n\n \n\n \n \n Acquisition\n mdi-chevron-right \n \n \n \n \n \n ",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -2674,7 +3274,8 @@
"96550b272e3da513",
"1c90e219a749e53e",
"09b551fa202d8029",
- "93a68ffbce74d70d"
+ "93a68ffbce74d70d",
+ "c04c3055a84e7597"
]
]
},
@@ -2682,34 +3283,9 @@
"id": "0931106adf64b43d",
"type": "ui-event",
"z": "190b0c9aa75e8843",
- "ui": "e6ae26617c24c3ea",
- "name": "UI Event",
- "x": 80,
- "y": 40,
- "wires": [
- [
- "a68e52177173f690"
- ]
- ]
- },
- {
- "id": "a68e52177173f690",
- "type": "switch",
- "z": "190b0c9aa75e8843",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "$pageview",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 1,
- "x": 280,
+ "ui": "e6ae26617c24c3ea",
+ "name": "UI Event",
+ "x": 80,
"y": 40,
"wires": [
[
@@ -2734,7 +3310,7 @@
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 580,
+ "x": 320,
"y": 40,
"wires": [
[
@@ -2787,7 +3363,7 @@
"initialize": "",
"finalize": "",
"libs": [],
- "x": 760,
+ "x": 960,
"y": 240,
"wires": [
[]
@@ -2805,7 +3381,7 @@
"initialize": "",
"finalize": "",
"libs": [],
- "x": 770,
+ "x": 970,
"y": 200,
"wires": [
[]
@@ -2856,7 +3432,7 @@
"initialize": "",
"finalize": "",
"libs": [],
- "x": 780,
+ "x": 980,
"y": 340,
"wires": [
[]
@@ -2867,14 +3443,14 @@
"type": "function",
"z": "190b0c9aa75e8843",
"name": "set object_datetime_end",
- "func": "if (msg.topic) {\n global.set(\"object_date_end\", msg.payload.object_date_end);\n global.set(\"object_time_end\", msg.payload.object_time_end);\n}\nreturn msg;\n",
+ "func": "if (msg.topic) {\n global.set(\"object_date_end\", msg.payload.object_date_end);\n global.set(\"object_time_end\", msg.payload.object_time_end);\n}\n\nif (msg.payload.sample_gear !== \"Horizontal Net\") {\n global.set(\"object_date_end\", undefined);\n global.set(\"object_time_end\", undefined);\n}\n\nreturn msg;\n",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
- "x": 790,
+ "x": 990,
"y": 300,
"wires": [
[]
@@ -2917,7 +3493,7 @@
"initialize": "",
"finalize": "",
"libs": [],
- "x": 790,
+ "x": 990,
"y": 400,
"wires": [
[]
@@ -2960,7 +3536,7 @@
"initialize": "",
"finalize": "",
"libs": [],
- "x": 800,
+ "x": 1000,
"y": 460,
"wires": [
[]
@@ -3007,12 +3583,102 @@
"initialize": "",
"finalize": "",
"libs": [],
- "x": 790,
+ "x": 990,
"y": 140,
"wires": [
[]
]
},
+ {
+ "id": "f495c897b992cb33",
+ "type": "debug",
+ "z": "190b0c9aa75e8843",
+ "name": "debug 3",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "payload",
+ "targetType": "msg",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 900,
+ "y": 760,
+ "wires": []
+ },
+ {
+ "id": "c04c3055a84e7597",
+ "type": "debug",
+ "z": "190b0c9aa75e8843",
+ "name": "debug 4",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "payload",
+ "targetType": "msg",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 140,
+ "y": 280,
+ "wires": []
+ },
+ {
+ "id": "1185f4705f9643a3",
+ "type": "switch",
+ "z": "190b0c9aa75e8843",
+ "name": "",
+ "property": "payload.sample_gear",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "eq",
+ "v": "Horizontal Net",
+ "vt": "str"
+ },
+ {
+ "t": "eq",
+ "v": "Vertical Net",
+ "vt": "str"
+ },
+ {
+ "t": "eq",
+ "v": "Niskin bottle",
+ "vt": "str"
+ },
+ {
+ "t": "eq",
+ "v": "Lab culture",
+ "vt": "str"
+ },
+ {
+ "t": "eq",
+ "v": "Demo / Test",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 5,
+ "x": 510,
+ "y": 640,
+ "wires": [
+ [],
+ [
+ "42d27ae01c6fd201",
+ "f495c897b992cb33"
+ ],
+ [
+ "42d27ae01c6fd201"
+ ],
+ [
+ "42d27ae01c6fd201"
+ ],
+ [
+ "42d27ae01c6fd201"
+ ]
+ ]
+ },
{
"id": "ffe728d961068c62",
"type": "ui-template",
@@ -3022,10 +3688,10 @@
"ui": "",
"name": "Streaming",
"order": 1,
- "width": "7",
- "height": "18",
+ "width": "0",
+ "height": "0",
"head": "",
- "format": " \n ",
+ "format": "\n \n \n\n \n \n \n
\n \n \n \n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
@@ -3044,65 +3710,257 @@
"group": "d2f77573ed4317e4",
"page": "",
"ui": "",
- "name": "Acquisition settings",
+ "name": "body",
"order": 1,
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n { acq_interframe_volume = v / 1000; autoCalculateVolumes(); }\"\n\n :max=\"100\"\n :min=\"0\"\n :step=\"1\"\n label=\"Volume between images\"\n hide-details\n @update:model-value=\"autoCalculateVolumes\"\n />\n \n \n { acq_interframe_volume = v / 1000; autoCalculateVolumes(); }\"\n\n type=\"number\"\n variant=\"outlined\"\n hide-details\n suffix=\"µL\"\n class=\"aligned-input\"\n @input=\"autoCalculateVolumes\"\n >\n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n Volume Imaged : \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n Total Volume Displaced : \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n \n {{ Math.min(100, Math.ceil(value || 0)) }}% \n \n \n \n \n\n \n \n \n Estimated duration left : \n \n \n \n \n \n\n \n \n \n \n {{ formattedStatus }}\n \n \n \n\n \n \n \n \n Stop \n Start \n \n \n \n\n \n \n \n {{ hintTitle }} \n \n \n \n Close \n \n \n \n \n \n\n\n\n",
+ "format": "\n \n \n \n \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n Advanced Parameters\n \n \n\n \n \n { acq_interframe_volume = v / 1000; autoCalculateVolumes(); }\" :max=\"100\"\n :min=\"1\" :step=\"1\" label=\"Volume between images\" hide-details />\n \n \n { acq_interframe_volume = v / 1000; autoCalculateVolumes(); }\"\n type=\"number\" :min=\"1\" variant=\"outlined\" hide-details suffix=\"µL\" class=\"aligned-input\"\n @input=\"autoCalculateVolumes\">\n \n \n mdi-information\n \n \n \n \n \n\n \n \n Volume Imaged : \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n Total Volume Displaced : \n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n \n mdi-information\n \n \n \n \n \n\n \n \n \n \n\n \n \n \n Stop \n Start \n \n \n \n \n \n \n \n {{ Math.min(100, Math.ceil(value || 0)) }}% \n \n \n \n \n \n \n \n Estimated duration left : \n \n \n \n \n \n \n \n \n \n {{ formattedStatus }}\n \n \n \n \n \n \n \n {{ hintTitle }} \n \n \n \n Close \n \n \n \n \n \n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 370,
+ "x": 350,
+ "y": 140,
+ "wires": [
+ [
+ "56b3c60c6d4ffd87"
+ ]
+ ]
+ },
+ {
+ "id": "a39e07fd1a86ac84",
+ "type": "ui-template",
+ "z": "35d7387466dd0bc0",
+ "group": "3ae252a2e5abca89",
+ "page": "",
+ "ui": "",
+ "name": "Navigation Top",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n \n \n \n mdi-chevron-left \n Metadata\n \n \n\n \n \n Acquisition\n \n \n\n \n \n Segmentation\n mdi-chevron-right \n \n \n \n \n \n ",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 1300,
+ "y": 40,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "56b3c60c6d4ffd87",
+ "type": "switch",
+ "z": "35d7387466dd0bc0",
+ "name": "",
+ "property": "topic",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "eq",
+ "v": "acq_params",
+ "vt": "str"
+ },
+ {
+ "t": "eq",
+ "v": "imager/image",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 2,
+ "x": 570,
+ "y": 140,
+ "wires": [
+ [
+ "f841ebf999dd8cb3"
+ ],
+ [
+ "09ecc922fe2214a8"
+ ]
+ ]
+ },
+ {
+ "id": "f841ebf999dd8cb3",
+ "type": "function",
+ "z": "35d7387466dd0bc0",
+ "name": "set acq_params",
+ "func": "if (msg.topic) {\n global.set(\"acq_id\", msg.payload.acq_id);\n global.set(\"acq_nb_frame\", msg.payload.acq_nb_frame);\n global.set(\"acq_interframe_volume\", msg.payload.acq_interframe_volume);\n global.set(\"acq_imaged_volume\", msg.payload.acq_imaged_volume);\n global.set(\"acq_pumped_volume\", msg.payload.acq_pumped_volume);\n global.set(\"acq_comment\", msg.payload.acq_comment);\n global.set(\"acq_progression\", msg.payload.acq_progression);\n global.set(\"acq_duration_left\", msg.payload.acq_duration_left);\n global.set(\"acq_start_timestamp\", msg.payload.acq_start_timestamp);\n \n}\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": "",
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 760,
+ "y": 140,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "e3c084164002f23f",
+ "type": "ui-event",
+ "z": "35d7387466dd0bc0",
+ "ui": "e6ae26617c24c3ea",
+ "name": "UI Event",
+ "x": 80,
+ "y": 40,
+ "wires": [
+ [
+ "54ae95ca6088f307"
+ ]
+ ]
+ },
+ {
+ "id": "54ae95ca6088f307",
+ "type": "switch",
+ "z": "35d7387466dd0bc0",
+ "name": "msg.payload.page.path === \"/acquisition\"",
+ "property": "payload.page.path",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "eq",
+ "v": "/acquisition",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 1,
+ "x": 330,
+ "y": 40,
+ "wires": [
+ [
+ "113085f8dd63df0c"
+ ]
+ ]
+ },
+ {
+ "id": "113085f8dd63df0c",
+ "type": "function",
+ "z": "35d7387466dd0bc0",
+ "name": "Get Global Variables",
+ "func": "const keys = global.keys(); // Get all global variable keys\nmsg.payload = {}; // Initialize the payload object\n\nkeys.forEach(key => {\n // Ignore keys that start with \"$\"\n if (!key.startsWith('$')) {\n msg.payload[key] = global.get(key);\n }\n});\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 120,
"y": 140,
"wires": [
[
- "56b3c60c6d4ffd87"
+ "aa6924c7e0aff26c"
+ ]
+ ]
+ },
+ {
+ "id": "8ceee00f68df186c",
+ "type": "mqtt in",
+ "z": "35d7387466dd0bc0",
+ "name": "",
+ "topic": "status/imager",
+ "qos": "0",
+ "datatype": "json",
+ "broker": "8dc3722c.06efa8",
+ "nl": false,
+ "rap": false,
+ "inputs": 0,
+ "x": 90,
+ "y": 200,
+ "wires": [
+ [
+ "aa6924c7e0aff26c"
]
]
},
{
- "id": "a39e07fd1a86ac84",
+ "id": "425d72ed91ea264e",
"type": "ui-template",
"z": "35d7387466dd0bc0",
- "group": "3ae252a2e5abca89",
+ "group": "3d88a1872dbbf8a7",
"page": "",
"ui": "",
- "name": "Navigation Top",
+ "name": "Navigation Bottom",
"order": 1,
"width": 0,
"height": 0,
"head": "",
- "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Metadata\n \n \n\n \n \n \n Segmentation\n mdi-chevron-right \n \n \n \n \n \n",
+ "format": "\n \n \n \n \n \n mdi-chevron-left \n Metadata\n \n \n\n \n\n \n\n \n \n Segmentation\n mdi-chevron-right \n \n \n \n \n \n ",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 1300,
- "y": 40,
+ "x": 1290,
+ "y": 160,
"wires": [
[]
]
},
{
- "id": "56b3c60c6d4ffd87",
+ "id": "46b98c54eb680c2e",
+ "type": "mqtt out",
+ "z": "35d7387466dd0bc0",
+ "name": "MQTT",
+ "topic": "",
+ "qos": "",
+ "retain": "",
+ "respTopic": "",
+ "contentType": "",
+ "userProps": "",
+ "correl": "",
+ "expiry": "",
+ "broker": "8dc3722c.06efa8",
+ "x": 1070,
+ "y": 200,
+ "wires": []
+ },
+ {
+ "id": "7d28a539738ca73a",
+ "type": "function",
+ "z": "35d7387466dd0bc0",
+ "name": "update_config",
+ "func": "const keys = global.keys(); // Récupère toutes les clés des variables globales\nlet config = {}; // Objet pour stocker la configuration\n\nkeys.forEach(key => {\n // Ignore les clés qui commencent par \"$\"\n if (!key.startsWith('$')) {\n // Ne garde que celles qui commencent par les préfixes demandés\n if (\n key.startsWith('sample_') ||\n key.startsWith('acq_') ||\n key.startsWith('object_') ||\n key.startsWith('process_') ||\n key.startsWith('img_')\n ) {\n config[key] = global.get(key);\n }\n }\n});\n\n// Crée le payload final\nmsg.payload = {\n action: \"update_config\",\n config: config\n};\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 760,
+ "y": 200,
+ "wires": [
+ [
+ "46b98c54eb680c2e"
+ ]
+ ]
+ },
+ {
+ "id": "09ecc922fe2214a8",
"type": "switch",
"z": "35d7387466dd0bc0",
"name": "",
- "property": "topic",
+ "property": "payload.status",
"propertyType": "msg",
"rules": [
{
"t": "eq",
- "v": "acq_params",
+ "v": "on",
"vt": "str"
},
{
"t": "eq",
- "v": "imager/image",
+ "v": "off",
"vt": "str"
}
],
@@ -3110,1034 +3968,1272 @@
"repair": false,
"outputs": 2,
"x": 570,
- "y": 140,
+ "y": 200,
"wires": [
[
- "f841ebf999dd8cb3"
+ "7d28a539738ca73a",
+ "bb2825f419cc6526"
],
[
- "09ecc922fe2214a8"
+ "44c8ea6299e13784"
]
]
},
{
- "id": "f841ebf999dd8cb3",
+ "id": "44c8ea6299e13784",
"type": "function",
"z": "35d7387466dd0bc0",
- "name": "set acq_params",
- "func": "if (msg.topic) {\n global.set(\"acq_id\", msg.payload.acq_id);\n global.set(\"acq_nb_frame\", msg.payload.acq_nb_frame);\n global.set(\"acq_interframe_volume\", msg.payload.acq_interframe_volume);\n global.set(\"acq_imaged_volume\", msg.payload.acq_imaged_volume);\n global.set(\"acq_pumped_volume\", msg.payload.acq_pumped_volume);\n global.set(\"acq_comment\", msg.payload.acq_comment);\n global.set(\"acq_progression\", msg.payload.acq_progression);\n global.set(\"acq_duration_left\", msg.payload.acq_duration_left);\n global.set(\"acq_start_timestamp\", msg.payload.acq_start_timestamp);\n \n}\nreturn msg;\n",
+ "name": "stop acquisition",
+ "func": "\nmsg.payload = {\n action: \"stop\"\n};\n\nreturn msg;\n",
"outputs": 1,
- "timeout": "",
+ "timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 760,
- "y": 140,
+ "y": 240,
"wires": [
- []
+ [
+ "46b98c54eb680c2e"
+ ]
]
},
{
- "id": "e3c084164002f23f",
- "type": "ui-event",
+ "id": "bb2825f419cc6526",
+ "type": "function",
+ "z": "35d7387466dd0bc0",
+ "name": "start acquisition",
+ "func": "const acq_interframe_volume = global.get(\"acq_interframe_volume\") || 0;\nconst acq_nb_frame = global.get(\"acq_nb_frame\") || 0;\n\n\n// Crée le payload final\nmsg.payload = {\n action: \"image\",\n pump_direction: \"FORWARD\",\n volume: acq_interframe_volume,\n nb_frame: acq_nb_frame,\n sleep: 0.1\n};\n\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 760,
+ "y": 280,
+ "wires": [
+ [
+ "ce5cca3b8fe34379"
+ ]
+ ]
+ },
+ {
+ "id": "ce5cca3b8fe34379",
+ "type": "delay",
"z": "35d7387466dd0bc0",
+ "name": "",
+ "pauseType": "delay",
+ "timeout": "1",
+ "timeoutUnits": "seconds",
+ "rate": "1",
+ "nbRateUnits": "1",
+ "rateUnits": "second",
+ "randomFirst": "1",
+ "randomLast": "5",
+ "randomUnits": "seconds",
+ "drop": false,
+ "allowrate": false,
+ "outputs": 1,
+ "x": 920,
+ "y": 280,
+ "wires": [
+ [
+ "46b98c54eb680c2e"
+ ]
+ ]
+ },
+ {
+ "id": "250979b4672d81b6",
+ "type": "ui-event",
+ "z": "0fd76ac156d78937",
"ui": "e6ae26617c24c3ea",
"name": "UI Event",
"x": 80,
"y": 40,
"wires": [
[
- "5bea829ec02ded61"
+ "66d295cc27d69cec"
]
]
},
{
- "id": "5bea829ec02ded61",
+ "id": "66d295cc27d69cec",
"type": "switch",
- "z": "35d7387466dd0bc0",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
+ "z": "0fd76ac156d78937",
+ "name": "msg.payload.page.path === \"/segmentation\"",
+ "property": "payload.page.path",
"propertyType": "msg",
"rules": [
{
"t": "eq",
- "v": "$pageview",
+ "v": "/segmentation",
"vt": "str"
}
],
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 280,
+ "x": 330,
"y": 40,
"wires": [
[
- "54ae95ca6088f307"
+ "d4129bf3f5622aa6",
+ "921be0438de344ac"
]
]
},
{
- "id": "54ae95ca6088f307",
+ "id": "d4129bf3f5622aa6",
+ "type": "list acquisitions",
+ "z": "0fd76ac156d78937",
+ "name": "",
+ "x": 100,
+ "y": 140,
+ "wires": [
+ [
+ "e23cbd161ffb9970"
+ ]
+ ]
+ },
+ {
+ "id": "9e44bf9a44615e20",
+ "type": "ui-template",
+ "z": "0fd76ac156d78937",
+ "group": "9f00807878a32dd5",
+ "page": "",
+ "ui": "",
+ "name": "Navigation Bottom",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Acquisition\n \n \n\n \n \n \n Visualization\n mdi-chevron-right \n \n \n \n \n \n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 1510,
+ "y": 160,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "d11906e048438931",
+ "type": "ui-template",
+ "z": "0fd76ac156d78937",
+ "group": "9b992ca6515ed058",
+ "page": "",
+ "ui": "",
+ "name": "Navigation Top",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n \n \n \n mdi-chevron-left \n Acquisition\n \n \n\n \n \n Segmentation\n \n \n\n \n \n Vizualisation\n mdi-chevron-right \n \n \n \n \n \n ",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 1520,
+ "y": 40,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "4bdd8a4afcdb704b",
+ "type": "mqtt out",
+ "z": "0fd76ac156d78937",
+ "name": "MQTT",
+ "topic": "",
+ "qos": "",
+ "retain": "",
+ "respTopic": "",
+ "contentType": "",
+ "userProps": "",
+ "correl": "",
+ "expiry": "",
+ "broker": "8dc3722c.06efa8",
+ "x": 1150,
+ "y": 140,
+ "wires": []
+ },
+ {
+ "id": "24c71b69e60e41bd",
"type": "switch",
- "z": "35d7387466dd0bc0",
- "name": "msg.payload.page.path === \"/acquisition\"",
- "property": "payload.page.path",
+ "z": "0fd76ac156d78937",
+ "name": "",
+ "property": "topic",
"propertyType": "msg",
"rules": [
{
"t": "eq",
- "v": "/acquisition",
+ "v": "segmenter/segment",
"vt": "str"
}
],
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 590,
- "y": 40,
- "wires": [
- [
- "113085f8dd63df0c"
- ]
- ]
- },
- {
- "id": "113085f8dd63df0c",
- "type": "function",
- "z": "35d7387466dd0bc0",
- "name": "Get Global Variables",
- "func": "const keys = global.keys(); // Get all global variable keys\nmsg.payload = {}; // Initialize the payload object\n\nkeys.forEach(key => {\n // Ignore keys that start with \"$\"\n if (!key.startsWith('$')) {\n msg.payload[key] = global.get(key);\n }\n});\n\nreturn msg;\n",
- "outputs": 1,
- "timeout": 0,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 120,
+ "x": 1030,
"y": 140,
"wires": [
[
- "aa6924c7e0aff26c"
+ "4bdd8a4afcdb704b",
+ "f8f4b34842d35713"
]
]
},
{
- "id": "8ceee00f68df186c",
+ "id": "34f6f23f1a46da67",
"type": "mqtt in",
- "z": "35d7387466dd0bc0",
+ "z": "0fd76ac156d78937",
"name": "",
- "topic": "status/imager",
+ "topic": "status/segmenter",
"qos": "0",
"datatype": "json",
"broker": "8dc3722c.06efa8",
"nl": false,
"rap": false,
"inputs": 0,
- "x": 90,
- "y": 200,
+ "x": 100,
+ "y": 260,
"wires": [
[
- "aa6924c7e0aff26c"
+ "33c0d1b8251b5d00"
]
]
},
{
- "id": "425d72ed91ea264e",
+ "id": "33c0d1b8251b5d00",
"type": "ui-template",
- "z": "35d7387466dd0bc0",
- "group": "3d88a1872dbbf8a7",
+ "z": "0fd76ac156d78937",
+ "group": "402b3d24c87ab0d2",
"page": "",
"ui": "",
- "name": "Navigation Bottom",
+ "name": "Details of Segmentation",
"order": 1,
- "width": 0,
- "height": 0,
+ "width": "0",
+ "height": "0",
"head": "",
- "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Metadata\n \n \n\n \n \n \n Segmentation\n mdi-chevron-right \n \n \n \n \n \n",
+ "format": "\n\n \n \n Error: {{ errorMessage }}\n \n\n \n \n {{ infoMessage }}\n \n\n \n \n \n Progress \n {{ progressText }} \n
\n\n \n\n \n Currently segmenting: {{ currentImage }} \n
\n \n\n \n \n Awaiting segmentation status…\n
\n\n \n\n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 1290,
- "y": 160,
+ "x": 370,
+ "y": 220,
"wires": [
[]
]
},
{
- "id": "46b98c54eb680c2e",
- "type": "mqtt out",
- "z": "35d7387466dd0bc0",
- "name": "MQTT",
- "topic": "",
- "qos": "",
- "retain": "",
- "respTopic": "",
- "contentType": "",
- "userProps": "",
- "correl": "",
- "expiry": "",
- "broker": "8dc3722c.06efa8",
- "x": 1070,
- "y": 200,
- "wires": []
- },
- {
- "id": "7d28a539738ca73a",
- "type": "function",
- "z": "35d7387466dd0bc0",
- "name": "update_config",
- "func": "const keys = global.keys(); // Récupère toutes les clés des variables globales\nlet config = {}; // Objet pour stocker la configuration\n\nkeys.forEach(key => {\n // Ignore les clés qui commencent par \"$\"\n if (!key.startsWith('$')) {\n // Ne garde que celles qui commencent par les préfixes demandés\n if (\n key.startsWith('sample_') ||\n key.startsWith('acq_') ||\n key.startsWith('object_') ||\n key.startsWith('process_') ||\n key.startsWith('img_')\n ) {\n config[key] = global.get(key);\n }\n }\n});\n\n// Crée le payload final\nmsg.payload = {\n action: \"update_config\",\n config: config\n};\n\nreturn msg;\n",
- "outputs": 1,
- "timeout": 0,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 760,
- "y": 200,
- "wires": [
- [
- "46b98c54eb680c2e"
- ]
- ]
- },
- {
- "id": "09ecc922fe2214a8",
+ "id": "cdb586337f186494",
"type": "switch",
- "z": "35d7387466dd0bc0",
+ "z": "0fd76ac156d78937",
"name": "",
- "property": "payload.status",
+ "property": "payload.is_segmented",
"propertyType": "msg",
"rules": [
{
- "t": "eq",
- "v": "on",
- "vt": "str"
+ "t": "true"
},
{
- "t": "eq",
- "v": "off",
- "vt": "str"
+ "t": "false"
}
],
"checkall": "true",
"repair": false,
"outputs": 2,
- "x": 570,
- "y": 200,
+ "x": 510,
+ "y": 140,
"wires": [
+ [],
[
- "7d28a539738ca73a",
- "bb2825f419cc6526"
- ],
- [
- "44c8ea6299e13784"
+ "89d1bfb7839c2077"
]
]
},
{
- "id": "44c8ea6299e13784",
+ "id": "f8f4b34842d35713",
"type": "function",
- "z": "35d7387466dd0bc0",
- "name": "stop acquisition",
- "func": "\nmsg.payload = {\n action: \"stop\"\n};\n\nreturn msg;\n",
+ "z": "0fd76ac156d78937",
+ "name": "set seg_params",
+ "func": "if (msg.topic) {\n global.set(\"seg_project_name\", msg.payload.dataset.project_name);\n global.set(\"seg_sample_id\", msg.payload.dataset.sample_id);\n global.set(\"seg_acquisition_id\", msg.payload.dataset.acquisition_id);\n global.set(\"seg_path\", msg.payload.dataset.path);\n global.set(\"process_min_ESD\", msg.payload.settings.process_min_ESD);\n}\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": "",
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 1180,
+ "y": 100,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "921be0438de344ac",
+ "type": "function",
+ "z": "0fd76ac156d78937",
+ "name": "Get Global Variables",
+ "func": "const keys = global.keys(); // Get all global variable keys\nmsg.payload = {}; // Initialize the payload object\n\nkeys.forEach(key => {\n // Ignore keys that start with \"$\"\n if (!key.startsWith('$')) {\n msg.payload[key] = global.get(key);\n }\n});\n\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
- "x": 760,
- "y": 240,
+ "x": 120,
+ "y": 200,
"wires": [
[
- "46b98c54eb680c2e"
+ "33c0d1b8251b5d00"
]
]
},
{
- "id": "bb2825f419cc6526",
+ "id": "89d1bfb7839c2077",
"type": "function",
- "z": "35d7387466dd0bc0",
- "name": "start acquisition",
- "func": "const acq_interframe_volume = global.get(\"acq_interframe_volume\") || 0;\nconst acq_nb_frame = global.get(\"acq_nb_frame\") || 0;\n\n\n// Crée le payload final\nmsg.payload = {\n action: \"image\",\n pump_direction: \"FORWARD\",\n volume: acq_interframe_volume,\n nb_frame: acq_nb_frame,\n sleep: 0.1\n};\n\nreturn msg;\n",
+ "z": "0fd76ac156d78937",
+ "name": "add process_min_ESD",
+ "func": "// Ensure msg.payload and msg.payload.settings exist\nmsg.payload = msg.payload || {};\n\n// Read the variable from global context\nconst process_min_ESD = global.get(\"process_min_ESD\");\n\n// Add it to the payload settings\nmsg.payload.process_min_ESD = process_min_ESD;\nmsg.payload.open_dialog = true;\n\n// Return the modified message\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
- "x": 760,
- "y": 280,
+ "x": 680,
+ "y": 140,
"wires": [
[
- "ce5cca3b8fe34379"
+ "7fde0897e436c334"
]
]
},
{
- "id": "ce5cca3b8fe34379",
- "type": "delay",
- "z": "35d7387466dd0bc0",
- "name": "",
- "pauseType": "delay",
- "timeout": "1",
- "timeoutUnits": "seconds",
- "rate": "1",
- "nbRateUnits": "1",
- "rateUnits": "second",
- "randomFirst": "1",
- "randomLast": "5",
- "randomUnits": "seconds",
- "drop": false,
- "allowrate": false,
- "outputs": 1,
- "x": 920,
- "y": 280,
+ "id": "7fde0897e436c334",
+ "type": "ui-template",
+ "z": "0fd76ac156d78937",
+ "group": "bfd4acb7b243514f",
+ "page": "",
+ "ui": "",
+ "name": "Dialog",
+ "order": 2,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n \n Segmentation Required\n \n\n \n \n The selected sample has not been segmented yet. \n Do you want to start segmentation now?\n
\n\n \n\n \n \n Project: {{ payload.project_name }} \n \n \n Sample: {{ payload.sample_id }} \n \n \n Acquisition: {{ payload.acquisition_id }} \n \n \n Operator: {{ payload.operator_name }} \n \n \n Images acquired: {{ payload.image_acquired_count }} \n \n \n Path: {{ payload.path }} \n \n \n\n \n\n \n \n\n \n \n \n Cancel\n \n \n Segment\n \n \n \n \n \n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 870,
+ "y": 140,
"wires": [
[
- "46b98c54eb680c2e"
+ "24c71b69e60e41bd"
]
]
},
{
- "id": "250979b4672d81b6",
- "type": "ui-event",
+ "id": "e23cbd161ffb9970",
+ "type": "ui-template",
"z": "0fd76ac156d78937",
+ "group": "bfd4acb7b243514f",
+ "page": "",
+ "ui": "",
+ "name": "List of Acquisitions",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n\n \n \n \n\n \n \n {{ item.project_name }}\n \n \n\n \n \n {{ item.sample_id }}\n \n \n\n \n \n {{ item.acquisition_id }}\n \n \n\n \n \n {{ item.operator_name }}\n \n \n\n \n \n mdi-image-outline \n \n {{ Number(item.image_acquired_count || 0).toLocaleString() }}\n \n
\n \n\n \n \n mdi-folder-image \n Gallery\n \n \n\n \n \n \n mdi-vector-polyline \n Segment\n \n\n \n mdi-check \n Done\n \n
\n \n\n \n \n
\n
No acquisitions found
\n
\n \n\n \n \n\n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 310,
+ "y": 140,
+ "wires": [
+ [
+ "cdb586337f186494"
+ ]
+ ]
+ },
+ {
+ "id": "77bb783e65c3ffd6",
+ "type": "ui-event",
+ "z": "14f8c9b5ce1235cc",
"ui": "e6ae26617c24c3ea",
"name": "UI Event",
"x": 80,
"y": 40,
"wires": [
[
- "72421c36bb0418bc"
+ "90c920ecdab16908",
+ "384aa9ab83bfe566"
]
]
},
{
- "id": "72421c36bb0418bc",
+ "id": "90c920ecdab16908",
"type": "switch",
- "z": "0fd76ac156d78937",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
+ "z": "14f8c9b5ce1235cc",
+ "name": "msg.payload.page.path === \"/visualization\"",
+ "property": "payload.page.path",
"propertyType": "msg",
"rules": [
{
"t": "eq",
- "v": "$pageview",
+ "v": "/visualization",
"vt": "str"
}
],
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 280,
+ "x": 330,
+ "y": 40,
+ "wires": [
+ [
+ "164baa371893de7a",
+ "543715b9199cdb72"
+ ]
+ ]
+ },
+ {
+ "id": "75b694eff7cf6747",
+ "type": "ui-template",
+ "z": "14f8c9b5ce1235cc",
+ "group": "1d3abb201c51ff47",
+ "page": "",
+ "ui": "",
+ "name": "Navigation Bottom",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Segmentation\n \n \n \n \n \n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 1290,
+ "y": 180,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "fe2e976833fd41a9",
+ "type": "ui-template",
+ "z": "14f8c9b5ce1235cc",
+ "group": "b570f76ef526af45",
+ "page": "",
+ "ui": "",
+ "name": "Navigation Top",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n \n \n \n mdi-chevron-left \n Segmentation\n \n \n\n \n \n Visualization\n \n \n\n \n \n \n \n \n ",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 1300,
"y": 40,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "164baa371893de7a",
+ "type": "list segmentations",
+ "z": "14f8c9b5ce1235cc",
+ "name": "",
+ "x": 170,
+ "y": 180,
"wires": [
[
- "66d295cc27d69cec"
+ "18a10804663dbaa1",
+ "72c774fc51af957b"
]
]
},
{
- "id": "66d295cc27d69cec",
- "type": "switch",
- "z": "0fd76ac156d78937",
- "name": "msg.payload.page.path === \"/segmentation\"",
- "property": "payload.page.path",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "/segmentation",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
+ "id": "18a10804663dbaa1",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "Insert export column",
+ "func": "msg.payload = msg.payload.map(item => {\n // extraction de l'acquisition_id\n const acq = item.acquisition_id; // ex: \"A_2\"\n\n // création du chemin export\n item.export = `/ps/data/browse/api/raw/export/ecotaxa/ecotaxa_${acq}.zip`;\n\n return item;\n});\nreturn msg;\n",
"outputs": 1,
- "x": 590,
- "y": 40,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 440,
+ "y": 180,
"wires": [
[
- "d4129bf3f5622aa6",
- "921be0438de344ac"
+ "92e2ed9f115c3991"
]
]
},
{
- "id": "61c2c281b1c6e055",
- "type": "ui-table",
- "z": "0fd76ac156d78937",
- "group": "bfd4acb7b243514f",
- "name": "List of acq",
- "label": "",
- "order": 1,
- "width": 0,
- "height": 0,
- "maxrows": "100",
- "passthru": false,
- "autocols": false,
- "showSearch": false,
- "deselect": true,
- "selectionType": "click",
- "columns": [
- {
- "title": "Project name",
- "key": "project_name",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Sample ID",
- "key": "sample_id",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Acquisition ID",
- "key": "acquisition_id",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Operator Name",
- "key": "operator_name",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Number of images",
- "key": "image_acquired_count",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Path",
- "key": "path",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Segmented",
- "key": "is_segmented",
- "keyType": "key",
- "type": "tickcross",
- "width": "",
- "align": "start"
- }
- ],
- "mobileBreakpoint": "sm",
- "mobileBreakpointType": "defaults",
- "action": "replace",
- "x": 330,
- "y": 140,
+ "id": "5ec998719a884cfe",
+ "type": "file in",
+ "z": "14f8c9b5ce1235cc",
+ "name": "",
+ "filename": "payload.path",
+ "filenameType": "msg",
+ "format": "utf8",
+ "chunk": false,
+ "sendError": false,
+ "encoding": "none",
+ "allProps": false,
+ "x": 180,
+ "y": 380,
"wires": [
[
- "cdb586337f186494"
+ "4caf5c846b3b9d01",
+ "a5ef1df890e7bb36",
+ "8cdf3c5527b9cb21",
+ "e26832811411f850",
+ "448a5c6830c6514f",
+ "ac9d555964747130",
+ "11323481847790c0",
+ "8425012e07c3700f",
+ "512e86195d14e773",
+ "9be1cc713ac79a7b",
+ "63ce87d36ae8f172",
+ "9abf70a949e76eaf"
]
]
},
{
- "id": "d4129bf3f5622aa6",
- "type": "list acquisitions",
- "z": "0fd76ac156d78937",
- "name": "",
- "x": 100,
- "y": 140,
+ "id": "aefbf6f96248c398",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "Get tsv path",
+ "func": "// 1. On s'assure que le path existe\nif (msg.payload.path) {\n \n // 2. On récupère le path actuel\n // ex: \"/home/pi/data/objects/2025-11-19/S_1/A_2\"\n const currentPath = msg.payload.path;\n\n // 3. On extrait l'identifiant (le dernier élément après le /)\n // Si le path finit par un /, on le retire d'abord pour éviter un ID vide\n const cleanPath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath;\n const acqId = cleanPath.split('/').pop(); \n\n // 4. On modifie le path dans l'objet\n // Résultat: .../A_2/ecotaxa_A_2.tsv\n msg.payload.path = `${cleanPath}/ecotaxa_${acqId}.tsv`;\n}\n\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 930,
+ "y": 180,
"wires": [
[
- "61c2c281b1c6e055"
+ "9a10d9afb1a0b1ca"
]
]
},
{
- "id": "9e44bf9a44615e20",
+ "id": "4244d2ed880cca66",
"type": "ui-template",
- "z": "0fd76ac156d78937",
- "group": "9f00807878a32dd5",
+ "z": "14f8c9b5ce1235cc",
+ "group": "0c1537ce71affc2b",
"page": "",
"ui": "",
- "name": "Navigation Bottom",
+ "name": "Gallery",
"order": 1,
"width": 0,
"height": 0,
"head": "",
- "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Acquisition\n \n \n\n \n \n \n Visualization\n mdi-chevron-right \n \n \n \n \n \n",
+ "format": "\n \n \n \n
\n \n \n \n
\n
\n Zoom x{{ zoom.toFixed(1) }}\n
\n
\n \n\n \n \n \n\n \n \n \n\n \n \n Total: {{ filteredItems.length }}\n \n \n \n\n\n \n\n\n \n \n
\n\n\n \n \n\n \n Object Details \n \n \n\n \n \n \n \n \n \n
\n \n
\n
\n {{ getScaleBar(selectedItem, '%').label }}\n
\n\n
\n\n \n ID: {{ selectedItem.id }} \n
\n \n\n \n \n \n Metric Value \n \n \n \n {{ formatKeyName(key) }} \n {{ value }} \n \n \n \n \n \n \n\n \n \n\n \n\n\n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 1510,
- "y": 160,
+ "x": 920,
+ "y": 1000,
"wires": [
[]
]
},
{
- "id": "d11906e048438931",
+ "id": "92e2ed9f115c3991",
"type": "ui-template",
- "z": "0fd76ac156d78937",
- "group": "9b992ca6515ed058",
+ "z": "14f8c9b5ce1235cc",
+ "group": "fa6393a7d7e3b7d7",
"page": "",
"ui": "",
- "name": "Navigation Top",
+ "name": "List of Segmentation",
"order": 1,
"width": 0,
"height": 0,
"head": "",
- "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Acquisition\n \n \n\n \n \n \n Visualization\n mdi-chevron-right \n \n \n \n \n \n",
+ "format": "\n \n \n \n \n \n \n\n \n \n \n {{ item.project_name }}\n \n \n\n \n \n \n {{ item.sample_id }}\n \n \n\n \n \n \n {{ item.acquisition_id }}\n \n \n\n \n \n \n mdi-image-outline \n \n {{ Number(item.image_acquired_count || 0).toLocaleString() }}\n \n
\n \n\n \n \n \n mdi-image-multiple \n Gallery\n \n \n\n \n \n \n mdi-download \n .zip\n \n \n\n \n \n \n mdi-chart-scatter-plot-hexbin \n Visualize\n \n \n\n \n \n \n
\n
No acquisitions found
\n
\n \n\n \n \n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 1520,
- "y": 40,
+ "x": 740,
+ "y": 180,
+ "wires": [
+ [
+ "aefbf6f96248c398"
+ ]
+ ]
+ },
+ {
+ "id": "2ca2f83cc939607f",
+ "type": "ui-template",
+ "z": "14f8c9b5ce1235cc",
+ "group": "53490f9c39e2065d",
+ "page": "",
+ "ui": "",
+ "name": "Infos",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n\n \n
\n\n \n
\n mdi-information-outline \n Select a dataset in the table above to display its information.\n
\n\n \n
\n\n \n \n Sample Identity\n
\n\n \n {{ meta.sample_id }}\n
\n\n \n Project: {{ meta.project || '--' }} \n Acq ID: {{ meta.acq_id || '--' }} \n
\n \n\n \n \n mdi-download \n Download EcoTaxa .zip\n \n \n\n \n
\n \n\n\n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 910,
+ "y": 440,
"wires": [
[]
]
},
{
- "id": "4bdd8a4afcdb704b",
- "type": "mqtt out",
- "z": "0fd76ac156d78937",
- "name": "MQTT",
- "topic": "",
- "qos": "",
- "retain": "",
- "respTopic": "",
- "contentType": "",
- "userProps": "",
- "correl": "",
- "expiry": "",
- "broker": "8dc3722c.06efa8",
- "x": 1150,
- "y": 140,
- "wires": []
+ "id": "4caf5c846b3b9d01",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "HEATMAP (object_x, object_y)",
+ "func": "// Heatmap – Flowcell distribution\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\n// Meta from first valid row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nif (!firstRow) return null;\n\nconst meta = {\n sample_id: val(firstRow, \"sample_id\") || \"\",\n project: val(firstRow, \"sample_project\") || \"\",\n acq_id: val(firstRow, \"acq_id\") || \"\"\n};\n\n// Data: x/y for heatmap\nconst data = lines.slice(1)\n .filter(l => !l.trim().startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n x: parseFloat(val(r, \"object_x\")),\n y: parseFloat(val(r, \"object_y\"))\n };\n });\n\nmsg.payload = { meta, data };\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 510,
+ "y": 520,
+ "wires": [
+ [
+ "0c90eb450c8d9b01"
+ ]
+ ]
},
{
- "id": "24c71b69e60e41bd",
- "type": "switch",
- "z": "0fd76ac156d78937",
- "name": "",
- "property": "topic",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "segmenter/segment",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
+ "id": "a5ef1df890e7bb36",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "ESD Histogram (object_equivalent_diameter)",
+ "func": "// ESD Size Spectrum – TSV → {meta, data[{d}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nconst meta = {\n sample_id: firstRow ? (val(firstRow, \"sample_id\") || \"\") : \"\"\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n d: parseFloat(val(r, \"object_equivalent_diameter\"))\n };\n });\n\nmsg.payload = { meta, data };\nreturn msg;\n",
"outputs": 1,
- "x": 1030,
- "y": 140,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 550,
+ "y": 560,
"wires": [
[
- "4bdd8a4afcdb704b",
- "f8f4b34842d35713"
+ "0a559ab05af2548e"
]
]
},
{
- "id": "34f6f23f1a46da67",
- "type": "mqtt in",
- "z": "0fd76ac156d78937",
- "name": "",
- "topic": "status/segmenter",
- "qos": "0",
- "datatype": "json",
- "broker": "8dc3722c.06efa8",
- "nl": false,
- "rap": false,
- "inputs": 0,
- "x": 100,
- "y": 260,
+ "id": "8cdf3c5527b9cb21",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "TIMELINE (sequence index + area)",
+ "func": "// Timeline – seq index vs object_area\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nlet seq = 0;\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n seq: seq++,\n area: parseFloat(val(r, \"object_area\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 520,
+ "y": 600,
+ "wires": [
+ [
+ "7b9d103ec5981051"
+ ]
+ ]
+ },
+ {
+ "id": "e26832811411f850",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "COLORSPACE (Saturation vs Value)",
+ "func": "// Colorspace – MeanSaturation vs MeanValue\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n s: parseFloat(val(r, \"object_MeanSaturation\")),\n v: parseFloat(val(r, \"object_MeanValue\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 530,
+ "y": 640,
+ "wires": [
+ [
+ "23a78a078fa1eca8"
+ ]
+ ]
+ },
+ {
+ "id": "448a5c6830c6514f",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "ASPECT (Width vs Height)",
+ "func": "// Aspect – width vs height\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n w: parseFloat(val(r, \"object_width\")),\n h: parseFloat(val(r, \"object_height\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 500,
+ "y": 680,
+ "wires": [
+ [
+ "09f4914fc0a54566"
+ ]
+ ]
+ },
+ {
+ "id": "ac9d555964747130",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "GREENNESS (custom index + circularity)",
+ "func": "// Greenness vs Circularity – custom_greenness + object_circ.\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n const hue = parseFloat(val(r, \"object_MeanHue\")) || 0;\n const circ = parseFloat(val(r, \"object_circ.\")) || 0;\n const greenDist = Math.abs(hue - 80);\n const g = Math.max(0, 100 - greenDist);\n return { g, circ };\n });\n\nmsg.payload = { data };\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 540,
+ "y": 720,
"wires": [
[
- "33c0d1b8251b5d00",
- "eed6aba5c7ab3b6c"
+ "7f4d047a4c73eb61"
]
]
},
{
- "id": "33c0d1b8251b5d00",
- "type": "ui-template",
- "z": "0fd76ac156d78937",
- "group": "402b3d24c87ab0d2",
- "page": "",
- "ui": "",
- "name": "Details of Segmentation",
- "order": 1,
- "width": "0",
- "height": "0",
- "head": "",
- "format": "\n \n \n \n \n Project: {{ segment.dataset.project_name }}\n \n \n \n \n Sample: {{ segment.dataset.sample_id }}\n \n \n \n \n Acquisition: {{ segment.dataset.acquisition_id }}\n \n \n \n \n Path: {{ segment.dataset.path }}\n \n \n \n\n \n\n \n \n Error: {{ errorMessage }}\n \n\n \n \n {{ infoMessage }}\n \n\n \n \n
\n Progress \n {{ progressText }} \n
\n\n
\n\n
\n Currently segmenting:\n {{ currentImage }} \n
\n
\n\n \n Awaiting segmentation status...\n
\n \n\n\n\n",
- "storeOutMessages": true,
- "passthru": true,
- "resendOnRefresh": true,
- "templateScope": "local",
- "className": "",
- "x": 610,
- "y": 240,
- "wires": [
- []
- ]
- },
- {
- "id": "cdb586337f186494",
- "type": "switch",
- "z": "0fd76ac156d78937",
- "name": "",
- "property": "payload.is_segmented",
- "propertyType": "msg",
- "rules": [
- {
- "t": "true"
- },
- {
- "t": "false"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 2,
- "x": 510,
- "y": 140,
+ "id": "11323481847790c0",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "COMPLEXITY (Area vs Perimeter)",
+ "func": "// Complexity – Area vs Perimeter\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n area: parseFloat(val(r, \"object_area\")),\n per: parseFloat(val(r, \"object_perim.\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 520,
+ "y": 760,
"wires": [
- [],
[
- "89d1bfb7839c2077"
+ "a7c8654291022da1"
]
]
},
{
- "id": "f8f4b34842d35713",
+ "id": "8425012e07c3700f",
"type": "function",
- "z": "0fd76ac156d78937",
- "name": "set seg_params",
- "func": "if (msg.topic) {\n global.set(\"seg_project_name\", msg.payload.dataset.project_name);\n global.set(\"seg_sample_id\", msg.payload.dataset.sample_id);\n global.set(\"seg_acquisition_id\", msg.payload.dataset.acquisition_id);\n global.set(\"seg_path\", msg.payload.dataset.path);\n global.set(\"process_min_ESD\", msg.payload.settings.process_min_ESD);\n}\nreturn msg;\n",
+ "z": "14f8c9b5ce1235cc",
+ "name": "TEXTURE (Area vs StdValue)",
+ "func": "// Texture – Area vs StdValue\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n area: parseFloat(val(r, \"object_area\")),\n std: parseFloat(val(r, \"object_StdValue\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n",
"outputs": 1,
- "timeout": "",
+ "timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
- "x": 1180,
- "y": 100,
+ "x": 500,
+ "y": 800,
"wires": [
- []
+ [
+ "ed9d6e31cc3a01b1"
+ ]
]
},
{
- "id": "921be0438de344ac",
+ "id": "512e86195d14e773",
"type": "function",
- "z": "0fd76ac156d78937",
- "name": "Get Global Variables",
- "func": "const keys = global.keys(); // Get all global variable keys\nmsg.payload = {}; // Initialize the payload object\n\nkeys.forEach(key => {\n // Ignore keys that start with \"$\"\n if (!key.startsWith('$')) {\n msg.payload[key] = global.get(key);\n }\n});\n\nreturn msg;\n",
+ "z": "14f8c9b5ce1235cc",
+ "name": "SOLIDITY (Histogram)",
+ "func": "// Solidity – histogram of object_solidity\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\nconst data = lines.slice(1)\n .filter(l => !l.startsWith(\"[\"))\n .map(l => {\n const r = l.split(\"\\t\");\n return {\n sol: parseFloat(val(r, \"object_solidity\"))\n };\n });\n\nmsg.payload = { data };\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
- "x": 120,
- "y": 200,
+ "x": 480,
+ "y": 840,
"wires": [
[
- "33c0d1b8251b5d00"
+ "c15ef66d7e115bd3"
]
]
},
{
- "id": "89d1bfb7839c2077",
+ "id": "0c90eb450c8d9b01",
+ "type": "ui-template",
+ "z": "14f8c9b5ce1235cc",
+ "group": "90b33e458dd29d04",
+ "page": "",
+ "ui": "",
+ "name": "Heatmap",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n\n\n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 920,
+ "y": 520,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "9be1cc713ac79a7b",
"type": "function",
- "z": "0fd76ac156d78937",
- "name": "add process_min_ESD",
- "func": "// Ensure msg.payload and msg.payload.settings exist\nmsg.payload = msg.payload || {};\n\n// Read the variable from global context\nconst process_min_ESD = global.get(\"process_min_ESD\");\n\n// Add it to the payload settings\nmsg.payload.process_min_ESD = process_min_ESD;\nmsg.payload.open_dialog = true;\n\n// Return the modified message\nreturn msg;\n",
+ "z": "14f8c9b5ce1235cc",
+ "name": "Sample Identity Metadata Only",
+ "func": "// FUNCTION: Extract minimal metadata for the “Sample Identity” panel\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const idx = headers.indexOf(col);\n return idx >= 0 ? row[idx] : null;\n};\n\n// --- 1. Find first data row (skip [f] / [t] metadata-like lines) ---\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\nif (!firstRow) return null;\n\n// --- 2. Extract minimal metadata ---\nconst meta = {\n sample_id: val(firstRow, \"sample_id\") || \"\",\n project: val(firstRow, \"sample_project\") || \"\",\n acq_id: val(firstRow, \"acq_id\") || \"\"\n};\n\n// --- 3. Output ONLY the metadata ---\nmsg.payload = { meta };\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
- "x": 680,
- "y": 140,
+ "x": 510,
+ "y": 440,
"wires": [
[
- "7fde0897e436c334"
+ "2ca2f83cc939607f"
]
]
},
{
- "id": "eed6aba5c7ab3b6c",
- "type": "debug",
- "z": "0fd76ac156d78937",
- "name": "debug 5",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 320,
- "y": 460,
- "wires": []
+ "id": "0a559ab05af2548e",
+ "type": "ui-template",
+ "z": "14f8c9b5ce1235cc",
+ "group": "47800358cf7cee25",
+ "page": "",
+ "ui": "",
+ "name": "ESD Histogram",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 940,
+ "y": 560,
+ "wires": [
+ []
+ ]
},
{
- "id": "7fde0897e436c334",
+ "id": "7b9d103ec5981051",
"type": "ui-template",
- "z": "0fd76ac156d78937",
- "group": "",
- "page": "7a4e042a60b734a6",
+ "z": "14f8c9b5ce1235cc",
+ "group": "1d9c6927a0e0a71b",
+ "page": "",
"ui": "",
- "name": "Dialog",
- "order": 2,
+ "name": "Timeline",
+ "order": 1,
"width": 0,
"height": 0,
"head": "",
- "format": "\n \n \n \n \n Segmentation Required\n \n\n \n \n The selected sample has not been segmented yet. \n Do you want to start segmentation now?\n
\n\n \n\n \n \n Project: {{ payload.project_name }} \n \n \n Sample: {{ payload.sample_id }} \n \n \n Acquisition: {{ payload.acquisition_id }} \n \n \n Operator: {{ payload.operator_name }} \n \n \n Images acquired: {{ payload.image_acquired_count }} \n \n \n Path: {{ payload.path }} \n \n \n\n \n\n \n \n \n\n \n \n \n Cancel\n \n \n Segment\n \n \n \n \n \n\n\n",
+ "format": "\n \n \n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
- "templateScope": "widget:page",
+ "templateScope": "local",
"className": "",
- "x": 850,
- "y": 140,
+ "x": 920,
+ "y": 600,
"wires": [
- [
- "24c71b69e60e41bd"
- ]
+ []
]
},
{
- "id": "77bb783e65c3ffd6",
- "type": "ui-event",
+ "id": "23a78a078fa1eca8",
+ "type": "ui-template",
"z": "14f8c9b5ce1235cc",
- "ui": "e6ae26617c24c3ea",
- "name": "UI Event",
- "x": 80,
- "y": 40,
+ "group": "03ad1c8f517e8769",
+ "page": "",
+ "ui": "",
+ "name": "Colorspace (Saturation vs Value)",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n\n\n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 990,
+ "y": 640,
"wires": [
- [
- "55814595d9207e95"
- ]
+ []
]
},
{
- "id": "55814595d9207e95",
- "type": "switch",
+ "id": "09f4914fc0a54566",
+ "type": "ui-template",
"z": "14f8c9b5ce1235cc",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "$pageview",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 1,
- "x": 280,
- "y": 40,
+ "group": "0be15f8190e6fd43",
+ "page": "",
+ "ui": "",
+ "name": "Aspect (Width vs Height)",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n\n\n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 970,
+ "y": 680,
"wires": [
- [
- "90c920ecdab16908"
- ]
+ []
]
},
{
- "id": "90c920ecdab16908",
- "type": "switch",
+ "id": "7f4d047a4c73eb61",
+ "type": "ui-template",
"z": "14f8c9b5ce1235cc",
- "name": "msg.payload.page.path === \"/visualization\"",
- "property": "payload.page.path",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "/visualization",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 1,
- "x": 590,
- "y": 40,
+ "group": "1edc962c44888abc",
+ "page": "",
+ "ui": "",
+ "name": "Greenness",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n\n\n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 930,
+ "y": 720,
"wires": [
- [
- "164baa371893de7a"
- ]
+ []
+ ]
+ },
+ {
+ "id": "a7c8654291022da1",
+ "type": "ui-template",
+ "z": "14f8c9b5ce1235cc",
+ "group": "67c63cc92c23c4b1",
+ "page": "",
+ "ui": "",
+ "name": "Complexity (Area vs Perimeter)",
+ "order": 1,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": "\n \n \n\n\n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 990,
+ "y": 760,
+ "wires": [
+ []
]
},
{
- "id": "75b694eff7cf6747",
+ "id": "ed9d6e31cc3a01b1",
"type": "ui-template",
"z": "14f8c9b5ce1235cc",
- "group": "1d3abb201c51ff47",
+ "group": "95a152a68b7ba779",
"page": "",
"ui": "",
- "name": "Navigation Bottom",
+ "name": "Texture (Area vs Std)",
"order": 1,
"width": 0,
"height": 0,
"head": "",
- "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Segmentation\n \n \n \n \n \n",
+ "format": "\n \n \n\n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 1290,
- "y": 160,
+ "x": 960,
+ "y": 800,
"wires": [
[]
]
},
{
- "id": "fe2e976833fd41a9",
+ "id": "c15ef66d7e115bd3",
"type": "ui-template",
"z": "14f8c9b5ce1235cc",
- "group": "b570f76ef526af45",
+ "group": "1baa943e505febf1",
"page": "",
"ui": "",
- "name": "Navigation Top",
+ "name": "Solidity Histogram",
"order": 1,
"width": 0,
"height": 0,
"head": "",
- "format": "\n\n \n \n \n \n \n mdi-chevron-left \n Segmentation\n \n \n\n \n \n \n",
+ "format": "\n \n \n\n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 1300,
- "y": 40,
+ "x": 950,
+ "y": 840,
"wires": [
[]
]
},
{
- "id": "164baa371893de7a",
- "type": "list segmentations",
+ "id": "63ce87d36ae8f172",
+ "type": "function",
"z": "14f8c9b5ce1235cc",
- "name": "",
- "x": 170,
- "y": 180,
+ "name": "EXPLORER",
+ "func": "// Explorer Function Node\n// TSV → {meta, keys, data: [{id, url, ...keys}]}\n\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) return null;\n\n// 1. Parse Headers\nconst headers = lines[0].split(\"\\t\");\nconst val = (row, col) => {\n const i = headers.indexOf(col);\n return i >= 0 ? row[i] : null;\n};\n\n// 2. Extract Meta from first valid data row\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n // Skip rows starting with '[' (metadata comments)\n if (!lines[i].trim().startsWith(\"[\")) {\n firstRow = lines[i].split(\"\\t\");\n break;\n }\n}\n\n// Default meta\nconst meta = {\n sample_id: \"\",\n project: \"\",\n acq_id: \"\",\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = val(firstRow, \"sample_id\") || \"\";\n meta.project = val(firstRow, \"sample_project\") || \"\";\n meta.acq_id = val(firstRow, \"acq_id\") || \"\";\n \n // CRITICAL: Extract pixel size (microns per pixel)\n // If process_pixel is missing or 0, default to 1 to prevent division by zero\n const px = parseFloat(val(firstRow, \"process_pixel\"));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 3. Explorer Keys to extract\nconst explorerKeys = [\n \"object_area\", \"object_equivalent_diameter\", \"object_perim.\",\n \"object_major\", \"object_minor\", \"object_width\", \"object_height\",\n \"object_circ.\", \"object_elongation\", \"object_solidity\",\n \"object_eccentricity\", \"object_MeanHue\", \"object_MeanSaturation\",\n \"object_MeanValue\", \"object_StdValue\"\n];\n\nlet seq = 0;\nconst data = [];\n\n// 4. Process Data Lines\nfor (let i = 1; i < lines.length; i++) {\n const line = lines[i];\n if (line.trim().startsWith(\"[\")) continue;\n\n const row = line.split(\"\\t\");\n\n const item = {\n id: val(row, \"object_id\"),\n // Construct URL\n url: `/ps/data/browse/api/preview/big/objects/${val(row,\"object_date\")}/${val(row,\"sample_id\")}/${val(row,\"acq_id\")}/${val(row,\"img_file_name\")}`,\n sequence_index: seq++\n };\n\n // Parse numeric keys\n explorerKeys.forEach(k => {\n let v = parseFloat(val(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n // Custom calc for greenness (example)\n const hue = item.object_MeanHue || 0;\n const dist = Math.abs(hue - 80);\n item.custom_greenness = Math.max(0, 100 - dist);\n\n data.push(item);\n}\n\nmsg.payload = {\n meta,\n keys: explorerKeys,\n data\n};\n\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 450,
+ "y": 920,
"wires": [
[
- "18a10804663dbaa1"
+ "d57a5bca66c04989"
]
]
},
{
- "id": "6532974092861ef7",
- "type": "ui-table",
+ "id": "d57a5bca66c04989",
+ "type": "ui-template",
"z": "14f8c9b5ce1235cc",
- "group": "fa6393a7d7e3b7d7",
- "name": "List of acq",
- "label": "",
+ "group": "c5651e1a3e56f3f5",
+ "page": "",
+ "ui": "",
+ "name": "Explorer",
"order": 1,
"width": 0,
"height": 0,
- "maxrows": "10",
- "passthru": false,
- "autocols": false,
- "showSearch": true,
- "deselect": true,
- "selectionType": "click",
- "columns": [
- {
- "title": "Project name",
- "key": "project_name",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Sample ID",
- "key": "sample_id",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Acquisition ID",
- "key": "acquisition_id",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Number of objects",
- "key": "image_acquired_count",
- "keyType": "key",
- "type": "text",
- "width": "",
- "align": "start"
- },
- {
- "title": "Gallery",
- "key": "gallery",
- "keyType": "key",
- "type": "link",
- "width": "",
- "align": "start"
- },
- {
- "title": "Download",
- "key": "export",
- "keyType": "key",
- "type": "link",
- "width": "",
- "align": "start"
- }
- ],
- "mobileBreakpoint": "sm",
- "mobileBreakpointType": "defaults",
- "action": "replace",
- "x": 650,
- "y": 180,
+ "head": "",
+ "format": "\n \n \n \n \n\n \n \n \n\n \n \n Load Plot\n \n \n \n\n\n \n\n \n \n
\n
\n \n \n\n \n \n\n \n\n \n\n
\n\n
\n
\n mdi-cursor-default-click-outline\n \n
\n Hover over a point to view details\n
\n
\n\n
\n\n
\n \n \n \n
\n \n \n\n
\n
ID: {{ hovered.id }} \n\n
\n
\n
{{ formattedScale }}
\n
\n
\n \n\n
\n
\n\n \n \n\n \n\n\n \n
\n
\n ID: {{ tooltip.id }}\n
\n
\n\n \n\n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 920,
+ "y": 920,
"wires": [
[]
]
},
{
- "id": "18a10804663dbaa1",
+ "id": "384aa9ab83bfe566",
"type": "function",
"z": "14f8c9b5ce1235cc",
- "name": "function 1",
- "func": "msg.payload = msg.payload.map(item => {\n // extraction de l'acquisition_id\n const acq = item.acquisition_id; // ex: \"A_2\"\n\n // création du chemin export\n item.export = `/ps/data/browse/files/export/ecotaxa/ecotaxa_${acq}.zip`;\n\n return item;\n});\n\nreturn msg;\n",
+ "name": "Clearing plots",
+ "func": "// This function clears the plot by sending an empty data array.\n// Any template using msg.payload.data will immediately purge the plot.\n\nmsg.payload = {\n data: []\n};\n\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
- "x": 360,
- "y": 180,
+ "x": 580,
+ "y": 380,
"wires": [
[
- "6532974092861ef7"
+ "0c90eb450c8d9b01",
+ "0a559ab05af2548e",
+ "7b9d103ec5981051",
+ "23a78a078fa1eca8",
+ "09f4914fc0a54566",
+ "7f4d047a4c73eb61",
+ "a7c8654291022da1",
+ "ed9d6e31cc3a01b1",
+ "c15ef66d7e115bd3",
+ "2ca2f83cc939607f",
+ "d57a5bca66c04989",
+ "4244d2ed880cca66"
]
]
},
{
- "id": "64ae825a687fb054",
- "type": "ecotaxa",
- "z": "8555b76c53e789e0",
- "name": "Import to Ecotaxa Project",
- "api_url": "https://ecotaxa.obs-vlfr.fr/api/",
- "project_id": "9366",
- "x": 830,
- "y": 300,
+ "id": "9abf70a949e76eaf",
+ "type": "function",
+ "z": "14f8c9b5ce1235cc",
+ "name": "GALLERY",
+ "func": "// GALLERY Function Node\n// TSV → { data: [], meta: { resolution: ... }, keys: ... }\n\n// 1. Handle Clear Signals\nif (msg.clear === true || (msg.payload && msg.payload.clear === true)) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 2. Validate Payload\nif (!msg.payload) return null;\nconst lines = msg.payload.toString().trim().split(/\\r?\\n/);\nif (lines.length < 2) {\n msg.payload = { data: [], meta: { resolution: 1 }, keys: [] };\n return msg;\n}\n\n// 3. Parse Headers\nconst headers = lines[0].split('\\t');\nconst get = (row, key) => {\n const idx = headers.indexOf(key);\n return idx >= 0 ? row[idx] : null;\n};\n\n// 4. Extract Meta (Resolution is key here)\nlet firstRow = null;\nfor (let i = 1; i < lines.length; i++) {\n const L = lines[i].trim();\n if (!L.startsWith('[') && L !== '') {\n firstRow = lines[i].split('\\t');\n break;\n }\n}\n\nconst meta = {\n sample_id: 'N/A',\n project: 'N/A',\n acq_id: 'N/A',\n resolution: 1.0 // Default fallback\n};\n\nif (firstRow) {\n meta.sample_id = get(firstRow, 'sample_id') || \"\";\n meta.project = get(firstRow, 'sample_project') || \"\";\n meta.acq_id = get(firstRow, 'acq_id') || \"\";\n\n // CRITICAL: Extract microns per pixel\n const px = parseFloat(get(firstRow, 'process_pixel'));\n if (!isNaN(px) && px > 0) {\n meta.resolution = px;\n }\n}\n\n// 5. Select Keys to Display\nconst usefulKeys = [\n \"object_area\", \"object_width\", \"object_height\",\n \"object_equivalent_diameter\", \"object_major\", \"object_minor\",\n \"object_MeanHue\", \"object_elongation\"\n];\n\nconst data = [];\n\n// 6. Process Rows\nfor (let i = 1; i < lines.length; i++) {\n const rowLine = lines[i].trim();\n if (rowLine.startsWith('[') || rowLine === '') continue;\n\n const row = rowLine.split('\\t');\n if (row.length !== headers.length) continue;\n\n const item = {\n id: get(row, 'object_id'),\n // Construct Image URL\n url: `/ps/data/browse/api/preview/big/objects/${get(row, 'object_date')}/${get(row, 'sample_id')}/${get(row, 'acq_id')}/${get(row, 'img_file_name')}`\n };\n\n // Parse numeric values\n usefulKeys.forEach(k => {\n let v = parseFloat(get(row, k));\n item[k] = isNaN(v) ? 0 : v;\n });\n\n data.push(item);\n}\n\nmsg.payload = {\n data,\n keys: usefulKeys,\n meta\n};\n\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 440,
+ "y": 1000,
"wires": [
[
- "c21f20a7a9902ee4"
+ "4244d2ed880cca66"
]
]
},
{
- "id": "af1ec6323cc00231",
- "type": "inject",
- "z": "8555b76c53e789e0",
- "name": "Lancer import",
- "props": [],
- "repeat": "",
- "crontab": "",
- "once": false,
- "onceDelay": "0",
- "topic": "",
- "x": 310,
+ "id": "d77ed8081a657507",
+ "type": "delay",
+ "z": "14f8c9b5ce1235cc",
+ "name": "",
+ "pauseType": "delay",
+ "timeout": "1",
+ "timeoutUnits": "seconds",
+ "rate": "1",
+ "nbRateUnits": "1",
+ "rateUnits": "second",
+ "randomFirst": "1",
+ "randomLast": "5",
+ "randomUnits": "seconds",
+ "drop": false,
+ "allowrate": false,
+ "outputs": 1,
+ "x": 180,
+ "y": 340,
+ "wires": [
+ [
+ "5ec998719a884cfe"
+ ]
+ ]
+ },
+ {
+ "id": "9e5c5ec670e343cf",
+ "type": "ui-template",
+ "z": "14f8c9b5ce1235cc",
+ "group": "",
+ "page": "d129fac8e7742d5b",
+ "ui": "",
+ "name": "CSS (This page)",
+ "order": 0,
+ "width": 0,
+ "height": 0,
+ "head": "",
+ "format": ".plot-container.plotly {\n height: 100%;\n}\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "page:style",
+ "className": "",
+ "x": 1300,
+ "y": 100,
+ "wires": [
+ []
+ ]
+ },
+ {
+ "id": "9a10d9afb1a0b1ca",
+ "type": "switch",
+ "z": "14f8c9b5ce1235cc",
+ "name": "",
+ "property": "topic",
+ "propertyType": "msg",
+ "rules": [
+ {
+ "t": "neq",
+ "v": "$pageview",
+ "vt": "str"
+ }
+ ],
+ "checkall": "true",
+ "repair": false,
+ "outputs": 1,
+ "x": 170,
"y": 300,
"wires": [
[
- "28f8eb0318735246"
+ "d77ed8081a657507"
]
]
},
{
- "id": "c21f20a7a9902ee4",
+ "id": "543715b9199cdb72",
"type": "debug",
- "z": "8555b76c53e789e0",
- "name": "Show import result",
+ "z": "14f8c9b5ce1235cc",
+ "name": "debug 5",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
- "complete": "payload",
- "targetType": "msg",
+ "complete": "false",
"statusVal": "",
"statusType": "auto",
- "x": 1110,
- "y": 300,
+ "x": 700,
+ "y": 40,
"wires": []
},
{
- "id": "28f8eb0318735246",
- "type": "function",
- "z": "8555b76c53e789e0",
- "name": "Set file_path",
- "func": "msg.payload = {}\nmsg.payload.file_path = \"/home/pi/data/export/ecotaxa/ecotaxa_A_2.zip\"\n\n\nreturn msg;",
- "outputs": 1,
- "timeout": 0,
- "noerr": 0,
- "initialize": "",
- "finalize": "",
- "libs": [],
- "x": 570,
- "y": 300,
- "wires": [
- [
- "64ae825a687fb054",
- "1457a0786f787415"
- ]
- ]
- },
- {
- "id": "1457a0786f787415",
+ "id": "72c774fc51af957b",
"type": "debug",
- "z": "8555b76c53e789e0",
- "name": "debug 9",
+ "z": "14f8c9b5ce1235cc",
+ "name": "debug 1",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
- "complete": "true",
- "targetType": "full",
+ "complete": "payload",
+ "targetType": "msg",
"statusVal": "",
"statusType": "auto",
- "x": 820,
- "y": 200,
+ "x": 400,
+ "y": 140,
"wires": []
},
{
@@ -4149,8 +5245,8 @@
"ui": "",
"name": "body",
"order": 1,
- "width": "12",
- "height": "6",
+ "width": "0",
+ "height": "0",
"head": "",
"format": "\n \n \n \n \n \n mdi-eyedropper-variant \n White Balance \n \n Use the color picker to measure the current color values and adjust the white balance accordingly.\n \n \n \n\n \n \n \n mdi-brightness-6 \n Lightness \n \n Measure the current lightness value and update the LED intensity or ISO if below optimal levels.\n \n \n \n\n \n \n \n mdi-ruler \n Pixel Size \n \n Place markers on the micrometric ruler image to calculate the pixel size in microns per pixel.\n \n \n \n\n \n \n \n mdi-water-pump \n Pump Calibration \n \n Measure the transferred volume and adjust pump steps to improve accuracy in fluid handling.\n \n \n \n \n \n \n",
"storeOutMessages": true,
@@ -4237,31 +5333,6 @@
"name": "UI Event",
"x": 80,
"y": 40,
- "wires": [
- [
- "a0df077ddbb184e3"
- ]
- ]
- },
- {
- "id": "a0df077ddbb184e3",
- "type": "switch",
- "z": "6426e7bea6900426",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "$pageview",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 1,
- "x": 280,
- "y": 40,
"wires": [
[
"874fd69a5398300e"
@@ -4285,7 +5356,7 @@
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 640,
+ "x": 380,
"y": 40,
"wires": [
[
@@ -4307,54 +5378,11 @@
"libs": [],
"x": 120,
"y": 140,
- "wires": [
- [
- "94a287dea58faa4d",
- "b8b60ce8a0cf6764"
- ]
- ]
- },
- {
- "id": "94a287dea58faa4d",
- "type": "ui-template",
- "z": "6426e7bea6900426",
- "group": "af8acdfe9afbad74",
- "page": "",
- "ui": "",
- "name": "Calibration - WB",
- "order": 2,
- "width": "0",
- "height": "0",
- "head": "",
- "format": "\n\n \n \n \n \n \n \n \n \n\n \n \n \n \n White Balance Calibration\n \n\n \n\n \n \n 1. Refresh \n \n Click Refresh the value of the gains to start.\n
\n \n Refresh\n \n \n\n \n\n \n \n\n \n\n \n \n 3. Apply Suggested Gains \n\n \n \n {{ item.current }}
\n \n \n \n \n \n\n \n Click Apply Suggested Gains after selecting your pixel.\n
\n\n \n Apply Suggested Gains\n \n \n\n \n\n \n \n 4. Repeat Until Saturation ≈ 0% \n \n Repeat steps 10 times until the saturation value approaches 0% .\n
\n\n \n Current saturation: {{ hsl.s }} % \n
\n\n \n
\n\n
\n \n Start calibration — click Refresh .\n \n \n Iteration {{ loopCount }} / {{ totalLoops }} — sample & apply.\n \n \n ✅ Calibration complete! Saturation should be near 0% .\n \n
\n
\n \n \n \n \n \n \n\n\n\n",
- "storeOutMessages": true,
- "passthru": true,
- "resendOnRefresh": true,
- "templateScope": "local",
- "className": "",
- "x": 400,
- "y": 140,
- "wires": [
- [
- "615edad94a4f77e7"
- ]
- ]
- },
- {
- "id": "9c40c3932475ea84",
- "type": "debug",
- "z": "6426e7bea6900426",
- "name": "debug 1",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "false",
- "statusVal": "",
- "statusType": "auto",
- "x": 940,
- "y": 260,
- "wires": []
+ "wires": [
+ [
+ "5ec541d6e203def5"
+ ]
+ ]
},
{
"id": "e52cb70984e0d25d",
@@ -4387,23 +5415,6 @@
[]
]
},
- {
- "id": "b8b60ce8a0cf6764",
- "type": "debug",
- "z": "6426e7bea6900426",
- "name": "debug 2",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 320,
- "y": 220,
- "wires": []
- },
{
"id": "615edad94a4f77e7",
"type": "switch",
@@ -4421,13 +5432,38 @@
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 650,
- "y": 180,
+ "x": 590,
+ "y": 140,
"wires": [
[
"d8f732f8fe251222",
- "e52cb70984e0d25d",
- "9c40c3932475ea84"
+ "e52cb70984e0d25d"
+ ]
+ ]
+ },
+ {
+ "id": "5ec541d6e203def5",
+ "type": "ui-template",
+ "z": "6426e7bea6900426",
+ "group": "af8acdfe9afbad74",
+ "page": "",
+ "ui": "",
+ "name": "Calibration - WB",
+ "order": 2,
+ "width": "0",
+ "height": "0",
+ "head": "",
+ "format": "\n \n \n \n \n \n\n
\n\n \n \n \n\n \n \n \n White Balance Calibration\n \n\n \n\n \n 1. Refresh \n \n Click Refresh Gains to reset the calibration and start.\n
\n \n Refresh Gains\n \n \n\n \n\n \n 2. Sample Average Pixel (Auto) \n \n Average color sampled from the central **red box**.\n
\n\n \n
\n
\n RGB: {{ sampledR }}, {{ sampledG }}, {{ sampledB }}\n \n
\n \n\n \n\n \n 3. Apply Suggested Gains \n\n \n \n {{ item.current }}
\n \n \n \n \n \n\n \n Click Apply Suggested Gains to send the new calibration values.\n
\n\n \n Apply Suggested Gains\n \n \n\n \n\n \n 4. Repeat Until Saturation ≈ 0% \n \n \n Repeat the cycle (Sample $\\rightarrow$ Apply) until the saturation is minimal.\n
\n \n \n
\n {{ hsl.s }} %\n
\n
\n Current Saturation (Target: 0%)\n
\n
\n\n \n \n \n Ready. Click \"Refresh Gains\" to begin.\n
\n \n \n Calibration Complete ({{ loopCount }} Iterations)\n
\n \n \n Iteration {{ loopCount }} — Continue sampling & applying.\n
\n \n \n \n \n \n \n \n\n\n",
+ "storeOutMessages": true,
+ "passthru": true,
+ "resendOnRefresh": true,
+ "templateScope": "local",
+ "className": "",
+ "x": 380,
+ "y": 140,
+ "wires": [
+ [
+ "615edad94a4f77e7"
]
]
},
@@ -4480,31 +5516,6 @@
"name": "UI Event",
"x": 80,
"y": 40,
- "wires": [
- [
- "068b250f8668e3ea"
- ]
- ]
- },
- {
- "id": "068b250f8668e3ea",
- "type": "switch",
- "z": "14c685bd04db8be5",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "$pageview",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 1,
- "x": 280,
- "y": 40,
"wires": [
[
"72dd2876e74c3ef2"
@@ -4528,7 +5539,7 @@
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 620,
+ "x": 360,
"y": 40,
"wires": [
[
@@ -4552,8 +5563,7 @@
"y": 140,
"wires": [
[
- "740188ac42d17f44",
- "d41870c7f587821b"
+ "740188ac42d17f44"
]
]
},
@@ -4569,13 +5579,13 @@
"width": "0",
"height": "0",
"head": "",
- "format": "\n \n \n \n \n\n\n\n \n\n \n \n \n Lightness Calibration\n \n\n \n \n \n Calibration steps
\n \n Step 1 \n Step 2 \n Step 3 \n Step 4 \n \n \n\n \n\n \n \n \n\n\n\n\n",
+ "format": "\n \n \n \n \n \n\n \n mdi-crosshairs \n
\n\n
\n\n \n \n \n\n \n \n\n \n Luminance Calibration\n \n\n \n\n \n \n \n
1. LED Control \n \n {{ currentLedValue.toFixed(12) }}\n \n \n\n \n \n \n {{ formatStep(v) }}\n \n
\n\n \n \n \n \n \n\n \n \n \n\n \n \n \n \n \n\n \n\n \n \n \n
2. Measured Luminance \n \n\n \n
\n {{ currentLuminance.toFixed(0) }}\n
\n
\n Measured from central region of preview\n
\n
\n\n \n \n \n {{ Math.round(currentLuminance) }}\n \n \n \n \n\n \n \n \n \n \n\n\n\n\n",
"storeOutMessages": true,
"passthru": true,
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 420,
+ "x": 400,
"y": 140,
"wires": [
[
@@ -4583,22 +5593,6 @@
]
]
},
- {
- "id": "21af2e731c368d3e",
- "type": "debug",
- "z": "14c685bd04db8be5",
- "name": "debug 3",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "false",
- "statusVal": "",
- "statusType": "auto",
- "x": 940,
- "y": 260,
- "wires": []
- },
{
"id": "785c59c553d71326",
"type": "mqtt out",
@@ -4616,37 +5610,20 @@
"id": "0283e992ff5da0f6",
"type": "function",
"z": "14c685bd04db8be5",
- "name": "set pump settings",
- "func": "if (msg.topic) {\n global.set(\"calibration_wbg_red\", msg.payload.settings.white_balance_gain.red);\n global.set(\"calibration_wbg_blue\", msg.payload.settings.white_balance_gain.blue);\n}\nreturn msg;",
+ "name": "set led_intensity",
+ "func": "if (msg.topic) {\n global.set(\"led_intensity\", msg.payload.value);}\nreturn msg;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
- "x": 990,
- "y": 180,
+ "x": 880,
+ "y": 160,
"wires": [
[]
]
},
- {
- "id": "d41870c7f587821b",
- "type": "debug",
- "z": "14c685bd04db8be5",
- "name": "debug 4",
- "active": true,
- "tosidebar": true,
- "console": false,
- "tostatus": false,
- "complete": "true",
- "targetType": "full",
- "statusVal": "",
- "statusType": "auto",
- "x": 320,
- "y": 220,
- "wires": []
- },
{
"id": "959de9b1292e047d",
"type": "switch",
@@ -4657,19 +5634,19 @@
"rules": [
{
"t": "eq",
- "v": "imager/image",
+ "v": "light",
"vt": "str"
}
],
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 650,
- "y": 180,
+ "x": 590,
+ "y": 140,
"wires": [
[
- "0283e992ff5da0f6",
- "21af2e731c368d3e"
+ "785c59c553d71326",
+ "0283e992ff5da0f6"
]
]
},
@@ -4732,7 +5709,7 @@
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 420,
+ "x": 400,
"y": 140,
"wires": [
[
@@ -4748,31 +5725,6 @@
"name": "UI Event",
"x": 80,
"y": 40,
- "wires": [
- [
- "78cf0ca00b9e1214"
- ]
- ]
- },
- {
- "id": "78cf0ca00b9e1214",
- "type": "switch",
- "z": "3afb1d2b21be9114",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "$pageview",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 1,
- "x": 280,
- "y": 40,
"wires": [
[
"943c62c18d43f2a6"
@@ -4796,7 +5748,7 @@
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 620,
+ "x": 360,
"y": 40,
"wires": [
[
@@ -4841,7 +5793,7 @@
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 610,
+ "x": 590,
"y": 140,
"wires": [
[
@@ -4861,8 +5813,8 @@
"initialize": "",
"finalize": "",
"libs": [],
- "x": 810,
- "y": 140,
+ "x": 1010,
+ "y": 180,
"wires": [
[]
]
@@ -4916,31 +5868,6 @@
"name": "UI Event",
"x": 80,
"y": 40,
- "wires": [
- [
- "2e99999b7cf152b7"
- ]
- ]
- },
- {
- "id": "2e99999b7cf152b7",
- "type": "switch",
- "z": "d5b2c64b84f8ed4f",
- "name": "msg.topic === \"$pageview\"",
- "property": "topic",
- "propertyType": "msg",
- "rules": [
- {
- "t": "eq",
- "v": "$pageview",
- "vt": "str"
- }
- ],
- "checkall": "true",
- "repair": false,
- "outputs": 1,
- "x": 280,
- "y": 40,
"wires": [
[
"138136e6a1917e49"
@@ -4964,7 +5891,7 @@
"checkall": "true",
"repair": false,
"outputs": 1,
- "x": 610,
+ "x": 350,
"y": 40,
"wires": [
[]
@@ -5008,7 +5935,7 @@
"resendOnRefresh": true,
"templateScope": "local",
"className": "",
- "x": 410,
+ "x": 390,
"y": 140,
"wires": [
[
@@ -5051,7 +5978,7 @@
"checkall": "true",
"repair": false,
"outputs": 2,
- "x": 610,
+ "x": 590,
"y": 140,
"wires": [
[
@@ -5094,12 +6021,101 @@
"initialize": "",
"finalize": "",
"libs": [],
- "x": 920,
- "y": 160,
+ "x": 1000,
+ "y": 180,
"wires": [
[]
]
},
+ {
+ "id": "64ae825a687fb054",
+ "type": "ecotaxa",
+ "z": "8555b76c53e789e0",
+ "name": "Import to Ecotaxa Project",
+ "api_url": "https://ecotaxa.obs-vlfr.fr/api/",
+ "project_id": "9366",
+ "x": 830,
+ "y": 300,
+ "wires": [
+ [
+ "c21f20a7a9902ee4"
+ ]
+ ]
+ },
+ {
+ "id": "af1ec6323cc00231",
+ "type": "inject",
+ "z": "8555b76c53e789e0",
+ "name": "Lancer import",
+ "props": [],
+ "repeat": "",
+ "crontab": "",
+ "once": false,
+ "onceDelay": "0",
+ "topic": "",
+ "x": 310,
+ "y": 300,
+ "wires": [
+ [
+ "28f8eb0318735246"
+ ]
+ ]
+ },
+ {
+ "id": "c21f20a7a9902ee4",
+ "type": "debug",
+ "z": "8555b76c53e789e0",
+ "name": "Show import result",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "payload",
+ "targetType": "msg",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 1110,
+ "y": 300,
+ "wires": []
+ },
+ {
+ "id": "28f8eb0318735246",
+ "type": "function",
+ "z": "8555b76c53e789e0",
+ "name": "Set file_path",
+ "func": "msg.payload = {}\nmsg.payload.file_path = \"/home/pi/data/export/ecotaxa/ecotaxa_A_2.zip\"\n\n\nreturn msg;",
+ "outputs": 1,
+ "timeout": 0,
+ "noerr": 0,
+ "initialize": "",
+ "finalize": "",
+ "libs": [],
+ "x": 570,
+ "y": 300,
+ "wires": [
+ [
+ "64ae825a687fb054",
+ "1457a0786f787415"
+ ]
+ ]
+ },
+ {
+ "id": "1457a0786f787415",
+ "type": "debug",
+ "z": "8555b76c53e789e0",
+ "name": "debug 9",
+ "active": true,
+ "tosidebar": true,
+ "console": false,
+ "tostatus": false,
+ "complete": "true",
+ "targetType": "full",
+ "statusVal": "",
+ "statusType": "auto",
+ "x": 820,
+ "y": 200,
+ "wires": []
+ },
{
"id": "7ccb5c8c66ad170a",
"type": "inject",
diff --git a/segmenter/planktoscope/segmenter/live.py b/segmenter/planktoscope/segmenter/live.py
new file mode 100644
index 000000000..456a0b617
--- /dev/null
+++ b/segmenter/planktoscope/segmenter/live.py
@@ -0,0 +1,725 @@
+# Copyright (C) 2021 Romain Bazile
+#
+# This file is part of the PlanktoScope software.
+#
+# PlanktoScope is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# PlanktoScope is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with PlanktoScope. If not, see .
+
+"""Live segmentation module for acquisition overlay analysis.
+
+This module provides real-time segmentation during acquisition. When enabled,
+it listens for image captures from the imager and segments each frame as it
+is captured, publishing results for overlay display.
+
+Features:
+- Real-time segmentation overlay for preview
+- Saves object crops to /home/pi/data/objects for visualization
+- Writes EcoTaxa-compatible TSV incrementally
+- Publishes MQTT updates for live dashboard refresh
+"""
+
+import base64
+import io
+import json
+import math
+import multiprocessing
+import os
+import time
+from datetime import datetime
+from pathlib import Path
+
+import cv2
+import numpy as np
+import PIL.Image
+import skimage.measure
+from loguru import logger
+
+import planktoscope.mqtt
+import planktoscope.segmenter.operations
+import planktoscope.segmenter.encoder
+
+logger.info("planktoscope.segmenter.live is loaded")
+
+# Hardware config path (same as used by controller)
+HARDWARE_CONFIG_PATH = "/home/pi/PlanktoScope/hardware.json"
+
+# Paths for visualization output
+IMG_BASE = "/home/pi/data/img"
+OBJECTS_BASE = "/home/pi/data/objects"
+LIVE_STATS_FILE = "/tmp/live_seg_stats.json"
+
+# EcoTaxa TSV column headers
+ECOTAXA_COLUMNS = [
+ "object_id", "object_date", "object_time",
+ "object_x", "object_y", "object_width", "object_height",
+ "object_area", "object_perim.", "object_major", "object_minor",
+ "object_circ.", "object_elongation", "object_solidity",
+ "object_equivalent_diameter",
+ "object_MeanHue", "object_MeanSaturation", "object_MeanValue",
+ "object_blur_laplacian",
+ "sample_id", "acq_id", "img_file_name"
+]
+
+
+class LiveSegmenterProcess(multiprocessing.Process):
+ """Live segmentation worker that analyzes frames during acquisition.
+
+ This process listens for image captures from the imager during acquisition
+ and performs real-time segmentation on each captured frame. Results are
+ published via MQTT for overlay display on the frontend.
+ """
+
+ @logger.catch
+ def __init__(self, event, data_path):
+ """Initialize the LiveSegmenter class.
+
+ Args:
+ event (multiprocessing.Event): shutdown event
+ data_path (str): base data path
+ """
+ super(LiveSegmenterProcess, self).__init__(name="live_segmenter")
+
+ logger.info("planktoscope.segmenter.live is initialising")
+
+ self.stop_event = event
+ self.live_client = None
+ self.imager_client = None
+ self.__data_path = data_path
+ self.__enabled = False # Whether live segmentation overlay is enabled
+ self.__overlay_mode = "bbox" # bbox, mask, or both
+ self.__min_area = 100 # Minimum area in pixels for detected objects
+ self.__pixel_size_um = self._load_pixel_size() # Load from hardware config
+ self.__remove_static = False # Remove objects that appear in same position across frames
+ self.__static_tracker = {} # Track objects by position: {(cx, cy): frame_count}
+ self.__static_threshold = 2 # FIX: Reduced from 3 to 2 for faster debris detection
+
+ # Visualization state
+ self.__save_crops = True # Save object crops for visualization gallery
+ self.__current_acq_folder = None
+ self.__object_counter = 0
+ self.__frame_counter = 0
+
+ logger.success("planktoscope.segmenter.live is initialised and ready to go!")
+
+ def _load_pixel_size(self):
+ """Load pixel size from hardware config file.
+
+ Reads process_pixel_fixed from /home/pi/PlanktoScope/hardware.json.
+ This ensures consistency with the calibration value set in the dashboard.
+
+ Returns:
+ float: Pixel size in micrometers per pixel. Defaults to 0.75 if not found.
+ """
+ default_pixel_size = 0.75
+ try:
+ with open(HARDWARE_CONFIG_PATH, "r") as f:
+ config = json.load(f)
+ pixel_size = config.get("process_pixel_fixed", default_pixel_size)
+ logger.info(f"Loaded pixel size from hardware config: {pixel_size} µm/pixel")
+ return float(pixel_size)
+ except FileNotFoundError:
+ logger.warning(
+ f"Hardware config not found at {HARDWARE_CONFIG_PATH}, "
+ f"using default pixel size: {default_pixel_size} µm/pixel"
+ )
+ return default_pixel_size
+ except (json.JSONDecodeError, ValueError) as e:
+ logger.error(
+ f"Error reading hardware config: {e}, "
+ f"using default pixel size: {default_pixel_size} µm/pixel"
+ )
+ return default_pixel_size
+
+ def _get_acquisition_info(self, image_path):
+ """Extract acquisition info from image path.
+
+ Path format: /home/pi/data/img/DATE/SAMPLE_ID/ACQ_ID/image.jpg
+ """
+ try:
+ parts = image_path.split("/")
+ if "img" in parts:
+ idx = parts.index("img")
+ date_folder = parts[idx + 1] if len(parts) > idx + 1 else ""
+ sample_folder = parts[idx + 2] if len(parts) > idx + 2 else ""
+ acq_folder = parts[idx + 3] if len(parts) > idx + 3 else ""
+
+ return {
+ "date": date_folder,
+ "sample_id": sample_folder,
+ "acq_id": acq_folder,
+ "acq_folder": acq_folder,
+ }
+ except Exception:
+ pass
+
+ return {
+ "date": datetime.now().strftime("%Y-%m-%d"),
+ "sample_id": "unknown",
+ "acq_id": "A_0",
+ "acq_folder": "unknown",
+ }
+
+ def _derive_output_dir(self, image_path):
+ """Get output directory for object crops, mirroring img structure."""
+ abs_path = os.path.abspath(image_path)
+ img_dir = os.path.dirname(abs_path)
+
+ if img_dir.startswith(IMG_BASE):
+ rel_path = os.path.relpath(img_dir, IMG_BASE)
+ return os.path.join(OBJECTS_BASE, rel_path)
+ return os.path.join(img_dir, "objects")
+
+ def _write_tsv_header(self, tsv_path):
+ """Write EcoTaxa TSV header."""
+ try:
+ with open(tsv_path, "w") as f:
+ f.write("\t".join(ECOTAXA_COLUMNS) + "\n")
+ types = ["[t]"] * len(ECOTAXA_COLUMNS)
+ f.write("\t".join(types) + "\n")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to write TSV header: {e}")
+ return False
+
+ def _append_tsv_row(self, tsv_path, row_data):
+ """Append a single row to the TSV file."""
+ try:
+ with open(tsv_path, "a") as f:
+ values = []
+ for col in ECOTAXA_COLUMNS:
+ val = row_data.get(col, "")
+ if isinstance(val, float):
+ values.append(f"{val:.4f}")
+ else:
+ values.append(str(val))
+ f.write("\t".join(values) + "\n")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to append TSV row: {e}")
+ return False
+
+ def _extract_object_features(self, img, region, bbox):
+ """Extract morphological features from an object for TSV."""
+ x, y, w, h = bbox
+
+ # Extract ROI
+ roi_img = img[y:y+h, x:x+w]
+
+ # Area and perimeter from region
+ area = int(region.area)
+ perimeter = float(region.perimeter)
+
+ # Major/minor axes
+ major = float(region.major_axis_length) if region.major_axis_length else max(w, h)
+ minor = float(region.minor_axis_length) if region.minor_axis_length else min(w, h)
+
+ # Derived metrics
+ circularity = (4 * math.pi * area / (perimeter ** 2)) if perimeter > 0 else 0
+ elongation = major / minor if minor > 0 else 1.0
+ solidity = float(region.solidity) if region.solidity else 1.0
+ equivalent_diameter = float(region.equivalent_diameter) if region.equivalent_diameter else (4 * area / math.pi) ** 0.5
+
+ # HSV color statistics
+ mean_hue, mean_sat, mean_val = 0, 0, 0
+ try:
+ roi_hsv = cv2.cvtColor(roi_img, cv2.COLOR_BGR2HSV)
+ mean_hue = float(np.mean(roi_hsv[:, :, 0]))
+ mean_sat = float(np.mean(roi_hsv[:, :, 1]))
+ mean_val = float(np.mean(roi_hsv[:, :, 2]))
+ except Exception:
+ pass
+
+ # Blur metric
+ blur_laplacian = planktoscope.segmenter.operations.calculate_blur(roi_img)
+
+ return {
+ "object_x": x + w / 2,
+ "object_y": y + h / 2,
+ "object_width": w,
+ "object_height": h,
+ "object_area": area,
+ "object_perim.": perimeter,
+ "object_major": major,
+ "object_minor": minor,
+ "object_circ.": circularity,
+ "object_elongation": elongation,
+ "object_solidity": solidity,
+ "object_equivalent_diameter": equivalent_diameter,
+ "object_MeanHue": mean_hue,
+ "object_MeanSaturation": mean_sat,
+ "object_MeanValue": mean_val,
+ "object_blur_laplacian": blur_laplacian,
+ }
+
+ def _publish_visualization_update(self, output_dir, total_objects, total_frames):
+ """Publish MQTT update for visualization dashboard refresh."""
+ try:
+ message = {
+ "status": "segmenting",
+ "total_objects": total_objects,
+ "total_images": total_frames,
+ "output_dir": output_dir,
+ "timestamp": time.time(),
+ }
+ self.live_client.client.publish(
+ "status/segmentation",
+ json.dumps(message),
+ )
+ except Exception as e:
+ logger.debug(f"Failed to publish visualization update: {e}")
+
+ def _esd_um_to_min_area(self, esd_um):
+ """Convert ESD in micrometers to minimum area in pixels.
+
+ Args:
+ esd_um (float): Equivalent spherical diameter in micrometers
+
+ Returns:
+ int: Minimum area in pixels
+ """
+ # Convert ESD from micrometers to pixels
+ esd_pixels = esd_um / self.__pixel_size_um
+ # Calculate area of a circle with this diameter
+ area = math.pi * (esd_pixels / 2) ** 2
+ return int(area)
+
+ def _create_simple_mask(self, img):
+ """Create a mask using simple thresholding.
+
+ Args:
+ img (np.array): BGR image
+
+ Returns:
+ np.array: binary mask
+ """
+ mask = planktoscope.segmenter.operations.simple_threshold(img)
+ mask = planktoscope.segmenter.operations.erode(mask)
+ mask = planktoscope.segmenter.operations.dilate(mask)
+ return mask
+
+ def _get_bbox_key(self, bbox):
+ """Get a grid key for a bounding box center for tracking.
+
+ Uses 100px grid cells - large enough to tolerate detection variation
+ in elongated objects while still distinguishing separate small objects.
+
+ Args:
+ bbox: [x, y, w, h] bounding box
+
+ Returns:
+ tuple: (grid_x, grid_y) key
+ """
+ cx = bbox[0] + bbox[2] / 2
+ cy = bbox[1] + bbox[3] / 2
+ grid_size = 100 # FIX: Larger grid (was 60) tolerates detection jitter for stuck objects
+ return (int(cx / grid_size), int(cy / grid_size))
+
+ def _update_static_tracker(self, current_bboxes):
+ """Update the static object tracker with current frame's objects.
+
+ Objects that appear in the same grid cell across multiple frames
+ get their count incremented. Objects not seen are removed.
+
+ Args:
+ current_bboxes: list of [x, y, w, h] bounding boxes from current frame
+ """
+ # Get all current grid positions
+ current_keys = set()
+ for bbox in current_bboxes:
+ key = self._get_bbox_key(bbox)
+ current_keys.add(key)
+
+ # Update tracker: increment seen, remove unseen
+ new_tracker = {}
+ for key in current_keys:
+ if key in self.__static_tracker:
+ new_tracker[key] = self.__static_tracker[key] + 1
+ else:
+ new_tracker[key] = 1
+
+ self.__static_tracker = new_tracker
+
+ def _is_static_object(self, bbox):
+ """Check if an object has been static for multiple frames.
+
+ Args:
+ bbox: [x, y, w, h] bounding box
+
+ Returns:
+ bool: True if object is static (appeared in same position for N+ frames)
+ """
+ key = self._get_bbox_key(bbox)
+ count = self.__static_tracker.get(key, 0)
+ return count >= self.__static_threshold
+
+ def _encode_mask_png(self, mask):
+ """Encode a binary mask as base64 PNG with alpha transparency.
+
+ Args:
+ mask (np.array): binary mask
+
+ Returns:
+ str: base64 encoded PNG string with alpha channel
+ """
+ # Convert binary mask to RGBA with alpha transparency
+ # Object pixels = white with full opacity, background = transparent
+ height, width = mask.shape
+ rgba = np.zeros((height, width, 4), dtype=np.uint8)
+ rgba[mask, :3] = 255 # White RGB for object pixels
+ rgba[mask, 3] = 255 # Full opacity for object pixels
+ # Background pixels remain (0,0,0,0) = transparent
+
+ img = PIL.Image.fromarray(rgba, mode="RGBA")
+ buffer = io.BytesIO()
+ img.save(buffer, format="PNG")
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
+
+ def segment_single_frame(self, img):
+ """Segment a single frame and return object data.
+
+ Args:
+ img (np.array): BGR image
+
+ Returns:
+ dict: segmentation results with objects, frame_blur, and image dimensions
+ """
+ # Get image dimensions for frontend scaling
+ img_height, img_width = img.shape[:2]
+
+ # Calculate frame-level blur
+ frame_blur = planktoscope.segmenter.operations.calculate_blur(img)
+
+ # Calculate regional blur for heatmap visualization (4x4 grid)
+ blur_grid = planktoscope.segmenter.operations.calculate_regional_blur(img, 4, 4)
+
+ # Create mask
+ mask = self._create_simple_mask(img)
+
+ # Find objects
+ labels, nlabels = skimage.measure.label(mask, return_num=True)
+ regionprops = skimage.measure.regionprops(labels)
+
+ # Filter by minimum area and sort by area (largest first)
+ regionprops_filtered = [
+ region for region in regionprops if region.area >= self.__min_area
+ ]
+ regionprops_filtered.sort(key=lambda r: r.area, reverse=True)
+
+ # Build list of all bboxes and regions for this frame
+ all_bboxes = []
+ bbox_region_pairs = []
+ for region in regionprops_filtered:
+ bbox = [
+ int(region.bbox[1]), # x
+ int(region.bbox[0]), # y
+ int(region.bbox[3] - region.bbox[1]), # width
+ int(region.bbox[2] - region.bbox[0]), # height
+ ]
+ all_bboxes.append(bbox)
+ bbox_region_pairs.append((bbox, region))
+
+ # Update static tracker with all detected objects BEFORE filtering
+ if self.__remove_static:
+ self._update_static_tracker(all_bboxes)
+
+ # Build output objects, filtering static ones if enabled
+ objects = []
+ max_masks = 100
+
+ for bbox, region in bbox_region_pairs:
+ # Skip static objects (only if they've been seen for N+ consecutive frames)
+ if self.__remove_static and self._is_static_object(bbox):
+ continue
+
+ obj_data = {
+ "bbox": bbox,
+ }
+
+ # Include mask for objects
+ if self.__overlay_mode in ("mask", "both") and len(objects) < max_masks:
+ obj_data["mask"] = self._encode_mask_png(region.filled_image)
+
+ objects.append(obj_data)
+
+ # Limit total objects for performance
+ if len(objects) >= 300:
+ break
+
+ return {
+ "objects": objects,
+ "frame_blur": float(frame_blur),
+ "blur_grid": blur_grid, # 4x4 regional blur heatmap
+ "object_count": len(objects), # Count after static filtering
+ "image_width": img_width,
+ "image_height": img_height,
+ }
+
+ def _process_captured_image(self, img_path):
+ """Process a captured image from acquisition.
+
+ Segments the image, saves object crops for visualization,
+ writes TSV data, and publishes results via MQTT.
+
+ Args:
+ img_path (str): path to the captured image file
+ """
+ if not self.__enabled:
+ return
+
+ try:
+ if not os.path.exists(img_path):
+ logger.warning(f"Image file not found: {img_path}")
+ return
+
+ # Load the captured image
+ frame = cv2.imread(img_path)
+ if frame is None:
+ logger.warning(f"Failed to load image: {img_path}")
+ return
+
+ logger.debug(f"Processing captured image: {img_path}")
+
+ # Get acquisition info
+ acq_info = self._get_acquisition_info(img_path)
+ acq_folder = acq_info.get("acq_folder", "")
+
+ # Reset counters if new acquisition
+ if acq_folder != self.__current_acq_folder:
+ self.__current_acq_folder = acq_folder
+ self.__object_counter = 0
+ self.__frame_counter = 0
+ self.__static_tracker = {}
+
+ self.__frame_counter += 1
+
+ # Segment the frame (returns objects with bbox, mask data)
+ result = self.segment_single_frame(frame)
+
+ # Setup output directory for crops
+ output_dir = self._derive_output_dir(img_path)
+ if self.__save_crops:
+ Path(output_dir).mkdir(parents=True, exist_ok=True)
+
+ # Setup TSV file
+ tsv_path = os.path.join(output_dir, f"ecotaxa_{acq_info['acq_id']}.tsv")
+ if self.__save_crops and not os.path.exists(tsv_path):
+ self._write_tsv_header(tsv_path)
+
+ # Get base name for crops
+ base_name = os.path.splitext(os.path.basename(img_path))[0]
+ img_date = acq_info.get("date", "")
+ img_time = "00:00:00"
+ if "_" in base_name:
+ time_part = base_name.split("_")[1] if len(base_name.split("_")) > 1 else ""
+ if time_part:
+ img_time = time_part.replace("-", ":")[:8]
+
+ # Re-segment to get regions for feature extraction and crop saving
+ mask = self._create_simple_mask(frame)
+ labels, _ = skimage.measure.label(mask, return_num=True)
+ regionprops = skimage.measure.regionprops(labels)
+
+ # Process each object in the result
+ saved_crops = 0
+ for obj in result.get("objects", []):
+ bbox = obj.get("bbox")
+ if not bbox:
+ continue
+
+ x, y, w, h = bbox
+
+ # Find matching region for this bbox
+ matching_region = None
+ for region in regionprops:
+ rx = int(region.bbox[1])
+ ry = int(region.bbox[0])
+ if abs(rx - x) < 5 and abs(ry - y) < 5:
+ matching_region = region
+ break
+
+ if not matching_region:
+ continue
+
+ self.__object_counter += 1
+ obj_id = self.__object_counter
+
+ # Save crop with padding
+ pad = max(5, int(max(w, h) * 0.1))
+ x1 = max(0, x - pad)
+ y1 = max(0, y - pad)
+ x2 = min(frame.shape[1], x + w + pad)
+ y2 = min(frame.shape[0], y + h + pad)
+ crop = frame[y1:y2, x1:x2]
+
+ if self.__save_crops and crop.size > 0:
+ crop_filename = f"{base_name}_{obj_id}.jpg"
+ crop_path = os.path.join(output_dir, crop_filename)
+ cv2.imwrite(crop_path, crop)
+ saved_crops += 1
+
+ # Extract features and write TSV row
+ features = self._extract_object_features(frame, matching_region, bbox)
+ row_data = {
+ "object_id": f"{acq_info['sample_id']}_{acq_info['acq_id']}_{obj_id}",
+ "object_date": img_date,
+ "object_time": img_time,
+ "sample_id": acq_info["sample_id"],
+ "acq_id": acq_info["acq_id"],
+ "img_file_name": crop_filename,
+ **features,
+ }
+ self._append_tsv_row(tsv_path, row_data)
+
+ # Encode the image as base64 JPEG for frontend display
+ _, jpeg_buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
+ result["image"] = base64.b64encode(jpeg_buffer).decode("utf-8")
+
+ # Publish results for overlay display
+ self.live_client.client.publish(
+ "status/segmenter/live",
+ json.dumps(result, cls=planktoscope.segmenter.encoder.NpEncoder),
+ )
+
+ # Publish visualization update
+ if self.__save_crops:
+ self._publish_visualization_update(
+ output_dir,
+ self.__object_counter,
+ self.__frame_counter
+ )
+
+ logger.debug(f"Published segmentation: {result['object_count']} objects, {saved_crops} crops saved")
+
+ except Exception as e:
+ logger.error(f"Error processing captured image: {e}")
+ import traceback
+ logger.error(traceback.format_exc())
+
+ def _check_imager_messages(self):
+ """Check for new messages from the imager.
+
+ Polls the imager MQTT client for progress events during acquisition
+ and triggers segmentation when live segmentation is enabled.
+ """
+ if not self.imager_client.new_message_received():
+ return
+
+ try:
+ message = self.imager_client.msg["payload"]
+ logger.debug(f"Imager message received: {message}")
+ self.imager_client.read_message()
+
+ # Check if this is a progress event during acquisition
+ # The imager publishes {"type": "progress", "path": "/path/to/image.jpeg", ...}
+ if message.get("type") == "progress" and "path" in message:
+ self._process_captured_image(message["path"])
+
+ except Exception as e:
+ logger.error(f"Error processing imager message: {e}")
+
+ @logger.catch
+ def treat_message(self):
+ """Process incoming MQTT messages for live segmentation control."""
+ if self.live_client.new_message_received():
+ logger.info("Live segmenter received a new message")
+ last_message = self.live_client.msg["payload"]
+ logger.debug(last_message)
+ self.live_client.read_message()
+
+ if "action" in last_message:
+ if last_message["action"] == "start":
+ logger.info("Enabling live segmentation overlay")
+ self.__overlay_mode = last_message.get("overlay", "bbox")
+
+ # Handle min_esd_um (micrometers) or fall back to min_area (pixels)
+ if "min_esd_um" in last_message:
+ min_esd = last_message.get("min_esd_um", 20)
+ self.__min_area = self._esd_um_to_min_area(min_esd)
+ logger.info(f"Minimum ESD: {min_esd} µm = {self.__min_area} pixels²")
+ else:
+ self.__min_area = last_message.get("min_area", 100)
+
+ # Handle remove_static option (subtract objects in same position across frames)
+ self.__remove_static = last_message.get("remove_static", True)
+ self.__static_tracker = {} # Reset tracker on start
+ if self.__remove_static:
+ logger.info("Static object removal enabled (filtering after 3+ consecutive frames)")
+
+ self.__enabled = True
+
+ # Publish status
+ self.live_client.client.publish(
+ "status/segmenter/live",
+ json.dumps({
+ "status": "Enabled",
+ "overlay": self.__overlay_mode,
+ "min_area": self.__min_area,
+ "remove_static": self.__remove_static
+ }),
+ )
+
+ elif last_message["action"] == "stop":
+ logger.info("Disabling live segmentation overlay")
+ self.__enabled = False
+ self.__static_tracker = {} # Clear static tracker
+
+ # Clear the overlay by publishing empty objects
+ self.live_client.client.publish(
+ "status/segmenter/live",
+ json.dumps({
+ "status": "Disabled",
+ "objects": [],
+ "object_count": 0
+ }),
+ )
+
+ @logger.catch
+ def run(self):
+ """Main process loop."""
+ logger.info(
+ f"The live segmenter control thread has been started in process {os.getpid()}"
+ )
+
+ # MQTT Client for receiving commands
+ self.live_client = planktoscope.mqtt.MQTT_Client(
+ topic="segmenter/live", name="live_segmenter_client"
+ )
+
+ # MQTT Client for imager status - listen for capture events
+ self.imager_client = planktoscope.mqtt.MQTT_Client(
+ topic="status/imager", name="live_imager_client"
+ )
+
+ # Publish ready status
+ self.live_client.client.publish(
+ "status/segmenter/live", '{"status":"Ready"}'
+ )
+
+ logger.success("Live Segmenter is READY!")
+
+ # Main loop - process control messages and imager events
+ while not self.stop_event.is_set():
+ self.treat_message()
+ self._check_imager_messages()
+ time.sleep(0.05)
+
+ logger.info("Shutting down the live segmenter process")
+ self.live_client.client.publish("status/segmenter/live", '{"status":"Dead"}')
+ self.live_client.shutdown()
+ self.imager_client.shutdown()
+ logger.success("Live segmenter process shut down! See you!")
+
+
+# This guy is called if this script is launched directly
+if __name__ == "__main__":
+ pass