Spaces:
Running
Running
File size: 22,255 Bytes
439fd56 a2bad67 439fd56 d1430bc 956b532 439fd56 d1430bc 439fd56 a2bad67 439fd56 d1430bc 439fd56 d1430bc 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 956b532 a2bad67 956b532 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 956b532 439fd56 956b532 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 956b532 439fd56 a2bad67 439fd56 956b532 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 a2bad67 439fd56 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | """Public statistics from a single, safely filtered aerial.videos catalog."""
from __future__ import annotations
from datetime import date
import html
import math
from typing import Callable, Mapping, Sequence
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
from live_inventory import VideoCatalog, VideoRecord, filter_records, summarize_records
from city_labels import city_display_name, is_city_label
from dashboard_charts import DATA_BLUE, style_figure
_UNKNOWN = "未知"
_FILTER_KEYS = ("pg_cities", "pg_date_range", "pg_include_unknown_dates",
"pg_codecs", "pg_periods")
_EXPORT_COLUMNS = (
"city", "collection_month", "period", "codec", "resolution", "fps",
"duration_range", "videos", "duration_hours", "size_tb",
"duration_known_videos", "size_known_videos",
)
def selected_records(catalog: VideoCatalog, state: Mapping) -> tuple[VideoRecord, ...]:
"""Use the same inclusive filters for KPIs, charts, maps and export."""
# Other pages and unavailable renders have no filter widgets. Their shared
# facts must still use the last selection, while current widgets override it.
state = {**state.get("_pg_filter_snapshot", {}),
**{key: state[key] for key in _FILTER_KEYS if key in state}}
dates = state.get("pg_date_range", ())
if isinstance(dates, date):
dates = (dates, dates)
start = dates[0] if dates else None
end = dates[-1] if dates else None
return filter_records(
catalog.records,
cities=state.get("pg_cities", ()),
start_date=start, end_date=end,
include_unknown_dates=state.get("pg_include_unknown_dates", True),
codecs=state.get("pg_codecs", ()),
periods=state.get("pg_periods", ()),
)
def _resolution(record: VideoRecord) -> str:
return f"{record.width} × {record.height}" if record.width and record.height else _UNKNOWN
def _fps(record: VideoRecord) -> str:
return f"{float(record.fps):g}" if record.fps is not None else _UNKNOWN
def _duration_range(record: VideoRecord) -> str:
value = record.duration_seconds
if value is None:
return _UNKNOWN
if value < 60:
return "< 1 min"
if value < 300:
return "1–5 min"
if value < 900:
return "5–15 min"
if value < 1800:
return "15–30 min"
if value < 3600:
return "30–60 min"
return "≥ 60 min"
def aggregate_rows(records: Sequence[VideoRecord]) -> pd.DataFrame:
"""Only coarse, aggregate metadata can be downloaded from the dashboard."""
groups: dict[tuple, list] = {}
for record in records:
key = (record.city, record.collected_on.strftime("%Y-%m") if record.collected_on else _UNKNOWN,
record.period, record.codec or _UNKNOWN, _resolution(record),
_fps(record), _duration_range(record))
totals = groups.setdefault(key, [0, 0.0, 0.0, 0, 0])
totals[0] += 1
if record.duration_seconds is not None:
totals[1] += float(record.duration_seconds) / 3600
totals[3] += 1
if record.size_bytes is not None:
totals[2] += float(record.size_bytes) / 1_000_000_000_000
totals[4] += 1
return pd.DataFrame(
[(city_display_name(key[0]), *key[1:], totals[0], totals[1] if totals[3] else None,
totals[2] if totals[4] else None, totals[3], totals[4])
for key, totals in sorted(groups.items())],
columns=_EXPORT_COLUMNS,
)
def duration_hours_by(records: Sequence[VideoRecord], key: Callable[[VideoRecord], str]) -> dict[str, float | None]:
"""Sum actual known durations per category; unknown-only is not measured zero."""
totals = {}
for record in records:
bucket = totals.setdefault(key(record), [0.0, False])
seconds = record.duration_seconds
if seconds is not None and not isinstance(seconds, bool) and math.isfinite(float(seconds)) and seconds >= 0:
bucket[0] += float(seconds) / 3600
bucket[1] = True
return {value: hours if known else None for value, (hours, known) in totals.items()}
def public_aggregate_rows(records: Sequence[VideoRecord]) -> pd.DataFrame:
"""Keep internal count fields for validation, expose hours and quality states."""
rows = aggregate_rows(records)
for kind in ('duration', 'size'):
known = rows[f'{kind}_known_videos']
rows[f'{kind}_status'] = ['unknown' if n == 0 else 'partial' if n < total else 'known'
for n, total in zip(known, rows.videos)]
return rows.drop(columns=['videos', 'duration_known_videos', 'size_known_videos'])
def render_dashboard(load_catalog: Callable, clear_catalog: Callable,
translate: Callable[[str], str], *, render_annotations: Callable | None = None) -> None:
"""Refresh the whole dashboard together, never a separate population."""
def tr(zh: str, en: str | None = None) -> str:
translated = translate(zh)
if st.session_state.get("lang") == "en" and translated == zh and en:
return en
return translated
def category(value: str) -> str:
if is_city_label(value):
return city_display_name(value)
labels = {
_UNKNOWN: "Unknown", "缺少坐标": "Missing coordinates",
"边界外或坐标异常": "Unassigned city",
"已归属城市": "Assigned city", "早高峰 (07-09)": "Morning peak (07-09)",
"日常规": "Daytime", "晚高峰 (17-19)": "Evening peak (17-19)",
"夜间 (19-07)": "Night (19-07)",
"未知采集时间": "Unknown collection date", "known": "Known", "partial": "Partly unknown",
"unknown": "Unknown", "< 1 min": "< 0.017 h", "1–5 min": "0.017–0.083 h",
"5–15 min": "0.083–0.25 h", "15–30 min": "0.25–0.5 h", "30–60 min": "0.5–1 h", "≥ 60 min": "≥ 1 h",
}
if value in {"< 1 min", "1–5 min", "5–15 min", "15–30 min", "30–60 min", "≥ 60 min"}:
return labels[value]
if value in {'known', 'partial', 'unknown'}:
return tr({'known':'已知','partial':'部分未知','unknown':'未知'}[value], labels[value])
return tr(value, labels.get(value))
def heading(anchor: str, zh: str, en: str, description: str = "", english: str = "") -> None:
st.markdown(
f"<div id='{anchor}' class='dash-section'><h2 class='dash-section-title'>"
f"{html.escape(tr(zh, en))}</h2></div>", unsafe_allow_html=True,
)
if description:
st.caption(tr(description, english))
def bar(counts: Mapping[str, float | None], title: str, english: str,
*, order: Sequence[str] | None = None) -> None:
keys = [key for key in order if key in counts] if order else sorted(counts, key=lambda key: (counts[key] is None, -(counts[key] or 0), key))
if order:
keys.extend(key for key in counts if key not in keys)
figure = go.Figure(go.Bar(x=[category(key) for key in keys],
y=[counts[key] for key in keys],
marker_color=DATA_BLUE,
hovertemplate="%{x}: %{y:.3f} h<extra></extra>"))
style_figure(figure, title=tr(title, english), height=320)
figure.update_yaxes(title='h', rangemode="tozero", tickformat=",.2f")
st.plotly_chart(figure, width="stretch")
def unavailable_annotations() -> None:
if render_annotations is not None:
render_annotations()
return
st.info(tr(
"当前视频表没有道路、天气、光照、动态目标、审核关键帧或经审定地点标签,相关标注分布与覆盖率暂不可统计。",
"The video table does not contain road, weather, lighting, dynamic-object, reviewed-keyframe or approved-location labels. Their distributions and annotation coverage are unavailable.",
))
@st.fragment(run_every="60s")
def render_current_catalog() -> None:
heading("nav-scenario", "实时视频统计", "Live video statistics")
if st.button(tr("刷新数据", "Refresh data"), key="pg_refresh"):
clear_catalog()
catalog, status = load_catalog()
if status == "not_configured":
st.info(tr("尚未配置 PostgreSQL 数据源。", "PostgreSQL data source is not configured."))
return
if status != "ok" or catalog is None:
st.warning(tr("PostgreSQL 数据暂不可用,请稍后刷新。", "PostgreSQL data is unavailable. Please refresh later."))
return
# Streamlit removes widget state when an unavailable page does not
# render its controls. Keep a nonwidget snapshot so recovery preserves
# the user's filters instead of silently showing the entire inventory.
saved_filters = st.session_state.get("_pg_filter_snapshot", {})
for key, value in saved_filters.items():
if key not in st.session_state:
st.session_state[key] = list(value) if isinstance(value, list) else value
updated = str(catalog.latest_updated_at)[:19] if catalog.latest_updated_at else "—"
st.caption(f"{tr('数据源', 'Source')}: aerial_db · aerial.videos | "
f"{tr('库内最近更新', 'Latest database update')}: {updated} | "
f"{tr('每 60 秒自动刷新', 'Refreshes every 60 seconds')}")
if not catalog.records:
st.info(tr("当前数据库没有有效视频记录。", "The database has no active video records."))
with st.expander(tr("筛选条件", "Filters"), expanded=True):
# Retain removed selections as available options. Clearing them would
# turn a deliberately narrow filter into the entire new population.
city_options = sorted({record.city for record in catalog.records} |
set(st.session_state.get("pg_cities", ())))
codec_options = sorted({record.codec or _UNKNOWN for record in catalog.records} |
set(st.session_state.get("pg_codecs", ())))
period_options = sorted({record.period for record in catalog.records} |
set(st.session_state.get("pg_periods", ())))
left, right = st.columns(2)
with left:
st.multiselect(tr("城市", "City"), city_options, key="pg_cities", format_func=category)
dates = [record.collected_on for record in catalog.records if record.collected_on]
if dates:
bounds = (min(dates), max(dates))
previous_bounds = st.session_state.get("_pg_date_bounds")
selected_dates = st.session_state.get("pg_date_range")
if selected_dates is None or tuple(selected_dates) == previous_bounds:
st.session_state["pg_date_range"] = bounds
st.session_state["_pg_date_bounds"] = bounds
if dates or "pg_date_range" in st.session_state:
# The default 'today' reads an existing range from state;
# this avoids competing widget-default and state values.
st.date_input(tr("采集日期范围", "Collection date range"),
key="pg_date_range")
else:
st.caption(tr("采集日期均未知。", "All collection dates are unknown."))
if "pg_include_unknown_dates" not in st.session_state:
st.session_state["pg_include_unknown_dates"] = True
st.checkbox(tr("包含未知采集日期", "Include unknown collection dates"),
key="pg_include_unknown_dates")
with right:
st.multiselect(tr("编码格式", "Codec"), codec_options, key="pg_codecs", format_func=category)
st.multiselect(tr("采集时段", "Collection period"), period_options,
key="pg_periods", format_func=category)
st.caption(tr("时段按北京时间分组;每个图表与导出均应用以上筛选。",
"Periods use China Standard Time. These filters apply to every chart and export."))
st.session_state["_pg_filter_snapshot"] = {
key: list(st.session_state[key]) if isinstance(st.session_state[key], list)
else st.session_state[key]
for key in _FILTER_KEYS if key in st.session_state
}
records = selected_records(catalog, st.session_state)
snapshot = summarize_records(records, catalog.latest_updated_at)
city_count = len({record.city for record in records if record.gps_status == "已归属城市"})
duration_known = sum(record.duration_seconds is not None for record in records)
size_known = sum(record.size_bytes is not None for record in records)
duration_display = f"{snapshot.duration_hours:,.1f}" if not records or duration_known else "—"
size_display = f"{snapshot.size_tb:,.2f}" if not records or size_known else "—"
gps_records = tuple(record for record in records if record.gps_status != "缺少坐标")
gps_hours = sum(record.duration_seconds / 3600 for record in gps_records if record.duration_seconds is not None)
gps_display = f"{gps_hours:,.2f}" if not gps_records or any(record.duration_seconds is not None for record in gps_records) else '—'
metrics = (
(duration_display, tr("已知时长合计(h)", "Known duration total (h)")),
(size_display, tr("已知容量合计(TB)", "Known size total (TB)")),
(str(city_count), tr("覆盖城市", "Cities")),
(gps_display, tr("含坐标视频时长(h)", "Geolocated video duration (h)")),
)
for index, (column, (value, label)) in enumerate(zip(st.columns(4), metrics)):
with column:
style = ("kpi-blue", "kpi-indigo", "kpi-teal", "kpi-purple")[index % 4]
st.markdown(f"<div class='kpi-card {style}'><div class='kpi-num'>{value}</div>"
f"<div class='kpi-lbl'>{html.escape(label)}</div></div>", unsafe_allow_html=True)
if not records:
st.info(tr("当前筛选没有匹配的视频,请调整条件。", "No videos match these filters. Adjust the selections."))
heading("nav-odd", "元数据与标注状态", "Metadata and annotation status")
unavailable_annotations()
return
heading("nav-city", "城市与采集分布", "Cities and collection distribution",
"城市按采集坐标与行政区边界归属;地图点为城市行政区代表点,不表示视频采集位置。",
"Cities are assigned from collection GPS and administrative boundaries. Map markers represent city administrative areas, not video collection positions.")
city_counts = duration_hours_by(records, lambda record: record.city)
centers = {center.city: center for center in catalog.city_centers}
mapped_cities = sorted(set(city_counts) & set(centers))
left, right = st.columns(2)
with left:
if mapped_cities:
figure = go.Figure(go.Scattermap(
lon=[centers[city].longitude for city in mapped_cities],
lat=[centers[city].latitude for city in mapped_cities],
text=[f"{category(city)} · " + (f"{city_counts[city]:.3f} h" if city_counts[city] is not None else tr("时长未知", "Duration unknown")) for city in mapped_cities],
customdata=[city_counts[city] for city in mapped_cities],
mode="markers", marker=dict(size=14, color=DATA_BLUE),
hovertemplate="%{text}<extra></extra>",
))
style_figure(figure, height=340)
figure.update_layout(map=dict(style="carto-positron", center=dict(lat=36, lon=113), zoom=3),
margin=dict(l=0, r=0, t=0, b=0), height=340)
st.plotly_chart(figure, width="stretch")
else:
st.info(tr("当前筛选没有可归属城市的记录。", "No selected videos can be assigned to a city."))
with right:
bar(city_counts, "城市视频时长(h)", "Video duration by city (h)")
left, right = st.columns(2)
with left:
months = duration_hours_by(records, lambda record: record.collected_on.strftime("%Y-%m") if record.collected_on else _UNKNOWN)
bar(months, "采集月份时长(h)", "Duration by collection month (h)", order=sorted(months))
with right:
bar(duration_hours_by(records, lambda record: record.period), "采集时段分布(h)", "Collection period (h)",
order=("早高峰 (07-09)", "日常规", "晚高峰 (17-19)", "夜间 (19-07)", _UNKNOWN))
heading("nav-params", "视频参数分布", "Video parameters")
for left_spec, right_spec in (
((lambda record: record.codec or _UNKNOWN, "编码格式分布", "Codec distribution"),
(_resolution, "分辨率分布", "Resolution distribution")),
((_fps, "帧率分布", "Frame rate distribution"),
(_duration_range, "时长区间分布", "Duration distribution")),
):
for column, (key, zh, en) in zip(st.columns(2), (left_spec, right_spec)):
with column:
order = ("< 1 min", "1–5 min", "5–15 min", "15–30 min", "30–60 min", "≥ 60 min", _UNKNOWN) if key is _duration_range else None
bar(duration_hours_by(records, key), zh + "(h)", en + " (h)", order=order)
heading("nav-odd", "元数据与标注状态", "Metadata and annotation status",
"所有分布按已知视频时长(h)汇总;缺失字段独立显示,未知时长不推算,空值与测得零分开。",
"Distributions sum known video duration in hours. Missing metadata remains separate; unknown durations are not estimated and blank is distinct from measured zero.")
quality_specs = (
("采集日期", "Collection date", lambda record: record.collected_on is not None),
("城市归属", "City attribution", lambda record: record.gps_status == "已归属城市"),
("编码", "Codec", lambda record: record.codec not in (None, "", _UNKNOWN)),
("分辨率", "Resolution", lambda record: bool(record.width and record.height)),
("帧率", "Frame rate", lambda record: record.fps is not None),
("时长", "Duration", lambda record: record.duration_seconds is not None),
("容量", "Size", lambda record: record.size_bytes is not None),
("入库日期", "Ingestion date", lambda record: record.added_on is not None),
)
labels, present, absent = [], [], []
for zh, en, predicate in quality_specs:
labels.append(tr(zh, en))
hours = duration_hours_by(records, lambda record: 'known' if predicate(record) else 'unknown')
present.append(hours.get('known', 0.0))
absent.append(hours.get('unknown', 0.0))
figure = go.Figure((
go.Bar(name=tr("已知 / 有效", "Known / valid"), x=labels, y=present, marker_color=DATA_BLUE),
go.Bar(name=tr("未知 / 无效", "Unknown / invalid"), x=labels,
y=absent, marker_color="#dce5ec"),
))
style_figure(figure, height=320)
figure.update_layout(barmode="stack", margin=dict(t=50))
figure.update_yaxes(title='h', rangemode="tozero", tickformat=",.2f")
figure.update_traces(hovertemplate='%{x}: %{y:.3f} h<extra>%{fullData.name}</extra>')
st.plotly_chart(figure, width="stretch")
left, right = st.columns(2)
with left:
bar(duration_hours_by(records, lambda record: record.gps_status), "坐标归属时长(h)", "GPS attribution duration (h)")
with right:
additions = duration_hours_by(records, lambda record: record.added_on.strftime("%Y-%m") if record.added_on else _UNKNOWN)
bar(additions, "入库月份时长(h)", "Duration by ingestion month (h)", order=sorted(additions))
unavailable_annotations()
st.subheader(tr("当前筛选汇总", "Filtered aggregate data"))
st.caption(tr("时长单位 h;空值表示该组时长未知,部分未知状态表示仅合计已知部分;测得零保留为零。",
"Duration is in h. Blank means unknown; partial means the known portion only. A measured zero remains zero."))
exported = public_aggregate_rows(records)
exported['duration_range'] = exported.duration_range.map(category)
exported['duration_status'] = exported.duration_status.map(category)
exported['size_status'] = exported.size_status.map(category)
st.dataframe(exported, hide_index=True, width="stretch", column_config={
"city": tr("城市", "City"), "collection_month": tr("采集月份", "Collection month"),
"period": tr("时段", "Period"), "codec": tr("编码", "Codec"),
"resolution": tr("分辨率", "Resolution"), "fps": tr("帧率", "FPS"),
"duration_range": tr("时长区间", "Duration range"),
"duration_hours": tr("时长(h)", "Duration (h)"), "size_tb": tr("容量(TB)", "Size (TB)"),
"duration_status": tr("时长完整度", "Duration completeness"),
"size_status": tr("容量完整度", "Size completeness"),
})
st.download_button(tr("下载汇总 CSV", "Download aggregate CSV"),
data=exported.to_csv(index=False).encode("utf-8-sig"),
file_name="aerial-videos-statistics.csv", mime="text/csv", key="pg_download")
render_current_catalog()
|