Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d41be0f
refactor: share echarts theming; add RawEChartsWidget
dfguerrerom Jul 15, 2026
12ffab6
feat: confusion-matrix heatmap chart
dfguerrerom Jul 15, 2026
1a5eea7
feat: accuracy-by-class bar chart
dfguerrerom Jul 15, 2026
fca7966
feat: estimated-area proportion pie chart
dfguerrerom Jul 15, 2026
382277a
feat: analysis dashboard modal
dfguerrerom Jul 15, 2026
f9bb3da
fix: transparent background for AreaEstimateChart
dfguerrerom Jul 15, 2026
5f579f7
feat: results summary card opens dashboard modal
dfguerrerom Jul 15, 2026
eb5a64e
chore: drop dead _OverallAccuracy; fix analysis_chart docstring
dfguerrerom Jul 15, 2026
4884649
test: harden empty-matrix guard; cover chart edge cases
dfguerrerom Jul 15, 2026
570bf65
feat: extract_map_codes samples a classification raster at points
dfguerrerom Jul 15, 2026
df33500
fix: drop reference points on the raster right/bottom edge
dfguerrerom Jul 15, 2026
1b3d9de
feat: map area source derives map_code + areas from a raster
dfguerrerom Jul 15, 2026
8907244
feat: classification-map area source in the analysis tab
dfguerrerom Jul 15, 2026
d4e5a31
fix: surface classification-map derivation errors instead of crashing…
dfguerrerom Jul 15, 2026
6eff565
feat: visualize the analysis classification map + points
dfguerrerom Jul 15, 2026
9bcf4a4
fix: surface classification-map layer-render failures instead of fail…
dfguerrerom Jul 15, 2026
fc5963a
test: standalone classification-map analysis end-to-end
dfguerrerom Jul 15, 2026
65b7a3f
test: assert correctness (accuracy, areas, diagonal matrix) in standa…
dfguerrerom Jul 15, 2026
089169b
fix: derive class colors from the raster for the standalone map
dfguerrerom Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions component/analysis/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def create_inputs_from_state(app_state) -> AnalysisInputs:
if raw_area is not None and not raw_area.empty
else pd.DataFrame()
)
elif app_state.analysis_area_source.value == "map":
# raster-derived area table is already canonical map_code / map_area
raw_area = app_state.analysis_area_df.value
area = raw_area.copy() if raw_area is not None else pd.DataFrame()
else:
# design side already yields map_code / map_area
area = app_state.area_data.value
Expand Down
8 changes: 7 additions & 1 deletion component/model/state_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,13 @@ def __init__(self):

# --- Analysis (accuracy assessment) ---
self.analysis_reference_df = solara.reactive(pd.DataFrame())
self.analysis_area_source = solara.reactive("design") # "design" | "upload"
self.analysis_area_source = solara.reactive(
"design"
) # "design" | "upload" | "map"
self.analysis_area_df = solara.reactive(pd.DataFrame())
self.analysis_classification_path = solara.reactive(
None
) # raster path for "map" source
self.analysis_column_mapping = solara.reactive({})
self.analysis_filter = solara.reactive(None)
self.analysis_confidence_level = solara.reactive(95.0)
Expand Down Expand Up @@ -569,6 +574,7 @@ def clear_analysis_data(self):
self.analysis_reference_df.value = pd.DataFrame()
self.analysis_area_source.value = "design"
self.analysis_area_df.value = pd.DataFrame()
self.analysis_classification_path.value = None
self.analysis_column_mapping.value = {}
self.analysis_filter.value = None
self.analysis_confidence_level.value = 95.0
Expand Down
20 changes: 20 additions & 0 deletions component/scripts/accuracy.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,26 @@ def overall_accuracy(pij: pd.DataFrame) -> float:
return float(np.trace(m.values))


def derive_from_classification(reference_df, mapping, raster_path):
"""Fill map_code by sampling a raster and compute the per-class area table.

Returns (reference_df_with_map_code, area_df, dropped_count). The map wins:
any existing map_code column is overwritten by the sampled value.
"""
from component.scripts.geospatial import (
compute_area_from_raster,
extract_map_codes,
)

x_col = mapping.get("x")
y_col = mapping.get("y")
if not x_col or not y_col:
raise ValueError("Classification-map analysis requires x/y column mapping.")
ref_out, dropped = extract_map_codes(reference_df, raster_path, x_col, y_col)
area_df = compute_area_from_raster(raster_path)
return ref_out, area_df, dropped


def convert_area(value: float, unit: str) -> float:
"""Convert a native (m2) area for display. 'ha' -> /10000; else identity."""
if unit == "ha":
Expand Down
40 changes: 40 additions & 0 deletions component/scripts/geospatial.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import pandas as pd
import rasterio
from rasterio.transform import xy
from rasterio.warp import transform as warp_transform
from rasterio.windows import Window
from shapely.geometry import Point

Expand Down Expand Up @@ -1243,3 +1244,42 @@ def get_crs_string(crs):
info["error"] = str(e)

return info


def extract_map_codes(
reference_df: pd.DataFrame,
raster_path: str,
x_col: str,
y_col: str,
points_crs: str = "EPSG:4326",
) -> "tuple[pd.DataFrame, int]":
"""Sample a classification raster at each reference point to fill map_code.

Reprojects the (x_col, y_col) points from points_crs to the raster CRS,
samples band 1, and returns (df_with_map_code, dropped_count). Rows whose
point falls outside the raster footprint or on the nodata value are dropped.
"""
df = reference_df.copy()
xs = df[x_col].astype(float).to_numpy()
ys = df[y_col].astype(float).to_numpy()
with rasterio.open(raster_path) as src:
if src.crs is not None and points_crs and str(src.crs) != str(points_crs):
rxs, rys = warp_transform(points_crs, src.crs, xs.tolist(), ys.tolist())
else:
rxs, rys = xs.tolist(), ys.tolist()
nodata = src.nodata
pts = list(zip(rxs, rys))
codes = []
for (x, y), val in zip(pts, src.sample(pts, indexes=1)):
row, col = src.index(x, y)
v = val[0]
inside = 0 <= row < src.height and 0 <= col < src.width
if inside and not (nodata is not None and v == nodata):
codes.append(int(v))
else:
codes.append(None)
df["map_code"] = codes
dropped = int(df["map_code"].isna().sum())
df = df[df["map_code"].notna()].copy()
df["map_code"] = df["map_code"].astype(int)
return df, dropped
182 changes: 178 additions & 4 deletions component/widget/analysis_chart.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
"""Bar chart of error-adjusted area per class, with confidence-interval bars."""
"""Charts for the analysis results.

Error-adjusted area bars, confusion-matrix heatmap, accuracy-by-class bars,
and estimated-area proportion pie.
"""

import solara
from ipecharts.option import Grid, Option, Title, Tooltip, XAxis, YAxis
from ipecharts.option.series import Bar, Custom
from ipecharts.option import Grid, Legend, Option, Title, Tooltip, XAxis, YAxis
from ipecharts.option.series import Bar, Custom, Pie
from ipecharts.tools import encode_js_fn

from component.model import app_state
from component.scripts.accuracy import convert_area
from component.widget.echarts import EChartsWidget
from component.widget.echarts import EChartsWidget, RawEChartsWidget


@solara.component
Expand Down Expand Up @@ -53,6 +57,7 @@ def AreaEstimateChart(results: dict, unit: str, theme_toggle=None):
)

option = Option(
backgroundColor="#1e1e1e00",
title=Title(
text="Error-adjusted area by class",
left="center",
Expand All @@ -72,3 +77,172 @@ def AreaEstimateChart(results: dict, unit: str, theme_toggle=None):
style={"height": "420px", "width": "100%"},
theme_toggle=theme_toggle,
)


def confusion_heatmap_data(confusion_matrix: dict):
"""Reshape a confusion-matrix dict into echarts heatmap inputs.

Returns (x_labels, y_labels, triples, max_count):
x_labels = reference classes (columns) as strings
y_labels = mapped classes (index) as strings
triples = [x_index, y_index, count] for every cell
max_count = largest cell value, at least 1 (for visualMap.max)
"""
x_labels = [str(c) for c in confusion_matrix.get("columns", [])]
y_labels = [str(i) for i in confusion_matrix.get("index", [])]
triples = []
max_count = 0.0
for y, row in enumerate(confusion_matrix.get("data", [])):
for x, value in enumerate(row):
count = float(value)
triples.append([x, y, count])
max_count = max(max_count, count)
return x_labels, y_labels, triples, max(max_count, 1.0)


@solara.component
def ConfusionMatrixChart(results: dict, theme_toggle=None):
cm = results.get("confusion_matrix")
if not cm or not cm.get("data"):
return
x_labels, y_labels, triples, max_count = confusion_heatmap_data(cm)
option = {
"backgroundColor": "#1e1e1e00",
"title": {
"text": "Confusion matrix (map -> reference)",
"left": "center",
"textStyle": {"fontSize": 13, "fontWeight": "normal"},
},
"tooltip": {"position": "top"},
"grid": {"top": "14%", "bottom": "18%", "left": "16%", "right": "8%"},
"xAxis": {
"type": "category",
"data": x_labels,
"name": "reference",
"nameLocation": "middle",
"nameGap": 28,
"splitArea": {"show": True},
"axisLabel": {"fontSize": 10},
},
"yAxis": {
"type": "category",
"data": y_labels,
"name": "map",
"splitArea": {"show": True},
"axisLabel": {"fontSize": 10},
},
"visualMap": {
"min": 0,
"max": max_count,
"calculable": True,
"orient": "horizontal",
"left": "center",
"bottom": "2%",
},
"series": [
{
"type": "heatmap",
"data": triples,
"label": {"show": True, "fontSize": 9},
"emphasis": {
"itemStyle": {
"shadowBlur": 6,
"shadowColor": "rgba(0,0,0,0.3)",
}
},
}
],
}
RawEChartsWidget.element(
option=option,
style={"height": "420px", "width": "100%"},
theme_toggle=theme_toggle,
)


@solara.component
def AccuracyByClassChart(results: dict, theme_toggle=None):
rows = results.get("accuracy_rows", [])
if not rows:
return
names = [r["class_name"] for r in rows]
users = [round(r["users_accuracy"] * 100, 1) for r in rows]
producers = [round(r["producers_accuracy"] * 100, 1) for r in rows]
option = Option(
backgroundColor="#1e1e1e00",
title=Title(
text="Accuracy by class",
left="center",
textStyle={"fontSize": 13, "fontWeight": "normal"},
),
tooltip=Tooltip(trigger="axis", axisPointer={"type": "shadow"}),
legend=Legend(bottom=0),
xAxis=XAxis(
type="category",
data=names,
axisLabel={"fontSize": 10, "interval": 0, "rotate": 30},
),
yAxis=YAxis(type="value", name="%", max=100),
grid=Grid(left="10%", right="6%", top="14%", bottom="18%"),
series=[Bar(name="User's", data=users), Bar(name="Producer's", data=producers)],
)
EChartsWidget.element(
option=option,
style={"height": "420px", "width": "100%"},
theme_toggle=theme_toggle,
)


_PIE_FALLBACK = [
"#5470c6",
"#91cc75",
"#fac858",
"#ee6666",
"#73c0de",
"#3ba272",
"#fc8452",
"#9a60b4",
"#ea7ccc",
]


@solara.component
def AreaProportionChart(results: dict, theme_toggle=None):
rows = results.get("class_estimates", [])
if not rows:
return
colors = app_state.class_colors.value or {}
total = sum(max(r["area_estimate"], 0.0) for r in rows) or 1.0
pie_data = []
chart_colors = []
for idx, r in enumerate(rows):
pct = 100.0 * max(r["area_estimate"], 0.0) / total
chart_colors.append(
colors.get(r["map_code"], _PIE_FALLBACK[idx % len(_PIE_FALLBACK)])
)
pie_data.append(
{"value": round(pct, 2), "name": f"{r['class_name']} ({pct:.1f}%)"}
)
pie = Pie(
data=pie_data,
radius=[50, 100],
itemStyle={"borderRadius": 5, "borderColor": "#fff", "borderWidth": 2},
label={"show": False, "position": "center"},
emphasis={"label": {"show": True, "fontSize": 12}},
)
option = Option(
backgroundColor="#1e1e1e00",
legend=Legend(bottom=0),
series=[pie],
color=chart_colors,
title=Title(
text="Estimated area proportion",
left="center",
textStyle={"fontSize": 13, "fontWeight": "normal"},
),
)
EChartsWidget.element(
option=option,
style={"height": "420px", "width": "100%"},
theme_toggle=theme_toggle,
)
Loading
Loading