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/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']