viseda 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- viseda/__init__.py +20 -0
- viseda/cli.py +144 -0
- viseda/core/__init__.py +3 -0
- viseda/core/base.py +69 -0
- viseda/hyperspectral/__init__.py +1 -0
- viseda/hyperspectral/eda.py +1849 -0
- viseda/image/__init__.py +1 -0
- viseda/image/eda.py +1840 -0
- viseda/pointcloud/__init__.py +3 -0
- viseda/pointcloud/eda.py +1167 -0
- viseda/report/__init__.py +3 -0
- viseda/report/html_report.py +213 -0
- viseda/text/__init__.py +3 -0
- viseda/text/eda.py +1613 -0
- viseda/utils/__init__.py +27 -0
- viseda/utils/helpers.py +120 -0
- viseda/video/__init__.py +3 -0
- viseda/video/eda.py +587 -0
- viseda-1.0.0.dist-info/METADATA +266 -0
- viseda-1.0.0.dist-info/RECORD +24 -0
- viseda-1.0.0.dist-info/WHEEL +5 -0
- viseda-1.0.0.dist-info/entry_points.txt +2 -0
- viseda-1.0.0.dist-info/licenses/LICENSE +21 -0
- viseda-1.0.0.dist-info/top_level.txt +1 -0
viseda/video/eda.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html, math
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional, Sequence, Union
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _cv2():
|
|
11
|
+
try:
|
|
12
|
+
import cv2
|
|
13
|
+
return cv2
|
|
14
|
+
except ImportError as exc:
|
|
15
|
+
raise ImportError('VideoEDA requires OpenCV for video files: pip install opencv-python') from exc
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _plt():
|
|
19
|
+
import matplotlib.pyplot as plt
|
|
20
|
+
return plt
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _stat(values):
|
|
24
|
+
a = np.asarray(values, dtype=float)
|
|
25
|
+
a = a[np.isfinite(a)]
|
|
26
|
+
if a.size == 0:
|
|
27
|
+
return dict(count=0, mean=None, std=None, min=None, p25=None, median=None, p75=None, max=None)
|
|
28
|
+
return dict(count=int(a.size), mean=round(float(a.mean()), 6), std=round(float(a.std()), 6),
|
|
29
|
+
min=round(float(a.min()), 6), p25=round(float(np.percentile(a,25)), 6),
|
|
30
|
+
median=round(float(np.median(a)), 6), p75=round(float(np.percentile(a,75)), 6),
|
|
31
|
+
max=round(float(a.max()), 6))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VideoRecord:
|
|
35
|
+
fields = [
|
|
36
|
+
'path','label','file_ext','file_size_kb','is_corrupt','error','n_frames','sampled_frames','fps','duration_sec',
|
|
37
|
+
'height','width','channels','aspect_ratio','megapixels','brightness_mean','brightness_std','contrast_mean',
|
|
38
|
+
'contrast_std','sharpness_mean','sharpness_std','sharpness_min','blur_fraction','frame_diff_mean','frame_diff_std',
|
|
39
|
+
'motion_intensity_mean','motion_intensity_std','motion_blur_fraction','scene_change_count','scene_change_rate',
|
|
40
|
+
'temporal_brightness_std','temporal_contrast_std','rgb_mean','rgb_std','sample_frames'
|
|
41
|
+
]
|
|
42
|
+
def __init__(self):
|
|
43
|
+
for f in self.fields:
|
|
44
|
+
setattr(self, f, None)
|
|
45
|
+
self.is_corrupt = False
|
|
46
|
+
self.error = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VideoEDA:
|
|
50
|
+
SUPPORTED_EXTS = {'.mp4','.avi','.mov','.mkv','.webm','.mpeg','.mpg','.m4v'}
|
|
51
|
+
|
|
52
|
+
def __init__(self, verbose: bool=True, max_videos: Optional[int]=None, frame_sample_rate: int=5,
|
|
53
|
+
max_frames_per_video: int=300, blur_threshold: float=80.0,
|
|
54
|
+
motion_blur_threshold: float=80.0, scene_change_threshold: float=35.0,
|
|
55
|
+
resize_width: Optional[int]=320):
|
|
56
|
+
self.verbose = verbose
|
|
57
|
+
self.max_videos = max_videos
|
|
58
|
+
self.frame_sample_rate = max(1, int(frame_sample_rate))
|
|
59
|
+
self.max_frames_per_video = max(1, int(max_frames_per_video))
|
|
60
|
+
self.blur_threshold = float(blur_threshold)
|
|
61
|
+
self.motion_blur_threshold = float(motion_blur_threshold)
|
|
62
|
+
self.scene_change_threshold = float(scene_change_threshold)
|
|
63
|
+
self.resize_width = resize_width
|
|
64
|
+
self._records = []
|
|
65
|
+
self._arrays = {}
|
|
66
|
+
self._label_map = {}
|
|
67
|
+
self._loaded = False
|
|
68
|
+
self._results = {}
|
|
69
|
+
|
|
70
|
+
def load(self, source: Union[str, Path, Sequence[Union[str, Path]]], labels: Optional[Dict[str,str]]=None,
|
|
71
|
+
label_from_parent: bool=False, recursive: bool=True):
|
|
72
|
+
paths = self._resolve_paths(source, recursive)
|
|
73
|
+
if self.max_videos is not None:
|
|
74
|
+
paths = paths[:self.max_videos]
|
|
75
|
+
if labels:
|
|
76
|
+
self._label_map = {str(Path(k).resolve()): v for k, v in labels.items()}
|
|
77
|
+
self._records, self._arrays = [], {}
|
|
78
|
+
self._log(f'Found {len(paths)} video file(s) — computing statistics …')
|
|
79
|
+
for i, p in enumerate(paths):
|
|
80
|
+
self._log(f' [{i+1}/{len(paths)}] {p.name}')
|
|
81
|
+
self._records.append(self._analyse_file(p, label_from_parent))
|
|
82
|
+
self._loaded = True
|
|
83
|
+
self._log(f'Done. {len(self._records)} video(s) loaded ({sum(r.is_corrupt for r in self._records)} corrupt).')
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def load_arrays(self, videos: Sequence[np.ndarray], labels: Optional[Sequence[str]]=None,
|
|
87
|
+
names: Optional[Sequence[str]]=None, fps: Union[float, Sequence[float]]=30.0):
|
|
88
|
+
if self.max_videos is not None:
|
|
89
|
+
videos = videos[:self.max_videos]
|
|
90
|
+
labels = labels[:self.max_videos] if labels is not None else None
|
|
91
|
+
names = names[:self.max_videos] if names is not None else None
|
|
92
|
+
self._records, self._arrays = [], {}
|
|
93
|
+
self._log(f'Loading {len(videos)} video array(s) …')
|
|
94
|
+
for i, arr in enumerate(videos):
|
|
95
|
+
r = VideoRecord()
|
|
96
|
+
r.path = names[i] if names and i < len(names) else f'<array_{i}>'
|
|
97
|
+
r.label = labels[i] if labels and i < len(labels) else None
|
|
98
|
+
r.file_ext = 'array'
|
|
99
|
+
try:
|
|
100
|
+
fpsi = float(fps[i]) if isinstance(fps, (list, tuple, np.ndarray)) else float(fps)
|
|
101
|
+
video = self._normalise_video_array(arr)
|
|
102
|
+
frames = self._sample_frames_from_array(video)
|
|
103
|
+
self._arrays[r.path] = frames
|
|
104
|
+
self._fill_stats(r, frames, video.shape[0], fpsi)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
r.is_corrupt, r.error = True, str(e)
|
|
107
|
+
self._log(f' ✗ {r.path}: {e}')
|
|
108
|
+
self._records.append(r)
|
|
109
|
+
self._loaded = True
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def summary(self):
|
|
113
|
+
self._check_loaded()
|
|
114
|
+
valid = [r for r in self._records if not r.is_corrupt]
|
|
115
|
+
corrupt = [r for r in self._records if r.is_corrupt]
|
|
116
|
+
if not valid:
|
|
117
|
+
out = {'inventory': {'total_videos': len(self._records), 'valid_videos': 0, 'corrupt_videos': len(corrupt),
|
|
118
|
+
'corrupt_paths': [r.path for r in corrupt], 'format_distribution': {}, 'label_distribution': None},
|
|
119
|
+
'spatial': {}, 'temporal': {}, 'quality': {}, 'motion': {}, 'colour': {},
|
|
120
|
+
'labels': {'label_distribution': None, 'class_imbalance_ratio': None}, 'error': 'No valid videos found.'}
|
|
121
|
+
self._results['summary'] = out
|
|
122
|
+
return out
|
|
123
|
+
def arr(x): return [getattr(r, x) for r in valid if getattr(r, x) is not None]
|
|
124
|
+
labels = [r.label for r in valid if r.label]
|
|
125
|
+
label_dist = dict(Counter(labels)) if labels else None
|
|
126
|
+
rgb_means = [r.rgb_mean for r in valid if r.rgb_mean is not None]
|
|
127
|
+
rgb_stds = [r.rgb_std for r in valid if r.rgb_std is not None]
|
|
128
|
+
out = {
|
|
129
|
+
'inventory': {'total_videos': len(self._records), 'valid_videos': len(valid), 'corrupt_videos': len(corrupt),
|
|
130
|
+
'corrupt_paths': [r.path for r in corrupt], 'format_distribution': dict(Counter(r.file_ext for r in valid)),
|
|
131
|
+
'label_distribution': label_dist, 'file_size_kb': _stat(arr('file_size_kb'))},
|
|
132
|
+
'spatial': {'height': _stat(arr('height')), 'width': _stat(arr('width')), 'aspect_ratio': _stat(arr('aspect_ratio')), 'megapixels': _stat(arr('megapixels'))},
|
|
133
|
+
'temporal': {'frame_count': _stat(arr('n_frames')), 'sampled_frames': _stat(arr('sampled_frames')), 'fps': _stat(arr('fps')), 'duration_sec': _stat(arr('duration_sec')), 'temporal_brightness_std': _stat(arr('temporal_brightness_std')), 'temporal_contrast_std': _stat(arr('temporal_contrast_std'))},
|
|
134
|
+
'quality': {'brightness_mean': _stat(arr('brightness_mean')), 'brightness_std': _stat(arr('brightness_std')), 'contrast_mean': _stat(arr('contrast_mean')), 'sharpness_mean': _stat(arr('sharpness_mean')), 'sharpness_min': _stat(arr('sharpness_min')), 'blur_fraction': _stat(arr('blur_fraction'))},
|
|
135
|
+
'motion': {'frame_diff_mean': _stat(arr('frame_diff_mean')), 'motion_intensity_mean': _stat(arr('motion_intensity_mean')), 'motion_blur_fraction': _stat(arr('motion_blur_fraction')), 'scene_change_count': _stat(arr('scene_change_count')), 'scene_change_rate': _stat(arr('scene_change_rate'))},
|
|
136
|
+
'colour': {'rgb_mean_mean': np.vstack(rgb_means).mean(axis=0).round(6).tolist() if rgb_means else None,
|
|
137
|
+
'rgb_std_mean': np.vstack(rgb_stds).mean(axis=0).round(6).tolist() if rgb_stds else None},
|
|
138
|
+
'labels': {'label_distribution': label_dist, 'class_imbalance_ratio': round(max(label_dist.values()) / max(min(label_dist.values()), 1), 6) if label_dist and len(label_dist)>1 else None}
|
|
139
|
+
}
|
|
140
|
+
self._results['summary'] = out
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
def get_record(self, index=0):
|
|
144
|
+
self._check_loaded(); return self._records[index]
|
|
145
|
+
|
|
146
|
+
def get_frames(self, index=0):
|
|
147
|
+
self._check_loaded(); r = self._records[index]
|
|
148
|
+
if r.is_corrupt: raise ValueError(r.error)
|
|
149
|
+
return self._arrays[r.path]
|
|
150
|
+
|
|
151
|
+
def temporal_profile(self, index=0):
|
|
152
|
+
frames = self.get_frames(index)
|
|
153
|
+
gray = self._gray(frames)
|
|
154
|
+
sharp = np.asarray([self._lap_var(g) for g in gray])
|
|
155
|
+
return {'brightness': gray.mean(axis=(1,2)), 'contrast': gray.std(axis=(1,2)), 'sharpness': sharp, 'frame_diff': self._diffs(gray)}
|
|
156
|
+
|
|
157
|
+
def pairwise_video_distances(self, max_videos=50):
|
|
158
|
+
self._check_loaded(); valid = [r for r in self._records if not r.is_corrupt][:max_videos]
|
|
159
|
+
if len(valid) < 2: raise ValueError('Need at least two valid videos.')
|
|
160
|
+
X, names = [], []
|
|
161
|
+
for r in valid:
|
|
162
|
+
X.append([r.n_frames or 0, r.fps or 0, r.duration_sec or 0, r.height or 0, r.width or 0, r.brightness_mean or 0, r.contrast_mean or 0, r.sharpness_mean or 0, r.blur_fraction or 0, r.motion_blur_fraction or 0, r.motion_intensity_mean or 0, r.scene_change_rate or 0])
|
|
163
|
+
names.append(r.label or Path(str(r.path)).stem)
|
|
164
|
+
X = np.nan_to_num(np.asarray(X, float)); sd = X.std(axis=0); sd[sd==0] = 1
|
|
165
|
+
X = (X - X.mean(axis=0)) / sd
|
|
166
|
+
return np.sqrt(((X[:,None,:] - X[None,:,:])**2).sum(axis=2)), names
|
|
167
|
+
|
|
168
|
+
def plot_dataset(self, figsize=(24, 24), save_path=None, dpi=150):
|
|
169
|
+
"""Comprehensive dataset-level dashboard for all loaded videos."""
|
|
170
|
+
self._check_loaded()
|
|
171
|
+
valid = [r for r in self._records if not r.is_corrupt]
|
|
172
|
+
if not valid:
|
|
173
|
+
raise RuntimeError('No valid videos to plot.')
|
|
174
|
+
plt = _plt()
|
|
175
|
+
import matplotlib as mpl
|
|
176
|
+
fig = plt.figure(figsize=figsize, facecolor='white')
|
|
177
|
+
fig.suptitle('VideoEDA — Dataset Analysis', fontsize=20, fontweight='bold', y=0.995)
|
|
178
|
+
gs = mpl.gridspec.GridSpec(6, 4, figure=fig, hspace=0.55, wspace=0.35,
|
|
179
|
+
left=0.05, right=0.98, top=0.96, bottom=0.03)
|
|
180
|
+
|
|
181
|
+
def ax(pos):
|
|
182
|
+
a = fig.add_subplot(pos)
|
|
183
|
+
a.set_facecolor('#f6f8fa')
|
|
184
|
+
for sp in a.spines.values():
|
|
185
|
+
sp.set_edgecolor('#d0d7de')
|
|
186
|
+
a.tick_params(colors='#57606a', labelsize=8)
|
|
187
|
+
return a
|
|
188
|
+
|
|
189
|
+
def values(attr):
|
|
190
|
+
return [getattr(r, attr) for r in valid if getattr(r, attr) is not None]
|
|
191
|
+
|
|
192
|
+
# Row 0: dataset overview and label distribution
|
|
193
|
+
self._plot_dataset_overview(ax(gs[0, :2]), valid)
|
|
194
|
+
self._plot_label_dist_horizontal(ax(gs[0, 2:]), valid)
|
|
195
|
+
|
|
196
|
+
# Row 1: spatial and temporal inventory
|
|
197
|
+
self._plot_hist(ax(gs[1, 0]), values('n_frames'), 'Frame Count')
|
|
198
|
+
self._plot_hist(ax(gs[1, 1]), values('duration_sec'), 'Duration (seconds)')
|
|
199
|
+
self._plot_hist(ax(gs[1, 2]), values('fps'), 'FPS')
|
|
200
|
+
self._plot_hist(ax(gs[1, 3]), values('aspect_ratio'), 'Aspect Ratio')
|
|
201
|
+
|
|
202
|
+
# Row 2: quality metrics
|
|
203
|
+
self._plot_hist(ax(gs[2, 0]), values('brightness_mean'), 'Brightness Mean')
|
|
204
|
+
self._plot_hist(ax(gs[2, 1]), values('contrast_mean'), 'Contrast Mean')
|
|
205
|
+
self._plot_hist(ax(gs[2, 2]), values('sharpness_mean'), 'Sharpness Mean')
|
|
206
|
+
self._plot_hist(ax(gs[2, 3]), values('blur_fraction'), 'Blur Fraction')
|
|
207
|
+
|
|
208
|
+
# Row 3: motion and temporal consistency
|
|
209
|
+
self._plot_hist(ax(gs[3, 0]), values('motion_intensity_mean'), 'Motion Intensity')
|
|
210
|
+
self._plot_hist(ax(gs[3, 1]), values('motion_blur_fraction'), 'Motion Blur Fraction')
|
|
211
|
+
self._plot_hist(ax(gs[3, 2]), values('scene_change_rate'), 'Scene Change Rate')
|
|
212
|
+
self._plot_hist(ax(gs[3, 3]), values('temporal_brightness_std'), 'Temporal Brightness Std')
|
|
213
|
+
|
|
214
|
+
# Row 4: relationships and colour
|
|
215
|
+
self._plot_motion_sharpness_scatter(ax(gs[4, 0]), valid)
|
|
216
|
+
self._plot_duration_motion_scatter(ax(gs[4, 1]), valid)
|
|
217
|
+
self._plot_rgb_mean_distribution(ax(gs[4, 2]), valid)
|
|
218
|
+
self._plot_format_distribution(ax(gs[4, 3]), valid)
|
|
219
|
+
|
|
220
|
+
# Row 5: previews, pairwise diversity, resolution, quality bars
|
|
221
|
+
self._plot_frame_preview_strip(ax(gs[5, 0]), valid)
|
|
222
|
+
self._plot_pairwise_heatmap(ax(gs[5, 1]), valid)
|
|
223
|
+
self._plot_resolution_scatter(ax(gs[5, 2]), valid)
|
|
224
|
+
self._plot_quality_bars(ax(gs[5, 3]), valid)
|
|
225
|
+
|
|
226
|
+
self._finalise(fig, save_path, dpi)
|
|
227
|
+
|
|
228
|
+
def plot(self, video_index=0, figsize=(16,10), save_path=None, dpi=150):
|
|
229
|
+
frames=self.get_frames(video_index); prof=self.temporal_profile(video_index); plt=_plt(); fig,ax=plt.subplots(2,3,figsize=figsize); ax=ax.ravel(); fig.suptitle('VideoEDA — Single Video',fontweight='bold')
|
|
230
|
+
ax[0].imshow(frames[len(frames)//2]); ax[0].set_title('Middle Frame'); ax[0].axis('off')
|
|
231
|
+
for a,k,t in [(ax[1],'brightness','Brightness'),(ax[2],'contrast','Contrast'),(ax[3],'sharpness','Sharpness'),(ax[4],'frame_diff','Frame Difference')]: a.plot(prof[k]); a.set_title(t)
|
|
232
|
+
self._hist(ax[5], prof['sharpness'], 'Sharpness Distribution'); self._finalise(fig, save_path, dpi)
|
|
233
|
+
|
|
234
|
+
def plot_videos_grid(self, n=12, cols=4, save_path=None, dpi=150):
|
|
235
|
+
idxs=[i for i,r in enumerate(self._records) if not r.is_corrupt][:n]
|
|
236
|
+
plt=_plt(); rows=math.ceil(len(idxs)/cols); fig,axes=plt.subplots(rows,cols,figsize=(cols*4,rows*3),squeeze=False)
|
|
237
|
+
for a in axes.ravel(): a.axis('off')
|
|
238
|
+
for a,i in zip(axes.ravel(),idxs):
|
|
239
|
+
f=self.get_frames(i); a.imshow(f[len(f)//2]); a.set_title(self._records[i].label or Path(str(self._records[i].path)).stem,fontsize=8)
|
|
240
|
+
self._finalise(fig,save_path,dpi)
|
|
241
|
+
|
|
242
|
+
def plot_motion_profile(self, video_index=0, save_path=None, dpi=150):
|
|
243
|
+
p=self.temporal_profile(video_index); plt=_plt(); fig,ax=plt.subplots(figsize=(12,5)); ax.plot(p['frame_diff'],label='Frame diff'); ax.plot(p['sharpness'][1:]/(p['sharpness'].max()+1e-9)*max(p['frame_diff'].max() if len(p['frame_diff']) else 1,1),label='Sharpness scaled'); ax.legend(); ax.set_title('Motion/Sharpness Profile'); self._finalise(fig,save_path,dpi)
|
|
244
|
+
|
|
245
|
+
def plot_pairwise_video_distances(self, save_path=None, dpi=150):
|
|
246
|
+
D,names=self.pairwise_video_distances(); plt=_plt(); fig,ax=plt.subplots(figsize=(8,7)); im=ax.imshow(D); plt.colorbar(im,ax=ax); ax.set_title('Pairwise Video Distances'); self._finalise(fig,save_path,dpi)
|
|
247
|
+
|
|
248
|
+
def plot_temporal_statistics(self, video_index=0, save_path=None, dpi=150):
|
|
249
|
+
"""Detailed temporal statistics for one video: brightness, contrast, sharpness and motion."""
|
|
250
|
+
p = self.temporal_profile(video_index)
|
|
251
|
+
plt = _plt()
|
|
252
|
+
fig, ax = plt.subplots(2, 2, figsize=(16, 10), facecolor='white')
|
|
253
|
+
ax = ax.ravel()
|
|
254
|
+
for a, key, title in [
|
|
255
|
+
(ax[0], 'brightness', 'Brightness over sampled frames'),
|
|
256
|
+
(ax[1], 'contrast', 'Contrast over sampled frames'),
|
|
257
|
+
(ax[2], 'sharpness', 'Sharpness over sampled frames'),
|
|
258
|
+
(ax[3], 'frame_diff', 'Frame difference / motion'),
|
|
259
|
+
]:
|
|
260
|
+
self._style_axis(a)
|
|
261
|
+
a.plot(p[key])
|
|
262
|
+
a.set_title(title)
|
|
263
|
+
a.set_xlabel('Sampled frame index')
|
|
264
|
+
fig.suptitle('VideoEDA — Temporal Statistics', fontsize=15, fontweight='bold')
|
|
265
|
+
self._finalise(fig, save_path, dpi)
|
|
266
|
+
|
|
267
|
+
def plot_quality_summary(self, save_path=None, dpi=150):
|
|
268
|
+
"""Dataset-level video quality summary."""
|
|
269
|
+
self._check_loaded()
|
|
270
|
+
valid = [r for r in self._records if not r.is_corrupt]
|
|
271
|
+
if not valid:
|
|
272
|
+
raise RuntimeError('No valid videos to plot.')
|
|
273
|
+
plt = _plt()
|
|
274
|
+
fig, ax = plt.subplots(2, 3, figsize=(18, 10), facecolor='white')
|
|
275
|
+
ax = ax.ravel()
|
|
276
|
+
self._plot_hist(ax[0], [r.brightness_mean for r in valid], 'Brightness Mean')
|
|
277
|
+
self._plot_hist(ax[1], [r.contrast_mean for r in valid], 'Contrast Mean')
|
|
278
|
+
self._plot_hist(ax[2], [r.sharpness_mean for r in valid], 'Sharpness Mean')
|
|
279
|
+
self._plot_hist(ax[3], [r.blur_fraction for r in valid], 'Blur Fraction')
|
|
280
|
+
self._plot_hist(ax[4], [r.motion_blur_fraction for r in valid], 'Motion Blur Fraction')
|
|
281
|
+
self._plot_motion_sharpness_scatter(ax[5], valid)
|
|
282
|
+
fig.suptitle('VideoEDA — Quality Summary', fontsize=15, fontweight='bold')
|
|
283
|
+
self._finalise(fig, save_path, dpi)
|
|
284
|
+
|
|
285
|
+
def plot_frame_samples(self, video_index=0, n=12, cols=4, save_path=None, dpi=150):
|
|
286
|
+
"""Grid of sampled frames from one video."""
|
|
287
|
+
frames = self.get_frames(video_index)
|
|
288
|
+
if len(frames) == 0:
|
|
289
|
+
raise RuntimeError('No sampled frames available.')
|
|
290
|
+
plt = _plt()
|
|
291
|
+
n = min(n, len(frames))
|
|
292
|
+
rows = math.ceil(n / cols)
|
|
293
|
+
fig, axes = plt.subplots(rows, cols, figsize=(cols * 4, rows * 3), squeeze=False, facecolor='white')
|
|
294
|
+
for a in axes.ravel():
|
|
295
|
+
a.axis('off')
|
|
296
|
+
idx = np.linspace(0, len(frames) - 1, n).astype(int)
|
|
297
|
+
for a, i in zip(axes.ravel(), idx):
|
|
298
|
+
a.imshow(frames[i])
|
|
299
|
+
a.set_title(f'Frame {i}', fontsize=8)
|
|
300
|
+
fig.suptitle('VideoEDA — Sampled Frames', fontsize=14, fontweight='bold')
|
|
301
|
+
self._finalise(fig, save_path, dpi)
|
|
302
|
+
|
|
303
|
+
def report(self, output_path='viseda_video_report.html'):
|
|
304
|
+
_generate_html_report(self.summary(), output_path); self._log(f'Report saved → {output_path}'); return output_path
|
|
305
|
+
|
|
306
|
+
def _analyse_file(self,p,label_from_parent):
|
|
307
|
+
r=VideoRecord(); r.path=str(p); r.file_ext=p.suffix.lower(); r.file_size_kb=p.stat().st_size/1024 if p.exists() else None; r.label=p.parent.name if label_from_parent else self._label_map.get(str(p.resolve()))
|
|
308
|
+
try:
|
|
309
|
+
frames,n,fps=self._read_file(p); self._arrays[r.path]=frames; self._fill_stats(r,frames,n,fps)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
r.is_corrupt=True; r.error=str(e); self._log(f' ✗ {p.name}: {e}')
|
|
312
|
+
return r
|
|
313
|
+
|
|
314
|
+
def _fill_stats(self,r,frames,n,fps):
|
|
315
|
+
frames=self._normalise_video_array(frames); T,H,W,C=frames.shape
|
|
316
|
+
r.n_frames=int(n); r.sampled_frames=int(T); r.fps=float(fps) if fps else None; r.duration_sec=float(n/fps) if fps else None; r.height=H; r.width=W; r.channels=C; r.aspect_ratio=W/H; r.megapixels=H*W/1e6
|
|
317
|
+
gray=self._gray(frames); bright=gray.mean(axis=(1,2)); cont=gray.std(axis=(1,2)); sharp=np.asarray([self._lap_var(g) for g in gray]); dif=self._diffs(gray)
|
|
318
|
+
r.brightness_mean=float(bright.mean()); r.brightness_std=float(bright.std()); r.contrast_mean=float(cont.mean()); r.contrast_std=float(cont.std()); r.sharpness_mean=float(sharp.mean()); r.sharpness_std=float(sharp.std()); r.sharpness_min=float(sharp.min()); r.blur_fraction=float(np.mean(sharp<self.blur_threshold)); r.temporal_brightness_std=float(bright.std()); r.temporal_contrast_std=float(cont.std())
|
|
319
|
+
if len(dif):
|
|
320
|
+
r.frame_diff_mean=float(dif.mean()); r.frame_diff_std=float(dif.std()); r.motion_intensity_mean=r.frame_diff_mean; r.motion_intensity_std=r.frame_diff_std; r.scene_change_count=int(np.sum(dif>self.scene_change_threshold)); r.scene_change_rate=float(r.scene_change_count/len(dif)); r.motion_blur_fraction=float(np.mean((dif>np.percentile(dif,75)) & (sharp[1:]<self.motion_blur_threshold)))
|
|
321
|
+
else:
|
|
322
|
+
r.frame_diff_mean=r.frame_diff_std=r.motion_intensity_mean=r.motion_intensity_std=0.0; r.scene_change_count=0; r.scene_change_rate=0.0; r.motion_blur_fraction=0.0
|
|
323
|
+
rgb=frames.astype(np.float32)/255.0; r.rgb_mean=rgb.reshape(-1,3).mean(axis=0); r.rgb_std=rgb.reshape(-1,3).std(axis=0); r.sample_frames=frames[np.linspace(0,T-1,min(T,6)).astype(int)]
|
|
324
|
+
|
|
325
|
+
def _read_file(self,p):
|
|
326
|
+
cv2=_cv2(); cap=cv2.VideoCapture(str(p));
|
|
327
|
+
if not cap.isOpened(): raise ValueError('Could not open video')
|
|
328
|
+
n=int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0); fps=float(cap.get(cv2.CAP_PROP_FPS) or 30.0); frames=[]; i=0
|
|
329
|
+
while True:
|
|
330
|
+
ok,fr=cap.read();
|
|
331
|
+
if not ok: break
|
|
332
|
+
if i%self.frame_sample_rate==0:
|
|
333
|
+
fr=cv2.cvtColor(fr,cv2.COLOR_BGR2RGB); frames.append(self._resize(fr))
|
|
334
|
+
if len(frames)>=self.max_frames_per_video: break
|
|
335
|
+
i+=1
|
|
336
|
+
cap.release();
|
|
337
|
+
if not frames: raise ValueError('No readable frames found')
|
|
338
|
+
return np.stack(frames), n or i, fps or 30.0
|
|
339
|
+
|
|
340
|
+
def _normalise_video_array(self,a):
|
|
341
|
+
a=np.asarray(a)
|
|
342
|
+
if a.ndim==3: a=a[:,:,:,None]
|
|
343
|
+
if a.ndim!=4: raise ValueError('Video arrays must be (T,H,W) or (T,H,W,C)')
|
|
344
|
+
if a.shape[0]<1: raise ValueError('Video has no frames')
|
|
345
|
+
if a.shape[-1]==1: a=np.repeat(a,3,axis=-1)
|
|
346
|
+
if a.shape[-1]>3: a=a[...,:3]
|
|
347
|
+
if not np.all(np.isfinite(a)): raise ValueError('Video contains NaN or infinite values')
|
|
348
|
+
if a.dtype!=np.uint8:
|
|
349
|
+
a=a.astype(np.float32); a=a*255 if a.max()<=1.5 else a; a=np.clip(a,0,255).astype(np.uint8)
|
|
350
|
+
return a
|
|
351
|
+
|
|
352
|
+
def _sample_frames_from_array(self,v):
|
|
353
|
+
idx=np.arange(0,v.shape[0],self.frame_sample_rate)[:self.max_frames_per_video]
|
|
354
|
+
return np.stack([self._resize(f) for f in v[idx]])
|
|
355
|
+
def _resize(self,f):
|
|
356
|
+
if self.resize_width is None or f.shape[1] <= self.resize_width: return f
|
|
357
|
+
cv2=_cv2(); h,w=f.shape[:2]; nw=self.resize_width; nh=max(1,int(h*nw/w)); return cv2.resize(f,(nw,nh),interpolation=cv2.INTER_AREA)
|
|
358
|
+
def _gray(self,f): return .299*f[...,0].astype(float)+.587*f[...,1].astype(float)+.114*f[...,2].astype(float)
|
|
359
|
+
def _lap_var(self,g):
|
|
360
|
+
try: return float(_cv2().Laplacian(g.astype(np.float32), _cv2().CV_32F).var())
|
|
361
|
+
except Exception: gy,gx=np.gradient(g.astype(float)); return float((gx*gx+gy*gy).var())
|
|
362
|
+
def _diffs(self,g): return np.mean(np.abs(np.diff(g.astype(float),axis=0)),axis=(1,2)) if len(g)>1 else np.asarray([])
|
|
363
|
+
def _resolve_paths(self,source,recursive):
|
|
364
|
+
if isinstance(source,(list,tuple)): paths=[Path(x) for x in source]
|
|
365
|
+
else:
|
|
366
|
+
p=Path(source)
|
|
367
|
+
if p.is_dir(): paths=[x for x in (p.rglob('*') if recursive else p.glob('*')) if x.is_file()]
|
|
368
|
+
elif p.is_file(): paths=[p]
|
|
369
|
+
else: raise FileNotFoundError(source)
|
|
370
|
+
return sorted([p for p in paths if p.suffix.lower() in self.SUPPORTED_EXTS])
|
|
371
|
+
def _check_loaded(self):
|
|
372
|
+
if not self._loaded: raise RuntimeError('No videos loaded. Call load() or load_arrays() first.')
|
|
373
|
+
def _log(self,msg):
|
|
374
|
+
if self.verbose: print(f'[viseda] {msg}')
|
|
375
|
+
def _plot_hist(self, ax, data, title):
|
|
376
|
+
"""Compatibility wrapper used by the comprehensive dashboard."""
|
|
377
|
+
return self._hist(ax, data, title)
|
|
378
|
+
|
|
379
|
+
def _hist(self,ax,data,title):
|
|
380
|
+
a=np.asarray([x for x in data if x is not None and np.isfinite(x)],float); ax.set_title(title)
|
|
381
|
+
if len(a): ax.hist(a,bins=min(20,max(5,len(a)))); ax.axvline(a.mean(),ls='--')
|
|
382
|
+
def _label_bar(self,ax,records):
|
|
383
|
+
labels=[r.label for r in records if r.label]; ax.set_title('Labels')
|
|
384
|
+
if labels:
|
|
385
|
+
k,v=zip(*Counter(labels).items()); ax.barh(k,v)
|
|
386
|
+
def _style_ax(self, ax):
|
|
387
|
+
"""Compatibility wrapper for older plot helper calls."""
|
|
388
|
+
return self._style_axis(ax)
|
|
389
|
+
|
|
390
|
+
def _style_axis(self, ax):
|
|
391
|
+
ax.set_facecolor('#f6f8fa')
|
|
392
|
+
for sp in ax.spines.values():
|
|
393
|
+
sp.set_edgecolor('#d0d7de')
|
|
394
|
+
ax.tick_params(colors='#57606a', labelsize=8)
|
|
395
|
+
|
|
396
|
+
def _plot_dataset_overview(self, ax, records):
|
|
397
|
+
ax.axis('off')
|
|
398
|
+
total = len(self._records)
|
|
399
|
+
valid = len(records)
|
|
400
|
+
corrupt = sum(r.is_corrupt for r in self._records)
|
|
401
|
+
labels = [r.label for r in records if r.label]
|
|
402
|
+
mean_duration = np.nanmean([r.duration_sec for r in records if r.duration_sec is not None]) if records else 0
|
|
403
|
+
mean_fps = np.nanmean([r.fps for r in records if r.fps is not None]) if records else 0
|
|
404
|
+
mean_frames = np.nanmean([r.n_frames for r in records if r.n_frames is not None]) if records else 0
|
|
405
|
+
mean_motion_blur = np.nanmean([r.motion_blur_fraction for r in records if r.motion_blur_fraction is not None]) if records else 0
|
|
406
|
+
lines = [
|
|
407
|
+
f'Total videos: {total}',
|
|
408
|
+
f'Valid videos: {valid}',
|
|
409
|
+
f'Corrupt videos: {corrupt}',
|
|
410
|
+
f'Unique labels: {len(set(labels)) if labels else 0}',
|
|
411
|
+
f'Mean frames: {mean_frames:.1f}',
|
|
412
|
+
f'Mean FPS: {mean_fps:.2f}',
|
|
413
|
+
f'Mean duration: {mean_duration:.2f}s',
|
|
414
|
+
f'Mean motion blur: {mean_motion_blur:.4f}',
|
|
415
|
+
]
|
|
416
|
+
ax.text(0.04, 0.94, '\n'.join(lines), va='top', ha='left', transform=ax.transAxes,
|
|
417
|
+
fontsize=9, family='monospace',
|
|
418
|
+
bbox=dict(boxstyle='round,pad=0.45', facecolor='#eaeef2', edgecolor='#d0d7de'))
|
|
419
|
+
ax.set_title('Dataset Overview')
|
|
420
|
+
|
|
421
|
+
def _plot_label_dist_horizontal(self, ax, records):
|
|
422
|
+
self._style_axis(ax)
|
|
423
|
+
labels = [r.label for r in records if r.label]
|
|
424
|
+
ax.set_title('Label Distribution')
|
|
425
|
+
if not labels:
|
|
426
|
+
ax.text(0.5, 0.5, 'No labels\n(use label_from_parent=True)', ha='center', va='center')
|
|
427
|
+
return
|
|
428
|
+
names, counts = zip(*Counter(labels).most_common(20))
|
|
429
|
+
y = np.arange(len(names))
|
|
430
|
+
ax.barh(y, counts)
|
|
431
|
+
ax.set_yticks(y)
|
|
432
|
+
ax.set_yticklabels(names, fontsize=8)
|
|
433
|
+
ax.set_xlabel('Count')
|
|
434
|
+
ax.invert_yaxis()
|
|
435
|
+
|
|
436
|
+
def _plot_motion_sharpness_scatter(self, ax, records):
|
|
437
|
+
self._style_axis(ax)
|
|
438
|
+
x = [r.motion_intensity_mean for r in records if r.motion_intensity_mean is not None and r.sharpness_mean is not None]
|
|
439
|
+
y = [r.sharpness_mean for r in records if r.motion_intensity_mean is not None and r.sharpness_mean is not None]
|
|
440
|
+
ax.scatter(x, y, alpha=0.75)
|
|
441
|
+
ax.set_title('Motion vs Sharpness')
|
|
442
|
+
ax.set_xlabel('Motion intensity')
|
|
443
|
+
ax.set_ylabel('Sharpness')
|
|
444
|
+
|
|
445
|
+
def _plot_duration_motion_scatter(self, ax, records):
|
|
446
|
+
self._style_axis(ax)
|
|
447
|
+
x = [r.duration_sec for r in records if r.duration_sec is not None and r.motion_intensity_mean is not None]
|
|
448
|
+
y = [r.motion_intensity_mean for r in records if r.duration_sec is not None and r.motion_intensity_mean is not None]
|
|
449
|
+
ax.scatter(x, y, alpha=0.75)
|
|
450
|
+
ax.set_title('Duration vs Motion')
|
|
451
|
+
ax.set_xlabel('Duration (s)')
|
|
452
|
+
ax.set_ylabel('Motion intensity')
|
|
453
|
+
|
|
454
|
+
def _plot_rgb_mean_distribution(self, ax, records):
|
|
455
|
+
self._style_axis(ax)
|
|
456
|
+
vals = [r.rgb_mean for r in records if r.rgb_mean is not None]
|
|
457
|
+
ax.set_title('RGB Mean Distribution')
|
|
458
|
+
if not vals:
|
|
459
|
+
ax.text(0.5, 0.5, 'No colour data', ha='center', va='center')
|
|
460
|
+
return
|
|
461
|
+
arr = np.vstack(vals)
|
|
462
|
+
ax.hist(arr[:, 0], alpha=0.45, label='Red')
|
|
463
|
+
ax.hist(arr[:, 1], alpha=0.45, label='Green')
|
|
464
|
+
ax.hist(arr[:, 2], alpha=0.45, label='Blue')
|
|
465
|
+
ax.legend(fontsize=7)
|
|
466
|
+
ax.set_xlabel('Mean channel value (0–1)')
|
|
467
|
+
|
|
468
|
+
def _plot_format_distribution(self, ax, records):
|
|
469
|
+
self._style_axis(ax)
|
|
470
|
+
counts = Counter(r.file_ext for r in records)
|
|
471
|
+
ax.set_title('File Format Distribution')
|
|
472
|
+
if counts:
|
|
473
|
+
ax.bar(list(counts.keys()), list(counts.values()))
|
|
474
|
+
ax.set_ylabel('Count')
|
|
475
|
+
|
|
476
|
+
def _plot_frame_preview_strip(self, ax, records):
|
|
477
|
+
ax.axis('off')
|
|
478
|
+
chosen = []
|
|
479
|
+
for rec in records[:6]:
|
|
480
|
+
try:
|
|
481
|
+
frames = self._arrays.get(rec.path)
|
|
482
|
+
if frames is not None and len(frames):
|
|
483
|
+
chosen.append(frames[len(frames)//2])
|
|
484
|
+
except Exception:
|
|
485
|
+
pass
|
|
486
|
+
ax.set_title('Representative Frames')
|
|
487
|
+
if not chosen:
|
|
488
|
+
ax.text(0.5, 0.5, 'No preview frames', ha='center', va='center')
|
|
489
|
+
return
|
|
490
|
+
min_h = min(f.shape[0] for f in chosen)
|
|
491
|
+
thumbs = []
|
|
492
|
+
for f in chosen:
|
|
493
|
+
if f.shape[0] != min_h:
|
|
494
|
+
step = max(1, f.shape[0] // min_h)
|
|
495
|
+
f = f[::step][:min_h]
|
|
496
|
+
thumbs.append(f[:min_h])
|
|
497
|
+
montage = np.concatenate(thumbs, axis=1)
|
|
498
|
+
ax.imshow(montage)
|
|
499
|
+
|
|
500
|
+
def _plot_pairwise_heatmap(self, ax, records):
|
|
501
|
+
self._style_axis(ax)
|
|
502
|
+
ax.set_title('Pairwise Video Distances')
|
|
503
|
+
try:
|
|
504
|
+
D, names = self.pairwise_video_distances(max_videos=min(30, len(records)))
|
|
505
|
+
im = ax.imshow(D, aspect='auto')
|
|
506
|
+
if len(names) <= 12:
|
|
507
|
+
ax.set_xticks(range(len(names)))
|
|
508
|
+
ax.set_xticklabels(names, rotation=45, ha='right', fontsize=6)
|
|
509
|
+
ax.set_yticks(range(len(names)))
|
|
510
|
+
ax.set_yticklabels(names, fontsize=6)
|
|
511
|
+
except Exception as exc:
|
|
512
|
+
ax.text(0.5, 0.5, f'Unavailable\n{exc}', ha='center', va='center')
|
|
513
|
+
|
|
514
|
+
def _plot_resolution_scatter(self, ax, records):
|
|
515
|
+
self._style_axis(ax)
|
|
516
|
+
x = [r.width for r in records if r.width is not None and r.height is not None]
|
|
517
|
+
y = [r.height for r in records if r.width is not None and r.height is not None]
|
|
518
|
+
ax.scatter(x, y, alpha=0.75)
|
|
519
|
+
ax.set_title('Resolution Scatter')
|
|
520
|
+
ax.set_xlabel('Width')
|
|
521
|
+
ax.set_ylabel('Height')
|
|
522
|
+
|
|
523
|
+
def _plot_quality_bars(self, ax, records):
|
|
524
|
+
self._style_axis(ax)
|
|
525
|
+
labels = ['Blur', 'Motion blur', 'Scene change']
|
|
526
|
+
vals = [
|
|
527
|
+
np.nanmean([r.blur_fraction for r in records if r.blur_fraction is not None]),
|
|
528
|
+
np.nanmean([r.motion_blur_fraction for r in records if r.motion_blur_fraction is not None]),
|
|
529
|
+
np.nanmean([r.scene_change_rate for r in records if r.scene_change_rate is not None]),
|
|
530
|
+
]
|
|
531
|
+
vals = [0 if not np.isfinite(v) else v for v in vals]
|
|
532
|
+
ax.bar(labels, vals)
|
|
533
|
+
ax.set_title('Quality Flags / Rates')
|
|
534
|
+
ax.set_ylim(bottom=0)
|
|
535
|
+
|
|
536
|
+
def _finalise(self, fig, save_path=None, dpi=220):
|
|
537
|
+
"""
|
|
538
|
+
Finalise and save/show figures without over-compressing dashboard panels.
|
|
539
|
+
This avoids tight_layout(), which can shrink large multi-panel dashboards.
|
|
540
|
+
"""
|
|
541
|
+
plt = _plt()
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
fig.set_constrained_layout(False)
|
|
545
|
+
except Exception:
|
|
546
|
+
pass
|
|
547
|
+
|
|
548
|
+
fig.subplots_adjust(
|
|
549
|
+
left=0.035,
|
|
550
|
+
right=0.985,
|
|
551
|
+
top=0.955,
|
|
552
|
+
bottom=0.045,
|
|
553
|
+
hspace=0.72,
|
|
554
|
+
wspace=0.45,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if save_path:
|
|
558
|
+
Path(save_path).parent.mkdir(parents=True, exist_ok=True)
|
|
559
|
+
fig.savefig(
|
|
560
|
+
save_path,
|
|
561
|
+
dpi=max(220, dpi),
|
|
562
|
+
bbox_inches=None,
|
|
563
|
+
pad_inches=0.20,
|
|
564
|
+
facecolor="white",
|
|
565
|
+
)
|
|
566
|
+
plt.close(fig)
|
|
567
|
+
else:
|
|
568
|
+
plt.show()
|
|
569
|
+
|
|
570
|
+
def _fmt(v):
|
|
571
|
+
if v is None: return 'N/A'
|
|
572
|
+
if isinstance(v,float): return f'{v:.4f}'
|
|
573
|
+
return html.escape(str(v))
|
|
574
|
+
def _card(title,stats):
|
|
575
|
+
rows=''.join(f'<div class="stat"><span>{k}</span><span class="val">{_fmt(stats.get(k))}</span></div>' for k in ['min','max','mean','median','std','p25','p75'] if stats and k in stats)
|
|
576
|
+
return f'<div class="card"><h3>{html.escape(title)}</h3>{rows or "No data"}</div>'
|
|
577
|
+
def _bar(title,d):
|
|
578
|
+
if not d: return f'<div class="card"><h3>{title}</h3>No data</div>'
|
|
579
|
+
m=max(d.values()) or 1; rows=''.join(f'<div class="bar-row"><span class="bar-label">{html.escape(str(k))}</span><div class="bar"><div class="bar-fill" style="width:{100*v/m:.1f}%"></div></div><span class="bar-count">{v}</span></div>' for k,v in d.items())
|
|
580
|
+
return f'<div class="card"><h3>{title}</h3>{rows}</div>'
|
|
581
|
+
def _generate_html_report(s,output_path):
|
|
582
|
+
inv=s.get('inventory',{}); sp=s.get('spatial',{}); tm=s.get('temporal',{}); q=s.get('quality',{}); mo=s.get('motion',{})
|
|
583
|
+
css='body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:2rem;color:#1f2328}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.9rem}.card{background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;padding:1rem}.stat{display:flex;justify-content:space-between;border-bottom:1px solid #d0d7de;padding:.18rem 0}.val{color:#58a6ff}.bar-row{display:flex;gap:.4rem;margin:.2rem 0}.bar-label{width:120px}.bar{flex:1;background:#d0d7de;border-radius:3px;height:9px}.bar-fill{height:100%;background:#58a6ff}.bar-count{width:45px;text-align:right;color:#58a6ff}h2{color:#58a6ff;margin-top:1.8rem}h3{color:#57606a;text-transform:uppercase;font-size:.78rem}'
|
|
584
|
+
html_doc=f'<!doctype html><html><head><meta charset="utf-8"><title>VisEDA Video Report</title><style>{css}</style></head><body><h1>🎬 VisEDA — Video EDA Report</h1><p><b>{inv.get("total_videos")}</b> videos · <b>{inv.get("valid_videos")}</b> valid · <b>{inv.get("corrupt_videos")}</b> corrupt</p><h2>📦 Inventory</h2><div class="grid"><div class="card"><h3>Counts</h3><div class="stat"><span>Total videos</span><span class="val">{inv.get("total_videos")}</span></div><div class="stat"><span>Valid videos</span><span class="val">{inv.get("valid_videos")}</span></div><div class="stat"><span>Corrupt videos</span><span class="val">{inv.get("corrupt_videos")}</span></div></div>{_bar("Label Distribution",inv.get("label_distribution"))}{_bar("Format Distribution",inv.get("format_distribution"))}</div><h2>🖼️ Spatial</h2><div class="grid">{_card("Height",sp.get("height"))}{_card("Width",sp.get("width"))}{_card("Aspect Ratio",sp.get("aspect_ratio"))}</div><h2>⏱️ Temporal Frame Statistics</h2><div class="grid">{_card("Frame Count",tm.get("frame_count"))}{_card("FPS",tm.get("fps"))}{_card("Duration",tm.get("duration_sec"))}{_card("Temporal Brightness Std",tm.get("temporal_brightness_std"))}</div><h2>🔎 Quality and Motion Blur</h2><div class="grid">{_card("Brightness",q.get("brightness_mean"))}{_card("Contrast",q.get("contrast_mean"))}{_card("Sharpness",q.get("sharpness_mean"))}{_card("Blur Fraction",q.get("blur_fraction"))}{_card("Motion Blur Fraction",mo.get("motion_blur_fraction"))}</div><h2>〰️ Motion</h2><div class="grid">{_card("Frame Difference",mo.get("frame_diff_mean"))}{_card("Motion Intensity",mo.get("motion_intensity_mean"))}{_card("Scene Change Rate",mo.get("scene_change_rate"))}</div><footer>Generated by VisEDA — Visual Exploratory Data Analysis</footer></body></html>'
|
|
585
|
+
Path(output_path).write_text(html_doc,encoding='utf-8')
|
|
586
|
+
|
|
587
|
+
__all__=['VideoEDA','VideoRecord']
|