Skip to content

Dilation plot#44

Open
reichmla wants to merge 7 commits into
masterfrom
dilation-plot
Open

Dilation plot#44
reichmla wants to merge 7 commits into
masterfrom
dilation-plot

Conversation

@reichmla
Copy link
Copy Markdown
Collaborator

No description provided.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 linechart Vue component and wires it into the right drawer for dilation-curve visualization.
  • Adds upset_metric state/UI and updates UpSet rendering to plot a chosen metric (IoU or overlap coefficient).
  • Extends AnalysisLoader to 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.

Comment on lines +53 to 66
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]
}));
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread src/bioset/ui/state.py
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)
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
state.setdefault("dilation_filter_dialog", False)
state.setdefault("dilation_filter_dialog", False)
state.setdefault("dilation_selected_channels", [])

Copilot uses AI. Check for mistakes.
Comment on lines +653 to +683
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}")

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +764 to +830
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

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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).

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread src/bioset/ui/state.py
Comment on lines 397 to +402
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()
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/bioset/ui/state.py
Comment on lines +472 to +477
@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()

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@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()

Copilot uses AI. Check for mistakes.
Comment on lines +848 to +888
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
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +694 to +712
# 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
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
result[curve_key] = dilation_curves[curve_key]
state.dilation_data = result

print(f"[callbacks] Dilation data updated: keys={list(state.dilation_data.keys())}")
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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}"
)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants