Dilation plot#44
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new “Dilation Curves” line plot to the right-side analysis drawer and extends the UpSet plot to support alternate metrics (IoU vs overlap coefficient), backed by new loader queries for overlap coefficient and dilation-curve aggregation.
Changes:
- Introduces a new D3-based
linechartVue component and wires it into the right drawer for dilation-curve visualization. - Adds
upset_metricstate/UI and updates UpSet rendering to plot a chosen metric (IoU or overlap coefficient). - Extends
AnalysisLoaderto compute overlap coefficient and to fetch dilation curves for all subcombinations of selected channels (plus caching for channel voxel totals).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/bioset/ui/state.py | Adds dilation/upset metric state + new change handlers (including dilation updates and a debug print hook). |
| src/bioset/ui/scripts/upset.js | Adds a metric prop and uses it for y-scaling and cardinality in UpSet rendering. |
| src/bioset/ui/scripts/linechart.js | New D3 line chart component used for dilation curves. |
| src/bioset/ui/scripts/init.py | Registers the new linechart.js script. |
| src/bioset/ui/components/right_drawer.py | Adds UI controls for UpSet metric and new “Dilation Curves” panel + settings dialog. |
| src/bioset/ui/callbacks.py | Adds dilation data updater, overlap_coeff mapping, and a verbose print_dilation_curve() helper. |
| src/bioset/analysis/loader.py | Adds overlap coefficient computation, subcombination dilation-curve queries, and voxel-total caching. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let maxMetricValue = 0; | ||
| if (sourceData.length > 0) { | ||
| const values = sourceData.map(d => d[this.metric]); | ||
| maxMetricValue = Math.max(...values); | ||
| } | ||
|
|
||
| const start = this.offset; | ||
| const end = start + this.limit; | ||
| const renderData = sourceData.slice(start, end); | ||
|
|
||
| const mappedData = renderData.map(item => ({ | ||
| sets: item.channels, | ||
| cardinality: item.iou | ||
| cardinality: item[this.metric] | ||
| })); |
There was a problem hiding this comment.
maxMetricValue is computed from sourceData.map(d => d[this.metric]) without validating values. If any row is missing the metric (or it's null/undefined), Math.max(...values) becomes NaN, which breaks yDomain and can make the plot render incorrectly. Consider filtering to finite numbers and defaulting missing cardinality values to 0 (or skipping those rows).
| state.setdefault("dilation_view_mode", "single") # one of: ["single", "multiple"] | ||
| state.setdefault("dilation_metric_single", "density") # always fixed to density | ||
| state.setdefault("dilation_metric_multiple", "iou") # one of: ["iou", "overlap_coeff", "count", "density"] | ||
| state.setdefault("dilation_filter_dialog", False) |
There was a problem hiding this comment.
dilation_selected_channels is used in a new @state.change handler and is set in load_analysis_file, but it is never initialized in init_state. Adding a state.setdefault("dilation_selected_channels", []) here would keep state shape consistent and avoid relying on ad-hoc key creation later.
| state.setdefault("dilation_filter_dialog", False) | |
| state.setdefault("dilation_filter_dialog", False) | |
| state.setdefault("dilation_selected_channels", []) |
| def print_dilation_curve(): | ||
| """Print dilation curves for all subcombinations of the selected channels.""" | ||
| loader = _refs.get("analysis_loader") | ||
| if not loader or not loader.is_loaded: | ||
| return | ||
|
|
||
| channels = state.heatmap_combination or [] | ||
| if not channels: | ||
| return | ||
|
|
||
| level = state.current_hierarchy_level | ||
| curves = loader.get_subcombination_dilation_curves(channels, level) | ||
| if not curves: | ||
| print(f"[dilation-curve] No data for {channels}") | ||
| return | ||
|
|
||
| print(f"[dilation-curve] Subcombination curves for {' | '.join(channels)} (hierarchy_level={level})") | ||
| for combo_key, curve in curves.items(): | ||
| is_single = "|" not in combo_key | ||
| print(f"\n --- {combo_key} ---") | ||
| if is_single: | ||
| print(f" {'Dilation':>10} {'Voxels':>12} {'Density':>10}") | ||
| print(f" {'-'*10} {'-'*12} {'-'*10}") | ||
| for pt in curve: | ||
| print(f" {pt['dilation']:>10.1f} {pt['count']:>12} {pt.get('density', 0):>10.6f}") | ||
| else: | ||
| print(f" {'Dilation':>10} {'Intersection':>12} {'IoU':>10} {'Overlap':>10}") | ||
| print(f" {'-'*10} {'-'*12} {'-'*10} {'-'*10}") | ||
| for pt in curve: | ||
| print(f" {pt['dilation']:>10.1f} {pt['count']:>12} {pt['iou']:>10.6f} {pt.get('overlap_coeff', 0):>10.6f}") | ||
|
|
There was a problem hiding this comment.
print_dilation_curve() prints potentially large, multi-line tabular output for every selected combination. Since this function is reachable from UI state changes, it can significantly clutter logs and slow down the UI thread. Consider removing it from production paths, limiting output (e.g., only first N rows), or using a logger with a debug level and an opt-in toggle.
| self, | ||
| channels: list[str], | ||
| hierarchy_level: int, | ||
| ) -> list[dict]: | ||
| """ | ||
| Get IoU, overlap coefficient, and density across dilations for a multi-channel combination. | ||
|
|
||
| Returns empty list if the combination doesn't exist in the database. | ||
| """ | ||
| # Get total volume for density calculation | ||
| bounds = self.metadata.volume_bounds | ||
| total_volume = ( | ||
| (bounds["x"][1] - bounds["x"][0]) * | ||
| (bounds["y"][1] - bounds["y"][0]) * | ||
| (bounds["z"][1] - bounds["z"][0]) | ||
| ) | ||
|
|
||
| channel_order = self.metadata.channels if self.metadata else [] | ||
| sorted_channels = sorted( | ||
| channels, | ||
| key=lambda c: channel_order.index(c) if c in channel_order else 999 | ||
| ) | ||
| channels_str = "|".join(sorted_channels) | ||
|
|
||
| cursor = self._conn.execute(''' | ||
| SELECT | ||
| dilation, | ||
| SUM(total_count) as sum_inter, | ||
| SUM(total_union) as sum_union | ||
| FROM combinations | ||
| WHERE channels = ? AND hierarchy_level = ? | ||
| GROUP BY dilation | ||
| ORDER BY dilation | ||
| ''', (channels_str, hierarchy_level)) | ||
|
|
||
| rows = cursor.fetchall() | ||
|
|
||
| if not rows: | ||
| return [] # Combination doesn't exist in database | ||
|
|
||
| results = [] | ||
| for row in rows: | ||
| dilation = row["dilation"] | ||
| sum_inter = row["sum_inter"] or 0 | ||
| sum_union = row["sum_union"] or 1 | ||
|
|
||
| # IoU | ||
| iou = sum_inter / sum_union if sum_union > 0 else 0.0 | ||
|
|
||
| # Overlap coefficient: intersection / min(channel_voxels) | ||
| overlap_coeff = self._compute_overlap_coeff( | ||
| channels, sum_inter, dilation, hierarchy_level | ||
| ) | ||
|
|
||
| # Density of intersection | ||
| density = (sum_inter / total_volume * 100) if total_volume > 0 else 0.0 | ||
|
|
||
| results.append({ | ||
| "dilation": dilation, | ||
| "count": sum_inter, | ||
| "iou": iou, | ||
| "overlap_coeff": overlap_coeff, | ||
| "density": density, | ||
| }) | ||
|
|
||
| return results | ||
|
|
There was a problem hiding this comment.
_get_multi_channel_dilation_curve_full has inconsistent indentation (parameters and body indented more than other methods in this file), which hurts readability and makes diffs noisier. Align the indentation with the surrounding class methods (4 spaces for method body indentation level).
| self, | |
| channels: list[str], | |
| hierarchy_level: int, | |
| ) -> list[dict]: | |
| """ | |
| Get IoU, overlap coefficient, and density across dilations for a multi-channel combination. | |
| Returns empty list if the combination doesn't exist in the database. | |
| """ | |
| # Get total volume for density calculation | |
| bounds = self.metadata.volume_bounds | |
| total_volume = ( | |
| (bounds["x"][1] - bounds["x"][0]) * | |
| (bounds["y"][1] - bounds["y"][0]) * | |
| (bounds["z"][1] - bounds["z"][0]) | |
| ) | |
| channel_order = self.metadata.channels if self.metadata else [] | |
| sorted_channels = sorted( | |
| channels, | |
| key=lambda c: channel_order.index(c) if c in channel_order else 999 | |
| ) | |
| channels_str = "|".join(sorted_channels) | |
| cursor = self._conn.execute(''' | |
| SELECT | |
| dilation, | |
| SUM(total_count) as sum_inter, | |
| SUM(total_union) as sum_union | |
| FROM combinations | |
| WHERE channels = ? AND hierarchy_level = ? | |
| GROUP BY dilation | |
| ORDER BY dilation | |
| ''', (channels_str, hierarchy_level)) | |
| rows = cursor.fetchall() | |
| if not rows: | |
| return [] # Combination doesn't exist in database | |
| results = [] | |
| for row in rows: | |
| dilation = row["dilation"] | |
| sum_inter = row["sum_inter"] or 0 | |
| sum_union = row["sum_union"] or 1 | |
| # IoU | |
| iou = sum_inter / sum_union if sum_union > 0 else 0.0 | |
| # Overlap coefficient: intersection / min(channel_voxels) | |
| overlap_coeff = self._compute_overlap_coeff( | |
| channels, sum_inter, dilation, hierarchy_level | |
| ) | |
| # Density of intersection | |
| density = (sum_inter / total_volume * 100) if total_volume > 0 else 0.0 | |
| results.append({ | |
| "dilation": dilation, | |
| "count": sum_inter, | |
| "iou": iou, | |
| "overlap_coeff": overlap_coeff, | |
| "density": density, | |
| }) | |
| return results | |
| self, | |
| channels: list[str], | |
| hierarchy_level: int, | |
| ) -> list[dict]: | |
| """ | |
| Get IoU, overlap coefficient, and density across dilations for a multi-channel combination. | |
| Returns empty list if the combination doesn't exist in the database. | |
| """ | |
| # Get total volume for density calculation | |
| bounds = self.metadata.volume_bounds | |
| total_volume = ( | |
| (bounds["x"][1] - bounds["x"][0]) * | |
| (bounds["y"][1] - bounds["y"][0]) * | |
| (bounds["z"][1] - bounds["z"][0]) | |
| ) | |
| channel_order = self.metadata.channels if self.metadata else [] | |
| sorted_channels = sorted( | |
| channels, | |
| key=lambda c: channel_order.index(c) if c in channel_order else 999 | |
| ) | |
| channels_str = "|".join(sorted_channels) | |
| cursor = self._conn.execute(''' | |
| SELECT | |
| dilation, | |
| SUM(total_count) as sum_inter, | |
| SUM(total_union) as sum_union | |
| FROM combinations | |
| WHERE channels = ? AND hierarchy_level = ? | |
| GROUP BY dilation | |
| ORDER BY dilation | |
| ''', (channels_str, hierarchy_level)) | |
| rows = cursor.fetchall() | |
| if not rows: | |
| return [] # Combination doesn't exist in database | |
| results = [] | |
| for row in rows: | |
| dilation = row["dilation"] | |
| sum_inter = row["sum_inter"] or 0 | |
| sum_union = row["sum_union"] or 1 | |
| # IoU | |
| iou = sum_inter / sum_union if sum_union > 0 else 0.0 | |
| # Overlap coefficient: intersection / min(channel_voxels) | |
| overlap_coeff = self._compute_overlap_coeff( | |
| channels, sum_inter, dilation, hierarchy_level | |
| ) | |
| # Density of intersection | |
| density = (sum_inter / total_volume * 100) if total_volume > 0 else 0.0 | |
| results.append({ | |
| "dilation": dilation, | |
| "count": sum_inter, | |
| "iou": iou, | |
| "overlap_coeff": overlap_coeff, | |
| "density": density, | |
| }) | |
| return results |
| def on_heatmap_combination_change(heatmap_combination, **kwargs): | ||
| print(f"[state] Heatmap combination changed: {heatmap_combination}") | ||
| if hasattr(ctrl, 'update_heatmap'): | ||
| ctrl.update_heatmap() | ||
| if hasattr(ctrl, 'print_dilation_curve'): | ||
| ctrl.print_dilation_curve() |
There was a problem hiding this comment.
Calling ctrl.print_dilation_curve() on every heatmap_combination change looks like debug-only behavior and can spam stdout (and potentially do heavy DB work) during normal interaction. Consider removing this call, gating it behind an explicit debug flag, or switching to structured logging at a debug level.
| @state.change("dilation_selected_channels") | ||
| def on_dilation_selected_channels_change(dilation_selected_channels, **kwargs): | ||
| print(f"[state] Dilation selected channels changed: {len(dilation_selected_channels)} channels") | ||
| if hasattr(ctrl, 'update_dilation_data'): | ||
| ctrl.update_dilation_data() | ||
|
|
There was a problem hiding this comment.
The new dilation_selected_channels state change handler triggers update_dilation_data(), but update_dilation_data() currently uses state.active_channels (IDs) as its input and ignores dilation_selected_channels entirely. This makes dilation_selected_channels effectively unused and can lead to confusing behavior if the dilation plot is intended to be independently filterable; either wire dilation_selected_channels into update_dilation_data() (and align types: names vs IDs) or remove the state/handler.
| @state.change("dilation_selected_channels") | |
| def on_dilation_selected_channels_change(dilation_selected_channels, **kwargs): | |
| print(f"[state] Dilation selected channels changed: {len(dilation_selected_channels)} channels") | |
| if hasattr(ctrl, 'update_dilation_data'): | |
| ctrl.update_dilation_data() |
| def update_dilation_data(): | ||
| """Update dilation curve data for the line plot.""" | ||
| loader = _refs.get("analysis_loader") | ||
| if not loader or not loader.is_loaded: | ||
| print("[callbacks] Cannot update dilation data - loader not ready") | ||
| state.dilation_data = {} | ||
| return | ||
|
|
||
| print( | ||
| f"[callbacks] Updating dilation data: mode={state.dilation_view_mode}, level={state.current_hierarchy_level}") | ||
|
|
||
| active_ids = state.active_channels or [] | ||
| if not active_ids: | ||
| state.dilation_data = {} | ||
| print("[callbacks] Dilation data cleared (no channels selected)") | ||
| return | ||
|
|
||
| channels_list = state.channels or [] | ||
| id_to_name = {ch["id"]: ch["name"] for ch in channels_list} | ||
| selected = [id_to_name[ch_id] for ch_id in active_ids if ch_id in id_to_name] | ||
|
|
||
| if not selected: | ||
| state.dilation_data = {} | ||
| return | ||
|
|
||
| view_mode = getattr(state, "dilation_view_mode", "single") | ||
| level = getattr(state, "current_hierarchy_level", 0) | ||
|
|
||
| dilation_curves = loader.get_subcombination_dilation_curves(selected, hierarchy_level=level) | ||
|
|
||
| result = {} | ||
| if view_mode == "single": | ||
| for curve_key in dilation_curves: | ||
| if "|" not in curve_key: | ||
| result[curve_key] = dilation_curves[curve_key] | ||
|
|
||
| else: | ||
| for curve_key in dilation_curves: | ||
| if "|" in curve_key: | ||
| result[curve_key] = dilation_curves[curve_key] | ||
| state.dilation_data = result |
There was a problem hiding this comment.
update_dilation_data() derives the dilation-curve inputs from state.active_channels (IDs) and ignores state.dilation_selected_channels (which is initialized on load and has its own state-change handler). This makes the new dilation selection state ineffective and may not match the intended UX. Consider using dilation_selected_channels as the source of truth (or removing it entirely) and keeping the representation consistent (either all IDs or all names).
| # Generate all subcombinations of size 1 to len(channels) | ||
| for size in range(1, len(channels) + 1): | ||
| for combo in iter_combinations(channels, size): | ||
| combo_list = list(combo) | ||
| combo_key = make_key(combo_list) | ||
|
|
||
| # Get dilation curve for this subcombination | ||
| if size == 1: | ||
| curve = self._get_single_channel_dilation_curve( | ||
| combo_list[0], hierarchy_level | ||
| ) | ||
| else: | ||
| curve = self._get_multi_channel_dilation_curve_full( | ||
| combo_list, hierarchy_level | ||
| ) | ||
|
|
||
| # Only include if data exists | ||
| if curve: | ||
| results[combo_key] = curve |
There was a problem hiding this comment.
get_subcombination_dilation_curves() generates and queries all subcombinations of the provided channels (2^N−1). If channels comes from active_channels, this can grow exponentially and cause noticeable UI stalls (many SQL queries, repeated per hierarchy change/view toggle). Consider enforcing an upper bound (e.g., max N), limiting to specific sizes (singles + pairs), or restructuring to batch-query curves more efficiently.
| result[curve_key] = dilation_curves[curve_key] | ||
| state.dilation_data = result | ||
|
|
||
| print(f"[callbacks] Dilation data updated: keys={list(state.dilation_data.keys())}") |
There was a problem hiding this comment.
update_dilation_data() logs keys={list(state.dilation_data.keys())}. In multiple mode (where many subcombinations may exist), this list can become very large and flood logs. Consider logging just the count (and maybe first few keys) instead.
| print(f"[callbacks] Dilation data updated: keys={list(state.dilation_data.keys())}") | |
| keys = list(state.dilation_data.keys()) | |
| max_preview = 10 | |
| preview_keys = keys[:max_preview] | |
| print( | |
| f"[callbacks] Dilation data updated: key_count={len(keys)}, first_keys={preview_keys}" | |
| ) |
No description provided.